Durant des années, j’ai confondu langage de programmation et DSL (Domain Specific Language).

Ne pensant pas que HTML pouvait être un DSL tout comme SQLGHERKIN  et bien d’autres.

Dans l’écosystème d’Apple, on utilise très souvent les DSL sans les connaitre, par exemple, le fichier podfile de CocoaPods ou même SwiftUI.

En effet, l’objectif de cet article est de simplifier le concept de DSL en présentant un exemple pratique qui nous permet d’écrire à travers des fonctions dites « builder » un DSL permettant d’appliquer des mises en forme sur des chaines de caractères.

Pour simplifier, on veut écrire un DSL qui permet d’écrire un code déclaratif plutôt que l’approche impérative que l’on implémente couramment lors de la mise en forme d’une chaine de caractères.

Chronologiquement, on va essayer de présenter:

  • Qu’est ce qu’un DSL ou langage dédié (traduction Wikipedia)?
  • Qu’est ce qu’un ResultBuilder?
  • Comment on peut utiliser en pratique un ResultBuilder pour créer un DSL qui permet de simplifier l’écriture des contraintes (NSAttributedConstraint)?

Qu’est ce qu’un DSL?

Un DSL est un langage dédié à un domaine particulier. Par exemple, SQL est un DSL pour interagir avec une base de données relationnelles. Si vous utilisez SPM, CocoaPods ou Carthage vous utilisez un DSL pour installer vos frameworks. Par exemple, le fichier podfile est un fichier en Ruby et toutes les commandes qu’on écrit genre pod “nom de framework”, ~> 2.0 respecte un syntaxe bien spécifique qui va faire appel à du code en Ruby.

// Cocoapods
platform :ios, '15.0'
use_frameworks!

target 'MyApp' do
  pod 'Utils', '~> 0.2'
  pod 'rxswift'
end

Sans cette écriture assez déclarative, on sera dans l’obligation d’écrire de pure Ruby pour pouvoir installer ces pods.

De la même manière pour SPM, le fichier package.swift est écrit dans un langage spécifique au domaine d’installation des frameworks mais à la base c’est du langage Swift.

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "MyLibrary",
    platforms: [
        .macOS(.v10_15),
    ],
    products: [
        .library(name: "MyLibrary", targets: ["MyLibrary"]),
    ],
    dependencies: [
        .package(url: "https://url/of/another/package/named/Utility", from: "1.0.0"),
    ],
    targets: [
        .target(name: "MyLibrary", dependencies: ["Utility"]),
        .testTarget(name: "MyLibraryTests", dependencies: ["MyLibrary"]),
    ]
)

Cette manière d’écrire du code déclaratif est assez répondu dans les DSL que j’ai vu. Cela est du à mon avis que l’écriture déclarative est plus simple et élégante que celle qu’on écrit généralement en pensant d’une façon impérative.

Dans son livre Domain-Specific Languages, Martin Fowler distingue entre deux types de DSL:

  • Les DSL internes qu’on appelle généralement Embeded Domain specific Language.
  • Les DSL externes

EDSL utilise généralement un autre langage pour mettre ses règles et son syntaxe qui est à la base du langage accueillant. Par exemple, SPM utilise Swift pour écrire le fichier package.swift alors il respecte le syntaxe de Swift.

Par contre SQL est un DSL externe qui possède sa syntaxe à lui et qui n’est pas lié à un langage existant.

ResultBuilder:

ResultBuilder est une évolution qui arrivait en Swift 5.4. C’est une évolution pourquoi? car son ancêtre est les functions builder apparus avec Swift 5.1. vous pouvez suivre la proposition faite par John McCallDoug Gregor ici SE-0289. En effet, ResultBuilder permet de créer une nouvelle valeur à la base d’une séquence entré en paramètre. Dans notre cas, nous voulons retourner un NSAttributedString qui constitue la combinaison de plusieurs NSAttributedString (un nombre qu’on maitrise pas). Pour ce faire, on peut créer un nouveau type qu’on appellera par exemple

Comment peut-on utiliser en pratique un ResultBuilder pour créer un DSL simplifiant l’écriture des contraintes (NSAttributedConstraint)

L’utilité de notre EDSL sera d’écrire d’une manière déclarative très proche de SwiftUI une fonction permettant de mettre en forme une liste de chaine de caractères.

On veut par exemple afficher ce texte:

