Tech Archive Help

17 Java – Generics

Was sind Generics?

Generics wurden in Java 5 hinzugefügt, um Klassen, Interfaces und Methoden zu programmieren, die den Umgang mit verschiedenen Datentypen, mittels Typ-Parametern, auf einer höheren generischen Abstraktionsebene ermöglichen.

Sie sorgen für Typsicherheit, da die Typüberprüfung bereits zur Kompilierungszeit erfolgt. Dadurch wird das Risiko von Laufzeitfehlern reduziert. Auch die Notwendigkeit von Type Castings entfällt, da der Typ einer Klasse oder Methode klar definiert ist.

Generische Methoden

Nehmen wir an, wir haben verschiedene Arrays mit unterschiedlichen Typen und wir wollen alle Elemente jedes Arrays einfach nur auf der Konsole ausgeben. Dann würden wir normalerweise Folgendes schreiben.

public static void main(String[] args) { int[] arr1 = {1, 2, 3}; String[] arr2 = {"A", "B", "C"}; printArray(arr1); System.out.println(); printArray(arr2); } public static void printArray(int[] array) { for (int i : array) { System.out.print(i + " "); } } public static void printArray(String[] array) { for (String s : array) { System.out.print(s + " "); } }
1 2 3 A B C

Da beide Arrays unterschiedliche Typen verwalten, müssen wir auch zwei verschiedene Methoden implementieren, die mit den entsprechenden Typen umgehen können.

Bei genauerer Betrachtung könnte jedoch auffallen, dass wir in beiden printArray()-Methoden genau dieselbe Logik implementiert haben. Wir haben also redundanten Code. Generics lösen dieses Problem.

