Generics dans Java: wildcard ? extends et ? super

1. Aperçu

Les generics aident le code Java à être type-safe dès la compilation. Mais lorsque vous écrivez une méthode qui accepte List<Product>, vous verrez qu’elle n’accepte pas List<Book>, bien que Book soit une sous-classe de Product. Cet article explique pourquoi il en est ainsi. Ensuite, je vous montre comment utiliser les wildcards ? extends et ? super pour écrire des API plus flexibles. Après avoir lu ceci, vous saurez quel wildcard choisir selon le principe PECS et éviter les erreurs de compilation déroutantes.

2. Le problème : les generics sont invariants

Nous allons utiliser une hiérarchie de classes simple pour cet article :

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 est un Product. Donc List<Book> est-il List<Product> ?

List<Book> books = new ArrayList<>();
List<Product> products = books; // Erreur de compilation : types incompatibles

La réponse est non. Les generics en Java sont invariants. Même si A est une sous-classe de B, List<A> et List<B> restent deux types sans relation.

La raison en est la sécurité des types elle-même. Si la ligne précédente se compilait, on pourrait écrire :

products.add(new Product("Stylo à bille", 5_000)); // ajouter Product dans une list « contenant seulement des Book »
Book first = books.get(0);                         // ClassCastException !

Les tableaux, eux, sont différents. Les tableaux en Java sont covariants, donc une erreur similaire n’est révélée qu’à l’exécution :

Object[] items = new String[2];
items[0] = 42; // compilation OK, ArrayStoreException à l'exécution

Les generics choisissent de bloquer les erreurs dès la compilation. Le prix à payer est une moindre flexibilité. Les wildcards ont été créés pour retrouver cette flexibilité.

3. Wildcard borné supérieurement : ? extends T

3.1. Lire des données d’une collection

Supposons que vous ayez besoin de calculer le prix total d’une liste de produits. La première version ressemble généralement à ceci :

static double totalPrice(List<Product> products) { ... }

Cette méthode n’accepte pas List<Book>. Nous la corrigeons avec ? 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> signifie « une liste contenant des éléments d’un type quelconque qui est Product ou une sous-classe de Product ». Grâce à cela :

  • Vous pouvez passer List<Product>, List<Book>, ou List<Ebook> s’il existe.
  • Chaque élément lu est sûrement un Product, donc la boucle for (Product p : products) est valide.
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. Pourquoi ne peut-on pas ajouter d’éléments ?

Essayons d’ajouter un élément à une liste avec ? extends :

static void addBook(List<? extends Product> products) {
    products.add(new Book("Refactoring", 400_000)); // Erreur de compilation
    products.add(null);                              // OK, mais sans sens
}

Le compilateur ne sait pas ce que ? est réellement. La liste passée pourrait être List<Ebook>. Dans ce cas, ajouter un Book ordinaire casserait la sécurité des types. C’est pourquoi le compilateur interdit toute opération add, sauf pour null.

Règle facile à retenir : ? extends T est uniquement pour lire T. La collection joue ici le rôle de producteur, c’est-à-dire une source de données pour vous.

4. Wildcard borné inférieurement : ? super T

Faisons maintenant l’inverse. Vous voulez une méthode qui charge des livres par défaut dans n’importe quelle liste capable de contenir Book :

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> signifie « une liste dont le type d’élément est Book ou un parent de Book ». Tout type comme celui-ci peut contenir un Book, donc add(new Book(...)) est toujours sûr.

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

En revanche, lorsque vous lisez, vous savez seulement avec certitude que l’élément est un Object :

static void printFirst(List<? super Book> target) {
    Object first = target.get(0); // OK
    Book book = target.get(0);    // Erreur de compilation
}

La liste pourrait être List<Object> contenant de tout, donc le compilateur ne peut garantir que le type Object.

Règle : ? super T pour écrire T dedans. La collection joue ici le rôle de consommateur, c’est-à-dire un endroit qui accepte les données que vous lui fournissez.

5. Le principe PECS

Les deux règles ci-dessus se résument en PECS : Producer Extends, Consumer Super.

  • Si un paramètre fournit T pour vous, utilisez ? extends T.
  • Si un paramètre accepte T de votre part, utilisez ? super T.
  • Si un paramètre à la fois lit et écrit, n’utilisez pas de wildcard, déclarez le type exact T.

5.1. Exemple : copier entre deux listes

public static <T> void copy(List<? extends T> source, List<? super T> target) {
    for (T item : source) {
        target.add(item);
    }
}

source est un producteur donc nous utilisons extends. target est un consommateur donc nous utilisons super. Le résultat est une méthode très flexible :

