Generics in Java: Wildcard ? extends und ? super
1. Übersicht
Generics sorgen dafür, dass dein Java-Code bereits zur Compile-Zeit typsicher ist. Aber wenn du eine Methode schreibst, die List<Product> erwartet, wirst du feststellen, dass sie List<Book> nicht akzeptiert, obwohl Book ein Subtyp von Product ist. Dieser Artikel erklärt, warum das so ist. Danach zeige ich dir, wie du die Wildcards ? extends und ? super verwendest, um flexiblere APIs zu schreiben. Nach dem Lesen wirst du wissen, welchen Wildcard du gemäß dem PECS-Prinzip wählst und wirst verwirrende Compile-Fehler vermeiden.
2. Das Problem: Generics sind invariant
Verwenden wir für diesen Artikel eine einfache Klassenhierarchie:
public class Product {
private final String name;
private final double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public String name() { return name; }
public double price() { return price; }
}
public class Book extends Product {
public Book(String name, double price) {
super(name, price);
}
}
Book ist ein Product. Ist also List<Book> auch List<Product>?
List<Book> books = new ArrayList<>();
List<Product> products = books; // Compiler-Fehler: incompatible types
Die Antwort ist nein. Generics in Java sind invariant. Wenn A ein Subtyp von B ist, sind List<A> und List<B> dennoch zwei nicht verwandte Typen.
Der Grund liegt in der Typsicherheit selbst. Angenommen, die Zeile oben würde kompilieren, dann könnten wir schreiben:
products.add(new Product("Bút bi", 5_000)); // fügt ein Product zu einer Liste ein, die nur Books enthält
Book first = books.get(0); // ClassCastException!
Arrays sind anders. Arrays in Java sind kovariant, daher wird ein ähnlicher Fehler nur zur Laufzeit offenbar:
Object[] items = new String[2];
items[0] = 42; // kompiliert OK, Laufzeit wirft ArrayStoreException
Generics wählen den Ansatz, Fehler bereits zur Compile-Zeit zu blocken. Der Preis dafür ist weniger Flexibilität. Wildcards wurden entwickelt, um diese Flexibilität zurückzugewinnen.
3. Oberer begrenzter Wildcard: ? extends T
3.1. Daten aus einer Collection lesen
Angenommen, du musst die Gesamtsumme einer Produktliste berechnen. Die erste Version sieht normalerweise so aus:
static double totalPrice(List<Product> products) { ... }
Diese Methode akzeptiert List<Book> nicht. Wir korrigieren das mit ? extends:
public final class CatalogUtils {
public static double totalPrice(List<? extends Product> products) {
double total = 0;
for (Product p : products) {
total += p.price();
}
return total;
}
}
List<? extends Product> bedeutet „eine Liste, deren Elemente vom Typ Product oder einem Subtyp davon sind”. Dadurch:
- Du kannst
List<Product>,List<Book>oderList<Ebook>übergeben, falls vorhanden. - Jedes Element, das du herausliest, ist sicher vom Typ
Product, daher ist diefor (Product p : products)-Schleife zulässig.
List<Book> books = List.of(
new Book("Clean Code", 300_000),
new Book("Effective Java", 450_000));
System.out.println(CatalogUtils.totalPrice(books));
750000.0
3.2. Warum kannst du keine Elemente hinzufügen?
Versuchen wir, ein Element zu einer Liste mit ? extends hinzuzufügen:
static void addBook(List<? extends Product> products) {
products.add(new Book("Refactoring", 400_000)); // Compiler-Fehler
products.add(null); // OK, aber bedeutungslos
}
Der Compiler weiß nicht, was ? wirklich ist. Die übergebene Liste könnte List<Ebook> sein. Das Hinzufügen eines Book würde dann die Typsicherheit verletzen. Daher verbietet der Compiler jede add-Operation außer mit null.
Leichte Merkhilfe: ? extends T dient nur zum Auslesen von T. Die Collection spielt dabei die Rolle eines Producer, das heißt, sie liefert dir Daten.
4. Unterer begrenzter Wildcard: ? super T
Jetzt machen wir das Umgekehrte. Du möchtest eine Methode, die Bücher in jede Liste lädt, die Book enthalten kann:
public static void addDefaultBooks(List<? super Book> target) {
target.add(new Book("Clean Code", 300_000));
target.add(new Book("Effective Java", 450_000));
}
List<? super Book> bedeutet „eine Liste, deren Elemente vom Typ Book oder einem übergeordneten Typ sind”. Jeder solche Typ kann ein Book enthalten, daher ist add(new Book(...)) immer sicher.
List<Book> books = new ArrayList<>();
List<Product> products = new ArrayList<>();
List<Object> anything = new ArrayList<>();
CatalogUtils.addDefaultBooks(books); // OK
CatalogUtils.addDefaultBooks(products); // OK
CatalogUtils.addDefaultBooks(anything); // OK
System.out.println(products.size());
2
Wenn du Elemente ausliest, weißt du dagegen nur, dass sie vom Typ Object sind:
static void printFirst(List<? super Book> target) {
Object first = target.get(0); // OK
Book book = target.get(0); // Compiler-Fehler
}
Die Liste könnte List<Object> sein und alles Mögliche enthalten; der Compiler kann daher nur die Object-Art garantieren.
Regel: ? super T dient zum Schreiben von T hinein. Die Collection ist jetzt ein Consumer, also ein Ort, der die Daten aufnimmt, die du hinzufügst.
5. Das PECS-Prinzip
Die beiden Regeln zusammengefasst ergeben PECS: Producer Extends, Consumer Super.
- Für einen Parameter, der
Tliefert, verwendest du? extends T. - Für einen Parameter, der
Tannimmt, verwendest du? super T. - Wenn ein Parameter sowohl gelesen als auch geschrieben wird, verwendest du keinen Wildcard und deklarierst stattdessen den exakten Typ
T.
5.1. Beispiel: Kopieren zwischen zwei Listen
public static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T item : source) {
target.add(item);
}
}
source ist ein Producer, daher verwendest du extends. target ist ein Consumer, daher verwendest du super. Das Ergebnis ist eine sehr flexible Methode:
List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();
CatalogUtils.<Book>copy(books, cart); // T = Book: liest Books aus, schreibt in übergeordnete Liste
CatalogUtils.<Product>copy(books, cart); // T = Product ist auch zulässig
Das JDK wendet dieses Prinzip korrekt an. Die Signatur von Collections.copy lautet:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
5.2. Beispiel: Comparator für übergeordnete Klassen
List.sort hat die Signatur sort(Comparator<? super E> c). Comparator ist ein Consumer, da er Elemente annimmt, um sie zu vergleichen. Dank ? super kannst du einen Comparator, der für Product geschrieben wurde, verwenden, um List<Book> zu sortieren:
Comparator<Product> byPrice = Comparator.comparingDouble(Product::price);
List<Book> books = new ArrayList<>(List.of(
new Book("Effective Java", 450_000),
new Book("Clean Code", 300_000)));
books.sort(byPrice);
books.forEach(b -> System.out.println(b.name()));
Clean Code
Effective Java
Du wirst dieses Muster sehr häufig in der Stream API sehen. Zum Beispiel map(Function<? super T, ? extends R> mapper): die Eingabe der Funktion ist ein Consumer (super), die Ausgabe ist ein Producer (extends).
6. Unbegrenzter Wildcard: ?
Wenn die Methode sich nicht um den Elementtyp kümmert, verwendest du ?:
public static int countNonNull(List<?> items) {
int count = 0;
for (Object item : items) {
if (item != null) count++;
}
return count;
}
List<?> akzeptiert jede Liste: List<Book>, List<String>, List<Integer>. Elemente, die du ausliest, sind vom Typ Object, und du kannst nichts außer null hinzufügen.
Verwechsle nicht List<?> mit List<Object>. Eine Methode, die List<Object> akzeptiert, akzeptiert List<String> nicht, auch wegen der Invarianz aus Abschnitt 2.
7. Verhalten testen
Füge JUnit 5 und AssertJ zu pom.xml hinzu:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Wenn dein Projekt Spring Boot 3.x verwendet, enthält spring-boot-starter-test bereits beide Bibliotheken.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CatalogUtilsTest {
@Test
void totalPrice_acceptsListOfSubtype() {
List<Book> books = List.of(
new Book("Clean Code", 300_000),
new Book("Effective Java", 450_000));
assertThat(CatalogUtils.totalPrice(books)).isEqualTo(750_000.0);
}
@Test
void addDefaultBooks_worksWithSupertypeList() {
List<Object> anything = new ArrayList<>();
anything.add("kein Buch");
CatalogUtils.addDefaultBooks(anything);
assertThat(anything).hasSize(3);
assertThat(anything.get(1)).isInstanceOf(Book.class);
}
@Test
void copy_movesBooksIntoProductList() {
List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();
CatalogUtils.copy(books, cart);
assertThat(cart).extracting(Product::name).containsExactly("DDD");
}
@Test
void addDefaultBooks_failsAtRuntimeOnImmutableList() {
List<Product> immutable = List.of();
assertThatThrownBy(() -> CatalogUtils.addDefaultBooks(immutable))
.isInstanceOf(UnsupportedOperationException.class);
}
}
Die ersten drei Tests bestätigen, dass die Methoden Subtypen und übergeordnete Typen wie erwartet akzeptieren. Der zweite Test zeigt, dass List<Object> alte Elemente behält und zusätzlich Book akzeptiert. Der letzte Test zeigt etwas Wichtiges: Wildcards prüfen nur den Typ zur Compile-Zeit. Sie wissen nicht, ob die Liste änderbar ist oder nicht.
8. Häufige Fehler und wann du Wildcards nicht verwenden solltest
Wildcards im Rückgabetyp verwenden.
public static List<? extends Product> findAll() { ... } // solltest du nicht tun
Der Aufrufer erhält eine Liste, die quasi nur lesbar ist, und muss ? überall herumschleppen. Wildcards sollten in den Parametern der Methode sein, nicht im Rückgabetyp.
? extends verwenden und dann versuchen, add aufzurufen. Wenn die Methode in die Collection schreiben muss, ist extends die falsche Wahl. Überprüfe nochmal PECS.
Wildcards verwenden, wenn du sowohl lesen als auch schreiben musst. Zum Beispiel zum Tauschen von zwei Elementen:
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <T> void swapHelper(List<T> list, int i, int j) {
T tmp = list.get(i);
list.set(i, list.get(j));
list.set(j, tmp);
}
Das direkte Aufrufen von list.set(i, list.get(j)) auf List<?> ist ein Compile-Fehler. Der generische Helper <T> „erfasst” (capture) den tatsächlichen Typ von ? um Operationen durchzuführen. Diese Technik heißt wildcard capture. Wenn die öffentliche API nicht zwingend List<?> sein muss, ist die direkte Deklaration <T> void swap(List<T> list, ...) einfacher.
Übermäßige Verwendung von Wildcards in internen Klassen. Wildcards sind am nützlichsten in gemeinsamen APIs wie Bibliotheken oder Utilities. Bei privaten Methoden, die von nur einer Stelle aus aufgerufen werden, ist ein konkreter Typ oft lesbarer.
Hinweis:
? extendsmacht eine Liste nicht unveränderbar. Die Methode kann immer nochclear()oderremove(...)aufrufen, da diese Operationen den Elementtyp nicht kennen müssen. Wenn du die Daten schützen musst, verwendeList.copyOf(...)oderCollections.unmodifiableList(...).
Hinweis: Du kannst nicht
extendsundsuperauf denselben Wildcard anwenden. Du kannst auch? supernicht für einen type parameter deklarieren, der bei einer Klasse oder Methode angegeben ist (<T super Book>ist nicht zulässig).
9. Fazit
Generics in Java sind invariant. Deshalb ist List<Book> nicht dasselbe wie List<Product>, und das hilft, Typfehler zur Laufzeit zu vermeiden. Wildcards geben APIs die Flexibilität zurück:
? extends Twird für Parameter verwendet, die Daten liefern. Du kannstTauslesen, aber nicht hinzufügen.? super Twird für Parameter verwendet, die Daten annehmen. Du kannstThinzufügen, aber nurObjectauslesen.?wird verwendet, wenn dir der Elementtyp egal ist.
Wenn du unsicher bist, erinnere dich einfach an PECS: Producer Extends, Consumer Super. Bewahre Wildcards außerdem in den Methodenparametern, nicht im Rückgabetyp.