11.6 Konsequenzen der Typlöschung: Typ-Token, Arrays und Brücken *
Die Typlöschung ist im Allgemeinen kein so großes Problem, doch in speziellen Situationen ist es lästig, dass der Typ nicht zur Laufzeit vorliegt.
11.6.1 Typ-Token
Wir haben zum Beispiel gesehen, dass, wenn eine Rakete mit der Typvariablen T deklariert wurde, dieses T nicht wirklich wie in einem Makro durch das Typargument ersetzt wird, sondern dass in der Regel nur einfach Object eingesetzt wird:
class Rocket<T> {
T newRocketContent() { return new T(); } // Compilerfehler
}
Aus new T() macht die Typlöschung also new Object(), und das ist nichts wert. Doch wie kann dennoch ein Typ erzeugt werden und der Typ T zur Laufzeit vorliegen?
Hier lässt sich ein Trick nutzen, nämlich ein Class-Objekt für den Typ einzusetzen.
Typargument | Class-Objekt repräsentiert Typargument |
---|---|
String | String.class |
Integer | Integer.class |
Dieses Class-Objekt, das nun den Typ repräsentiert, heißt Typ-Token (engl. type token). Es kommt uns natürlich entgegen, dass Class selbst als generischer Typ deklariert ist und zwei interessante Methoden ebenfalls »generifiziert« wurden.
[zB] Beispiel
Erfrage die Class-Objekte:
Class<String> clazz1 = String.class;
String newInstance = clazz1.getConstructor().newInstance();
Class<? extends String> clazz2 = newInstance.getClass();
System.out.println( clazz1.equals( clazz2 ) ); // true
Zunächst ist da die Methode getConstructor(), die über das Class-Objekt den parameterlosen Konstruktor heraussucht. Über das Constructor-Objekt erzeugt dann newInstance() ein neues Exemplar mit dem Typ, den das Class-Objekt repräsentiert:
Mit einem gegebenen Objekt lässt sich mit getClass() das zugehörige Class-Objekt zur Klasse erfragen:
class java.lang.Object
final native Class<?> getClass()
Liefert Class-Objekt.
[»] Hinweis
Die Rückgabe Class<?> bei getClass() ist unschön, insbesondere die allgemeine Wildcard. Sie verhindert, dass sich Folgendes schreiben lässt:
Class<String> clazz = "ARTE".getClass(); // Compilerfehler "Type mismatch"
Stattdessen muss es so heißen:
Class<? extends String> clazz = "ARTE".getClass();
Da Object nicht generisch deklariert ist, ist es kein Wunder, dass getClass() keine genaueren Angaben machen kann.
Lösungen mit dem Typ-Token
Um das Typ-Token einzusetzen, muss das Class-Objekt mit als Argument in einem Konstruktor oder einer Methode übergeben werden. So lässt sich etwa eine newInstance()-Methode nachbauen, die die geprüften Exceptions fängt und im Fehlerfall als RuntimeException meldet.
Gut zu sehen ist, wie sich der Typ des Class-Objekts auf die Rückgabe überträgt:
public static <T> T newInstance( Class<T> type ) {
try {
return type.getConstructor().newInstance();
}
catch ( ReflectiveOperationException e ) {
throw new RuntimeException( e );
}
}
Die ReflectiveOperationException ist die Oberklasse von ClassNotFoundException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException, NoSuchMethodException. Es ist praktisch, diesen Basistyp anzugeben, weil das Schreibarbeit spart – bei Reflection kann immer eine Menge schiefgehen, und es gibt unzählige geprüfte Ausnahmen.
11.6.2 Super-Type-Token
Mit einem Class-Objekt lässt sich gut ein Typ repräsentieren, allerdings gibt es ein Problem. Das Class-Objekt kann selbst keine generischen Typen darstellen:
Class-Objekt repräsentiert Typargument | |
---|---|
String | String.class |
Integer | Integer.class |
Rocket<String> | Rocket<String>.class Geht nicht! |
Der wirkliche Typ lässt sich nur mit viel Getrickse bestimmen und festhalten. Hier kommt die Reflection-API zum Einsatz, sodass nur kurz die Klasse und ein Beispiel vorgestellt werden sollen. Hier ist die Klasse:
public abstract class TypeRef<T> {
public final Type type;
protected TypeRef() {
ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
type = superclass.getActualTypeArguments()[0];
}
}
Und hier ist ein Beispiel, das eine anonyme Unterklasse erzeugt und so den Typ zugänglich macht:
TypeRef<Rocket<String>> ref1 = new TypeRef<>(){};
System.out.println( ref1.type ); // com.tutego.insel.generic.Rocket<java.lang.String>
TypeRef<Rocket<Byte>> ref2 = new TypeRef<>(){};
System.out.println( ref2.type ); // com.tutego.insel.generic.Rocket<java.lang.Byte>
Damit konnten wir das Typargument über java.lang.reflect.Type festhalten, und ref1 unterscheidet sich eindeutig von ref2.
Der Typ liegt jedoch nicht als Class-Objekt vor, und Operationen wie getConstructor(). newInstance() sind auf Type nicht möglich – die Schnittstelle deklariert überhaupt keine Methoden, sondern repräsentiert nur Typen.
11.6.3 Generics und Arrays
Die Typlöschung ist der Grund dafür, dass Arrays nicht so umgesetzt werden können, wie es sich der Entwickler denkt.[ 217 ](Bei Oracle (http://bugs.java.com/bugdatabase/view_bug.do?bug_id=4888066) ist dafür ein Bug gelistet. Suns Antwort auf die Bitte, ihn zu beheben, lautet lapidar: »Some day, perhaps, but not now.« ) Folgendes ergibt einen Compilerfehler:
class TwoBox<T> {
T[] array = new T[ 2 ]; // Cannot create a generic array of T
T[] getArray() { return array; }
}
Der Grund für diesen Fehler ist dann gut zu erkennen, wenn wir überlegen, zu welchem Programmcode die Typlöschung führen würde:
class TwoBox {
Object[] array = new Object[ 2 ]; // (1)
Object[] getArray() { return array; }
}
Der Aufrufer würde nun die TwoBox parametrisiert verwenden wollen:
TwoBox<String> twoStrings = new TwoBox<String>();
String[] stringArray = twoStrings.getArray();
Denken wir an dieser Stelle wieder an die Typlöschung und an das, was der Compiler generiert:
TwoBox twoStrings = new TwoBox();
String[] stringArray = (String[]) twoStrings.getArray(); // (2)
Jetzt ist es auffällig: Während (1) ein Object-Array der Länge 2 aufbaut und auch getArray() dies als Object-Array nach außen gibt, castet (2) dieses Object-Array auf ein String-Array. Das geht aber nicht, denn diese beiden Typen sind nicht typkompatibel. Zwar kann natürlich ein Object-Array Strings referenzieren, aber das Array selbst als Objekt ist eben ein Object[] und kein String[].
Reflection hilft
Die Java-API bietet über Reflection wieder eine Möglichkeit, Arrays eines Typs zu erzeugen:
T[] array = (T[]) Array.newInstance( clazz, 2 );
Allerdings muss der Class-Typ clazz bekannt sein und als zusätzlicher Parameter übergeben werden. Die Syntax T.class ergibt einen Compilerfehler, denn über die Typlöschung wäre das ja sowieso immer Object.class, was den gleichen Fehler wie vorher zur Folge hätte und kein Fortschritt wäre.
11.6.4 Brückenmethoden
Aus der Tatsache, dass mit Generics übersetzte Klassen auf einer JVM lauffähig sein müssen, die kein generisches Typsystem besitzt, folgen diverse Hacks, die der Compiler zur Erhaltung der heiligen Kompatibilität vornimmt. Er fügt neue Methoden ein, sogenannte Brückenmethoden, damit der Bytecode nach der Typlöschung auch von älteren Programmen genutzt werden kann.
Brückenmethode wegen Typvariablen in Parametern
Starten wir mit der Schnittstelle Comparable, die generisch deklariert wurde:
public interface Comparable<T> { public int compareTo( T o ); }
Die bekannte Klasse Integer implementiert zum Beispiel diese Schnittstelle und kann somit sagen, wie die Ordnung zu einem anderen Integer-Objekt ist:
public final class Integer extends Number implements Comparable<Integer> {
private final int value;
public Integer( int value ) { this.value = value; }
public int compareTo( Integer anotherInteger ) {
int thisVal = this.value;
int anotherVal = anotherInteger.value;
return ( thisVal < anotherVal ? -1 : (thisVal == anotherVal ? 0 : 1) );
}
...
}
Die Klasse Integer implementiert die Methode compareTo(…) mit dem Parametertyp Integer. Der Compiler wird also eine Methode mit der Signatur compareTo(Integer) erstellen. Doch damit beginnt ein Problem! Wir haben eine unbekannte Anzahl an Zeilen Quellcode, die sich auf eine Methode compareTo(Object) beziehen, denn vor Java 5 war die Signatur ja anders.
Damit es nicht zu Inkompatibilitäten kommt, setzt der Compiler einfach noch die Methode compareTo(Object) bei Integer dazu. Die Implementierung sieht so aus, dass sie einfach delegiert:
Brückenmethode wegen kovarianter Rückgabetypen
Wenn eine Methode überschrieben wird, so muss die Unterklasse die gleiche Signatur (also den gleichen Methodennamen und die gleiche Parameterliste) besitzen. Nehmen wir eine Klasse CloneableFont, die Font erweitert und die clone()-Methode aus Object überschreibt. Eine Klasse, die sich auch unter Java 1.4 übersetzen lässt, würde so aussehen:
public class CloneableFont extends Font implements Cloneable {
public CloneableFont( String name, int style, int size ) {
super( name, style, size );
}
@Override public Object clone() {
return new Font( getAttributes() );
}
}
Im Bytecode der Klasse CloneableFont sind somit ein Konstruktor und eine Methode vermerkt.
Dazu kurz ein Blick auf die Ausgabe des Dienstprogramms javap, das die Signaturen anzeigt:
$ javap com.tutego.insel.nongeneric.CloneableFont
Compiled from "CloneableFont.java"
public class com.tutego.insel.nongeneric.CloneableFont extends java.awt.Font{
public com.tutego.insel.nongeneric.CloneableFont(java.lang.String, int, int);
public java.lang.Object clone();
}
Nehmen wir nun an, eine zweite Klasse ist Nutzer von CloneableFont:
CloneableFont font = new CloneableFont( "Arial", Font.BOLD, 12 );
Object font2 = font.clone();
Beim Aufruf font.clone() prüft der Compiler, ob die Methode clone() in CloneableFont aufrufbar ist, und trägt dann die exakte Signatur mit Rückgabe – das ist der entscheidende Punkt – in den Bytecode ein. Die Anweisung font.clone() sieht im Bytecode von CloneableFontDemo.class etwa so aus (disassembliert mit javap):
invokevirtual #23;
#23 ist ein Verweis auf die clone()-Methode von CloneableFont, und invokevirtual ist der Bytecodebefehl zum Aufruf der Methode. Hinter der 23 steckt eine JVM-Methodenkennung, die von javap so ausgegeben wird:
com/tutego/insel/nongeneric/CloneableFont.clone:()Ljava/lang/Object;
Im Bytecode steht exakt ein Verweis auf die Methode clone() mit dem Rückgabetyp Object.
Seit Java 5 ist eine kleine Änderung beim Überschreiben hinzugekommen. Wenn eine Unterklasse eine Methode überschreibt, kann sie den Rückgabetyp auf einen Untertyp präzisieren – das nennt sich kovariantes Überschreiben. Also kann clone() statt Object jetzt Font zurückgeben.
Gleicher Rückgabetyp wie die überschriebene Methode | Kovarianter Rückgabetyp |
---|---|
com.tutego.insel.nongeneric.CloneableFont @Override public Object clone() { | com.tutego.insel.generic.CloneableFont @Override public Font clone() { |
Da der Rückgabetyp der überschriebenen Methode nun nicht mehr Object, sondern Font ist, ändert sich auch der Bytecode von CloneableFont. Die Datei CloneableFont.class ist ohne kovariante Rückgabe 593 Byte groß und mit kovarianter Rückgabe 739 Byte. (Warum dieser satte Unterschied? Dazu gleich mehr im folgenden Abschnitt.)
Stellen wir uns nun Folgendes vor: Die erste Version von CloneableFont wurde lange vor der Existenz von Generics implementiert und konnte kein kovariantes Überschreiben nutzen. Die Klasse CloneableFont ist unglaublich populär, und die Methode clone() – die Object liefert – wird von vielen Stellen aufgerufen. Im Bytecode der nutzenden Klassen gibt es also immer einen Bezug zu der Methode mit der JVM-Signatur:
com/tutego/insel/nongeneric/CloneableFont.clone:()Ljava/lang/Object;
Bei einem Refactoring geht der Autor der Klasse CloneableFont über seine Klasse und sieht, dass er bei clone() die kovariante Rückgabe nutzen kann. Er korrigiert die Methode und setzt statt Object den Typ Font ein. Er compiliert die Klasse und setzt sie wieder in die Öffentlichkeit.
Nun stellt sich die Frage, was mit den Nutzern ist, also Klassen wie CloneableFontDemo, die nicht neu übersetzt werden, denn sie suchen eine Methode mit dieser JVM-Signatur:
com/tutego/insel/nongeneric/CloneableFont.clone:()Ljava/lang/Object;
Da clone() in CloneableFont in der aktuellen Version nun Font zurückgibt, müsste die JVM einen Fehler auslösen, denn der Bytecode ist ja anders, und die gefragte Methode mit der Rückgabe Object ist nicht mehr da. Das wäre ein riesiges Problem, denn so würden durch die Änderung des Autors alle nutzenden Klassen illegal, und die Projekte mit diesen Klassen ließen sich alle nicht mehr übersetzen. Doch es gibt keinen Anlass zu Panik. Es kommt nicht zu einem Compilerfehler, da der Compiler eine Hilfsmethode einfügt, die in der JVM-Signatur mit der Java 1.4-Variante von clone(), also mit der Rückgabe Object, übereinstimmt. Der Disassembler javap zeigt das gut:
$ javap com.tutego.insel.generic.CloneableFont
Compiled from "CloneableFont.java"
public class com.tutego.insel.generic.CloneableFont extends java.awt.Font{
public com.tutego.insel.generic.CloneableFont(java.lang.String, int, int);
public java.awt.Font clone();
public java.lang.Object clone() throws java.lang.CloneNotSupportedException;
}
Die clone()-Methode gibt es also zweimal! Interessant ist die Besonderheit, dass Dinge im Bytecode erlaubt sind, die im Java-Programmcode nicht möglich sind. In Java gehört der Rückgabetyp nicht zur Signatur, und der Java-Compiler erlaubt nicht zwei Methoden mit der gleichen Signatur. Im Bytecode allerdings gehört der Rückgabetyp schon dazu, und daher sind die Methoden dort erlaubt, da sie klar unterscheidbar sind.
Übersetzt ein aktueller Compiler die Klasse CloneableFont mit dem kovarianten Rückgabetyp bei clone(), so setzt er automatisch die Brückenmethode ein. Das erklärt auch, warum der Bytecode der Klassen mit kovarianten Rückgabetypen größer ist. So finden auch die alten Klassen, die auf die ursprüngliche clone()-Methode mit der Rückgabe Object compiliert sind, diese Methode, und es gibt keinen Laufzeitfehler.
Als Letztes muss noch geklärt werden, was der Compiler eigentlich genau für eine Brückenmethode generiert. Das ist einfach, denn in die Brückenmethode setzt der Compiler eine Weiterleitung:
@Override public Font clone() {
return new Font( getAttributes() );
}
@Override public Object clone() {
return (Font) clone(); // Vom Compiler in Bytecode generiert
}
Bleibt festzuhalten, dass auf Ebene der JVM kovariante Rückgabetypen nicht möglich sind und im Bytecode immer die Methode inklusive Rückgabetyp referenziert wird.
[»] Hinweis
In Java sind alle Methoden einer Klasse, die sich auch im Bytecode befinden, über Reflection zugänglich. Lästig wäre es nun, wenn Tools Methoden sähen, die der Compiler eingeführt hat, weil dieser herumtricksen und Beschränkungen umschiffen musste. Die Brückenmethoden werden daher mit einem speziellen Flag markiert und als synthetische Methoden (engl. synthetic methods) gekennzeichnet. Das Flag lässt sich über Reflection mit isSynthetic() an den Field-Objekten erfragen.
Brückenmethode wegen einer Typvariablen in der Rückgabe
Werfen wir einen Blick auf ein ähnliches Szenario, bei dem der Rückgabetyp durch eine Typvariable einer generisch deklarierten Klasse bestimmt wird, und sehen wir uns an, welche Konsequenzen sich im Bytecode daraus ergeben.
Die Schnittstelle Iterator dient im Wesentlichen dazu, Datensammlungen nach ihren Daten zu fragen. Die beiden Spalten zeigen die Deklaration der Iterator-Schnittstelle unter Java 1.4 und seit Java 5:
Java 1.4 | Java 5 |
---|---|
public interface Iterator { | public interface Iterator<E> { |
So soll hasNext() immer true ergeben, wenn der Iterator weitere Daten liefern kann, und next() liefert schlussendlich das Datum selbst. In der Variante unter Java 5 ist der Rückgabetyp von next() durch die Typvariable bestimmt.
Warum Brückenmethoden benötigt werden, zeigt wieder ein Beispiel, in dem Entwickler mit Java 1.4 begannen und später ihr Programm mit Generics verfeinerten. Trotz der Änderung muss eine alte, mit Java 1.4 compilierte Version noch funktionieren.
Üblicherweise liefern Iteratoren alle Elemente einer Datenstruktur. Doch anstatt durch eine Datenstruktur zu laufen, soll unser Iterator, ein EndlessRandomIterator, unendlich viele Zufallszahlen liefern. Wir interessieren uns besonders für die Implementierung der Schnittstelle Iterator. Ohne Generics unter Java 1.4 sieht das so aus:
public class EndlessRandomIterator implements Iterator {
@Override public boolean hasNext() { return true; }
@Override public Object next() { return Double.valueOf( Math.random() ); }
@Override public void remove() { throw new UnsupportedOperationException(); }
}
Ein Programm, das den EndlessRandomIterator nutzt, empfängt bei next() nun ein Double. Aber durch den Ausschluss der Kovarianz in Java 1.4 kann durch die Deklaration von Object next() in Iterator in unserer Klasse EndlessRandomIterator auch nur Object next() stehen. Ein Nutzer von next() wird also auf jeden Fall die Methode next() mit dem Rückgabetyp Object erwarten und so im Bytecode vermerken – die Begründung hatten wir schon bei der Kovarianz im vorangehenden Abschnitt aufgeführt.
Passen wir den EndlessRandomIterator mit Generics an, ändern sich drei Dinge:
Erstens wird implements Iterator zu implements Iterator<Double>.
Dann wird Object next() zu Double next().
Drittens kann der Code im Rumpf von next() durch das Autoboxing etwas kürzer werden, nämlich return Math.random().
Der wichtige Punkt ist, dass sich der Rückgabetyp bei next() von Object in Double ändert. An der Stelle muss der Compiler mit einer Brückenmethode eingreifen, sodass im Bytecode wieder zwei next()-Methoden stehen: einmal Object next() und dann Double next(). Denn ohne Object next() würde der alte Programmcode, der Object next() erwartet, plötzlich nicht mehr laufen, und das wäre ein Bruch der Abwärtskompatibilität.