AngelikaLanger.com - Java Generics - Type Erasure Pitfall (original) (raw)

Java Generics - Type Erasure Pitfall

Java Generics: Type Erasure Konsequenzen und Einschränkungen JavaSPEKTRUM, September 2007 Klaus Kreft & Angelika Langer Dies ist das Manuskript eines Artikels, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im JavaSPEKTRUM erschienen ist. Die übrigen Artikel dieser Serie sind ebenfalls verfügbar (click here).

In der letzten Ausgabe dieser Kolumne haben wir uns angesehen, wie mit Hilfe der Type Erasure Technik generische Typen in Java übersetzt werden und welche Repräsentation sie dann zur Laufzeit haben. Die Type Erasure dient in erster Linie der Kompatiblität von altem Code, der keine parametrisierten Typen verwendet, und neuen Code, der von den parametrisierten Typen in Java 5.0 Gebrauch macht. Diesmal wollen wir uns einige Konsequenzen ansehen, die sich aus der Type Erasure ergeben.

Fangen wir noch einmal mit einer kurzen Wiederholung der Type Erasure an. Als Type Erasure wird die Technik bezeichnet, mit der generische Typen in Java übersetzt werden. Die Regeln für die Übersetzung sind dabei:

class LinkedList implements List {
protected class Node {
A elt;
Node next = null;
Node (A elt) { this.elt = elt; }
}
public void add (A elt) { ... }
public A get(int i) { ... }
...
}
final class Test {
public static void main (String[ ] args) {
LinkedList ys = new LinkedList();
ys.add("zero"); ys.add("one");
String y = ys.get(0);
}
}

Dieser Source-Code wird vom Java Compiler zu Byte-Code übersetzt, in dem nur noch normale (d.h. nicht-generische bzw. nicht-parametrisierte) Typen vorkommen:

class LinkedList implements List {
protected class Node {
Object elt; Node next = null;
Node (Object elt) { this.elt = elt; }
}
public void add (Object elt) { ... }
public Object get(int i) { ... }
...
}
final class Test {
public static void main (String[ ] args) {
LinkedList ys = new LinkedList();
ys.add("zero"); ys.add("one");
String y = (String) ys.get(0);
}
}

Zur Laufzeit ist der generische Typ LinkedList ein ganz normaler Referenztyp, der nichts mehr von seiner Parametrisierung weiß. Dies gilt auch für ein Objekt eines parametrisierten Typs: die LinkedList weiß zur Laufzeit nicht, dass sie mit String parametrisiert wurde. Das heißt, sämtliche Typprüfungen auf Basis der Parametrisierung mit String erfolgen nur durch den Compiler zum Übersetzungszeitpunkt; zur Laufzeit ist keinerlei Typinformation über den Parametertyp mehr vorhanden.

Typisierung und Cast

Soweit wir das im letzten Artikel betrachtet haben, hat die Art und Weise, wie die generischen Typen in Java implementiert sind, nur Vorteile:

Anweisung statisch dynamisch
String stringRefToString = new String(); String String
Object objectRefToString = new String(); Object String
Object objectRefToObject = new Object(); Object Object

Die Tabelle enthält in der ersten Spalte drei Variablendefinitionen mit Konstruktion und Zuweisung von Objekten. Die zweite und dritte Spalte zeigen jeweils den statischen Typ der Variable bzw. den dynamischen Typ des Objekts aus der vorhergehenden Anweisung. Der statische Typ wird vom Compiler für Prüfung bei Zuweisungen sowie bei der Übergabe von Methodenparametern und Returnwerten verwendet. Daneben wird der statische Typ vom Compiler auch zur Overload Resolution verwendet. Der dynamische Typ hingegen wird zu Laufzeitprüfungen benutzt, etwa beim Cast oder bei der Benutzung des Operators instanceof. Typisch für Java ist, dass der dynamische Typ mindestens so exakt, wenn nicht exakter ist, als der statische Typ.

Wie sieht das Ganze nun bei parametrisierten Typen aus? Sehen wir uns einige Beispiel mit parametrisierten Typen an:

Anweisung statisch dynamisch
LinkedList refToInegerList = new LinkedList(); LinkedList LinkedList
LinkedList refToStringList = new LinkedList(); LinkedList LinkedList
Object objectRefToStringList = new LinkedList(); Object LinkedList

Auf Grund der Type Erasure sind statischer und dynamischer Typ nie gleich. Auch ist der dynamische Typ nicht mindestens so exakt wie der statische; das kann man an den ersten beiden Anweisungen sehen. Ganz offensichtlich werden einige Regeln des Java Typsystems bei der Benutzung parametrisierter Typen neu definiert. Schauen wir uns an, was es in der Praxis bedeuet.

