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:

  1. Das erste T (ExtStack<T>) ist der Typ-Parameter, der dann durch den Verwender gesetzt wird, z. B. durch ExtStack<String> s = new ExtStack<String>(10).
  2. Das zweite T ist bereits die erste Verwendung des Typ-Parameters, der an die Klasse SimpleStack 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 Typ T[] 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-Erasure Object[] 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:

  1. 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 Typ String oder Date speichern. Bei einer Zuweisung wird zur Laufzeit überprüft, ob der dynamische Typ des Arrays zum Typ des hinzugefügten Objektes passt.
  2. Die Referenz auf das Array kann selbst wieder polymorph sein. Eine Referenz vom Typ Object[] kann auf ein Array vom Typ String[] 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.


Copyright © 2025 Thomas Smits