Programmation Java
Les génériques Java : paramètres de type, bornes, wildcards et effacement
Intermédiaire8 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Les génériques permettent d'écrire une classe ou une méthode une seule fois et de l'utiliser sans risque avec de nombreux types, en faisant passer les erreurs de cast de l'exécution à la compilation. Ce guide explique comment déclarer et utiliser des paramètres de type, comment les bornes et les wildcards expriment ce dont une méthode a besoin, pourquoi une List<Dog> n'est pas une List<Animal>, ce que l'effacement de type permet et interdit, et quels idiomes — PECS, méthodes génériques, type tokens — rendent le code générique lisible.
Ce que vous allez apprendre
- Déclarer des classes, interfaces et méthodes génériques avec des paramètres de type pertinents
- Appliquer les bornes supérieures et inférieures et la règle PECS aux paramètres wildcard
- Expliquer l'invariance et pourquoi les tableaux se comportent autrement que les collections génériques
- Décrire l'effacement de type et contourner ses limites avec des class tokens et des méthodes utilitaires
- Reconnaître dans les raw types et les warnings unchecked des bugs en puissance
Avant l'arrivée des génériques en Java 5, une List contenait des Object, chaque lecture exigeait un cast, et un cast erroné se traduisait par une ClassCastException loin du code fautif. Les génériques confient cette vérification au compilateur : une List<String> ne peut contenir que des chaînes, et get renvoie un String sans cast. La mécanique est simple jusqu'à ce qu'on rencontre les wildcards et l'effacement de type, là où la plupart des développeurs cessent de comprendre ce qu'ils écrivent. Ce guide vous fait franchir ces deux caps. Les exemples ciblent Java 21.
Classes et interfaces génériques
Un paramètre de type est un emplacement réservé pour un type, déclaré entre chevrons après le nom de la classe et utilisé dans la classe partout où un type pourrait figurer :
public final class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A first() { return first; }
public B second() { return second; }
public <C> Pair<A, C> withSecond(C newSecond) { // generic method inside a generic class
return new Pair<>(first, newSecond);
}
}
Pair<String, Integer> p = new Pair<>("age", 42);
Integer age = p.second(); // no cast
Pair<String, Boolean> q = p.withSecond(true);
Le diamant <> de new Pair<>(...) demande au compilateur de déduire les arguments à partir du type cible : vous ne les écrivez qu'une fois. Par convention, les paramètres de type sont des majuscules uniques : T pour un type, E pour un élément, K/V pour une clé et une valeur, R pour un résultat. Des noms plus longs comme Request sont autorisés et parfois plus clairs dans les signatures complexes.
Les paramètres de type doivent être des types référence. List<int> ne compile pas ; vous écrivez List<Integer> et payez le coût du boxing. C'est pour cela que l'API streams propose IntStream et que java.util.function contient IntPredicate, ToLongFunction et d'autres spécialisations pour les types primitifs.
Les interfaces génériques fonctionnent de la même façon. Comparable<T>, Iterable<T>, Function<T, R> et Map<K, V> sont celles que vous implémentez ou acceptez le plus souvent, et une classe peut fixer le paramètre au moment de l'implémentation — class Version implements Comparable<Version> — ce qui permet à compareTo de prendre un Version plutôt qu'un Object.
Méthodes génériques
Une méthode peut introduire son propre paramètre de type, déclaré avant le type de retour. C'est le bon choix pour les utilitaires statiques et pour toute méthode dont la relation entre types ne dépend pas de la classe englobante :
public static <T> List<T> repeat(T value, int times) {
List<T> result = new ArrayList<>(times);
for (int i = 0; i < times; i++) result.add(value);
return result;
}
public static <T, R> List<R> mapAll(List<T> input, Function<? super T, ? extends R> fn) {
List<R> out = new ArrayList<>(input.size());
for (T item : input) out.add(fn.apply(item));
return out;
}
List<String> xs = repeat("x", 3); // T inferred as String
List<Integer> lengths = mapAll(xs, String::length); // T = String, R = Integer
L'inférence trouve presque toujours les arguments. Quand elle échoue — typiquement avec une collection vide ou un appel chaîné dont le type cible est inconnu —, vous pouvez les indiquer explicitement avec un type witness : Collections.<String>emptyList().
Paramètres de type bornés
Dans la méthode, un T non borné équivaut en pratique à Object : vous pouvez le stocker et le renvoyer, mais pas appeler quoi que ce soit dessus. Une borne restreint le paramètre et donne accès aux méthodes de la borne :
public static <T extends Comparable<? super T>> T max(Collection<? extends T> items) {
Iterator<? extends T> it = items.iterator();
T best = it.next();
while (it.hasNext()) {
T candidate = it.next();
if (candidate.compareTo(best) > 0) best = candidate; // compareTo is available
}
return best;
}
T extends Comparable<? super T> se lit « T peut être comparé à lui-même ou à l'un de ses supertypes ». Le ? super T compte : si Employee implements Comparable<Employee> et Manager extends Employee, alors max(List<Manager>) compile parce que Manager est comparable via le Comparable<Employee> hérité. Avec une simple borne Comparable<T>, l'appel ne compilerait qu'en inférant T comme Employee : max vous rendrait alors un Employee et vous seriez de nouveau obligé de caster. Les bornes multiples utilisent & : <T extends Number & Comparable<T>>, avec au plus une classe et un nombre quelconque d'interfaces.
Le mot-clé est toujours extends, même pour les interfaces ; il n'y a pas de implements dans une borne.
L'invariance, et pourquoi les tableaux sont différents
Les types génériques sont invariants : List<Manager> n'est pas un sous-type de List<Employee>, bien que Manager soit un sous-type de Employee. C'est une question de sûreté du typage. Si l'affectation était permise :
List<Manager> managers = new ArrayList<>();
List<Employee> employees = managers; // hypothetically allowed
employees.add(new Contractor()); // legal on a List<Employee>...
Manager m = managers.get(0); // ...and now this explodes
Les tableaux ont fait le choix inverse : Manager[] est un sous-type de Employee[] (covariance), et la JVM vérifie chaque écriture à l'exécution, en levant une ArrayStoreException si l'on range un Contractor dans ce qui est en réalité un Manager[]. Les génériques ont déplacé la vérification à la compilation et ont renoncé à la covariance pour y parvenir. C'est aussi pourquoi vous ne pouvez pas créer de tableau générique — new T[10] et new List<String>[3] sont interdits —, car la vérification à l'exécution aurait besoin d'un type que l'effacement a supprimé.
Les wildcards et la règle PECS
L'invariance est sûre mais contraignante : une méthode printAll(List<Employee>) refuserait une List<Manager>. Les wildcards redonnent de la souplesse côté utilisation, en indiquant ce dont la méthode a besoin.
List<? extends Employee>— une liste d'Employee ou d'un de ses sous-types. Vous pouvez en lire les éléments en tant qu'Employee. Vous ne pouvez rien y ajouter saufnull, car le type réel des éléments pourrait êtreManageret l'objet que vous avez en main, unContractor.List<? super Manager>— une liste de Manager ou d'un de ses supertypes. Vous pouvez y ajouter unManager(il convient quel que soit le type réel). La lecture vous donne unObject, car le type réel pourrait êtreEmployeeouObject.List<?>— une liste de quelque chose. Lecture enObject, aucun ajout.
Le moyen mnémotechnique est PECS : Producer Extends, Consumer Super. Si le paramètre produit les valeurs que vous lisez, utilisez extends ; s'il consomme les valeurs que vous y placez, utilisez super ; s'il fait les deux, utilisez le type exact.
// Copy from a producer into a consumer
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T item : src) dest.add(item);
}
List<Employee> staff = new ArrayList<>();
List<Manager> mgrs = List.of(new Manager("Ada"), new Manager("Grace"));
copy(staff, mgrs); // dest is a super of Manager, src produces Managers
Les signatures du JDK lui-même suivent cette règle : Collections.copy(List<? super T> dest, List<? extends T> src), Stream.map(Function<? super T, ? extends R>), Collection.addAll(Collection<? extends E>). Quand vous voyez ? super T sur un Comparator ou un Consumer, c'est PECS à l'œuvre : un Comparator<Object> sait comparer des employés, donc sort(List<T>, Comparator<? super T>) l'accepte.
Les wildcards ont leur place dans les paramètres de méthode. Les types de retour doivent être concrets : une méthode qui renvoie List<? extends Shape> oblige chaque appelant à composer avec le wildcard, sans aucun bénéfice.
L'effacement de type
Les génériques n'existent qu'à la compilation. Une fois les vérifications faites, le compilateur efface les paramètres de type : un T non borné devient Object, un T extends Number borné devient Number, et des casts sont insérés aux points d'appel. List<String> et List<Integer> sont la même classe à l'exécution. C'était le prix de la rétrocompatibilité avec le bytecode antérieur aux génériques, et cela a des conséquences :
| Vous ne pouvez pas écrire | Parce que |
|---|---|
new T() | Il n'y a pas de classe à instancier à l'exécution |
new T[n] | Un tableau a besoin d'un type d'élément à l'exécution |
obj instanceof List<String> | Le paramètre n'existe plus ; instanceof List<?> est autorisé |
catch (T e) | Les types d'exception doivent être réifiables |
Les surcharges f(List<String>) et f(List<Integer>) | Elles ont la même signature une fois effacée |
static T field | Les membres statiques sont partagés par toutes les paramétrisations |
Le contournement classique quand « j'ai besoin de la classe à l'exécution » est un type token : passer explicitement un Class<T>.
public static <T> T firstOfType(List<?> items, Class<T> type) {
for (Object item : items) {
if (type.isInstance(item)) return type.cast(item); // safe, checked cast
}
return null;
}
Integer n = firstOfType(List.of("a", 7, 2.0), Integer.class); // 7
Class<T>.cast et isInstance vous donnent un cast vérifié, sans warning unchecked. Des frameworks comme Jackson et Spring utilisent cet idiome et le prolongent avec des sous-classes de ParameterizedTypeReference/TypeReference pour capturer un type complet comme List<Foo> par réflexion sur une sous-classe anonyme — le seul endroit où l'effacement laisse la signature complète intacte.
L'effacement introduit aussi des méthodes pont (bridge methods) : quand Version implements Comparable<Version>, le compilateur génère une méthode cachée compareTo(Object) qui caste et délègue, afin que le code appelant via l'interface effacée fonctionne toujours. Vous ne les remarquerez que dans les stack traces et par réflexion.
Raw types et warnings unchecked
Un raw type est un type générique utilisé sans arguments : List list = new ArrayList();. Il n'existe que pour que le code antérieur à Java 5 compile, et l'utiliser désactive les vérifications de type pour tout ce qui y touche. Chaque raw type dans une base de code moderne est une ClassCastException en sommeil.
Un warning unchecked signifie que le compilateur n'a pas pu prouver qu'un cast ou un appel est sûr — typiquement un cast vers un type paramétré, comme (List<String>) obj. Ne le masquez jamais avec @SuppressWarnings("unchecked") sans un commentaire qui démontre pourquoi c'est sûr, et placez l'annotation sur la portée la plus réduite possible (une variable locale, pas la classe). L'idiome Class<T>.cast vu plus haut évite complètement le warning pour les casts vers un type simple.
Un cas particulier fréquent : les varargs génériques. List<String>... lists crée un tableau d'un type générique, ce qui déclenche un warning du compilateur à chaque point d'appel. Si la méthode se contente de lire le tableau, annotez-la avec @SafeVarargs (autorisé sur les méthodes static, final et private) et le warning disparaît aux points d'appel.
Bornes récursives et builders fluides
<T extends Comparable<T>> est une borne de type récursive : le paramètre apparaît dans sa propre borne. La même astuce permet à une classe de base de builder de renvoyer le type concret de la sous-classe depuis ses méthodes fluides :
abstract class Builder<B extends Builder<B>> {
protected String name;
public B name(String name) { this.name = name; return self(); }
protected abstract B self();
}
final class UserBuilder extends Builder<UserBuilder> {
private String email;
public UserBuilder email(String email) { this.email = email; return this; }
@Override protected UserBuilder self() { return this; }
public User build() { return new User(name, email); }
}
User u = new UserBuilder().name("Ada").email("[email protected]").build();
// Without the recursive bound, name() would return Builder and email() would not exist
Utilisez-la avec parcimonie : elle est puissante mais difficile à lire, et un unique builder concret suffit souvent.
Erreurs courantes
Les raw types. new ArrayList() ou Map map jettent les vérifications à la poubelle. Ajoutez les arguments de type ; utilisez le diamant pour ne les écrire qu'une fois.
Renvoyer des wildcards. List<? extends Number> compute() reporte la complexité sur chaque appelant. Renvoyez List<Number> ou un type précis.
Des wildcards des deux côtés. void merge(List<? extends T> a, List<? extends T> b) ne peut rien ajouter de b dans a. Quand un paramètre est à la fois lu et écrit, utilisez le paramètre de type tel quel.
Croire que List<String> survit jusqu'à l'exécution. La désérialisation, la réflexion et instanceof ne voient que List. Utilisez des type tokens ou des conversions explicites.
Mélanger tableaux et génériques. Les champs T[], new ArrayList<String>[n] et List<String>[] produisent tous des warnings ou des erreurs. Préférez List<List<String>>.
Abuser d'Object pour éviter les génériques. Une méthode qui prend un Object et caste à l'intérieur, c'est le monde d'avant les génériques avec des étapes en plus. Introduisez un paramètre de type.
Borner avec Comparable<T> seul. Cela rejette les sous-classes d'une classe comparable. Utilisez Comparable<? super T>.
Points clés à retenir
- Les paramètres de type font passer les échecs de cast à la compilation ; déclarez-les sur des classes, des interfaces ou des méthodes individuelles, et laissez le diamant et l'inférence compléter les arguments.
- Les bornes (
T extends X) rendent appelables les méthodes de la borne ;Comparable<? super T>est la bonne borne pour des éléments triables. - Les types génériques sont invariants. Utilisez
? extendspour les paramètres que vous lisez et? superpour ceux dans lesquels vous écrivez (PECS) ; renvoyez des types concrets. - L'effacement supprime les arguments de type à l'exécution :
new T(), les tableaux génériques etinstanceof List<String>sont donc impossibles ; passez un tokenClass<T>quand vous avez besoin du type. - Les raw types et les warnings unchecked non traités sont des bugs en puissance ; corrigez-les plutôt que de les faire taire.
- Les tableaux sont covariants et vérifiés à l'exécution ; les collections sont invariantes et vérifiées à la compilation. Préférez les collections dans le code générique.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi ne puis-je pas affecter une List<Dog> à une List<Animal> ?
- Parce que les types génériques sont invariants. Si l'affectation était permise, du code manipulant la List<Animal> pourrait ajouter un Cat dans ce qui est en réalité une liste de chiens, et l'erreur se manifesterait plus tard sous la forme d'une ClassCastException, à un endroit sans rapport. Utilisez List<? extends Animal> si vous avez seulement besoin de lire des animaux dans la liste.
- Que signifie « effacement de type » (type erasure) ?
- Le compilateur vérifie les types génériques, puis les retire du bytecode : il remplace les paramètres de type par leur borne (en général Object) et insère des casts là où c'est nécessaire. À l'exécution, une List<String> et une List<Integer> sont la même classe. C'est pourquoi vous ne pouvez écrire ni new T(), ni T[], ni instanceof List<String>.
- Quelle est la différence entre List<Object> et List<?> ?
- List<Object> est une liste qui peut contenir n'importe quel objet et à laquelle vous pouvez tout ajouter ; seule une List<Object> peut lui être affectée. List<?> est une liste d'un type précis mais inconnu : n'importe quelle List<X> peut lui être affectée, mais vous ne pouvez y ajouter que null, car le compilateur ne sait pas ce qu'est X.
- Faut-il écrire T extends Comparable<T> ou T extends Comparable<? super T> ?
- La seconde forme est plus souple. Si une classe Employee implémente Comparable<Employee> et que Manager étend Employee, Manager est comparable grâce à l'implémentation de sa superclasse. Comparable<? super T> l'accepte ; Comparable<T> seul refuse une List<Manager>.