Trouver un emploi en informatique au Québec

CV Entrevue Intégration Pour tous les parcours

Bénévole LinkedIn

Généricité Java : wildcards, PECS et invariance — guide complet

Développeur écrivant du code Java — généricité wildcards PECS programmation

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.

Avant Java 5 — sans généricité
// 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.

Java 5+ — avec généricité
// 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.

Java — ce que le = sépare vraiment
//    ① 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.

Java — l'instance change, le contrat de la variable reste
// 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 :

List<Document> docs = new ArrayList<Facture>(); — incompatible types

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 :

Java — pourquoi l'invariance protège (scénario hypothétique)
// 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.
Héritage — relation ✓ Object Document Facture FactureAvoir sous-type → substitution possible Génériques — invariance ✕ List<Document> aucune relation List<FactureAvoir> types distincts, même si Facture hérite de Document

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.

Java — ? extends : lecture (Producer)
// 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.

Java — ? super : écriture (Consumer)
// 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 T et sous-types
  • Lecture retourne Object seulement

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 :

Java — PECS combiné
// 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
Point de référence : Facture Object Document Facture ← référence FactureAvoir FacturePro <? extends Facture> Facture + sous-classes → LECTURE Producer Facture, FactureAvoir,FacturePro <? super Facture> Facture + super-classes → ÉCRITURE Consumer Object, Document, Facture extends = Facture et en dessous → lecture super = Facture et au-dessus → écriture

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.

PECS n'est pas une règle à mémoriser. C'est une conséquence naturelle du raisonnement du compilateur. Une fois qu'on comprend le pourquoi, la règle vient d'elle-même.

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.

goutas.hilal@gmail.com