Effacement de type en Java
Comment Java implémente les génériques via l'effacement de type, ce qui est effacé à l'exécution et les conséquences pratiques.
L'effacement de type est la façon dont Java implémente les génériques : les paramètres de type existent pendant l'exécution du compilateur, puis ils sont supprimés avant l'écriture du bytecode. Un List<String> et un List<Integer> arrivent dans la JVM en tant que simple List — interchangeables à l'exécution. Le système de types à la compilation garantit la sécurité ; à l'exécution, c'est le même List que le langage avait en 1995. Ce choix de conception est le fait le plus important à connaître sur les génériques Java, et il explique chaque restriction bizarre que vous rencontrerez dans le chapitre suivant.
Ce chapitre explique par quoi l'effacement remplace les paramètres de type, pourquoi Java l'a choisi plutôt que les génériques réifiés, les conséquences à l'exécution (getClass, instanceof, new T()), les méthodes ponts générées par le compilateur pour maintenir le fonctionnement du remplacement de méthode, et pourquoi l'effacement bloque certaines surcharges. Si vous débutez avec les génériques, commencez par Java Generics.
Pourquoi Java a fait ce choix
Lorsque les génériques ont été ajoutés dans Java 5, la bibliothèque standard avait déjà une décennie d'existence. Chaque List, Map et Comparator existant était non générique, et tous les programmes existants dans le monde les utilisaient comme types bruts. L'exigence absolue de Sun était la compatibilité binaire descendante : le code compilé avec la bibliothèque standard antérieure à la version 5 devait continuer à fonctionner avec la nouvelle sans recompilation.
Deux conceptions étaient envisagées :
- Génériques réifiés — conserver les informations de type à l'exécution, comme C# l'a finalement fait. Plus rapides, plus expressifs, mais nécessitent que chaque fichier de classe existant dans le monde soit réémis.
- Génériques avec effacement — supprimer les informations de type à la compilation, laisser la forme du bytecode inchangée. Plus lents par appel (conversions de type supplémentaires), moins expressifs (pas de
new T()), mais chaque ancien JAR continue de fonctionner sans modification.
Sun a choisi l'effacement. Le prix pragmatique payé pour la mise à niveau a été au détriment de la flexibilité à long terme du langage. Ce n'est pas la conception que l'on choisirait sur une feuille blanche — mais c'est la conception que Java possède, et la comprendre permet de mettre en place tout ce qui concerne les génériques.
Ce que l'effacement fait réellement
Lorsque le compilateur rencontre un type générique, il fait deux choses :
- Efface chaque paramètre de type vers sa borne la plus à gauche — ou vers
Objects'il n'y a pas de borne. - Insère des conversions de type à chaque endroit où une valeur générique est lue, afin que les valeurs d'exécution atterrissent dans les bons emplacements.
Prenons cette classe générique :
public class Box<T extends Number> {
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public void set(T value) { this.value = value; }
}Après effacement, le bytecode ressemble approximativement à ceci (en équivalent source Java) :
public class Box {
private Number value;
public Box(Number value) { this.value = value; }
public Number get() { return value; }
public void set(Number value) { this.value = value; }
}Le T a disparu. Il est devenu Number parce que c'était la borne. S'il n'y avait pas eu de borne, il serait devenu Object.
Et au site d'appel :
Box<Integer> b = new Box<>(42);
int x = b.get(); // sourcedevient (après effacement) :
Box b = new Box(42);
int x = (Integer) b.get(); // compiler inserted the castLa conversion de type était invisible dans le code source. Le compilateur l'ajoute car il sait que le type au niveau du source était Integer, même si le type au niveau du bytecode est Number (ou Object).
Ce que cela signifie à l'exécution
Plusieurs conséquences découlent directement de l'effacement, et elles sont la source de chaque moment "mais je pensais pouvoir…" qu'un développeur vit avec les génériques Java :
Box<Integer> a = new Box<>(1);
Box<Double> b = new Box<>(1.0);
a.getClass() == b.getClass(); // true — both are Box.classIl n'y a pas de Box<Integer>.class ou Box<Double>.class à l'exécution — il n'y a que Box.class. Les deux instances sont la même classe, parce que la JVM ne peut littéralement pas les distinguer.
Vous ne pouvez pas demander "est-ce un instanceof Box<Integer>" :
if (obj instanceof Box<Integer>) { ... } // ❌ does not compile
if (obj instanceof Box<?>) { ... } // ✓ — wildcard is allowed
if (obj instanceof Box) { ... } // ✓ — raw form worksVous ne pouvez pas écrire new T() :
public class Factory<T> {
public T create() { return new T(); } // ❌ — no T at runtime
}Vous ne pouvez pas capturer un type d'exception générique :
try { ... }
catch (MyException<String> e) { ... } // ❌Toutes ces erreurs de compilation remontent au même fait : la JVM n'a pas le paramètre de type au moment où ce code devrait s'exécuter. Le compilateur refuse d'émettre du code qu'il sait ne pas pouvoir réussir.
T" consiste à passer le type explicitement — généralement sous la forme d'un paramètre Class<T> et clazz.getDeclaredConstructor().newInstance(), ou d'une fabrique Supplier<T>. Les informations supprimées par l'effacement doivent être fournies par l'appelant ; le compilateur ne peut pas les reconstruire.Méthodes ponts — la comptabilité cachée de l'effacement
Il existe une subtilité où l'effacement interagit avec le remplacement de méthode. Supposons que vous ayez :
interface Container<T> {
void put(T value);
}
class IntContainer implements Container<Integer> {
public void put(Integer value) { ... }
}Au niveau du source, IntContainer.put(Integer) remplace Container.put(T). Mais après l'effacement, la méthode d'interface a la signature put(Object) — et IntContainer n'a que put(Integer). Comment le polymorphisme fonctionne-t-il encore lorsque quelqu'un appelle put via une référence Container ?
Le compilateur génère une méthode pont dans IntContainer :
// Generated by the compiler, invisible in source:
public void put(Object value) {
put((Integer) value); // delegate to the real one
}Cette méthode pont est celle qui est appelée lorsque la dispatch polymorphique arrive sur la signature effacée. Vous ne l'écrivez pas, vous ne la voyez pas, mais javap vous la montrera. C'est la colle qui fait fonctionner l'effacement avec le dispatch virtuel.
Effacement et surcharge
Une conséquence directe de l'effacement : vous ne pouvez pas surcharger deux méthodes si leurs signatures diffèrent uniquement dans leurs paramètres génériques, car après l'effacement elles ont la même signature :
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile errorC'est le même problème latent avec les remplacements. Si deux méthodes s'effacent vers la même signature, le compilateur refuse de les compiler. Il n'y a pas de solution de contournement au niveau du langage — vous auriez besoin de noms de méthodes différents, ou d'un paramètre réellement différent après l'effacement.
Un exemple concret : l'effacement en action
Le programme illustre les éléments qui découlent de l'effacement — l'égalité à l'exécution de getClass, un instanceof en forme brute qui fonctionne, et la conversion de type non vérifiée que le bytecode effectue pour vous à chaque lecture.
Les lignes getClass() confirment que l'exécution ne peut pas distinguer Box<Integer> de Box<String> — il n'y a qu'une seule classe Box. L'astuce avec le List en forme brute est l'histoire classique de l'effacement : le mauvais add(99) passe la vérification de type, et l'échec survient à la lecture suivante car c'est là que vit la conversion de type insérée par le compilateur. Le message d'exception vous indique même quelle était la conversion : il a essayé de convertir Integer en String.
Et ensuite
L'effacement n'est pas un détail académique — c'est la raison derrière presque chaque "vous ne pouvez pas faire ça" que le compilateur vous enverra lorsque vous tenterez du code générique élaboré. Le dernier chapitre de cette partie catalogue la liste complète de ces restrictions et explique chacune d'elles en termes de ce que l'effacement empêche. Continuez vers Java Generics Restrictions.