Kotlin DSLs mit Extension-Functions

#Kotlin #Spring #TornadoFX #JavaFX

In Kotlin lässt sich Code mit Hilfe von domänenspezifischen Erweiterungen starkvereinfachen. Inzwischen gibt es zahlreiche solcher DSLs in Kotlin, z.B. für SQL, Gradle und Vaadin. In diesem Workshop beleuchten wir die Extension-Functions von Kotlin am Beispiel einer JavaFX-Anwendung und werden anschließend selbst eine kleine domänenspezifische Erweiterung konstruieren.

In diesem Workshop werden wir eine wirklich sehr simple Newsletter-Anmeldung mit JavaFX bauen (Abb. 1). In Java könnte der Code möglicherweise wie in (Listing 1) aussehen:

(Listing 1)
GridPane gridPane = new GridPane();
gridPane.setVgap(10.0);
gridPane.setHgap(10.0);
gridPane.setPadding(new Insets(10.0,10.0,10.0,10.0));

Label label = new Label(„Email“);
GridPane.setConstraints(label,1,1);

TextField textField = new TextField();
GridPane.setConstraints(textField,2,1);

Button button = new Button(„Subscribe“);
GridPane.setConstraints(button,1,2);
gridPane.getChildren().addAll(label,textField,button)

Oberfläche der Newsletter-Anmeldung. (Abb. 1)

Zuerst benötigen wir ein Layout, in unserem Fall eine GridPane, auf welcher wir ein Label, ein Textfeld und einen Button platzieren. Das Styling lässt sich mit JavaFX in CSS auslagern, aber wir starten erstmal klein.

An dieser Stelle kommt nun TornadoFX in Spiel, das unter anderem einige sehr brauchbare Erweiterungen mitbringt um JavaFX-Oberflächen zu konstruieren. Dabei macht es intensiv von einigen Kotlin-Features Gebrauch, die wir genauer durchleuchten wollen. Die selbe Oberfläche mit Tornado und Kotlin könnte in etwa so aussehen:

(Listing 2)
class NewsletterView : View() {
  override val root = gridpane {
    row {
        label(„Email“)
        textfield()
    }
    row {
        button(„Subscribe“)
    }
    vgap = 10.0
    hgap = 10.0
    padding = Insets(10.0, 10.0, 10.0, 10.0)
  }
}

Was hier wirklich ins Auge sticht ist, dass wir den Aufbau und die Struktur unserer Elemente auf den ersten Blick auch in der Struktur unseres Codes wiederfinden. Tornado bietet uns hier eine domänenspezifische Sprache (DSL) um unser UI zu konstruieren. Solche syntaktischen Muster dürften einigen Entwicklern bereits aus Groovy bekannt sein. Die Idee ist an sich nichts Neues. Der große Unterschied den Kotlin hier macht ist, dass diese DSL typsicher ist und anders als in Groovy der Compiler prüfen kann, ob zum Beispiel row { label() } syntaktisch korrekt ist. Wenn wir verstehen wollen wie das Ganze funktioniert, müssen wir zuerst wissen, was eine Extension-Function in Kotlin ist. Ein einfaches Beispiel finden wir in der Kotlin-Dokumentation:

(Listing 3)
To declare an extension function, we need to prefix its name with
a receiver type, i.e. the type being extended. The following
adds a swap function to MutableList<Int>: fun MutableList<Int>.

