Sprache als Werkzeug: DSLs mit Kotlin bauen

Eigene Domain-specific Languages mit Kotlin zu erstellen, gehört zwar nicht zu den Standardaufgaben von Entwicklern. Die zunehmende Zahl an DSLs etwa für Gradle, Spring Beans oder Spring Cloud Contract beweist jedoch, dass sich die Sprache hervorragend dafür eignet.

Sprachen  –  5 Kommentare

(Bild: fuyu liu / shutterstock.com)

Kotlin erfreut sich seit einigen Jahren stetig wachsender Beliebtheit. Ein Erfolg, der sich nicht zuletzt auf die Einfachheit und Kompaktheit der Sprache zurückführen lässt. Die Möglichkeiten zum Erstellen eigener domänenspezifischer Sprachen (Domain-specific Languages – DSLs) mit Kotlin und deren zunehmende Verbreitung unterstreichen dies eindrucksvoll. Die wesentlichen Eigenschaften solcher DSLs und die in Kotlin bereitstehenden Sprachelemente hierfür beleuchtet dieser Beitrag im Detail.

Was sind DSLs und welche Vorteile bieten sie?

DSLs eignen sich dazu, komplexe Sachverhalte kompakter und lesbarer auszudrücken. Dabei steht die Beschränkung auf einen reduzierten Umfang – die Domäne – im Vordergrund. Während sich mit allgemeinen Programmiersprachen beliebige Programmabläufe erstellen lassen, sind DSLs nicht Turing-vollständig. Der deklarative Aspekt einer DSL überwiegt, während der imperative Anteil, also die Ausführung selbst, gekapselt ist. Dies erleichtert zum einen die Verständlichkeit für Domänen-Experten und reduziert zum anderen die Fehleranfälligkeit. Während man beispielsweise in SQL "deklariert", welche Ergebnisse eine Abfrage liefern soll, obliegt die optimale Ausführung dem Datenbanksystem selbst.

Grundsätzlich ist zwischen externen und internen DSLs zu unterscheiden. Externe DSLs wie SQL oder reguläre Ausdrücke stellen eigenständige Sprachen dar und benötigen daher einen zusätzlichen Parser. Interne DSLs hingegen integrieren sich in die sie umgebende allgemeine Programmiersprache. Während externe DSLs aufwendiger zu erstellen sind, lassen sie sich flexibler dem Anwendungsbereich anpassen. Reguläre Ausdrücke lassen sich letztlich sowohl in allen gängigen Programmiersprachen verwenden als auch auf der Kommandozeile mit entsprechenden Werkzeugen.

Externe DSLs sind zumeist in sich geschlossen, während interne DSLs es ermöglichen, Programm- mit DSL-Fragmenten zu kombinieren. Darüber hinaus benötigen interne DSLs keinen eigenständigen Parser und die IDE-Unterstützung, zum Beispiel durch Autovervollständigung, ist sozusagen ein Abfallprodukt, wenn die Host-Sprache bereits unterstützt wird. Interne DSLs finden sich im Java-Umfeld zum Beispiel im Bereich testgetriebener Entwicklung, wo Mock- und Assertion-Frameworks wie Mockito und AssertJ sogenannte "fluent" APIs (sprechende Schnittstellen) anbieten, um die teils komplizierte Erzeugung der Mocks und Validierungslogik zu kapseln.

Kotlin-Sprachelemente für die DSL-Erstellung

Die Programmiersprache Kotlin zeichnet sich insbesondere gegenüber Java durch einige Sprachkonstrukte aus, die das Erstellen interner DSLs vereinfachen oder überhaupt erst ermöglichen. Eine wesentliche Rolle dabei spielen Funktionsausdrücke in Form von Lambdas. Diese lassen sich zum Beispiel als Parameter ohne zusätzliche runde Klammern übergeben, sofern die aufgerufene Funktion maximal einen solchen Funktionsparameter erwartet. Außerdem bedarf es keiner obligatorischen Deklaration des Lambda-Parameters. Liegt maximal ein Parameter vor, erhält er standardmäßig die Bezeichnung "it". Damit lassen sich geschachtelte Funktionsaufrufe, wie sie bei Builder-artigen DSLs üblich sind, mit geringem Overhead ausdrücken.

Listing 1: HTML-Builder-DSL (Zeilen 4 bis 20)

package de.digitalfrontiers.html

fun main() {
val myHtml = html {
table {
// table header
th {
td { /* .. */ }
td { /* .. */ }
}

// dynamic table content
for (i in 1..10) {
tr {
td { /* .. */ }
td { /* .. */ }
}
}
}
}
}

Neben den rein parametrisierbaren Lambdas, die in allen funktionalen Programmiersprachen vorliegen, bietet Kotlin zusätzlich die Möglichkeit, den Empfänger des Lambda-Ausdrucks explizit festzulegen. Diese Form trägt daher häufig die Bezeichnung "Lambda with Receiver". Dabei wird dem Funktionsausdruck eine spezielle Referenz auf das aktuelle Objekt this mitgegeben. Da this in allen gängigen Programmiersprachen bei Methodenaufrufen oder Variablenzugriffen auf das umgebende Objekt impliziert wird, erleichtert man dem Anwender einer DSL den direkten Zugriff auf Funktionen in speziellen Kontexten, kann diesen aber gleichzeitig darauf beschränken.

Das obige Beispiel der HTML-Builder-DSL nutzt solche Empfänger, um die jeweils gültigen Funktionsaufrufe – also den Scope – einzuschränken. Schließlich ergibt der Aufruf einer tr-Funktion außerhalb einer table wenig Sinn. Im Beispiel zu den Lambda Types sind die Deklarationen (Zeilen 7 und 12) und Aufrufe (Zeilen 9, 18-20 und 14, 22-24) der beiden Varianten von Lambda-Ausdrücken nochmals gegenübergestellt, wobei sich diese auch kombinieren lassen. Das heißt, ein Empfängerobjekt schließt die Verwendung weiterer Lambda-Parameter nicht aus.

Listing 2: Lambda Types

package de.digitalfrontiers.lambda

class Context {
fun doSomething() { /* .. */ }
}

fun <T> doWithContext(action: (Context) -> T): T {
val ctx = Context()
return action(ctx)
}

fun <T> doWithContextAsReceiver(action: Context.() -> T): T {
val ctx = Context()
return ctx.action()
}

fun main() {
doWithContext {
it.doSomething()
}

doWithContextAsReceiver {
doSomething()
}
}