Généricité Java : wildcards, PECS et invariance — guide complet
Photo : Unsplash (libre de droits)
Sur LinkedIn, j'ai posté sur <? extends T> et <? super T>. Beaucoup de développeurs connaissent la syntaxe, mais ne savent pas vraiment pourquoi on choisit l'un plutôt que l'autre. J'ai moi-même deviné pendant longtemps — quand ça compilait, je passais à autre chose.
Cet article part du début : pourquoi ces notions existent, ce qu'elles signifient concrètement, et comment les reconnaître d'instinct. Chaque terme est expliqué en contexte.
1. Pourquoi la généricité existe en Java
Avant Java 5 (2004), les collections (List, Set, Map) ne portaient aucune information sur le type de leurs éléments. Tout était stocké comme un Object — la classe mère de toutes les classes Java — et récupéré avec un transtypage forcé (cast). Si le type était mauvais, le programme plantait à l'exécution (runtime), parfois des semaines après l'écriture du code.
// Aucun contrôle de type : la liste accepte n'importe quoi List rapports = new ArrayList(); rapports.add(new Rapport()); rapports.add("erreur"); // accepté sans avertissement // Le cast (transtypage) peut planter à l'exécution Rapport r = (Rapport) rapports.get(1); // ClassCastException à l'exécution !
Les génériques (Generics), introduits en Java 5, ont réglé ce problème. On déclare le type entre chevrons < >, et le compilateur (compiler — le programme qui transforme votre code Java en bytecode) vérifie la cohérence à la compilation (compile time). Les erreurs sont détectées avant même de lancer le programme.
// Le type est déclaré : le compilateur veille List<Rapport> rapports = new ArrayList<>(); rapports.add(new Rapport()); // ✓ rapports.add("erreur"); // ✗ erreur de COMPILATION — String ≠ Rapport // Plus besoin de cast — le type est garanti Rapport r = rapports.get(0); // ✓
L'apport fondamental
Les génériques déplacent les erreurs de type de l'exécution vers la compilation. Un bug détecté pendant le développement coûte infiniment moins cher qu'un bug en production.
2. Le contrat de la variable et la liberté de l'instance
C'est le point que beaucoup de développeurs n'ont jamais vu clairement expliqué.
Dans une déclaration Java, le = sépare deux choses de nature très différente.
// ① le contrat (fixe) ② l'instance (peut changer) // ↓ ↓ List<Rapport> liste = new ArrayList<Rapport>(); // ↑ // type de la variable : // exprime la POSSIBILITÉ — cette variable peut référencer // tout objet compatible avec List<Rapport>
① À gauche : le type de la variable — c'est le contrat.
List<Rapport> exprime une possibilité : cette variable peut référencer n'importe quel objet qui implémente List<Rapport> — un ArrayList<Rapport>, un LinkedList<Rapport>, etc. Ce type est fixé à la déclaration et ne change pas. Il définit ce que le compilateur autorisera ou refusera sur cette variable.
② À droite : l'instance — elle peut changer.
new ArrayList<Rapport>() crée un objet concret en mémoire. Mais la variable peut à tout moment être redirigée vers une autre instance compatible. C'est parfaitement légal — c'est même courant.
// Le contrat est déclaré : List<Rapport> // L'instance initiale : un ArrayList List<Rapport> liste = new ArrayList<Rapport>(); liste.add(new Rapport()); // On change l'instance — "liste" pointe maintenant vers un LinkedList // C'est valide : LinkedList<Rapport> est compatible avec List<Rapport> liste = new LinkedList<Rapport>(); // ✓ l'instance change // Le contrat de la variable n'a pas changé : elle reste de type List<Rapport> // Ce que le compilateur refuse toujours via cette variable : liste.add("texte"); // ✗ String ≠ Rapport — le contrat l'interdit liste.add(new Facture()); // ✗ Facture ≠ Rapport — le contrat l'interdit
En une phrase
Le type à gauche définit le contrat de la variable — ce qu'elle accepte ou refuse — et ce contrat est fixe. L'instance à droite peut changer : on peut pointer la variable vers n'importe quel objet compatible avec ce contrat. C'est la séparation entre ce qui est permis (le type) et ce qui existe en mémoire (l'instance).
3. L'invariance : pourquoi List<Facture> ≠ List<Document>
Prenons une hiérarchie classique : Facture extends Document, et FactureAvoir extends Facture. En Java orienté objet, une Facture peut être utilisée partout où un Document est attendu — c'est l'héritage (inheritance) et le principe de substitution.
L'intuition naturelle : puisque Facture est un sous-type de Document, une List<Facture> devrait pouvoir aller là où une List<Document> est attendue. Mais Java refuse catégoriquement :
La généricité Java est invariante (invariant) : il n'existe aucune relation de sous-typage entre List<Facture> et List<Document>, même si Facture extends Document. Voici pourquoi c'est une protection nécessaire :
// Imaginons que ceci soit autorisé — ça ne l'est PAS List<Document> docs = new ArrayList<Facture>(); // ← IMPOSSIBLE // Si c'était possible, on pourrait ensuite ajouter n'importe quel Document... docs.add(new Devis()); // Devis est un Document, mais PAS une Facture // Résultat : un Devis dans une liste qui est physiquement un ArrayList<Facture> // → ClassCastException à l'exécution. On revient au problème d'avant Java 5.
L'héritage entre classes ne se transfère pas aux types génériques — c'est l'invariance.
4. Les wildcards : dépasser l'invariance avec souplesse
L'invariance est nécessaire, mais parfois trop rigide. Comment écrire une méthode qui calcule la somme des montants d'une List<Facture> et d'une List<FactureAvoir> sans dupliquer le code ?
C'est le rôle des wildcards (jokers) — représentés par ?. Plutôt que de déclarer un type exact, on exprime une contrainte sur ce type. Il en existe deux formes, et les confondre produit soit une erreur de compilation, soit un code fragile.
? extends T — je veux lire depuis la liste
List<? extends Document> signifie : une liste dont les éléments sont de type Document ou d'un sous-type quelconque de Document. Le compilateur garantit que chaque élément lu est au moins un Document — d'où le droit de lire. Mais comme il ne sait pas quel sous-type exact est présent, il interdit tout ajout.
// On LIT la liste → extends — elle PRODUIT des données pour nous static double totalMontants(List<? extends Document> docs) { double total = 0; for (Document d : docs) // ✓ garanti : au moins un Document total += d.getMontant(); return total; } List<Facture> factures = List.of(new Facture()); List<FactureAvoir> avoirs = List.of(new FactureAvoir()); totalMontants(factures); // ✓ totalMontants(avoirs); // ✓ // L'écriture est interdite à l'intérieur de la méthode docs.add(new Facture()); // ✗ compilation — la liste pourrait être List<FactureAvoir> // Le compilateur ne peut pas garantir qu'une Facture y est acceptable
? super T — je veux écrire dans la liste
List<? super Facture> signifie : une liste dont le type d'élément est Facture ou l'une de ses super-classes. Le compilateur sait qu'on peut y insérer des Facture et ses sous-types (toute super-classe de Facture peut les accueillir par polymorphisme). En contrepartie, la lecture ne peut garantir que Object.
// On ÉCRIT dans la liste → super — elle CONSOMME nos données static void ajouterFactures(List<? super Facture> liste) { liste.add(new Facture()); // ✓ liste.add(new FactureAvoir()); // ✓ FactureAvoir est un sous-type de Facture } ajouterFactures(new ArrayList<Facture>()); // ✓ ajouterFactures(new ArrayList<Document>()); // ✓ ajouterFactures(new ArrayList<Object>()); // ✓ // En lecture, seul Object est garanti Object o = liste.get(0); // ✓ Facture f = liste.get(0); // ✗ compilation — la liste pourrait être List<Document>
5. La règle PECS — la clé pour ne plus jamais hésiter
Joshua Bloch, dans Effective Java, a résumé les deux cas en une formule mémnémotechnique (aide-mémoire) : PECS — Producer Extends, Consumer Super. La seule question à se poser : est-ce que la collection produit des données (vous lisez dedans) ou les consomme (vous y écrivez) ?
PECS — Producer Extends, Consumer Super (Producteur Étend, Consommateur Super)
? extends T → Producer (producteur)
Vous lisez depuis cette liste.
Elle produit des T pour vous.
- Lecture garantie : retourne un
T - Écriture interdite (sauf
null)
Ex : calculer, transformer, afficher
? super T → Consumer (consommateur)
Vous écrivez dans cette liste.
Elle consomme vos T.
- Écriture de
Tet sous-types - Lecture retourne
Objectseulement
Ex : remplir, alimenter, copier
6. Les deux ensemble : la méthode de copie
La méthode Collections.copy() de la bibliothèque standard Java illustre PECS dans sa forme la plus complète. La source produit, la destination consomme :
// source → on lit dedans → Producer → extends // dest → on y écrit → Consumer → super static <T> void copier( List<? super T> dest, // Consumer List<? extends T> source // Producer ) { for (T elem : source) dest.add(elem); // ✓ } List<FactureAvoir> src = List.of(new FactureAvoir()); List<Facture> dest = new ArrayList<>(); copier(dest, src); // ✓ T est inféré comme FactureAvoir par le compilateur
extends (borne supérieure, upper bound) — Facture et ce qui est en dessous · super (borne inférieure, lower bound) — Facture et ce qui est au-dessus
7. Tableau récapitulatif
| Forme | Nom | Lecture | Écriture | Utiliser quand |
|---|---|---|---|---|
List<T> |
Type exact | ✓ T | ✓ T | Vous lisez ET écrivez des T |
List<? extends T> |
Borne supérieure upper bound |
✓ T garanti | ✕ interdit | Vous lisez depuis la liste (Producer) |
List<? super T> |
Borne inférieure lower bound |
Object seulement | ✓ T et sous-types | Vous écrivez dans la liste (Consumer) |
List<?> |
Non borné unbounded |
Object | ✕ interdit | Vous parcourez sans connaître le type |
8. Ce que ça change avec l'IA
GitHub Copilot et Claude génèrent ces wildcards sans sourciller. Pour des méthodes classiques, ils ont souvent raison. Mais dans un contexte métier plus complexe — hiérarchies profondes, génériques imbriqués (nested generics) — l'IA peut confondre extends et super avec la même assurance qu'elle a quand elle a raison. L'erreur est silencieuse : le code compile, mais son comportement est fragile.
Le réflexe en revue de code
Quand vous voyez un wildcard dans du code — généré par l'IA ou écrit par un collègue — posez une seule question : est-ce que ce paramètre est utilisé en lecture ou en écriture ?
Lecture → extends. Écriture → super. Les deux → type exact T.
Des questions sur la généricité Java ou votre parcours TI au Québec ?
Je réponds à tous les messages — généralement en 2-3 jours.