public static void main(String[] args) { Integer[] arr1 = {1, 2, 3}; // int -> Integer String[] arr2 = {"A", "B", "C"}; printArray(arr1); System.out.println(); printArray(arr2); } public static <T> void printArray(T[] array) { for (T element : array) { System.out.print(element + " "); } }

Wir entfernen eine der printArray() - Methoden und deklarieren die verbleibende Methode als generische Methode mit Hilfe des Diamond-Operators <>, indem wir den generischen Typ T vor den Rückgabetyp setzen. Der Parameter wird entsprechend zum Typparameter T[] angepasst. Damit kann die Methode nun Arrays beliebiger Typen entgegennehmen. Die Logik der Methode bleibt unverändert und wir sparen uns doppelten Code.

Ein Punkt ist jedoch noch zu beachten. Bei der Verwendung der generischen Methode muss das int-Array durch den entsprechenden Wrapper-Typ Integer ersetzt werden. Das liegt daran, da Generics nur mit Referenztypen arbeiten können, nicht mit primitiven Typen wie int.

Auch die Angabe von mehreren Typparametern durch Kommas getrennt ist möglich.

public <K, V> void foo(K key, V value) { // ... }

Beschränkte Typparameter

Es kann vorkommen, dass ihr die Arten von Typen einschränken möchtet, die an einen Typparameter übergeben werden sollen. Wenn beispielsweise eine generische Methode nur mit Zahlen arbeitet, soll diese möglicherweise auch nur Instanzen der Klasse Number oder deren Unterklassen akzeptieren. Dafür sind Bounded Type Parameters (beschränkte Typparameter) gedacht.

public static void main(String[] args) { System.out.println(max(10, 20)); // 20 System.out.println(max(3.5, 4.1)); // 4.1 System.out.println(max(-1, -5)); // -1 } public static <T extends Number> T max(T x, T y) { if (x == null || y == null) throw new IllegalArgumentException("None of the arguments can be null"); return x.doubleValue() > y.doubleValue() ? x : y; }

Da wir in diesem Fall festlegen, dass T nur Instanzen der Klasse Number annehmen darf, können wir innerhalb der Methode auch auf die entsprechenden doubleValue()-Methoden zugreifen. Diese sind in der Number-Klasse deklariert und werden in den Unterklassen, also den entsprechenden Wrapper-Klassen für numerische Werte, überschrieben.

Weiterführende Frage

Jetzt könnte man sich fragen, warum es in der Math - Klasse für jede der numerischen primitiven Typen eine eigene Methode max() gibt. Unsere obige max()-Methode bietet doch eine viel kürzere Lösung.

Das stimmt. Dennoch gibt es gleich zwei Gründe, weshalb die spezialisierten max()-Methoden der Math-Klasse weiterhin relevant sind.

  • Zum einen existiert Java schon recht lange und die ursprünglichen Versionen von Java besaßen noch keine Generics. Da Java stets auf Rückwärtskompatibilität bedacht war, wurden mit der Einführung von Generics in Java 5 die APIs so angepasst, dass sie die neuen Sprachfunktionen unterstützen, ohne bestehenden Code zu beeinträchtigen.

    Die max() - Methoden für primitive Datentypen, so wie viele weitere Methoden, sind also Bestandteil des ursprünglichen Designs. Sie wurden beibehalten, um sicherzustellen, dass ältere Programme auch in neueren Laufzeitumgebungen korrekt funktionieren.

Java 5 ist mittlerweile veraltet und man könnte annehmen, dass alle Software-Projekte auf modernere Java-Versionen migriert sind, nicht wahr? Well ...

Migration to newer Java Versions

Das Updaten von Code-Basen auf neuere Versionen ist tatsächlich ein allgemeines Problem und ja, viele Entwicklerteams sitzen noch auf älteren Java-Versionen fest. Aber diese Problematik würde hier den Rahmen sprengen. Allerdings führt es uns direkt zum zweiten Grund, wenn die vorherige Problematik wegfallen würde.

  • In unserer generischen Implementierung der max() - Methode werden die Werte als Referenztypen behandelt, also als Objekte. Konkret handelt es sich um T extends Number, also Instanzen der Klasse Number. Um die Werte zu vergleichen, muss für jedes Objekt die Methode doubleValue() aufgerufen werden.

    Und das wiederum führt zu einem Overhead, da Objekte zusätzlich Ressourcen benötigen. Im Gegensatz dazu arbeiten die spezialisierten max()-Methoden der Math-Klasse direkt mit primitiven Datentypen wie int oder double. Dies erfordert weder Autoboxing noch Unboxing, sodass die Werte ohne Umweg direkt verglichen werden können. Das spart Zeit und Ressourcen.

Multiple Typbeschränkung

Generics lassen sich nicht nur durch einen einzigen Typ beschränken. Wenn ein Typparameter bestimmte Eigenschaften oder Objektmethoden mehrerer Klassen oder Interfaces erfüllen soll, ist es besonders nützlich, mehrere Typbeschränkungen zu verwenden.

Dabei gilt: Ein Typparameter kann eine Klasse und beliebig viele Interfaces als Begrenzung haben. Dabei muss jedoch die Reihenfolge eingehalten werden, sollte die Begrenzung sowohl von einer Klasse, als auch von mehreren Interfaces abhängen → zuerst die Angabe der Klasse, dann die Angabe des oder der Interfaces.

public interface Flyable { void fly(); } public interface Swimmable { void swim(); } public class Animal { // ... } public class Duck extends Animal implements Flyable, Swimmable { @Override public void fly() { System.out.println("Flying"); } @Override public void swim() { System.out.println("Swimming"); } } public class Zoo { public <T extends Animal & Flyable & Swimmable> void train(T animal) { animal.fly(); animal.swim(); } }

Die Methode train() stell hier sicher, dass das übergebene Objekt

  • vom Typ Animal ist,

  • das Interface Flyable implementiert und

  • das Interface Swimmable implementiert.

Generische Klassen

Es sind jedoch nicht nur generische Methoden möglich, sondern auch generische Klassen und Interfaces. Eine generische Klasse sieht genauso aus, wie eine normale Klasse. Allerdings befindet sich hinter dem Klassennamen noch ein Typparameterabschnitt. Wie bei generischen Methoden kann der Typparameterabschnitt einer generischen Klasse einen oder mehrere durch Kommas getrennte Typparameter enthalten.

public class Box<T> { private T value; public Box() { this.value = null; } public T getValue() { return this.value; } public void setValue(T value) { this.value = value; } public boolean isEmpty() { return this.value == null; } } public class Main { public static void main(String[] args) { Box<Integer> b1 = new Box<>(); Box<String> b2 = new Box<>(); b1.setValue(42); b2.setValue("Hello World!"); System.out.println("Box 1 Content: " + b1.getValue()); System.out.println("Box 2 Content: " + b2.getValue()); } }

Solche Klassen werden als parametrisierte Klassen oder parametrisierte Typen bezeichnet, da sie einen oder mehrere Parameter akzeptieren. Auch hier können die Klassen mit beschränkten Typparametern versehen werden. Dazu ein sehr einfaches Beispiel aus einem kleinen Projekt von mir.

public class Focus<T extends Entity> { private T value; public Focus() { this.value = null; } public T getEntity() { return value; } public void setFocusOn(T value) { this.value = value; } public void setFreeMode() { this.value = null; } public boolean isFreeMode() { return this.value == null; } }

Die parametrisierte Klasse Focus<T extends Entity> nutze ich im Rahmen eines Kamerasystems innerhalb eines Spiels. Dadurch kann ich einer Klasse Camera mitteilen, auf welchem Entity sie den Fokus behalten soll. Standardmäßig liegt dort der Fokus auf der Player-Klasse, welche wiederum von Entity erbt, sodass die Kamera dem Spieler folgt.

Weiterführende Frage

Einige Fortgeschrittenere unter euch könnten sich jetzt vielleicht fragen, warum ich nicht einfach die Klasse Optional<T> verwende. Diese bringt tatsächlich genau die Funktionalität mit, die ich bräuchte.

Wieso ich für diesen Zweck eine eigene Klasse geschrieben habe, hat mehrere Gründe.

  • Einer davon ist die Tatsache, dass Optional<T> primär als Rückgabetyp bei Methoden gedacht ist, um die Absenz eines fehlenden Wertes auszudrücken. Focus<T extends Entity> verwende ich jedoch als Objektvariablen bzw. Attribut innerhalb der Klasse Camera.

    Um darauf hinzuweisen, dass Optional<T> nicht für die Modellierung von Objektzuständen gedacht ist, gibt IntelliJ tatsächlich eine Warnung aus, sollte die Klasse entgegen der Konvention als Attribut verwendet werden.

  • Außerdem wollte ich den Fokus der Kamera nur für Objekte der Klasse Entity und deren Unterklasse zulassen. Optional<T> lässt hingegen jede Art von Typ zu, da diese keinen beschränkten Typparameter besitzt.

  • Schlussendlich lässt sich aus meiner Klasse noch ableiten, dass ich einen "Freien Modus" implementieren wollte. Es sollte die Möglichkeit geben die Kamera flexibel zu steuern, ohne an eine bestimmte Entität gebunden zu sein.

Wildcards

Es kann vorkommen, dass wir an manchen Stellen im Code mit Objekten arbeiten, dessen Typ wir jedoch nicht genau kennen. Zu diesem Zweck wurden Wildcards implementiert, die eine flexiblere Architektur ermöglichen. Eine Wildcard wird mittels Fragezeichen ? dargestellt und steht für einen unbekannten generischen Typ. Sie erlauben es, generische Typen flexibler zu handhaben, indem sie eine gewisse Variabilität bei Typangaben zulassen, ohne dass der genaue Typ festgelegt werden muss.

Wildcards lassen sich dabei in drei Arten unterteilen.

Unbounded Wildcards

Unbounded Wildcards ? stehen für einen beliebigen Typ, ohne Einschränkung. Wenn der genaue Typ nicht wichtig ist, sondern es sich nur um irgendeinen generischen Typ handeln muss, dann wird diese Art verwendet.

Ein Beispiel wäre eine Methode, welche eine Liste von beliebigen Objekten akzeptiert, ohne auf deren Typ zuzugreifen.

public void printList(List<?> list) { for (Object object : list) { System.out.println(object); } }

Die Methode printList() akzeptiert eine Liste beliebigen Typs, ohne die Elemente der Liste zu verändern oder spezifisch zu behandeln.

Upper Bound Wildcards

Upper Bound Wildcards werden mit ? extends T definiert, wobei T die Obergrenze repräsentiert. Das bedeutet, dass die Wildcard einen Typ repräsentiert, welcher T selbst oder eine Unterklasse von T darstellt. Schauen wir uns dazu das vorherige Beispiel an und modifizieren es etwas.

public void printList(List<? extends Number> numbers) { for (Number number : numbers) { System.out.println(number.doubleValue()); } }

In diesem Beispiel kann die Methode Listen vom Typ Number oder beliebigen Unterklassen (z. B. Integer, Byte, Float etc.) entgegennehmen. Das macht dann Sinn, wenn Elemente nur gelesen, aber nicht sicher hinzugefügt werden können.

Lower Bound Wildcards

Lower Bound Wildcards werden mit ? super T definiert und legen für den Typ eine Untergrenze fest. Die Wildcard repräsentiert also einen Typ, der T selbst oder eine Oberklasse von T darstellt. Sollen Elemente einer Datenstruktur hinzugefügt werden, ist es sinnvoll, diese Art zu verwenden, da sicher ist, dass die Struktur Objekte vom Typ T oder einer Oberklasse akzeptiert.

public void addIntegers(List<? super Integer> list) { list.add(1); list.add(2); }

Type Erasure – Typlöschung

Type Erasure beschreibt den Prozess, bei dem der Compiler Typinformationen generischer Klassen und Methoden zur Laufzeit entfernt. Nach der Überprüfung der Typen und ihrer Verwendung ersetzt der Compiler alle Typparameter durch deren obere Typschranken sowie durch entsprechende explizite Cast-Operationen im Bytecode

public static <T> void printType(T[] array) { for (T element : array) { System.out.println(element); } }
public static void printType(Object[] array) { for (Object element : array) { System.out.println(element); } }

Die Klasse Optional<T>

Weiter oben im Abschnitt "Generische Klassen: Weiterführende Frage" bin ich bereits ein wenig auf die Klasse Optional<T> eingegangen.

Die Klasse Optional<T> wurde in Java 8 eingeführt und ist als moderne Alternative für die Methodenrückgabe anstelle von null vorgesehen.

Rückgabe null vs Rückgabe Optional<T>

10 September 2025