Ausarbeitung - Leibniz Universität Hannover

Werbung
Dynamische Sprachen auf der JVM
Christian Müller
Leibniz Universität Hannover
1 Einführung
Der Begriff “dynamische Sprache” ist nicht genau definiert, sehr oft wird er lediglich mit dynamischer Typprüfung gleichgesetzt. Diese Definition wäre allerdings zu spezifisch. Sie würde viele Eigenschaften die mit dynamischen Sprachen in Verbindung gebracht werden nicht miteinbeziehen. Wir betrachten deshalb eine Sprache als dynamisch, wenn sie Verhalten zur Laufzeit ausführen kann, das andere Sprachen, wenn überhaupt, nur während der Kompilierung ausführen können. Beispiele für dieses „Verhalten“ sind:
•
dynamische Typprüfung:
Es werden keine Variablentypen im Quelltext angegeben. Zur Laufzeit wird überprüft ob die benutzten Funktionen mit den vorliegenden Daten ausgeführt werden können.
•
eval­Funktion:
Die eval­Funktion interpretiert ein zur Laufzeit vorliegenden String als Quellcode und führt ihn aus.
•
Closures:
Funktionen die die Variablenbelegungen aus dem lexikalischen Kontext in dem sie definiert wurden konservieren.
•
Continuations:
Eine Continuation beschreibt den aktuellen Zustand während der Programmausführung und ermöglicht somit das Speichern dieses Zustandes und das spätere Fortsetzen von diesem Zustand ausgehend.
•
Mixins:
Mixins ermöglichen es einem Objekt zur Laufzeit zusätzliche Funktionalität zu geben.
•
Reflection:
Wenn ein Programm zur Laufzeit seine eigene Struktur analysieren und verändern kann spricht man von Reflection.
Es kommt aber nicht darauf an das in einer Sprache alle diese Eigenschaften vorhanden sind um sie als dynamisch zu bezeichnen.
Der Vorteil den diese Eigenschaften einer Sprache bringen ist eine Erleichterung bei der Programmierung. Benutzt man beispielsweise eine Sprache ohne eval­
Funktion, kann auf diese aber nicht verzichten, dann muss man sehr wahrscheinlich selber einen einfachen Interpreter für diese Sprache implementieren, den man dann als eval­Funktion benutzen kann.
Da die Java VM von diesen Eigenschaften nur Reflection direkt unterstützt stellt sich die Frage, warum man mit einem Compiler für eine dynamische Sprache die Java VM als Zielplattform wählen sollte. Ein Hauptgrund ist sicherlich die weite Verbreitung von Java VM's und die Tatsache das für praktisch jedes Betriebssystem eine Java VM verfügbar ist, und damit eine hohe Zielgruppe. Oder auch die große Anzahl von Bibliotheken die für Java vorhanden sind und die über die Java VM direkt genutzt werden können.
Ursprünglich wurde die Java VM nur für Java ausgelegt, aber auch Sun hat erkannt das dynamische Sprachen immer beliebter werden und arbeitet deswegen an einer neuen Spezifikation für Java VM's, die es unter anderem erleichtern soll dynamische Sprachen umzusetzen. Es ist zum Beispiel ein neuer Bytecode­Befehl in Planung der von Java gar nicht benutzt werden soll.
2 Dynamische Typprüfung
Um Typsicherheit zu gewährleisten muss der Compiler, beziehungsweise der Interpreter, eine Typprüfung durchführen.
Wird diese Typprüfung wie bei C oder Java während des Kompilierens vorgenommen spricht man von statischer Typprüfung. Dabei sind den Variablen im Quellcode vom Programmierer die jeweiligen Typen zugeordnet.
Von dynamischer Typprüfung spricht man, wenn sie während der Laufzeit durchgeführt wird, wie beispielsweise in Lisp oder Ruby. Dabei sind die Typen nicht den Variablen, sondern den Werten zugeordnet. Eine Variable kann also zu unterschiedlichen Zeitpunkten Werte von unterschiedlichem Typ enthalten.
Typprüfung auf der JVM
Die vom Java Compiler erzeugte class­Datei enthält in ihrem Konstantenpool zu jeder Variablen und Funktion einen sogenannten “Type Descriptor” der den Typ festlegt. Für Variablen ist dieser Type Descriptor ein String der nach folgender Tabelle kodiert ist. Für eine Integer Variable also “I”, und für ein zweidimensionales Feld von Floats “[[F”. Bei Klassen wird der volle Klassenname benutzt, der Type Descriptor für einen String ist also "Ljava/lang/String;".
byte
char
double
float
int
long
short
boolean
Klasse
Feld
B
C
D
F
I
J
S
Z
L<Klassenname>;
[
Bei Funktionen wird der Type Descriptor zusammengesetzt aus den Typen der Parameter und dem Rückgabetyp: zuerst die Typen der Parameter in runden Klammern, ohne Trennzeichen, gefolgt vom Rückgabetyp. Beispielsweise hätte eine Funktion die einen long, einen String und einen boolean als Parameter hat und einen int zurück gibt den Type Descriptor "(JLjava/lang/String;Z)I".
Funktionsaufrufe im Quelltext werden je nach Art mit den Bytecodes invokestatic, invokevirtual, invokeinterface oder invokespecial übersetzt. Bei der Ausführung dieser Bytecodes zur Laufzeit wird von der Java VM überprüft ob der Type Descriptor der Funktion mit den vorliegenden Typen der Argumente der Funktion übereinstimmt. Zum Beispiel invokestatic, der Befehl zum aufrufen von static Funktionen:
invokestatic Bytecode: 0xb8 = 184
• Format: invokestatic indexbyte1 indexbyte2
• Stack (vorher): … , [arg1, [arg2 …]]
• Stack (nachher): …
Aus indexbyte1 und indexbyte2 wird ein Index für den Konstantenpool der aktuellen Klasse konstruiert. An dieser Stelle muss sich eine symbolische Referenz für die aufzurufende Funktion befinden. Über diese Referenz erhält die Java VM unter anderem den Type Descriptor der Funktion und überprüft ob die Argumente auf dem Stack den richtigen Typ haben, in der richtigen Reihenfolge sind und ob es genug Argumente auf dem Stack gibt.
dynamische Typprüfung auf der JVM
Daraus ergeben sich zwei Probleme die gelöst werden müssen um dynamische Typprüfung auf der Java VM zu realisieren:
1.
Die Type Descriptoren für dynamische Variablen (beziehungsweise Funktionsparameter) müssen so gestaltet sein, das es möglich ist diesen Variablen einen Wert von beliebigem Typ zuzuweisen.
Das lässt sich einfach implementieren, da man die Klassenhierarchie von Java ausnutzen kann. Jede Java Klasse ist eine Unterklasse von Object, also gibt man dynamischen Variablen den Typ Object. Da die primitiven Datentypen, wie int oder float, außerhalb der Klassenhierarchie stehen, muss man sie in den entsprechenden Wrappern kapseln, wenn man sie einer dynamischen Variablen zuweisen will.
2.
Zur Laufzeit muss eine Typprüfung ausgeführt werden, um die Typsicherheit zu gewährleisten.
Es existieren zwei sehr ähnliche Bytecodes mit denen diese Typprüfung durchgeführt werden kann, checkcast und instanceof. Besser geeignet für unsere Bedürfnisse ist checkcast, da es den Stack nicht verändert und eine Fehlermeldung ausgibt wenn die beiden Typen nicht kompatibel sind. Bei instanceof wird in diesem Fall nur das Ergebnis, ein int mit dem Wert 0, auf den Stack geschrieben.
instanceof Bytecode: 0xc1 = 193
• Format: instanceof indexbyte1 indexbyte2
• Stack (vorher): … , objectref
•
Stack (nachher): … , result
checkcast Bytecode: 0xc0 = 192
• Format: checkcast indexbyte1 indexbyte2
• Stack (vorher): … , objectref
• Stack (nachher): … , objectref
Aus indexbyte1 und indexbyte2 wird ein Index für den Konstantenpool der aktuellen Klasse konstruiert. An dieser Stelle muss sich eine symbolische Referenz auf eine Klasse, ein Feld oder ein Interface befinden, dessen Typ wir T nennen. Den Typ von objectref nennen wir S. Dann bestimmt checkcast mit folgenden Regeln, ob objectref zu Typ T gecastet werden kann.
1.
2.
3.
Wenn S eine normale Klasse (kein Feld) ist, dann:
1. Wenn T eine Klasse ist, dann muss S dieselbe Klasse wie T sein, oder eine Unterklasse von T.
2. Wenn T ein Interface ist, dann muss S das Interface T implementieren.
Wenn S ein Interface ist, dann:
1. Wenn T eine Klasse ist, dann muss T Object sein.
2. Wenn T ein Interface ist, dann muss T dasselbe Interface wie S sein, oder ein Superinterface von S.
Wenn S ein Feld mit Komponenten des Typs SC ist, dann:
1. Wenn T eine Klasse ist, dann muss T Object sein.
2. Wenn T ein Feld mit Komponenten des Typs TC ist, dann muss eine der folgenden Aussagen stimmen:
1. TC und SC sind dieselben primitiven Typen.
2. TC und SC sind Referenzen und SC kann zu TC gecastet werden, durch rekursive Anwendung dieser Regeln.
3. Wenn T ein Interface ist, dann muss T eines der Interfaces sein, das von Array implementiert wird.
Wie man sieht kann man primitive Datentypen so nicht testen, entweder kapselt man sie in den entsprechenden Wrappern (Punkt 1.1), oder in einem Feld (Punkt 3.2.1).
3 Closures
Es gibt Programmiersprachen in denen ist es möglich Funktionen innerhalb anderer Funktionen zu definieren und auch Funktionen als Argumente zu übergeben. Dort ist es möglich das eine Funktion freie Variablen, aus dem lexikalischen Bereich in dem sie definiert wird, benutzt.
In diesem Beispiel wird innerhalb der Funktion f eine weitere Funktion g definiert. Allerdings ist die Variable x, die in g benutzt wird, nicht mehr sichtbar, wenn g in der Funktion doSomething aufgerufen wird. Es muss also nicht nur die Funktion übergeben werden, sondern auch eine Referenz zu allen freien Variablen, und diese Variablen müssen so lange existieren wie die Funktion g existiert. Die Variable x ist für g eine freie Variable. Funktionen die wie g freie Variablen benutzen werden Closure genannt.
Wie man in diesem Beispiel sieht kann man Closures für Information Hiding benutzen, denn die Variable x kann nur durch g verändert werden, da sie nur dort sichtbar ist. Außerdem können mehrere Funktionen dieselben freien Variablen benutzen und so privat kommunizieren.
Hier sieht man, wie mit Hilfe von Closures ein einfaches Objekt realisiert werden kann. Die Funktion createCounter setzt value auf den übergebenen Startwert und liefert eine Liste mit zwei Funktionen, die beide value als freie Variable benutzen. Die erste Funktion erhöht bei jedem Aufruf den Zähler, und die zweite Funktion setzt den Zähler zurück auf Null.
Closures auf der Java VM
Die Umsetzung von Closures erfordert, das Funktionen als Argumente übergeben werden können. Mit den normalen Funktionsdefinitionen die Java bereitstellt ist das nicht möglich, deshalb muss für jede Closure eine eigene Klasse erstellt werden, die das Interface Function implementiert. Diese Klasse enthält eine invoke­Funktion, in der die eigentliche Funktionalität enthalten ist. In dem lexikalischem Bereich in dem die Closure definiert wurde, können dann von dieser Klasse Objekte erstellt werden und als Argumente an andere Funktionen übergeben werden.
Außerdem müssen Closures Referenzen auf ihre freien Variablen beinhalten. Dies lässt sich mit Hilfe von Instanzvariablen realisieren. Für jede freie Variable hat die Closure eine Instanzvariable, der durch den Konstruktor eine Referenz auf das Objekt zugewiesen wird, das zur Definitionszeit der Closure an die freie Variable gebunden war.
Dadurch ist auch gewährleistet das die freien Variablen mindestens so lange existieren, wie die Closure existiert. Denn so lange das ursprüngliche Objekt von der Closure referenziert wird, wird es nicht von Javas Garbage Collector gelöscht.
Das erste Beispiel aus diesem Kapitel könnte vom Compiler wie folgender Quelltext umgesetzt werden:
4 Mixins
Um die Wiederverwendung von Quelltext zu unterstützen gibt es in den meisten Programmiersprachen eine Form der Vererbung. In vielen Fällen reicht ein einfaches Vererbungssystem aus, aber damit lassen sich nicht alle Probleme der Wiederverwendung lösen. Mixins bilden eine mögliche Erweiterung des Vererbungssystems um diese Unzulänglichkeiten zu beheben, ähnlich wie die Mehrfachvererbung.
Einfache Vererbung (single inheritance) ermöglicht es einer Klasse alle Variablen und Funktionen von einer Oberklasse zu erben, und eventuell zu überschreiben. Dabei kann jede Klasse nur eine direkte Oberklasse haben. Diese Form der Vererbung wird in Java für Klassen benutzt.
Bei Mehrfachvererbung (multiple inheritance) kann eine Klasse mehrere direkte Oberklassen haben. Dies hat allerdings einige unerwünschte Konsequenzen, allen voran das Diamantenproblem.
A
B
C
D
Bild 1: Das Diamantenproblem
Es tritt auf wenn eine Klasse D über zwei verschiedene Wege, hier B und C, von einer gemeinsamen Oberklasse A erbt. Wenn in D nun eine Funktion aufgerufen wird, die in A definiert und in B und C überschrieben wurde, kann nicht entschieden werden welche Implementierung benutzt werden soll. Eine Mehrfachvererbung ist in Java für Klassen nicht möglich, allerdings kann eine Klasse mehrere Interfaces implementieren. Das hilft aber leider nicht dabei Quelltext wieder zu verwenden, da ein Interface nur die Schnittstelle beschreibt, die Implementierung muss in jeder Klasse wiederholt werden.
Ein Mixin ist eine teilweise Klassendefinition die, wenn man sie auf eine Klasse anwendet, eine neue Unterklasse dieser Klasse liefert, die erweitert um die Variablen und Funktionen, samt Implementation, des Mixin ist. Vereinfacht ausgedrückt kann man sagen das sich ein Mixin wie ein Interface verhält, nur das zusätzlich die im Mixin vorliegenden Implementationen übernommen werden. Die Dynamik von Mixins ergibt sich aus der Tatsache das man sie auch zur Laufzeit auf einzelne Objekte anwenden kann.
Beispielsweise kann man mit Mixins sehr gut Persistenz in ein Programm einführen. Dazu erstellt man ein Mixin das eine Klasse in eine Datenbank schreibt. Dann kann man dieses Mixin auf alle Klassen, oder auch nur auf die entsprechenden Objekte zur Laufzeit, anwenden, die man speichern möchte. Dabei kann man auch erst zur Laufzeit die Entscheidung treffen welches Mixin, und damit welche Datenbank, man benutzen will.
Mixins auf der Java VM
Möchte man Mixins auf der JVM umsetzen muss man dafür sorgen das der generierte Bytecode richtig typisiert ist und das die Implementationen aus dem Mixin in die erzeugte Unterklasse übernommen werden. Für Mixins die auf Klassenebene verwendet werden, und somit während der Kompilierung berechnet werden können, ist das leicht umzusetzen. Man macht sich einfach die Ähnlichkeit zu Interfaces zunutze. Für jedes Mixin erzeugt der Compiler ein Interface. Klassen die ein Mixin benutzen sollen, implementieren dieses Interface. Dabei werden die Implementationen aus dem Mixin vom Compiler einfach in die neue Klasse übernommen.
A
A
B
C
Mixin1
Mixin2
B
C
<<interface>>
<<interface>>
Mixin1
Mixin1
Werden Mixins zur Laufzeit auf Objekte angewendet muss man dynamisch eine neue Unterklasse erstellen und laden, die die ursprüngliche Klasse des Objektes um das Mixin erweitert, und dann eine Kopie des ursprünglichen Objekts mit der neuen Unterklasse erstellen.
A
<<interface>>
Mixin1
a: A
Bild 2: zur Laufzeit, vor der Anwendung von Mixin1 auf a
A
<<interface>>
Mixin1
Amixin1
a: AMixin1
Bild 3: zur Laufzeit, nach der Anwendung von Mixin1 auf a
In Java werden sogenannte Classloader benutzt um zur Laufzeit dynamisch neue Klassen zu laden. Diese Classloader stehen auch dem Programmierer zur Verfügung und können modifiziert werden.
Um auf diesem Weg Mixins zu realisieren muss der Compiler aus der Definition eines Mixins zwei Teile erstellen, ein Interface, welches die Schnittstelle repräsentiert, und eine Abstrakte Klasse, welche die Implementation enthält. Zur Laufzeit kann dann ein modifizierter Classloader aus der ursprünglichen Klasse, dem Interface und der abstrakten Klasse die gewünschte Unterklasse erstellen.
Dieser beispielhafte Quellcode könnte vom Compiler mit Hilfe der Reflection API wie folgt übersetzt werden:
In der von uns implementierten Methode ModifiedClassLoader.loadClass müsste der Bytecode der ursprünglichen Klasse A geladen werden, und dann wie folgt verändert werden:
• der Name der Klasse muss zu A_Mixin geändert werden
• das vom Compiler erstellte Interface muss implementiert werden
• die Implementation aus der vom Compiler erstellten abstrakten Klasse muss übernommen werden
• die Funktion void copyVars(A a) muss erstellt werden, damit die Daten des ursprünglichen Objekts später übernommen werden können
6 Zusammenfassung
Sc
a
la
on
Jy
th
y
Jr
ub
ov
G
ro
C
lo
ju
r
Ja
va
e
y
In dieser Arbeit wurden ausgewählte Eigenschaften dynamischer Sprachen vorgestellt, die von der Java VM nicht unterstützt werden. Es wurde untersucht wie sich diese Eigenschaften auf der Java VM umsetzen lassen, ohne Veränderungen an der Java VM vorzunehmen. Es konnte gezeigt werden wie sich dynamische Typprüfung, Closures und Mixins realisieren lassen.
Die meisten dieser Eigenschaften wurden auch schon in verschiedenen Compilern umgesetzt. Die folgende Tabelle enthält einige Beispiele.
dynamische Typprüfung
­
x
x
x
x
­
eval Funktion
­
x
x
x
x
­
Mixins
Closures
Continuations
­
­
­
­
x
x
­
x
­
x
x
x
­
x
­
*
x
x
Reflection
x
x
x
x
x
x
* Scala unterstützt Mixins nur zur Compile­Zeit
Quellen
1. Bernard Paul Serpette, Manuel Serrano: Compiling Scheme to JVM bytecode: a performance study
2. Scott Malabarbara, Raju Pandey, Jeff Gragg, Earl Barr, J. Fritz Barnes: Runtime support for type­safe dynamic Java classes
3. Sheng Liang, Gilad Bracha: Dynamic Class Loading in the Java Virtual Machine
4. Vija Saraswat: Java is not type­safe
5. The JavaTM Virtual Machine Specification ­ Second Edition
http://java.sun.com/docs/books/jvms/second_edition/html/VMSpecTOC.doc.html
6. wikipedia.org
http://en.wikipedia.org/wiki/Dynamic_programming_language und weitere
7. R. Parchmann: Vorlesungsskript Compiler­Konstruktion II
8. Michel Schinz: Compiling Scala for the Java Virtual Machine 9. Olin Shivers: Supporting dynamic languages on the Java virtual machine
http://www.ccs.neu.edu/home/shivers/papers/javaScheme.html
Herunterladen