swap(index1: Int, index2: Int) {

Natürlich können wir rein technisch eine bestehende Klasse nicht wirklich erweitern. Was Kotlin uns hier bietet ist syntaktischer Zucker, denn eigentlich sind das nicht mehr als (meist) statische Hilfsmethoden, die (hier) eine MutableList als zusätzlichen Parameter erhalten. Wirklich angenehm daran ist, dass wir nun so tun können als wären wir in der Lage, auf einer MutableList die swap Methode zu benutzen. Tatsächlich ist es keine echte Methode von MutableList, denn dazu müsste das Classfile von Mutable List nachträglich geändert werden. Was stattdessen entsteht, ist ein neues Classfile mit einer statischen Methode, die eine List als ersten Parameter hat. Mittels des Tools javap erhalten wir Zugriff auf den Code:

(Listing 4)
javap MutableSwapKt.class
Compiled from „MutableSwap.kt“
public final class de.eiswind.tornado.MutableSwapKt {
  public static final void swap(
    java.util.List<java.lang.Integer>, int, int);
}

Es gibt also immer eine kleine Einschränkung: Wir können in der Extension-Function nur auf die öffentliche API der Klasse zugreifen, die wir erweitern. Doch wie wird daraus jetzt die strukturierte DSL mit der wir unser User-Interface so schön bauen konnten? Um das zu verstehen, werfen wir am besten mal einen Blick auf die erste Funktion, die wir aufrufen, nämlich root = gridpane { … Diese Funktion ist folgendermaßen definiert:

(Listing 5)
fun EventTarget.gridpane(op: (GridPane.() -> Unit)? = null) =
opcr(this, GridPane(), op)

Der Typ EventTarget taucht in der Vererbungshierarchie unserer Basisklasse View auf. Damit ist gridpane eine Extension, die wir in unserer eigenen Klasse ohne weiteres benutzen können. Auf die opcr Funktion gehen wir im späteren Verlauf dieses Artikels noch genauer ein. Der eigentlich spannende Teil kommt jetzt erst noch. Unsere Extension hat einen einzigen Parameter, der wie folgt spezifiziert ist:

(Listing 6)
gridpane(op: (GridPane.() -> Unit)? = null)

Das bedeutet, dass gridPane als Parameter eine parameterlose Funktion ()->Unit erwartet. Doch hier ist zusätzlich noch ein Extension-Typ angegeben:

(Listing 7)
GridPane.() -> Unit

Dies hat zur Folge, dass jede Funktion die wir hier übergeben, eine Extension-Function von GridPane wird. Wenn wir also folgendes schreiben:

(Listing 8)
gridpane {
    vgap = 10.0
}
Gridpane op:{}
node = GridPane()
op.invoke (node)
⬄„node.op“

Dann ist { vgap = 10.0 } eine Extension-Function von gridPane. Die GridPane selbst wird beim Aufruf der opcr Funktion erzeugt und ist gleichzeitig die Instanz, auf der unsere neue Extension aufgerufen wird. this innerhalb der Lambda ist also eine GridPane:

(Listing 9)
fun <T : Node> opcr(parent: EventTarget, node: T,
               op: (T.() -> Unit)? = null): T {
  parent.addChildIfPossible(node)
  op?.invoke(node)
  return node
}

Da vgap eine Property von GridPane ist, können wir hier typsicher einen Wert zuweisen. Auf der Classfile-Ebene entsteht eine Lambda, in der wir die GridPane als Parameter von invoke wiederfinden:

(Listing 10)
javap ‚NewsletterView$root$1.class‘
Compiled from „NewsletterKotlinApp.kt“
final class de.eiswind.tornado.NewsletterView$root$1 extends
kotlin.jvm.internal.Lambda implements
kotlin.jvm.functions.Function1<
javafx.scene.layout.GridPane, kotlin.Unit> {
  public static final de.eiswind.tornado.NewsletterView$root$1INSTANCE;
  public java.lang.Object invoke(java.lang.Object);
  public final void invoke(javafx.scene.layout.GridPane);
  de.eiswind.tornado.NewsletterView$root$1();
  static {};
}

Wie können wir das Prinzip nun für unsere eigenen Zwecke nutzen? Nehmen wir an, wir wollen folgende Syntax erlauben:

(Listing 11)
cat {
  times = 2
  meow()
}

Dazu benötigen wir eine Hilfsklasse, die wie folgt aussehen könnte:

(Listing 12)
class Cat{

  var times : Int = 1

  fun meow(){
    for(i in 1..times){
      println(„Meow“)
    }
  }
}

Der Trick ist nun eine Funktion cat zu schreiben, die als Parameter eine Extension für die Klasse Cat erwartet. So haben wir in der Extension Zugriff auf alle Funktionen von Cat:

(Listing 13)
fun cat(op: Cat.() -> Unit)

In dieser Funktion konstruieren wir eine Instanz von unserer Hilfsklasse und rufen die übergebene Funktion op auf:

(Listing 14)
fun cat(op: Cat.() -> Unit) {
  val cat = Cat()
  op.invoke(cat)
}

Dadurch wird die Variable times auf 2 gesetzt und meow ausgeführt. Zugegeben, dies ist ein sehr einfaches Beispiel, aber es lässt sehr gut erkennen, mit welch einfachen Konstrukten eine solche domänenspezifische Erweiterung gebaut werden kann. Unser erstes JavaFX-Beispiel nutzt TornadoFX, eine Library, die sehr intensiv Gebrauch von diesem Muster macht um eine sehr umfangreiche DSL für JavaFX bereitzustellen. Es gibt inzwischen zahlreiche solcher typsicheren DSLs in Kotlin z.B. für Vaadin, SQL, Gradle, etc. Wie wir gesehen haben, ist es kein Hexenwerk, selbst eine DSL zu erstellen.

 

Thomas Kratz ist unabhängiger Trainer und Softwareentwickler aus Hamburg. Er beschäftigt sich seit über 20 Jahren mit UI und Datenbankentwicklung und gibt sein Wissen in lebendigen Trainings- und Coachingeinsätzen an Entwicklerteams weiter. Seine Lieblingsthemen sind aktuell Kotlin, Vaadin Flow und jooq mit Postgres.

Carolyn Molski


Leave a Reply