On fera un appel à une fonction qu’on peut appeler welcomeBuilder avec ses paramètres :

welcomeBuilder(name: "Walid",titles: ["Tunisian", "iOS Developer", "work at Sephora"])

Ce qui nous intéresse comment on veut écrire cette fonction de la manière la plus déclarative possible. Imaginons par exemple que notre code à la fin sera écrit de cette manière 🙂 :

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !titles.isEmpty && !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            SpecialCharacters.comma
            SpecialCharacters.lineBreak
            Text(title)
                .font(.systemFont(ofSize: 20))
                .color(.black)
        }
    } else {
        Text("No Title")
    }
}

Vous remarquez surement qu’on est très proche de SwiftUI. Si par exemple, nous écrivons le code d’une manière habituel, on aura un code très proche de celui la:

func welcome(name: String, titles: [String]) -> NSAttributedString {
    let message = NSMutableAttributedString()
    if !titles.isEmpty && !name.isEmpty {
        let attributes = [NSAttributedString.Key.backgroundColor :
                            UIColor.red]
        message.append(NSAttributedString(string: "Hello "))
        message.append(NSAttributedString(string: name, attributes:
                                            attributes))
        for title in titles {
            message.append(NSAttributedString(string: ", "))
            message.append(NSAttributedString(string: "\n"))
            let attributes2 = [
                NSAttributedString.Key.font : UIFont.systemFont(ofSize: 20),
                NSAttributedString.Key.backgroundColor : UIColor.black
            ]
            message.append(NSAttributedString(string: title, attributes: attributes2))
        }
        
        return message
    } else {
        message.append(NSAttributedString(string: "No Title"))
        return message
    }
}

Vous remarquez surement la différence entre les deux méthodes et le nombre de lignes de code qu’on peut gagner.

Pour réaliser la création du DSL relatif à la première méthode, on peut utiliser un nouveau type de fonctions qui sont les result Builder.

@resultBuilder
struct AttributedStringBuilder { ... }

On ajoute l’annotation @resultBuilder avant le nom du nouveau type (auparavant on utilisait @_functionBuilder).

En effet, l’ajout de l’annotation nous ramène à implémenter obligatoirement une méthode static buildBlock

static buildBlock

Cette méthode permet de construire le résultat final produit par le nouveau type AttributedStringBuilder qui sera normalement de type NSAttributedString. En effet, la méthode prendra en paramètre un nombre inconnu de NSAttributedString. L’idée ensuite, est de nous permettre de passer une séquence de NSAttributedString à cette méthode afin de nous retourner le résultat finale attendu.

La méthode utilise un variadic paramètre pour gérer le nombre inconnu de paramètres qu’on peut envoyer à notre builder.

static func buildBlock(_ components: NSAttributedString...) -> NSAttributedString {
  let resultAttributedString = NSMutableAttributedString()
    for component in components {
        resultAttributedString.append(component)
    }
  return resultAttributedString
}

Vous remarquez que la logique faite par le buildBlock est très simple, on essayera de “append” à chaque fois un composant de type NSAttributedString à notre resultAttributedString.

On peut exploiter notre AttributedStringBuilder de cette manière. En effet, la méthode welcomeBuilder ajoute à chaque fois un NSAttributedString relatif à un texte. N’oubliez pas d’ajouter une nouvelle annotation relative à notre nouvelle type AttributedStringBuilder. Vous remarquez la magie derrière qui permet au compilateur de transférer nos Text(“Hello”) et Text(name) vers la méthode buildBlock afin de construire le résultat final.

typealias Text = NSMutableAttributedString
extension NSMutableAttributedString {
  convenience init(_ string: String) {
    self.init(string: string)
  }
}
@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name)
}

Imaginons maintenant qu’on veut appliquer une mise en forme spécifique à un Text (changement de couleur de text ou de font par exemple). On peut le faire simplement en ajoutant une petite extension sur NSAttributedString.

extension NSMutableAttributedString {
    .....
    func color(_ color: UIColor) -> NSMutableAttributedString {
        self.addAttribute(
            .backgroundColor,
            value: color,
            range: NSRange(location: 0, length: self.length)
        )
        return self
    }
}

Ce changement n’affectera pas notre EDSL et comment notre ResultBuilder transforme ces Text vers le NSAttributedString final. On écrira par exemple:

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name).color(.red)
}

Poussons notre ResultBuilder DSL

Imaginons maintenant qu’on veut afficher un message d’erreur à la place de Hello ‘name’. On est dans l’obligation d’ajouter une condition pareil à celle la:

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
    } else {
        Text("No Title")
    }
}

Cependant notre ResultBuilder aura des problèmes à interpréter ce code Swift, vu qu’il ne connait pas comment il doit interagir avec les conditions et quel résultat il doit retourner.

ResultBuilder control flow error

La solution est d’implémenter deux méthodes statics dans notre AttributedStringBuilder qui sont :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
   return component
}

static func buildEither(second component: NSAttributedString) -> NSAttributedString {
   return component
}

La première méthode permet de gérer la première condition de vérité en retournant le composant tel qu’il est. Concernant la deuxième méthode, elle permet de gérer le cas de else.

Imaginons que l’on veuille appliquer  une mise en forme sur les deux components texts lorsque la condition est vrai. On peut à ce moment écrire notre méthode buildEither(first component) de cette manière :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
    var resultAttributedString = NSMutableAttributedString()
    resultAttributedString = component as! NSMutableAttributedString
    resultAttributedString.addAttribute(
          .foregroundColor,
          value: UIColor.green,
          range: NSRange(location: 0, length: component.length)
     )
    return resultAttributedString
}

Vous constatez surement qu’au niveau de l’appel de ma fonction welcomeBuilder on envoie un tableau de String pour lequel on applique une mise en forme bien spécifique.

On écrit maintenant le code suivant :

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty && !titles.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            Text(title).font(.systemFont(ofSize: 20))
        }
    } else {
        Text("No Title")
    }
}

pareil à la structure conditionnelle, le ResultBuilder ne sait pas comment il va faire pour itérer sur une séquence de valeur genre un tableau de String. Alors on doit l’informer à travers une méthode static qui est le buildArray:

static func buildArray(_ components: [NSAttributedString]) -> NSAttributedString {
   let attributedString = NSMutableAttributedString()
   for component in components {
      attributedString.append(component)
   }
return attributedString
}

En effet, le résultat de l’itération sur le tableau titles est un NSAttributedString que l’on retourne ensuite à la méthode principal static buildBlock.

notre DSL commence à prendre forme sauf que le résultat final n’est pas assez lisible:

L’idée maintenant c’est d’ajouter des séparateurs mais bien sur en utilisant notre langage spécifique AttributedStringBuilder

if !name.isEmpty && !titles.isEmpty {
   Text("Hello ")
   SpecialCharacters.lineBreak
   Text(name).color(.red)
   for title in titles {
       SpecialCharacters.comma
       SpecialCharacters.lineBreak
       Text(title).font(.systemFont(ofSize: 20))
   }
} else {
   Text("No Title")
}

Pour ce faire, j’ai ajouté un nouveau type énumère appelé SpecialCharacters avec deux cases jusqu’à maintenant:

enum SpecialCharacters {
    case lineBreak
    case comma
}

Afin que mon nouveau type soit reconnu par mon DSL, je dois implémenter une nouvelle méthode qui est le buildExpression:

static func buildExpression(_ expression: SpecialCharacters) -> NSAttributedString {
  switch expression {
     case .lineBreak:
       return Text("\n")
     case .comma:
       return  Text(",")
     }
}

Vous remarquez que la méthode transforme une expression à base de SpecialCharacters vers son equivalent NSAttributedString ou en autre Text.

Mais on remarquera qu’on a des erreurs bizarres après l’ajout de la méthode buildExpression:

Le problème provient du fait que la méthode sera appelé systématiquement pour chaque component quel que soit Text ou SpecialCharacters alors pour résoudre ce problème on doit implémenter buidExpression pour gérer le cas spécifique de Text :

static func buildExpression(_ expression: NSAttributedString) -> NSAttributedString {
    return expression
}

La différence réside dans le paramètre d’entré qui sera de type NSAttributedString.

Conclusion

Nous constatons qu’un ResultBuilder constitue un outil assez poussé pour créer des DSL élégants. Le sujet peut être creusé avec des exemples différents notre AttributedStringBuilder. Vous pouvez retrouvez dans ce repo GitHub plusieurs idées de DSL à base de result builder.

Références:

https://www.hackingwithswift.com/swift/5.4/result-builders

https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/

https://theswiftdev.com/result-builders-in-swift/

https://www.raywenderlich.com/books/swift-apprentice/v7.0/chapters/20-result-builders

https://martinfowler.com/dsl.html