Sprachen für die JVM im Zusammenspiel mit Java

Seite 2: Benefits

Inhaltsverzeichnis

Zeitgemäße JVM-Sprachen bieten eine hohe Flexibilität. Oft ist beispielsweise eine viel feinere Modularisierung von Paketen und Klassen möglich, und eine Datei kann mehrere Klassen oder Interfaces enthalten. Auch eine Verschachtelung ist denkbar. In Java würde das unübersichtlichen Code erzeugen, da Klassen viel Boilerplate Code wie Konstruktoren oder Getter- beziehungsweise Setter-Methoden erfordern. Die alternativen JVM-Sprachen benötigen das nicht zwangsweise. Daher kann solch eine Strukturierung oftmals durchaus Sinn ergeben und für eine bessere Übersicht sorgen. Insbesondere beim Einarbeiten in neue Bibliotheken und APIs fällt es dann viel einfacher, wenn man das gesamte Beispiel trotz zahlreicher Klassen in eine Datei schreiben kann. Zusätzlich ermöglichen sogenannte Mixins, eine Art Mehrfachvererbung, ebenfalls eine höhere Flexibilität.

Dynamisch typisierte Sprachen stehen für eine hohe Flexibilität, da sie Verhalten ohne Rekompilierung oder Redeployment zur Laufzeit ändern können. Mittlerweile lassen sie sich mit dem JSR 292 (Supporting Dynamically Typed Languages on the Java Platform) aus Java 7 deutlich einfacher und performanter in die JVM integrieren. Damit können Entwickler Änderungen ohne aufwendige Releasezyklen durchführen. Durch den schon länger etablierten JSR 223 (Scripting for the Java Platform) können sie zudem nur einzelne Skripte von Java aus aufrufen und in Skript-Engines ausführen.

Meta-Programmierung ermöglicht es dynamischen Sprachen sogar, kompilierten Bytecode zu ändern und neue Funktionen hinzufügen – egal von welcher Sprache der Bytecode stammt. Im folgenden Beispiel erweitert Groovy die Java-Klasse Integer um die Methode getSquareRoot, die die Quadratwurzel berechnet. Dazu wird in einem Closure die delegate-Referenz übergeben, welche den Wert des Inputs enthält. Die Methode sqrt der Java-Klasse Math führt die Berechnung durch.

Integer.metaClass.getSquareRoot = { ->
Math.sqrt(delegate)
}

// 16 ist ein Java-Integer, der die Methode squareRoot eigentlich
// gar nicht besitzt, dennoch bietet Groovy nun diese Methode an.
assert 4 == 16.squareRoot

Bei aufwendigeren Beispielen gestaltet sich auch die Meta-Programmierung deutlich komplexer. Oftmals kann der Entwickler aber einfach nur auf die Funktionen vorhandener Bibliotheken zugreifen. Ein Beispiel aus der Praxis sind die dynamischen Finder-Methoden des Groovy-Frameworks Grails. Für jede Datenbank-Entität wird für jede Kombination von Attributen wie Name oder Wohnort dynamisch ein Finder erzeugt, der eine entsprechende Datenbankabfrage durchführt. Der Entwickler muss Code nicht selbst schreiben, sondern nur die dynamisch zur Laufzeit generierte Methode aufrufen. Im genannten Beispiel gäbe es neben vielen anderen die Methoden findByName, findAllByNameAndWohnort und findAllByNameOrWohnort.

Doch auch statisch typisierte Sprachen können Teile der dynamischen Meta-Programmierung simulieren, zu sehen an der impliziten Typumwandlung in Scala. Das folgende Beispiel wandelt einen String automatisch in eine Integer-Klasse um.

implicit def str2Int(str: String) = str.toInt

Dadurch ist beispielsweise die Berechnung 10 - "3" möglich und würde 7 als Ergebnis zurückgeben, obwohl Integer gar keine Methode zum Subtrahieren eines Strings anbietet. Ohne die implizite Typumwandlung würde daher eine Exception geschmissen werden.