LinkedList refToStringList = new LinkedList();
LinkedList refToIntegerList = new LinkedList();
refToStringList = refToIntegerList; // Zeile 3: inkompatibel – lässt sich nicht übersetzen

LinkedList refToStringList = new LinkedList();
Object objectRefToStringList = new LinkedList();
refToStringList = objectRefToStringList; // Zeile 6: inkompatibel – lässt sich nicht übersetzen

Der Compiler nutzt weiterhin den statischen Typ, um die Typverträglichkeit bei Zuweisungen zu prüfen. Damit ergibt sich, dass die Zeilen 3 und 6 nicht übersetzbar sind, da in beiden Fällen der Typ der Variable auf der rechten Seite der Zuweisung inkompatibel zum Typ auf der linken Seite ist. Soweit ist alles genauso wie bei nicht-parametrisierten Typen. Was ist, wenn man die Zeile 6 folgendermaßen ändert?

LinkedList refToStringList = new LinkedList();
Object objectRefToStringList = new LinkedList();
refToStringList = (LinkedList)objectRefToStringList; // Zeile 6: lässt sich nun übersetzen

Der zusätzliche Cast führt dazu, dass die Zeile sich nun kompilieren lässt und zur Laufzeit gibt es auch keine Probleme, da beide Seiten vom Typ LinkedList sind.

Was passiert aber, wenn die Object-Referenz in Zeile 5 gar nicht auf eine LinkedList sondern auf eine LinkedList verweist?

LinkedList refToStringList = new LinkedList();
Object objectRefToStringList = new LinkedList();
refToStringList = (LinkedList)objectRefToStringList; // Zeile 6: lässt sich immer noch übersetzen

Auch das lässt sich wegen des eingefügten Casts compilieren. Es entspricht auch unseren Erwartungen, denn wir haben den Cast ja explizit eingefügt, um den Compiler dazu zu bringen, die rechte Seite als LinkedList zu interpretieren. Der Code bringt aber auch beim Ablauf keinen Fehler - und das entspricht nicht ganz unseren Vorstellungen. Eigentlich hätten wir eine ClassCastException erwartet: auf der linken Seite der Zuweisung steht eine LinkedList und auf der rechten Seite eine LinkedList. Also sollte die Typprüfung auf Grund des Casts zur Laufzeit die Unverträglichkeit der Typen feststellen und per Exception melden. Genau das passiert aber nicht, weil die beteiligten Objekte nur im Source Code von unterschiedlichen Typen sind. Nach dem Compilieren und der Type Erasure steht zur Laufzeit auf beiden Seiten der Zuweisung eine LinkedList und deshalb gibt es keinen Grund, warum die dynamische Typprüfung des Casts scheitern sollte.

Dieser Effekt ist wirklich eine Überraschung, denn zumindest für parametrisierte Typen gilt nicht mehr, dass man zur Laufzeit den exakten Typ eines Objekts ermitteln kann. Das liegt an der Type Erasure: das Typargument eines parametrisierten Typs ist zur Laufzeit nicht mehr vorhanden. Sehr pointiert kann man es mit dem folgenden Vergleich illustrieren, der immer true liefert:

(new LinkedList()).getClass() == (new LinkedList()).getClass()

Kommen wir noch einmal auf das Beispiel von oben zurück. Was passiert eigentlich, nachdem sich die Zuweisung der LinkedList an die LinkedList wegen des Casts übersetzen lässt und die dynamische Prüfung zur Laufzeit auch nicht fehlschlägt? Macht es sich irgendwie bemerkbar, dass eine Variable vom Typ LinkedList auf ein Objekt vom Typ LinkedList verweist, oder macht das gar nichts? Stellen wir uns den weiteren Verlauf des Programms vor. Wahrscheinlich wird irgendwann auf refToStringList zugegriffen:

LinkedList refToStringList = new LinkedList();
Object objectRefToStringList = new LinkedList();
// ... objectRefToStringList mit Integer füllen ...
refToStringList = (LinkedList)objectRefToStringList;
// ...
String tmpString = refToStringList.get(0);

Wie wir von unserer Diskussion oben wissen, erzeugt der Compiler bei der Übersetzung per Type Erasure folgenden Code:

LinkedList refToStringList = new LinkedList();
Object objectRefToStringList = new LinkedList();
// ... objectRefToStringList mit Integer füllen ...
refToStringList = (LinkedList)objectRefToStringList;
// ...
String tmpString = (String)refToStringList.get(0);

Beim Zugriff auf die Liste von Integers über die Variable refToStringList geht dann der compiler-generierte Cast von Integer auf String schief, so dass es doch noch zu einem Fehler kommt. Ideal ist diese Situation natürlich nicht, weil man die eigentlich Fehlerursache (die inkompatible LinkedList Zuweisung) erst noch suchen muss – und das kann mühselig sein, weil das Fehlersymptom (die ClassCastException) vom der Fehlerursache (der fragwürdigen Zuweisung) sehr weit entfernt sein kann.

