Ein kurzer Blick auf die Umsetzung von JVM-Sprachen – Am Beispiel Kotlin
Wie genau funktionieren JVM-Sprachen eigentlich? Ist es nicht so, dass ausschließlich die Sprache Java dazu vorgesehen war, auf der Java Virtual Machine lauffähig zu sein? In diesem Beitrag möchte ich am Beispiel der JVM-Sprache Kotlin aufzeigen, wie diese Sprache eine große Menge ausgefallener Features bereitstellen kann, die in Java selbst nicht verfügbar sind. Beispiele dazu sind angemessene Funktionstypen, Extension Functions oder auch Data Classes. Mehr dazu ist in meinem vorherigen Blogbeitrag zur Programmiersprache Kotlin zu lesen.
Die Java Virtual Machine (JVM)
Die schnelle und vereinfachte Erklärung könnte lauten: Die Java Virtual Machine wird von Computern verwendet, um Java Bytecode auszuführen.
Genauer betrachtet gibt es zu diesem Thema deutlich mehr zu erfahren, was ausführlich in der Oracle JVM Spezifikation nachgelesen werden kann. Die JVM ist eine Art abstrakter virtueller Computer, der auf verschiedensten Betriebssystemen lauffähig ist und die Sprache Java „plattformunabhängig“ macht. Der Grund dafür ist schlichtweg, dass die JVM eine Abstraktion zwischen ablaufendem Code und dem Betriebssystem schafft. So wie physische Computer, stellt auch die JVM eine Menge Instruktionen bereit, die von einem Programm genutzt werden können und durch die JVM zu maschinenspezifischen Instruktionen übersetzt werden.
Wie in der Oracle JVM Spezifikation beschrieben, kennt die Java Virtual Machine die Programmiersprache Java nicht. Vielmehr wird ein Binärformat in Form einer .class
-Datei beschrieben, in welchem JVM-Maschineninstruktionen, auch als „Java Bytecodes“ bezeichnet, gelistet werden müssen. Das ist ein sehr interessanter Punkt, der folgende Schlüsse zulässt:
- Die JVM ist nicht auf die Sprache Java beschränkt.
- Es müssen
class
-Dateien bereitstellt werden, die den sehr strikten Beschränkungen der Spezifikation folgen. Die Erzeugung dieser ist nicht spezifiziert. - Dadurch kann unabhängig von der Erzeugung, jeglicher Bytecode unterschiedlicher „Herkunftssprachen“ mit anderem auf der JVM interoperieren.
Generierung von class
-Dateien
Der Prozess, vom Entwickler geschriebenen Source-Code in class
-Dateien zu übersetzen, wird von Compilern übernommen. Ein Beispiel hierfür ist das Tool javac
, das durch Oracle selbst bereitgestellt wird. Es ist in der Lage .java
-Dateien in .class
-Dateien zu überführen.
Neben Java sind in den letzten Jahren viele andere JVM-Programmiersprachen entstanden, die beabsichtigen, eine alternative Abstraktion für uns Entwickler bereitzustellen, mit welcher Programme für die Java Virtual Machine erzeugbar sind. Eine dieser Sprachen ist Kotlin.
Ein paar Worte zu Kotlin
Kotlin ist in den letzten Monaten zu einer der bekanntesten JVM-Sprachen gereift, was unter anderem auf das große Interesse der Android-Community, allem voran aber auch den Einfluss Google’s zurückführbar ist. Es existieren sogar bereits Analysen, die vermuten lassen, dass Kotlin im Laufe des nächsten Kalenderjahres 2018, zumindest im Android-Bereich, noch massivere Zuwäche erfahren wird („Kotlin is about to change the whole Android ecosystem“).
Der Ruf nach Innovationen in der Java-Community ist vorhanden. Das hat Oracle offenbar erkannt. Um gegenüber alternativen Sprachen weiter konkurrenzfähig zu bleiben, wurde bereits ein schnellerer Release-Prozess initiiert.
Kotlin: Bytecode Erstellung
Wie in den offiziellen Kotlin FAQs beschrieben, erstellt der Kotlin-Compiler „Java compatible bytecode“. Dieser ist also in der Lage, all die ausgefallenen Kotlin-Features in JVM-kompatible Instruktionen zu übersetzen. Das Ergebnis kann mithilfe eines IntelliJ IDEA Werkzeugs sichtbar gemacht werden. Im Folgenden soll dies anhand einiger Beispiele gezeigt werden.
//File.kt fun foobar(){}
Diese simple Top-Level Funktion, definiert in einer Kotlin-Sourcedatei (Endung .kt
), kann mit dem oben erwähnten Tool in IntelliJ untersucht werden: „Tools → Kotlin → Show Kotlin Bytecode“ öffnet ein neues Fenster innerhalb der IDE, in dem eine Live-Vorschau des Java-Bytecodes gezeigt wird, der vom Compiler für die aktuell geöffnete Datei erzeugt wird.
public final class de/ndesign/kotlin/FileKt { // access flags 0x19 public final static foobar()V L0 LINENUMBER 3 L0 RETURN L1 MAXSTACK = 0 MAXLOCALS = 0 @Lkotlin/Metadata; // compiled from: File.kt }
Vermutlich sind die Wenigsten in der Lage, diese Datei auf Anhieb vollständig zu verstehen, weshalb man zusätzlich die Option „Decompile“ wählen kann. Diese versucht den Bytecode als Java-Sourcecode zu formulieren.
public final class FileKt { public static final void foobar() { } }
Wie man erkennen kann, wird eine Kotlin Top-Level Funktion zu einer final
Java-Klasse kompiliert, die eine statische Methode beinhaltet. Der Name der Klasse ergibt sich aus dem Namen der kt
-Datei. Nachfolgend ein etwas komplizierteres Beispiel:
class MyClass(val i: Int) fun MyClass.myExtension(value: String) = value.length
Gezeigt wird eine einfache Klasse names MyClass
mit einem Property des Typs Int
, sowie eine Extension Function, die auf Top-Level definiert wurde. Als erstes sollten wir uns ansehen, wie die Klasse kompiliert wird, was bereits relativ interessant ist, da ein Primary constructor
und das val
Keyword benutzt wurden.
public final class MyClass { private final int i; public final int getI() { return this.i; } public MyClass(int i) { this.i = i; } }
Wie man vermuten würde: Das Property wird zu einem final
Member der Klasse, das im Konstruktor zugewiesen wird. Eine einfache aber nützliche Vereinfachung seitens Kotlin. Die Extension Function, auf der anderen Seite, wird zu einer statischen Methode kompiliert, die ihren „Receiver“, hier MyClass
als Parameter erhält.
public final class FileKt { public static final int myExtension(@NotNull MyClass $receiver, @NotNull String value) { Intrinsics.checkParameterIsNotNull($receiver, "$receiver"); Intrinsics.checkParameterIsNotNull(value, "value"); return value.length(); } }
Solche Konstrukte sind zu Hauf in sogenannten Utility-Klassen, wie Java’s Collections
, auffindbar. Diese Methoden lassen sich auf leichte Weise durch Extension Functions ersetzen. Einer der Hauptanwendungsfälle dieses sehr mächtigen Features.
Eine weitere Sache, die man in diesem Beispiel erkennen kann, ist die Nutzung der Klasse Intrinsics
. Diese ist Teil der Kotlin Standardlibrary und wird in diesem Fall genutzt, um Parameter auf null
zu prüfen. Kotlin nämlich unterscheidet durch sein Typsystem zwischen jenen Typen, die null
sein dürfen, und solchen, die nicht „nullable“ sind. An dieser Stelle ist es interessant zu sehen, was passiert, wenn der Parameter der Extension Function zu value: String?
geändert wird. Außerdem muss dann der Zugriff auf length
mit dem sicheren Operator value?.length
stattfinden, da value
in diesem Fall null
sein könnte und NullpointerException
s durch die Sprache bereits zur Compilezeit gezielt abgefangen werden.
public final class FileKt { @Nullable public static final Integer myExtension(@NotNull MyClass $receiver, @Nullable String value) { Intrinsics.checkParameterIsNotNull($receiver, "$receiver"); return value != null? Integer.valueOf(value.length()) : null; } }
Die Prüfung des value
über Intrinsics
ist nach der Anpassung nicht mehr notwendig, da null
nun ein akzeptierter Wert für value
ist. Allerdings wird der Zugriff auf die length()
Methode jetzt von einem sicheren Prüfschritt umgeben, der das Ergebnis des Safe-Operators ?.length
darstellt. Das nachfolgende Beispiel ist etwas kniffliger und zugleich das mit den größten Abweichungen zwischen Java- und Kotlin-Code.
Ranges in Kotlin
fun loopWithRange(){ for(i in (5 downTo 1 step 2)){ print(i) } }
Ranges in Java
public static final void loopWithRange() { IntProgression var10000 = RangesKt.step(RangesKt.downTo(5, 1), 2); int i = var10000.getFirst(); //i: 5 int var1 = var10000.getLast(); //var1: 1 int var2 = var10000.getStep(); //var2: -2 if(var2 > 0) { if(i > var1) { return; } } else if(i < var1) { return; } while(true) { System.out.print(i); if(i == var1) { return; } i += var2; } }
Gezeigt wird hier die Benutztung von sogenannten „Ranges“, die von Kotlin durch den geschickten Einsatz von infix
Funktionen umgesetzt sind. Obwohl der generierte Javacode relativ verständlich ist, würde wohl niemand diese Umsetzung wählen, da eine simple for
-Schleife das Problem ohne großen Aufwand lösen könnte. Der Prozess, solche hier gezeigten Kotlin „Ranges“ zu kompilieren ist nicht optimal, was auch bereits erkannt wurde und als Issue bei JetBrains existiert.
Schlusswort
In den meisten Fällen ist es wohl nicht das Hauptgeschäft eines Softwareentwicklers zu verstehen, was ein Compiler im Hintergrund für ihn erledigt. Trotzdem ist es durchaus interessant zu erkennen, wie bestimmte Sprachkonstrukte in einer Programmiersprache wie Kotlin realisiert werden können. Auffällig dabei ist die Tatsache, dass der Kotlin-kompilierte Java-Bytecode sich teilweise nur sehr aufwendig als Java-Sourcecode ausdrücken lässt. Kotlin ist natürlich mehr als ein intelligenter Compiler. Die Sprache stellt viele Erweiterungen bereits erprobter und vielfach genutzter Java-Klassen wie List
oder String
bereit, was überwiegend über Extension Functions ermöglicht wird.
Es ist auch zu erwähnen, dass die teilweise komplizierteren Compileprozesse einer modernen JVM-Sprache wie Kotlin oder auch Scala sich auf die Perfomance des Bytecode-Erzeugens auswirken können. Um diese Zusammenhänge genauer zu untersuchen, verweise ich auf eine Präsentation von Dmitry Jemerov, Autor des Buches „Kotlin in Action“, der verschiedene Kotlin-Sprachkonstrukte dahingehend untersucht hat.
Abschließend kann man sagen, dass die gesamte Magie einer JVM-Sprache nachvollziehbar ist, wenn man sich klar macht, dass die JVM unabhängig von der Java-Programmiersprache arbeitet. Es ist wichtig, der JVM class-Dateien bereitzustellen, die kompatiblen Java-Bytecode enthalten, wobei verschiedene Sprachen und damit auch Compiler genutzt werden können.