Auch das Build-Management profitiert von den JVM-Sprachen. Ein Build automatisiert diverse Aufgaben, um lauffähige Software zu generieren, beispielsweise das Kompilieren von Quellcode, das Einbinden externer Bibliotheken oder die Ausführung von Tests. Gradle ist ein Groovy-Tool, das die Flexibilität des Entwicklers erhöht. Es kombiniert die Mächtigkeit von Ant mit der Einfachheit von Maven. Bereits bekannte Ant-Tasks oder Maven-Repositories lassen sich damit einfach integrieren und weiterverwenden. Dank Groovy ist das Erstellen eines Builds oder die Implementierung von eigenen Erweiterungen mit Gradle allerdings wesentlich flexibler als bei Ant oder Maven. Beispielsweise lassen sich einfach Java-nahe, aber dennoch betriebssystemunabhängige Skripte schreiben. Für zukünftige, flexible Builds sollten Entwickler Gradle daher als Alternative in Betracht ziehen.

Die alternativen JVM-Sprachen können in einigen Situationen die Komplexität deutlich reduzieren, indem sie dem Entwickler ein höheres Abstraktionslevel anbieten. Ein gutes Einsatzgebiet ist eine interne domänenspezifische Sprache (Domain Specific Languages, DSL). Das ist eine formale, speziell für ein bestimmtes Problemfeld implementierte Sprache. Im besten Fall kann den DSL-Code sogar eine Person schreiben, die gar kein Entwickler ist.

Im Gegensatz zu Java lassen sich mit den JVM-Sprachen dank Features wie Currying, impliziter Typumwandlung, automatischer Typinferenz oder dem Weglassen unnötiger Zeichen einfache und gut lesbare DSLs erstellen. Diese fühlen sich oftmals fast wie eine natürliche Sprache an. Beispielweise können Tester einfach Unit-Tests schreiben, auch wenn sie keine Programmierexperten sind. Statt dem Java-Ausdruck

assertTrue(map.containsKey('a'));

lässt sich beispielsweise folgender klarer, sprechender Scala-Code schreiben:

map should contain key 'a' 

Ein weiteres Einsatzgebiet, das grundsätzlich hohe Komplexität erfordert und aufgrund von Multi-Core-Prozessoren in Zukunft zunehmend wichtiger wird, ist die Nebenläufigkeit. Java verwendet hierzu die Thread API und das Konzept des "Shared Memory", bei dem mehrere Prozesse auf die gleichen Variablen zugreifen können. Die Konsequenz sind oft Deadlocks, Race Conditions oder andere Probleme, die mit Locks und Synchronisation zu lösen sind. Dank Mutex-Verfahren kann immer nur ein Prozess einen Lock zu einem Zeitpunkt besitzen. Die Komplexität ist allerdings enorm hoch, Debugging und Fehlersuche sind zeitaufwendig. Auch die Vereinfachungen der letzten Java-Versionen im java.util.concurrent-Paket lösen dieses Problem nicht, obwohl mittlerweile immerhin auf die Low-level-Thread-Konstrukte wait, notify und join verzichten werden kann.

Einige JVM-Sprachen setzen daher statt auf das "Shared Memory"- auf das "Share Nothing"-Verfahren und ein höheres Abstraktionslevel. Dabei werden funktionale Konzepte wie unveränderliche Objekte eingesetzt. Beispielsweise wurde Clojure von vornherein explizit für Multithreading-Umgebungen entworfen. Das äußert sich vor allem in einer expliziten Trennung von Identitäten und Werten, die in der objektorientierten Welt miteinander verknüpft werden. Alle Werte und Datenstrukturen sind in Clojure von Grund auf unveränderlich. Identitäten sind nur innerhalb von Transaktionen veränderbar.

Clojure baut zwar implizit auf Java-Threads auf, kommt aber dennoch ohne explizite Locks und Synchronisierung aus, um sichere Nebenläufigkeit ohne Race Conditions zu realisieren. Der folgende Codeschnipsel zeigt das durch die Nutzung von Refs und des dosync-Makros gemäß Software Transactional Memory (STM).

(def acc1 (ref (make-account 1000 "alice")))
(def acc2 (ref (make-account 1000 "bob")))

(defn transfer [from to amount]
(dosync (alter from withdraw amount) (alter to deposit amount)))

(defn run-thread-fn [f]
"Runs function f in a new thread"
(.start (new Thread f)))

