Einschränkungen bei Java Generics
Was mit Java Generics nicht funktioniert: keine primitiven Typen, keine statischen Typparameter, keine generischen Arrays und mehr.
Dieses Kapitel ist ein Katalog der Dinge, die man mit Java Generics nicht tun kann, mit einer kurzen Erklärung, warum jedes davon verboten ist. Fast jede Einschränkung geht auf eine einzige Tatsache zurück, die im vorherigen Kapitel behandelt wurde: Der Typparameter wird vor der Bytecode-Erzeugung gelöscht (Type Erasure), sodass alles, was den Parameter zur Laufzeit benötigt, nicht funktioniert. Lesen Sie dieses Kapitel als abschließende Referenzkarte für diesen Teil — es ist die Liste der „Ich habe es versucht, der Compiler hat sich beschwert"-Momente auf einer einzigen Seite.
1. Keine primitiven Typen als Typargumente
List<int> ints = new ArrayList<>(); // ❌
List<Integer> ints = new ArrayList<>(); // ✓Die durch Type Erasure entstehende Form von List<E> speichert ihre Elemente als Object, und primitive Typen sind keine Objects. Die Lösung ist die entsprechende Wrapper-Klasse — Integer, Long, Double, Boolean, Character usw. Autoboxing überbrückt dann die Lücke: ints.add(5) und int x = ints.get(0) funktionieren beide, allerdings auf Kosten eines Integer-Objekts auf dem Heap für jedes Element.
Project Valhalla ist das langfristige Vorhaben, das List<int> tatsächlich zum Laufen bringen soll, durch Value Types und spezialisierte Generics. Ab Java 25 ist das noch nicht umgesetzt.
2. Kein new T()
public class Box<T> {
public T newInstance() { return new T(); } // ❌
}Zur Laufzeit gibt es kein T — die JVM kennt nur Object (oder die Schranke). Sie hat kein Klassenobjekt, auf dem sie einen Konstruktor aufrufen kann, und keine Möglichkeit zu wissen, welchen Konstruktor sie aufrufen soll. Die übliche Lösung besteht darin, eine Factory als Parameter zu übergeben:
public class Box<T> {
public T newInstance(Supplier<T> factory) { return factory.get(); }
}
Box<String> b = new Box<>();
String fresh = b.newInstance(String::new);Der Supplier<T> trägt die tatsächliche Factory zur Laufzeit, auf eine Weise, die der Typparameter nie könnte.
3. Kein T.class oder instanceof T
public <T> boolean isIt(Object o) {
return o instanceof T; // ❌
}
public <T> Class<T> klass() {
return T.class; // ❌
}Auch hier gilt: kein T zur Laufzeit. Die Lösung in beiden Fällen besteht darin, das Class<T>-Token als Argument zu übergeben:
public <T> boolean isIt(Object o, Class<T> type) {
return type.isInstance(o);
}Class.isInstance(Object) ist die reflektive Form von instanceof und funktioniert mit dem übergebenen Laufzeit-Class-Objekt. Die Standardbibliothek verwendet dies überall — Collections.checkedList(List<E>, Class<E>), EnumSet.noneOf(Class<E>), JSON-Deserialisierer und so weiter.
4. Keine Arrays eines generischen Typs
T[] arr = new T[10]; // ❌ — generic array creation
List<String>[] lists = new List<String>[10]; // ❌ — sameDieser Punkt ist etwas subtiler. Arrays in Java sind reifiziert — ein Integer[] weiß zur Laufzeit, dass es ein Integer[] ist, und Speichervorgänge werden geprüft. Generics hingegen werden gelöscht — die JVM kann List<String>[] nicht von List<Integer>[] unterscheiden. Wenn beide Einschränkungen nicht durchgesetzt würden, könnte man den Heap mit wenigen Zeilen korrumpieren:
List<String>[] strs = new List<String>[1]; // pretend this is legal
Object[] objs = strs; // arrays are covariant
objs[0] = List.of(42); // stores an Integer list
String s = strs[0].get(0); // KABOOMDer Compiler verweigert die Erstellung generischer Arrays, um dies zu verhindern.
Workarounds:
- Verwenden Sie
(T[]) new Object[n]mit@SuppressWarnings("unchecked")(das haben Sie beim generischen Stack bereits gesehen). Sicher, wenn das Array intern bleibt und nie alsT[]nach außen dringt. - Oder verwenden Sie einfach eine
List<T>statt eines Arrays. In neun von zehn Fällen ist das die richtige Antwort.
5. Keine statischen Felder eines Typparameters
public class Box<T> {
private static T defaultValue; // ❌
public static T empty() { ... } // ❌
}Der Typparameter gehört zu einer Instanz — jede Box<...> trägt ihr eigenes T. Statische Member gehören zur Klasse selbst, die kein T hat. Die beiden Geltungsbereiche sind nicht verbunden.
Wenn Sie eine statische Methode benötigen, die über einen Typ polymorphisch ist, deklarieren Sie einen eigenen Typparameter (das haben wir in generischen Methoden behandelt):
public class Box<T> {
public static <U> Box<U> empty() { return new Box<>(null); }
}<U> ist lokal für die Methode — unabhängig von einem eventuellen T auf Klassenebene.
6. Keine generischen Exception-Typen
public class MyException<T> extends Exception { ... } // ❌Die Exception-Handling-Tabellen der JVM suchen catch-Blöcke anhand der gelöschten Klasse. Wenn zwei unterschiedliche generische Exception-Typen zur selben Klasse gelöscht würden, würde ein catch (MyException<String> e) auch eine MyException<Integer> abfangen — was das Typsystem stillschweigend korrumpieren würde. Anstatt das zum Laufen zu bringen, verbietet Java die Deklaration vollständig. Sie können auch keinen generischen Typparameter in einer catch-Klausel verwenden:
try { ... } catch (T e) { ... } // ❌Wenn Ihre Exception wirklich eine typisierte Nutzlast transportieren muss, speichern Sie diese als generisches Feld in einer nicht-generischen Exception:
public class TaggedException extends Exception {
public final Object payload;
public TaggedException(String message, Object payload) {
super(message);
this.payload = payload;
}
}Oder deklarieren Sie die Auslösestelle eng und reservieren Sie typisierte Nutzlasten für normale Rückgabepfade.
7. Keine Überladungen, die sich nur in generischen Parametern unterscheiden
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... } // ❌ — both erase to process(List)Nach dem Löschen haben beide Methoden die Signatur process(List). Java löst Überladungen anhand gelöschter Signaturen auf, sodass es die beiden nicht unterscheiden kann. Die Lösung besteht darin, ihnen unterschiedliche Namen zu geben — processStrings und processInts — oder eine List<Object> entgegenzunehmen und zur Laufzeit zu prüfen.
8. Generische Typen sind invariant
Das ist keine Regel „Der Compiler lehnt das ab", sondern eine Regel „Der Compiler lehnt ab, was Sie für legal gehalten hätten", und wir haben das im Detail in Wildcards behandelt:
List<Integer> ints = ...;
List<Number> nums = ints; // ❌ — generic types are invariant
List<? extends Number> nums = ints; // ✓ — wildcard restores the flexibilityWichtig zu wissen, weil es die Einschränkung ist, auf die man am häufigsten stößt. Wildcards sind das Sicherheitsventil.
9. Keine generischen Enum-Typen
public enum Box<T> { // ❌
EMPTY, FULL;
T value;
}Enums werden in eine einzelne Klasse mit einer festen Menge von Konstanten übersetzt — es gibt keine Möglichkeit, dass die Konstanten ein einziges, sinnvolles T teilen. Die Lösung besteht normalerweise darin, die Methoden generisch zu machen, nicht das Enum selbst:
public enum Box {
EMPTY, FULL;
public <T> T orDefault(T fallback) { return this == FULL ? null : fallback; }
}Oder, wenn jede Konstante wirklich ihren eigenen Typ haben soll, verwenden Sie eine nicht-Enum-Klassenhierarchie und eine Map<Name, Box>.
10. Aufruf einer generischen Methode über einen Raw Type
List rawList = new ArrayList();
rawList.add("hi"); // unchecked-warning, but allowed
List<String> typed = rawList; // unchecked-warning, dangerousDas Mischen von Raw Types und generischen Typen deaktiviert alle Kompilierzeitprüfungen von Generics für diese Variable. Der Compiler warnt (Unchecked call to add(E) as a member of raw type java.util.List), und wer die Warnung ignoriert, erhält das Sicherheitsproblem aus der Zeit vor Java 5 zurück — falsch typisierte Werte werden stillschweigend eingeschleust und explodieren beim nächsten Lesezugriff.
Raw Types existieren für Abwärtskompatibilität, nicht als Feature. Behandeln Sie die Warnung in jedem neuen Code als Fehler.
Ein Anwendungsbeispiel: alle Einschränkungen nebeneinander
Das folgende Programm versucht, jede der verbotenen Dinge zu tun (auskommentiert, damit die Datei kompiliert), und zeigt dann den kanonischen Workaround für jede. Lesen Sie die Kommentare — sie entsprechen eins-zu-eins den nummerierten Einschränkungen oben.
Jede nummerierte Einschränkung entspricht einem einzeiligen Workaround im Programm. Das Muster ist bei allen gleich: Alles, was den Typparameter zur Laufzeit benötigt, erhält die Information explizit übergeben — ein Class<T>-Token, ein Supplier<T>, ein Typparameter auf Methodenebene, ein Wildcard. Type Erasure hat die implizite Form entfernt; man gibt sie an der API-Grenze zurück.
Das schließt Teil 10 ab
Generics ist das tiefste einzelne Sprachfeature außerhalb der JVM selbst. Sie haben jetzt das nötige Vokabular: Typparameter an Klassen, Methoden und Interfaces; Schranken; Wildcards und PECS; Type Erasure; und den Katalog der Einschränkungen, die Type Erasure dem Design aufzwingt. Jede moderne Java API ist von diesen Regeln geprägt, und das Lesen von Bibliothekscode (oder das Entwerfen eigener APIs) ist mit diesem Modell im Kopf wesentlich einfacher.
Was kommt als Nächstes
Generics sind kein Selbstzweck — sie existieren, weil Java eine Möglichkeit brauchte, „Container von T" auszudrücken, ohne eine Klasse pro Elementtyp zu kopieren. Der nächste Teil des Buches ist der Ort, für den alle generischen Mechanismen dieses Teils heimlich vorbereitet haben: das Collections Framework. List, Set, Map, Queue und die Dutzende von Implementierungen dahinter — alle parametrisiert, alle nach den Regeln gestaltet, die Sie gerade gelernt haben. Weiter zu Java Collections Einführung.