|
|||||||||||||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||||||||||||||||||||
|
Java Generics - Type Erasure Pitfall
|
||||||||||||||||||||||||||||||||||||||||||
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. Type ErasureFangen 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<A> implements List<A> {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 {Zur Laufzeit ist der generische Typ LinkedList<A> ein ganz normaler Referenztyp, der nichts mehr von seiner Parametrisierung weiß. Dies gilt auch für ein Objekt eines parametrisierten Typs: die LinkedList<String> 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 CastSoweit wir das im letzten Artikel betrachtet haben, hat die Art und Weise, wie die generischen Typen in Java implementiert sind, nur Vorteile:
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:
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<String> refToStringList = new LinkedList<String>();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<String> refToStringList = new LinkedList<String>();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<String> sondern auf eine LinkedList<Integer> verweist? LinkedList<String> refToStringList = new LinkedList<String>();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<String> 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<String> und auf der rechten Seite eine LinkedList<Integer>. 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<Integer>()).getClass() == (new LinkedList<String>()).getClass()
LinkedList<String> refToStringList = new LinkedList<String>();Wie wir von unserer Diskussion oben wissen, erzeugt der Compiler bei der Übersetzung per Type Erasure folgenden Code: LinkedList refToStringList = new LinkedList();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<String> 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 RegelnWie gerade ausführlich diskutiert, kann man den folgenden Cast hinschreiben:refToStringList = (LinkedList<String>)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) … // okayinstanceof 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 Statische Felder und Methoden einer generischen KlasseWenn 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<T> {Im Folgenden nutzten wir nun zwei Parametrisierungen dieses generischen Typs: MyClass<Integer> myi = new MyClass<Integer>();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<Integer> als auch MyClass<String> 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<Integer> als MyClass<Integer>.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++; // okayDer 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<T> {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 {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 LaufzeitWenn 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<D> 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<D>.getBounds() die Typen der Boundsklausel und TypeVariable<D>.getName() den Namen des Typparameters im Sourcecode. Schauen wir uns dazu ein Beispiel an: LinkedList<String> l = new LinkedList<String>();Wir initialisiern l mit einem LinkedList<String>-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: EDies stimmt genau mit der Beschreibung von java.util.LinkedList<E> 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<String> zu erfahren. Was man über Reflection erhält, ist ausschließlich statische Typinformation. ZusammenfassungIn 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:
|
|||||||||||||||||||||||||||||||||||||||||||
© Copyright 1995-2012 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/33.TypeErasurePitfall/33.TypeErasurePitfall.html> last update: 4 Nov 2012 |