(run-thread-fn #(transfer acc1 acc2 100))
(list acc1 acc2)

Die Funktion transfer wird entweder ganz oder gar nicht ausgeführt. Neben STM existieren in Clojure noch weitere Konzepte für Parallelverarbeitung wie Atome und Agenten. Ausführliche Erläuterungen zu den nebenläufigen Konzepten von Clojure finden sich hier.

Leider ist Clojure für Java-Entwickler durch seine funktionale Herkunft und eine Lisp-ähnliche Syntax relativ aufwendig zu erlernen. Andere Sprachen wie Groovy oder Scala mit objektorientiertem Hintergrund eignen sich oftmals besser. Diese bieten mit Aktoren ebenfalls implizit eine Alternative für die einfache Umsetzung von Nebenläufigkeit. Im Gegensatz zu Clojure existieren zwar veränderliche Zustände, Aktoren tauschen Informationen aber ausschließlich über unveränderliche Nachrichten aus. Zustände werden nur innerhalb der Aktor-Instanzen verwaltet. Locks oder Synchronisierung werden ebenfalls nicht benötigt. Dynamische Sprachen setzen gleichfalls auf dieses Konzept. Während Groovy Aktoren mit der GPars-Bibliothek durch einfach Annotationen realisiert, sind sie in Erlang sogar ein implizites Kernkonstrukt.

Auch Aktoren bieten ein paar Fallstricke, die Komplexität von Locks und Synchronisierung erreichen sie aber nicht. Der Vollständigkeit halber sei erwähnt, dass STM und Aktoren sich auch in Kombination mit Java-APIs verwenden lassen, allerdings mit deutlich mehr Boilerplate Code und dadurch höherer Komplexität.

Der Einsatz alternativer JVM-Sprachen kann für eine Konsolidierung der IT-Landschaft sorgen. So braucht man für Build-Management, Skripte und dynamische Datenbankabfragen nicht mehr spezifische Sprachen wie XML, Shell oder PL/SQL verwenden, denn diese Aufgaben lassen sich mit der Programmiersprache selbst umsetzen. Während es mit Java unmöglich ist, "alles aus einer Hand" zu realisieren, kann das mit Sprachen wie Groovy oder Scala durchaus eine Option sein.

Nun stellt sich die Frage, welche modernen Sprachen neben Java eingesetzt werden sollen. Hier sind neben den geschäftsspezifischen Anforderungen einige Kriterien zu beachten: Zu prüfen wären unter anderem der Reifegrad der Sprache, die Verfügbarkeit stabiler Tools und Bibliotheken sowie die Frage, ob die Sprache schon reif für den Unternehmenseinsatz ist.

Ebenso spielt ihre Verbreitung eine wichtige Rolle. Dafür sollte geprüft werden, ob für die jeweilige Sprache überhaupt Entwickler verfügbar sind beziehungsweise wie leicht sich die eigenen weiterbilden lassen. Ein weiterer Indikator für die Verbreitung ist eine große Community sowie ein ausreichendes Maß an Fachliteratur, Tutorials, Blogs, Artikeln und Konferenzvorträgen. Hinsichtlich des Reifegrades und der Verbreitung sind neben Portierungen etablierter Sprachen wie JRuby oder Jython insbesondere Groovy und Scala empfehlenswert, da auf diese die genannten Kriterien zutreffen.

Ein weiterer wichtiger Aspekt bei der Auswahl der Sprache ist die Frage nach den Vorkenntnissen der Entwickler in ähnlichen Sprachen. Werden beispielsweise funktionale Konzepte benötigt, wird ein Lisp-Entwickler mit Clojure keine Schwierigkeiten haben, während ein Java-Entwickler wohl eher auf Scala setzen wird.

Auch Java-Nähe ist ein wichtiger Faktor, um den Einstieg zu erleichtern. Der folgende Code zeigt schon an einem kleinen Beispiel, wie groß die Unterschiede sein können. Während Groovy nahezu gleiche und Scala ähnliche Syntax bieten, haben viele andere Sprachen wie JRuby oder Clojure eine etwas oder gar stark abweichende Syntax.

import java.util.*; // Java 
Date today = new Date(); // Java

today = new Date() // Groovy

import java.util._ // Scala
var today = new Date // Scala

java_import 'java.util.Date' # JRuby
today = Date.new # JRuby

(import '(java.util Date)) ; Clojure
(def today (new Date)) ; Clojure

Letztlich sollte die Sprache gut im Umgang mit Java sein und einen echten Mehrwert schaffen. Daher empfiehlt es sich, neben Java zumindest eine Sprache mit impliziter Unterstützung für funktionale Konzepte wie Clojure oder Scala und eine dynamisch typisierte Sprache wie Groovy, JRuby oder Jython für Features wie Meta-Programmierung in petto zu haben.