Allerdings ist man als Entwickler vorgewarnt, denn der Compiler meldet eine unchecked-Warnung, wenn der Zieltyp eines Casts ein parametrisierter Typ ist. Das heißt, auf den Cast nach LinkedList in der fragwürdigen Zuweisung in unserem Beispiel wird vom Compiler ausdrücklich hingewiesen, weil der Cast im Source-Code exakter aussieht, als er zur Laufzeit nach der Type Erasure tatsächlich ausgeführt wird. Man sollte solche unchecked-Warnungen also durchaus ernst nehmen.

Weitere Einschränkungen und Regeln

Wie gerade ausführlich diskutiert, kann man den folgenden Cast hinschreiben:

refToStringList = (LinkedList)objectRefToStringList;

obwohl die Prüfung zur Laufzeit den Typparameter String gar nicht berücksichtigt. Beim instanceof-Operator ist der Compiler strenger. Auf der rechten Seite des instanceof-Operators darf nur der Raw Type stehen, nicht aber ein parametrisierter Typ. Damit ist unmissverständlich klar, dass Typparameter bei der instanceof-Prüfung keine Rolle spielen:

if (o instanceof LinkedList) … // okay
if (o instanceof LinkedList) … // Fehler beim Kompilieren

instanceof und Cast unterliegen also verschiedenen Regeln. Das liegt daran, dass der Cast, je nach Situation, auch Compilezeit-Effekte hat, instanceof aber immer nur zur Laufzeit relevant ist. Ein Beispiel für die Compilezeit-Relevanz ist ein Cast von String nach Integer. Der Compiler weiss, dass ein Cast von String nach Integer (im Gegensatz zu einem Cast von Object nach Integer) niemals erfolgreich sein kann, und meldet einen Fehler.

Aus ähnlichen Gründen wie oben gibt es keine Class-Literale für parametrisierte Typen, sondern nur für den Raw Type:

Class<? extends LinkedList> c1 = java.util.LinkedList.class; // okay
Class<? extends LinkedList> c2 = java.util.LinkedList.class; // Fehler beim Kompilieren

Statische Felder und Methoden einer generischen Klasse

Wenn man sich erst einmal daran gewöhnt hat, dass es für alle Parametrisierungen sowie den Raw Type eines generischen Typs nur genau eine Klassenrepräsentation zur Laufzeit gibt, dann kann man sich vorstellen, dass static members (also statische Felder und Methoden) eines generischen Typs ebenfalls besonderen Regeln unterliegen.

Schauen wir uns dazu das Beispiel eines generischen Typs mit einem statischen Feld an:

public class MyClass {
public static int cnt = 0;

}

Im Folgenden nutzten wir nun zwei Parametrisierungen dieses generischen Typs:

MyClass myi = new MyClass();
MyClass mys = new MyClass();
myi.cnt++;
mys.cnt++;

Die Frage ist nun, ob es zwei verschiedene cnt Felder (je eines pro Parametrisierung) oder überhaupt nur eines gibt. Die Antwort ist: es gibt nur ein Feld, das den Wert 2 enthält, nachdem der Code oben durchlaufen wurde. Dies ist naheliegend, wenn man bedenkt, dass es nach der Type Erasure nur noch eine Klasse gibt, die sowohl MyClass als auch MyClass repräsentiert.

Was wir hier am Beispiel des Feldes cnt besprochen haben, gilt grundsätzlich für alle statischen Felder und Methoden: das jeweilige statische Feld bzw. die jeweilige statische Methode existiert nur genau einmal für alle Parametrisierungen des generischen Typs und seinen Raw Type.

In Java ist es möglich, ein statisches Element über ein Objekt anzusprechen, zum Beispiel als: myi.cnt++. Die Anweisung führt zu einer Warnung: „The static field cnt should be accessed in a static way.” Das heißt, das Feld soll über eine Klasse statt über ein Objekt angesprochen werden. Wie macht man dass bei einer generischen Klasse? Spricht man statische Felder über den parametrisierten Typ an? Es wäre zumindest denkbar, dass man das statische cnt-Feld des parametrisierten Typs MyClass als MyClass.cnt anspricht. Das ist aber nicht zulässig. Wie beim Class-Literal und beim instanceof Operator ist nur der Raw Type zulässig:

MyClass.cnt++; // okay
MyClass.cnt++; // Fehler beim Kompilieren
MyClass.cnt++; // Fehler beim Kompilieren

Der Zugriff über den parametrisierten Typ führt dagegen zu einem Fehler beim Kompilieren.

