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:

  1. Die JVM ist nicht auf die Sprache Java beschränkt.
  2. Es müssen class-Dateien bereitstellt werden, die den sehr strikten Beschränkungen der Spezifikation folgen. Die Erzeugung dieser ist nicht spezifiziert.
  3. 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 NullpointerExceptions 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.