List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();

CatalogUtils.<Book>copy(books, cart);   // T = Book : lire Book, écrire dans une liste parent
CatalogUtils.<Product>copy(books, cart); // T = Product est aussi valide

Le JDK applique correctement ce principe. La signature de Collections.copy est :

public static <T> void copy(List<? super T> dest, List<? extends T> src)

5.2. Exemple : Comparator partagé pour une classe parent

List.sort a la signature sort(Comparator<? super E> c). Un Comparator est un consommateur, car il accepte des éléments pour les comparer. Grâce à ? super, vous pouvez utiliser un comparateur écrit pour Product pour trier une List<Book> :

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

Vous rencontrerez ce motif très souvent dans l’API Stream. Par exemple, map(Function<? super T, ? extends R> mapper) : l’entrée de la fonction est un consommateur (super), la sortie est un producteur (extends).

6. Wildcard non borné : ?

Lorsqu’une méthode ne se soucie pas du type d’élément, utilisez ? :

public static int countNonNull(List<?> items) {
    int count = 0;
    for (Object item : items) {
        if (item != null) count++;
    }
    return count;
}

List<?> accepte n’importe quelle liste : List<Book>, List<String>, List<Integer>. Les éléments lus sont des Object, et vous ne pouvez ajouter que null.

Ne confondez pas List<?> avec List<Object>. Une méthode qui accepte List<Object> n’accepte pas List<String>, toujours à cause de l’invariance mentionnée à la section 2.

7. Test du comportement

Ajoutez JUnit 5 et AssertJ à votre pom.xml :

<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>

Si votre projet utilise Spring Boot 3.x, spring-boot-starter-test inclut déjà ces deux bibliothèques.

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("pas un livre");

        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);
    }
}

Les trois premiers tests confirment que les méthodes acceptent les sous-types et les super-types comme prévu. Le deuxième test montre que List<Object> conserve l’élément existant et accepte l’ajout de Book. Le dernier test rappelle quelque chose d’important : les wildcards ne vérifient que le type à la compilation. Ils ne savent pas si la liste permet la modification.

8. Erreurs courantes et quand ne pas utiliser

Utiliser un wildcard dans le type de retour.

public static List<? extends Product> findAll() { ... } // ne pas faire

L’appelant recevra une liste pratiquement en lecture seule, et devra traîner le ? partout. Les wildcards devraient se trouver dans les paramètres de la méthode, pas dans le type de retour.

Utiliser ? extends puis essayer d’appeler add. Si la méthode a besoin d’écrire dans la collection, extends est le mauvais choix. Repensez à PECS.

Utiliser un wildcard lorsque vous avez besoin de lire et écrire avec le type exact. Par exemple, échanger deux éléments :

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);
}

Appeler list.set(i, list.get(j)) directement sur List<?> causera une erreur de compilation. La méthode générique <T> « capture » le type réel du ? pour pouvoir l’utiliser. Cette technique s’appelle wildcard capture. Si l’API publique n’a pas besoin d’être List<?>, déclarer directement <T> void swap(List<T> list, ...) sera plus simple.

Abuser de wildcards pour les classes internes. Les wildcards sont surtout utiles dans les API partagées comme les bibliothèques ou les utilitaires. Pour une méthode privée appelée depuis un seul endroit, un type précis est généralement plus lisible.

Note : ? extends ne rend pas la liste immutable. Vous pouvez toujours appeler clear() ou remove(...), car ces opérations ne dépendent pas du type d’élément. Si vous voulez protéger les données, utilisez List.copyOf(...) ou Collections.unmodifiableList(...).

Note : Vous ne pouvez pas utiliser à la fois extends et super sur le même wildcard. Vous ne pouvez pas non plus écrire ? super pour un type parameter déclaré dans une classe ou une méthode (<T super Book> n’est pas valide).

9. Conclusion

Les generics en Java sont invariants. C’est pourquoi List<Book> n’est pas List<Product>, ce qui aide à éviter les erreurs de type à l’exécution. Les wildcards retrouvent la flexibilité pour l’API :

  • ? extends T est utilisé pour les paramètres qui fournissent des données. Vous pouvez lire T mais pas l’ajouter.
  • ? super T est utilisé pour les paramètres qui acceptent des données. Vous pouvez ajouter T mais lire seulement Object.
  • ? est utilisé lorsque vous ne vous souciez pas du type d’élément.

Lorsque vous êtes dans le doute, souvenez-vous simplement de PECS : Producer Extends, Consumer Super. En outre, gardez les wildcards dans les paramètres de méthode, pas dans le type de retour.

javagenericswildcardpecscollections