Logo von Developer

Suche
preisvergleich_weiss

Recherche in 2.445.245 Produkten

Frank Scheffler 5

Sprache als Werkzeug: DSLs mit Kotlin bauen

Sprache als Werkzeug: DSLs mit Kotlin bauen

Bild: fuyu liu / shutterstock.com

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.

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()
}
}

Eine DSL zur testgetriebenen Entwicklung mit Gherkin

Die zuvor beschriebenen Konzepte sollen anhand einer DSL für Gherkin-basierte Tests veranschaulicht werden.

Gherkin, bekannt geworden durch das Testwerkzeug Cucumber, nutzt eine vorgegebene Form zur Strukturierung automatisierter Testfälle, besser bekannt als "Given, When, Then". Dabei beschreibt der Given-Teil die Testvorbereitung, der When-Teil die Testausführung und der Then-Teil die Validierung der Ergebnisse im Beispiel Calculator Test. Ohne Einsatz eines entsprechenden Frameworks oder einer DSL lässt sich diese Strukturierung zumeist lediglich als Vorgabe an Entwickler übergeben. Ob sie tatsächlich eingehalten wird, lässt sich kaum überprüfen, da sich die Struktur dem Compiler und somit auch der IDE nicht erschließt. Eine Kontrolle über die Ausführung ist daher ebenfalls nicht möglich.

Strukturierung automatisierter Testfälle mit Gherkin (Abb. 1).
Strukturierung automatisierter Testfälle mit Gherkin (Abb. 1).

Listing 3: Calculator Test (Zeilen 14 bis 24)

de.digitalfrontiers.gherkin

import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.jupiter.api.Test

class Calculator {

fun add(a: Int, b: Int) = a + b
}

class CalculatorTest {

@Test
fun `adds up numbers (classic)`() {
// given
val subject = Calculator()

// when
val result = subject.add(2, 3)

// then
assertThat(result).isEqualTo(5)
}

@Test
fun `adds up numbers (gherkin)`() {
given {
Calculator()
}.on {
add(2, 3)
}.then {
isEqualTo(5)
}
}
}

Eine entsprechende DSL kann helfen, die Struktur einzuhalten. Darüber hinaus kapselt sie die eigentliche Testausführung, wodurch sich Aspekte wie die mehrfache Testausführung zentral innerhalb der DSL umsetzen lassen. Schließlich lässt sich der zu erstellende Programmcode mit der DSL auf ein Minimum beschränken, da insbesondere temporäre Variablenzuweisungen entfallen können.

Der DSL-basierte Test zeigt die klare Strukturierung nach Gherkin auf, auch wenn hier anstatt when, einem reservierten Schlüsselwort in Kotlin, on verwendet wird. Der given-Block erwartet die Rückgabe des Testsubjekts, wodurch eine separate Variablenzuweisung entfallen kann. Da Kotlin den jeweils letzten Ausdruck eines Lambdas als Rückgabe wertet, kann hier sogar das explizite return entfallen. Der on-Block empfängt das Testsubjekt und führt die zu testende Operation durch, bevor er wiederum das Ergebnis zurückgibt. Der then-Block empfängt schließlich das Ergebnis gekapselt mittels des verwendeten Assertion-Frameworks, so dass hier assertThat() entfallen kann.

Listing 4: Calculator Test (Zeilen 26 bis 35)

package de.digitalfrontiers.gherkin

import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.jupiter.api.Test

class Calculator {

fun add(a: Int, b: Int) = a + b
}

class CalculatorTest {

@Test
fun `adds up numbers (classic)`() {
// given
val subject = Calculator()

// when
val result = subject.add(2, 3)

// then
assertThat(result).isEqualTo(5)
}

@Test
fun `adds up numbers (gherkin)`() {
given {
Calculator()
}.on {
add(2, 3)
}.then {
isEqualTo(5)
}
}
}

Die Umsetzung einer solchen DSL nutzt die erwähnten "Lambdas with Receiver", um Entwicklern die entsprechenden Empfänger-Referenzen für die jeweiligen Kontexte der given-, on- und then-Blöcke zur Verfügung zu stellen.

Den Einstieg im Beispiel Gherkin-DSL stellt dabei der Konstruktoraufruf der Klasse given dar, die hier bewusst mit einem Kleinbuchstaben beginnt. Sie speichert den ihr übergebenen Lambda-Ausdruck, der dafür verantwortlich ist, das generische Testsubjekt zu erzeugen. Die on-Methode ist ein Member der Klasse und steht somit nur infolge eines vorausgegangenen given-Aufrufs zur Verfügung. Sie erwartet einen "Lambda with Receiver"-Ausdruck, wobei der Empfänger in diesem Fall das Testsubjekt ist, sodass es sich direkt im Lambda aufrufen lässt.

Listing 5: Gherkin DSL

package de.digitalfrontiers.gherkin

import assertk.Assert
import assertk.assertThat

class given<S>(private val setup: () -> S) {

fun <R> on(test: S.() -> R): Result<R> =
Result { setup().test() }
}

class Result<R>(private val result: () -> R) {

fun then(assert: Assert<R>.() -> Unit) {
assertThat(result()).assert()
}
}

Der Rückgabewert wird durch eine weitere Result-Klasse repräsentiert. Deren Aufgabe ist es, das Ausführen der zuvor gespeicherten Funktionskette anzustoßen und schließlich in Zusammenspiel mit dem verwendeten Assertion-Framework die Validierung auszuführen. Hierbei wird das vom Framework bereitgestellte Assert-Objekt erneut als Empfänger an den then-Block übergeben.

Fazit

Kotlin bietet mit seiner kompakten Ausdrucksform für Lambdas die besten Voraussetzungen zum Erstellen interner, typsicherer DSLs. "Lambdas with Receiver" spielen dabei eine zentrale Rolle, da sie es erlauben, den Empfänger-Kontext bei der Verwendung der DSL zu kontrollieren.

In einer Fortsetzung dieses Artikels folgen Erläuterungen zu erweiterten Konzepten, die es ermöglichen, DSLs noch eleganter und besser lesbar zu gestalten.

Frank Scheffler
ist Mitbegründer des IT-Beratungsunternehmens Digital Frontiers und beschäftigt sich im Rahmen seiner Tätigkeit als Senior Solution Architect mit agilen Entwicklungsprozessen und dem Entwurf Cloud-nativer Anwendungsarchitekturen.

Quellen

Die Code-Beispiele stehen im GitHub-Repository des Autors zur Verfügung.

5 Kommentare

Themen: