Generische Typen in Java
Begriffe
Name | Beispiel |
---|---|
Generischer Typ (generic type) | List<E> |
Formaler Typ-Parameter (formal type parameter) | E |
Parametrisierter Typ (parametrized type) | List<String> |
Aktualer Typ-Parameter (actual type parameter) | String |
Ungebundener Wildcard-Typ (unbounded wildcard type) | List<?> |
Gebundener Wildcard-Typ (bounded wildcard type) | List<? extends Number> |
Raw Type | List |
Gebundener Typ-Parameter (bounded type parameter) | <E extends Number> |
Rekursiv gebundener Typ-Parameter (recursive bounded type) | <T extends Comparable<T>> |
Generische Methode (generic method) | static <E> List<E> asList(E[] a) |
Die Tabelle zeigt eine ganze Reihe von Begriffen, die im Zusammenhang mit generischen Typen relevant sind. Die meisten sind bis jetzt noch unbekannt und sollen in den folgenden Abschnitten beschrieben werden.
Generics und Vererbung
- Generische Klassen können Subklassen haben
- Entweder
- ist die Subklasse selbst wieder generisch oder
- die Subklasse setzt den Typ-Parameter und ist damit nicht generisch
Wenn man von einer generischen Klasse erbt, hat man zwei Möglichkeiten: (a) Der Subtyp ist selbst ein generischer Typ oder (b) man setzt beim Erben den Typ-Parameter, sodass die Subklasse nicht mehr generisch ist.
Beispiel: Generische Vererbung
public class ExtStack<T> extends SimpleStack<T> {
public ExtStack(int size) {
super(size);
}
public T peek() {
T wert = pop();
push(wert);
return wert;
}
}
In diesem Beispiel ist die Subklasse selbst wieder generisch und gibt den Typ-Parameter weiter an die Basisklasse. Für das Verständnis wichtig ist die Tatsache, dass hier zwei verschiedene <T>
zu sehen sind:
- Das erste
T
(ExtStack<T>
) ist der Typ-Parameter, der dann durch den Verwender gesetzt wird, z. B. durchExtStack<String> s = new ExtStack<String>(10)
. - Das zweite
T
ist bereits die erste Verwendung des Typ-Parameters, der an die KlasseSimpleStack
weitergereicht wird.
Beispiel: Vererbung mit gesetztem Parameter
public class FormStack extends SimpleStack<Form> {
public FormStack(int size) {
super(size);
}
public Form peek() {
Form wert = pop();
push(wert);
return wert;
}
}
Hier wird der Typ-Parameter der Superklasse bei der Vererbung gesetzt, d. h. die Subklasse einer generischen Klasse ist selbst nicht mehr generisch. Der Typ-Parameter der Klasse SimpleStack
wird im Rahmen der extends
-Klausel gesetzt.
Generics als Compile-Zeit-Konstrukt
Java Generics existieren nur zur Compile-Zeit
- Typlöschung (type erasure): Typ-Informationen werden entfernt
- Typ-Parameter ist zur Laufzeit vom Typ
Object
- Typ-Parameter kann nicht in statischen Variablen oder Methoden verwendet werden
- Typ-Parameter kann nicht verwendet werden, um Objekte zu erzeugen
- Es gibt nur eine einzige Klasse pro generischem Typ, den Raw type
- Sie verhalten sich damit grundlegend anders als C++-Templates
Die Besonderheit der Generics in Java ist, dass der Typ-Parameter nur zur Compilezeit existiert. Im generierten Byte-Code gibt es ihn nicht mehr, weswegen man von Typlöschung spricht. Der ursprünglich beliebig wählbare Typ-Parameter wird im Bytecode einfach durch Object
ersetzt, sodass zur Laufzeit der generische Typ gar nicht mehr generisch ist. Von der Klasse existiert nur eine einzige Ausprägung, der raw type. Da aber der Compiler alle Verwendungen des Typs bereits zur Compilezeit überprüft und wo nötig Casts einfügt, ist die Typlöschung für den Verwender nahezu unsichtbar.
Der Grund für dieses ungewöhnliche Vorgehen liegt darin, dass die Java-Entwickler die Kompatibilität zu existierenden Java-Programmen nicht zerstören wollten. Vor allem sollten Programme, die Collection-Klassen aus historischen Gründen ohne Generics benutzen, weiter funktionieren.
Aus der Typlöschung folgen einige Konsequenzen, die die Programmierung mit Generics nicht unbedingt vereinfachen:
- Man kann keine Objekte mit
new T()
vom Typ des Typ-Parameters erzeugen. - Man kann keine Arrays mit
new T[]
vom Typ des Typ-Parameters erzeugen. - Man kann den Typ-Parameter nicht in statischen Methoden verwenden.
An dieser Stelle wird sofort klar, warum der Ausdruck stack = (T[]) new Object[size]
mit T[] stack
aus dem ersten Beispiel zu Generics funktioniert:
- Man muss ein Object-Array anlegen, weil ein
new T[size]
nicht zulässig ist. - Zur Compilezeit muss gecastet werden, weil
stack
vom TypT[]
ist und sonst die Zuweisung an ein Object-Array nicht durchgehen würde. Zur Laufzeit müsste der Cast aber eigentlich fehlschlagen. - Nach dem Kompilieren wird im Bytecode aus
T[] stack
wegen der Type-ErasureObject[] stack
und aus(T[]) new Object[size]
(Object[]) new Object[size]
Somit ist durch die Typ-Löschung der eigentlich falsche Ausdruck wieder richtig.
Bei C++-Templates werden generische Typen über Code-Generatoren realisiert, d. h. für jede Ausprägung eines Templates wird der Code für eine Klasse bzw. Funktion generiert und compiliert. Im Gegensatz zu Java existieren C++-Templates also während der Laufzeit und es gibt ein Klasse pro Typ-Parameter-Wert.
Beispiel: Type Erasure
var s1 = new SimpleStack<String>(4);
var s2 = new SimpleStack<Integer>(4);
SimpleStack sRaw = new SimpleStack(4);
System.out.println(s1.getClass());
System.out.println(s2.getClass());
System.out.println(sRaw.getClass());
class pr2.erasure.SimpleStack
class pr2.erasure.SimpleStack
class pr2.erasure.SimpleStack
Im Beispiel wird der Name der Klasse ausgegeben, die hinter den jeweiligen Objekten liegt. Man sieht, dass aufgrund der Typ-Löschung aus allen generischen Typen eine einzige Klasse SimpleStack
erzeugt wird.
Invarianz
Generics sind invariant
- Generische Typen von Subtypen sind selbst keine Subtypen (invariante Typen, keine Kovarianz)
- Es gibt keine Polymorphie zwischen verschiedenen Ausprägungen desselben generischen Typs
Anders als man also erwarten würde, führt die Tatsache, dass Generics von Subtypen selbst nicht wieder Subtypen voneinander sind dazu, dass man für die Aussage „egal welcher Typ-Parameter für den generischen Typ gesetzt wurde, ich nehme alles“ nicht GenerischerTyp<Object>
sagen kann.
Eine Referenz vom Typ GenerischerTyp<Object>
kann also nicht auf ein Objekt vom Typ GenerischerTyp<String>
zeigen, obwohl eine Referenz vom Typ Object
auf ein String
-Objekt zeigen könnte. Es gibt also keine Polymorphie, wie man sie auf den ersten Blick vielleicht erwarten würde.
Beispiel: Invarianz
public class Generic<T> {
public void doIt(T param) {
// ...
}
}
public class Verwender {
public static void main(String[] args) {
A a1 = new B();
A[] a2 = new B[1];
Generic<A> ga = new Generic<B>(); // FEHLER!!
}
}
Das Beispiel zeigt den drastischen Unterschied zwischen normalen Typen, Arrays und generischen Typen. Während normale Referenzen polymorph sind (A a1 = new B()
) und sich auch Arrays so verhalten (A[] a2 = new B[1]
), führt dies bei Generics zu einem Fehler (Generic<A> ga = new Generic<B>()
).
Generics vs. Arrays
Generics und Arrays unterscheiden sich grundsätzlich und harmonieren im Allgemeinen schlecht
- Arrays sind kovariant (covariant)
- Generics sind invariant (invariant)
- Arrays prüfen ihren Typ zur Laufzeit (reified)
- Generics prüfen ihren Typ zur Compilezeit (erasure)
- Es ist nicht möglich ein Array zu erstellen aus
- einem generischen Typ (
List<E>[]
) - einem parametrierten Typ (
List<String>[]
) - einem Typ-Parameter (
E[]
)
Wegen der grundlegenden Unterschiede funktionieren Arrays und Generics nur schlecht zusammen. Arrays sind kovariant und führen daher die Prüfung zur Laufzeit durch. Generics sind invariant und verlagern alle Prüfungen auf die Compilezeit.
Bei Arrays gibt es Polymorphie auf zwei Ebenen:
- In einem Array vom Typ einer Superklasse, kann man, wegen der Polymorphie, ebenfalls Objekte von Subklassen speichern. Z. B. kann man in einem
Object
-Array Objekte vom TypString
oderDate
speichern. Bei einer Zuweisung wird zur Laufzeit überprüft, ob der dynamische Typ des Arrays zum Typ des hinzugefügten Objektes passt. - Die Referenz auf das Array kann selbst wieder polymorph sein. Eine Referenz vom Typ
Object[]
kann auf ein Array vom TypString[]
zeigen.
// Polymorphie des Arrays
Object[] o1 = new Object[10];
o1[0] = "Hallo";
o1[1] = new Date();
// Polymorphie der Referenz
Object[] o2 = new String[5];
o2[0] = "Welt";
Bei Generics entfällt die zweite Ebene: In einem generischen Typ, dessen Typ-Parameter vom Typ einer Superklasse ist, kann man wegen der Polymorphie,Objekte von Subklassen speichern. Z. B. kann man in einem Generic<Object>
Objekte vom Typ String
oder Date
speichern.
// Polymorphie des generischen Typs
Generic<Object> go = new Generic<>();
go.put("Hallo");
go.put(new Date());
Wie man bei Generics doch eine polymorphe Referenz definieren kann, wird später noch im Rahmen von Wildcards erläutert.
Beispiel: Kovarianz vs. Invarianz
A[] a1 = new A[10];
A[] a2 = new B[10];
B[] b1 = (B[]) a2;
ArrayList<A> l1 = new ArrayList<A>();
ArrayList<A> l2 = new ArrayList<B>(); // FEHLER!!
de/smits_net/pr2/covariance/ArrayExample.java:26: incompatible types
found : java.util.ArrayList<de.smits_net.pr2.covariance.B>
required: java.util.ArrayList<de.smits_net.pr2.covariance.A>
ArrayList<A> l2 = new ArrayList<B>();
^
Beispiel: Reified vs. Erasure (Array)
Object[] array = new String[10];
array[0] = "Hallo";
array[1] = new Integer(15); // FEHLER!!
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at de.smits_net.pr2.covariance.Refied.main(Refied.java:11)
Im Beispiel sieht man, dass die Überprüfung des dynamischen Typs eines Arrays bei einer Zuweisung erst zur Laufzeit passiert. Für den Compiler ist die Zuweisung array[1] = new Integer(15)
korrekt, weil er nur den statischen Typ der Array-Referenz (Object[]
) betrachtet. Zur Laufzeit schlägt sie aber fehl, weil das Array tatsächlich den dynamischen Typ String[]
hat.
Beispiel: Reified vs. Erasure (Generic)
ArrayList<Object> list = new ArrayList<String>(); // FEHLER!!
list.add(new Integer(15));
de/smits_net/pr2/covariance/Refied.java:16: incompatible types
found : java.util.ArrayList<java.lang.String>
required: java.util.ArrayList<java.lang.Object>
ArrayList<Object> list = new ArrayList<String>();
^
Bei Generics wird eine fehlerhafte Zuweisung bereits vom Compiler erkannt. Da der statische und der dynamische Typ bei der Deklaration nicht auseinanderfallen können, ist zu jedem Zeitpunkt für den Compiler klar, welche Typen in dem generischen Typ gespeichert werden können.