Es gibt noch eine weitere gravierende Einschränkung bei generischen Typen und statischen Elementen: ein Typparameter des generischen Typs darf nicht in der Definition von statischen Feldern oder statischen Methoden verwendet werden. Das bedeutet, dass die folgenden drei Definitionen Fehler beim Kompilieren liefern:

public final class X {
private static T field; // Fehler beim Kompilieren
public static T getField() { return field; } // Fehler beim Kompilieren
public static void setField(T t) { field = t; } // Fehler beim Kompilieren
}

Das Problem ist: es ist unklar, wofür die Typvariable T in einem statischen Kontext steht. Welchen Returnwert hat zum Beispiel X.getField()? Es gibt nur ein statisches Feld field, unabhängig von der Parametrisierung von X. Naheliegend wäre also, dass das Feld field und der Returnwert von X.getField() vom Typ Object sind. Aber dann macht die Parametrisierung der Klasse keinen Sinn und letztlich wäre eine Implementierung der Klasse als nicht-generische Klasse unter Verwendung von Object statt T deutlich klarer. Und genau deshalb gibt es die Regel: ein Typparameter des generischen Typs darf nicht in der Definition von statischen Feldern oder statischen Methoden verwendet werden.

Das bedeutet aber nicht, dass statische Methoden nicht generisch sein können. Ganz im Gegenteil. Es gibt viele Beispiele für generische, statische Methoden. Zum Beispiel die Methoden der Klasse java.util.Collections:

class Collections {
public static void sort(List list, Comparator<? super T> comp);
// ... und so weiter ...
}

Die Klasse Collections ist selbst nicht generisch, sondern bildet lediglich die Hülle um eine Ansammlung von jeweils generischen Methoden. Die statische Methode sort() ist generisch und sortiert eine Liste list mit Hilfe der durch den Comperator comp definierten Ordnung. Die Methode hat einen eigenen Typparameter T, der den Elementtyp der Liste repräsentiert.

Unterschiede zwischen generischen Typen und normalen Referenztypen zur Laufzeit

Wenn wir Vorträgen zu diesem Thema halten, kommen manchmal Einwände bei der Aussage, dass nach der Type Erasure die Typparameter nicht mehr bekannt sind. Unterstrichen werden die Einwände meist mit dem Verweis auf neue Interfaces im Package java.lang.reflect. Diese sind speziell mit dem JDK 5.0 eingeführt worden, um über Reflection Information über generische Typen zu erhalten. Beispiele für neue Interfaces sind GnericDeclaration, sowie Type mit seinen Sub-Interfaces GenericArrayType, ParametrizedType, TypVariable und WildcardType.

Schaut man sich diese Interfaces einmal genauer an, so stellt man fest, dass sich die Information, die man über diese Interfaces bekommt, nicht auf den Laufzeittyp der Typparameter bezieht. Was man erfragen kann, sind vielmehr die Details des formalen Typparameters eines generischen Typs. So liefert zum Beispiel TypeVariable.getBounds() die Typen der Boundsklausel und TypeVariable.getName() den Namen des Typparameters im Sourcecode. Schauen wir uns dazu ein Beispiel an:

LinkedList l = new LinkedList();

TypeVariable tp = (l.getClass().getTypeParameters())[0];

System.out.println("name: " + tp.getName());

Type[] types = tp.getBounds();
if (types[0] instanceof Class)
System.out.println("classname: " + ((Class)types[0]).getName());

Wir initialisiern l mit einem LinkedList-Objekt und holen uns in tp ein Objekt, das den ersten Typparameter von LinkedList repräsentiert. Dann geben wir den Namen dieses Typparameters und den Klassennamen seines ersten Bounds aus. Die Ausgabe des Programms ist:

name: E
classname: java.lang.Object

Dies stimmt genau mit der Beschreibung von java.util.LinkedList in der JavaDoc und dem Sourcecode der Implementierung der LinkedList überein. Der Name des Typparameters im Sourcecode ist E und der Typparameter E hat keine expliziten Bounds; deshalb kommt als erstes (und einziges) Bound Object heraus.

Wie man sieht, ist diese Information nicht dazu geeignet, etwas über den aktuellen Typparameter unserer LinkedList zu erfahren. Was man über Reflection erhält, ist ausschließlich statische Typinformation.

Zusammenfassung

In diesem Artikel haben wir uns die Auswirkungen der Type Erasure Technik auf die Java Programmierung angesehen. Die zwei wichtigsten Erkenntnisse sind dabei:

Literaturverweise und weitere Informationsquellen

Die gesamte Serie über Java Generics:

If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar Effective Java - Java best practice programming techniques, common pitfalls, and off-the-beaten-path language features 4 day seminar (open enrollment and on-site)