Eine DSL ist eine anwendungsspezifische Sprache, die konkret auf ein bestimmtes Anwendungsgebiet und dessen typisches Vokabular zugeschnitten ist.
Die Gründe eine solche anwendungsspezifische Sprache zu definieren sind, unter anderen, folgende:
Der klassische Weg eine DSL zu implementieren ist der, eine Compiler für diese DLS zu implementieren der dann, entweder ein Modul generiert was in ein anderes Programm eingebunden werden kann, um dann dort, mittels der dort verwendeten Programmiersprache, genutzt zu werden.
Wenn man sich den Aufwand sparen möchte einen vollständigen Compiler zu schreiben besteht die Möglichkeit einen “Transpiler” zu schreiben. Dieser generiert auf der Basis einer DSL-Sprachbeschreibung Programmcode in der gewünschten Zielsprache, z.B Java oder Swift.
Diese transpilierte Modul kann dann direkt von der Host-Programmiersprache der Anwendung angesprochen werden.
Bei den beiden zuvor aufgeführten Lösungsansätzen ist aber ein gewisser, nicht zu unterschätzender Aufwand, einzuplanen.
Als dritte Möglichkeit, anstatt der beiden zuvor aufgeführten ( Compiler und Transpiler), bietet sich in Swift seit der Version 5.4 bzw. 5.5 die Möglichkeit das Konstrukt der sogenannten ResultBuilder ( https://docs.swift.org/swift-book/LanguageGuide/AdvancedOperators.html#ID630)zu verwenden.
In der dazugehörigen Dokumentation zu ResultBuildern , auf swift.org, findet man folgende Aussage:
“A result builder is a type you define that adds syntax for creating nested data, like a list or tree, in a natural, declarative way. The code that uses the result builder can include ordinary Swift syntax, like if and for, to handle conditional or repeated pieces of data.”
ResultBuilder ist also ein Typ oder Konstrukt der es ermöglicht verschachtelte, komplexe Datentypen, wie z.B. Listen oder Bäume, auf eine deklarative Art und Weise zu erstellen – und zwar unter der Verwendung der Sprache Swift.
Der Code, welcher diesen ResultBuilder benutzt, kann normale Swift-Syntax verwenden, inklusive der Konstrukte wie z.B. “if-then-else” und “for”-Schleifen.
Die Anwendungsfälle für eine DSL sind sicherlich sehr vielfältig und nahezu grenzenlos. Prinzipiell machen sie dort Sinn, wo man komplexe Datenstrukturen auf eine einfache und verständliche Art und Weise erstellen will.
Eine komplexe Datenstruktur kann ein Dokument sein, in HTML oder im Word-Format. Strukturierte Datensätze, hierarchische Strukturen, wie eben z.B. HTML-Dokumente oder andere Konstrukte dieser Art.
Eine DSL sollte in solch einem Anwendungsfall dahin ausgerichtet sein folgende Ziele zu erreichen:
Als praktisches Beispiel für eine DSL implementiere ich das Beispiel einer rudimentären Burger-DSL. Es ist eine Domain Specific Language um Hamburger “zusammenzubauen”.
Zuerst definiere ich die prinzipielle Struktur eines Burgers, dieser besteht generell aus folgenden Komponenten, hierbei ist auch die Reihenfolge wichtig:
BURGER {
TOPBUN
TOPPING(1..n)
PADDY
SPREAD
BOTTOMBUN
}
Dabei hat ein Burger zwei halbe Brötchenteile, die auch aus verschiedenen Getreidearten bestehen können. Nach dem oberen Brötchen gibt es 1 bis N verschiedene Topping, wie zum Beispiel Tomaten, Gurken, Zwiebeln und Käse.
Danach kommt ein Paddy und dann ein Aufstrich, wobei hier auch EINER aus verschiedenen Sorten gewählt werden kann ( Ketchup, Senf, Mayonnaise etc.).
Die konkrete Anwendung der Burger-DSL um einen Burger zusammenzubauen könnten wie folgt aussehen:
let burger = makeBurger {
addWheatBun()
addToppings {
addBacon()
addPickles()
addOnions()
}
addBeefPaddy()
addKetchup()
addRyeBun()
}
Beim genauen Hinsehen erkennt man, dass ein Burger letztendlich auch eine Hierarchie von untergeordneten Zutaten bzw. Elementen darstellt.
Die Reihenfolge der Elemente ist dabei relevant, wie man sehen kann. Eine beliebige Anzahl von Toppings, es können auch KEINE Toppings auf dem Burger sein.
Eventuell wäre es sinnvoll mehrere, auch verschieden Paddy’s zu erlauben, ebenso mit dem Aufstrich – dem Spread.
Die Struktur eines Burger-Objektes könnte wie folgt aussehen, bezogen auf das zuvor aufgeführte Beispiel:
struct Burger {
var topBun: TopBun
var toppings: [Topping]
var paddy: Paddy
var spread: Spread?
var bottomBun: BottomBun
}
Ein Burger besteht aus zwei komplexen Strukturen. Zum einen ist das der Burger selbst und dann sind das noch die Toppings, welches ein Array ist – und damit auch eine komplexe Struktur.
Prinzipiell gibt es zwei Möglichkeiten diesen Entwurf mit ResultBuildern zu implementieren:
Welche Möglichkeit man letztendlich wählt hat her etwas mit persönlichem Geschmack zu tun. Ich habe mich für die Bottom-Up-Methode entschieden. D.h. ich beginne den notwendigen Code für die Toppings zu implementieren um diese durch einen ResultBuilder erstellen zu lassen.
Für einen Burger können aktuell vier verschiedene Arten von Toppings verarbeitet werden:
Als erstes wird ein “Marker”-Protokoll definiert, dass dann alle Toppings implementieren:
protocol Topping { }
Anschließend die Implementierungen des Protokolls:
struct Salad: Topping {}
struct Onions: Topping {}
struct Pickles: Topping {}
struct Bacon: Topping {}
Die gewünschte Clojure-basierende Syntax für das hinzufügen von Toppings ist wie folgt:
addToppings {
addBacon()
addPickles()
addOnions()
addSalad()
}
Es wird also ein ResultBuilder benötigt der Topping als einen variadischen Parameter entgegen nimmt und daraus ein Array von Toppings generiert:
@resultBuilder
enum ToppingBuilder {
static func buildBlock(_ toppings: Topping...) -> [Topping] {
print("ToppingBuilder was called") // Debug code
let t = toppings.map{$0}
return t
}
}
Der ResultBuilder ist ein Enum definiert, da man aus einem Enum, dass keine Cases enthält, keine Instanz erzeugen kann.
Abschließend noch die Implementierung einer Funktion welche diesen ToppingBuilder-ResultBuilder verwendet:
func addToppings( @ToppingBuilder _ toppings: () -> [Topping]) -> [Topping] {
let t = toppings()
return t
}
Durch die Annotation @ToppingBuilder
weiß der Compiler, dass er beim Aufruf der Closure toppings()
den Code der Funktion buildBlock(…)
der Enumeration ToppingBuilder
aufrufen soll. Durch diese Ausgabe des print
– Statements kann man dies auch sehen, dass letztendlich im Zuge der Konstruktion des Topping-Arrays dieser Code ausgeführt wird.
let toppings = addToppings{
addBacon()
addSalad()
}
Bei der Untersuchung der Variablen toppings
im Debugger, ist zu erkennen, dass diese ein Array von Toppings ist und in diesem Fall aus genau zwei Elementen besteht:
Es gibt zwei Brötchentypen. Eine Brötchenhälfte für den oberen Deckel und eine andere Brötchenhälfte für den unteren Deckel.
Da ein Burger IMMER aus genau einem oberen und einem unteren Deckel besteht definieren wird ein Protokoll was generell Bun’s repräsentiert und dann zwei weitere Protokoll welche ein Top-Bun und ein Bottom-Bun repräsentieren.
protocol Bun { }
protocol TopBun: Bun {}
protocol BottomBun: Bun {}
struct WheatTopBun: TopBun {}
struct WheatBottomBun: BottomBun {}