Стирание типов в Java
Как Java реализует обобщения через стирание типов, что стирается на этапе выполнения и каковы последствия.
Стирание типов — это то, как Java реализует обобщения: параметры типов существуют во время работы компилятора, а затем выбрасываются до записи байт-кода. List<String> и List<Integer> попадают в JVM как обычный List — взаимозаменяемый на этапе выполнения. Система типов на этапе компиляции обеспечивает безопасность; на этапе выполнения это тот же List, который существовал в языке в 1995 году. Это проектное решение — самый важный факт об обобщениях Java, и он объясняет каждое странное ограничение, с которым вы столкнётесь в следующей главе.
В этой главе рассматривается, чем стирание заменяет параметры типов, почему Java выбрала его вместо реифицированных обобщений, последствия для выполнения программы (getClass, instanceof, new T()), генерируемые компилятором мостовые методы, которые обеспечивают работу переопределения, и почему стирание блокирует определённые перегрузки. Если вы только начинаете знакомиться с обобщениями, сначала прочитайте Java Generics.
Почему Java выбрала этот подход
Когда в Java 5 были добавлены обобщения, стандартная библиотека уже существовала десять лет. Каждый из существующих классов List, Map и Comparator был не-обобщённым, и каждая существующая программа в мире использовала их как сырые типы. Жёстким требованием Sun была двоичная обратная совместимость: код, скомпилированный под стандартную библиотеку до версии 5, должен был продолжать работать с новой библиотекой без перекомпиляции.
На рассмотрении было два подхода:
- Реифицированные обобщения — сохранение информации о типах на этапе выполнения, как в итоге сделал C#. Быстрее, выразительнее, но требует повторной генерации каждого существующего файла классов в мире.
- Стираемые обобщения — удаление информации о типах на этапе компиляции с сохранением неизменной формы байт-кода. Медленнее на каждый вызов (дополнительные приведения), менее выразительно (нет
new T()), но каждый старый JAR продолжает работать без изменений.
Sun выбрала стирание. Прагматичная цена за обновление была оплачена долгосрочной гибкостью языка. Это не тот дизайн, который кто-то выбрал бы с чистого листа — но именно его имеет Java, и понимание этого расставляет всё остальное об обобщениях по своим местам.
Что на самом деле делает стирание
Когда компилятор видит обобщённый тип, он делает две вещи:
- Стирает каждый параметр типа до его крайней левой границы — или до
Object, если границы нет. - Вставляет приведения в каждое место, где считывается обобщённое значение, чтобы значения во время выполнения попадали в нужные слоты.
Возьмём этот обобщённый класс:
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; }
}После стирания байт-код выглядит примерно так (в эквиваленте исходного кода 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; }
}T исчез. Он стал Number, потому что это была граница. Если бы границы не было, он стал бы Object.
И на месте вызова:
Box<Integer> b = new Box<>(42);
int x = b.get(); // sourceстановится (после стирания):
Box b = new Box(42);
int x = (Integer) b.get(); // compiler inserted the castПриведение было невидимым в исходном коде. Компилятор добавляет его, потому что знает, что тип на уровне исходного кода был Integer, даже если тип на уровне байт-кода — Number (или Object).
Что это означает во время выполнения
Несколько последствий напрямую вытекают из стирания, и они являются источником каждого момента «но я думал, что смогу…» у разработчика, работающего с обобщениями Java:
Box<Integer> a = new Box<>(1);
Box<Double> b = new Box<>(1.0);
a.getClass() == b.getClass(); // true — both are Box.classНе существует Box<Integer>.class или Box<Double>.class во время выполнения — есть только Box.class. Два экземпляра являются одним и тем же классом, потому что JVM буквально не может их различить.
Нельзя спросить «является ли это instanceof Box<Integer>»:
if (obj instanceof Box<Integer>) { ... } // ❌ does not compile
if (obj instanceof Box<?>) { ... } // ✓ — wildcard is allowed
if (obj instanceof Box) { ... } // ✓ — raw form worksНельзя написать new T():
public class Factory<T> {
public T create() { return new T(); } // ❌ — no T at runtime
}Нельзя перехватить обобщённый тип исключения:
try { ... }
catch (MyException<String> e) { ... } // ❌Все эти ошибки компиляции восходят к одному факту: JVM не имеет параметра типа в тот момент, когда этот код должен был бы выполняться. Компилятор отказывается генерировать код, который заведомо не может выполниться.
T» — явная передача типа, обычно в виде параметра Class<T> и clazz.getDeclaredConstructor().newInstance(), или фабрики Supplier<T>. Информацию, которую стирание удалило, должен предоставить вызывающий код; компилятор не может её восстановить.Мостовые методы — скрытая бухгалтерия стирания
Есть тонкость в том, как стирание взаимодействует с переопределением. Предположим, у вас есть:
interface Container<T> {
void put(T value);
}
class IntContainer implements Container<Integer> {
public void put(Integer value) { ... }
}На уровне исходного кода IntContainer.put(Integer) переопределяет Container.put(T). Но после стирания метод интерфейса имеет сигнатуру put(Object) — а у IntContainer есть только put(Integer). Как же полиморфизм продолжает работать, когда кто-то вызывает put через ссылку Container?
Компилятор генерирует мостовой метод в IntContainer:
// Generated by the compiler, invisible in source:
public void put(Object value) {
put((Integer) value); // delegate to the real one
}Этот мостовой метод вызывается при полиморфной диспетчеризации по стёртой сигнатуре. Вы его не пишете, не видите, но javap покажет его вам. Это клей, который заставляет стирание работать с виртуальной диспетчеризацией.
Стирание и перегрузка
Прямое следствие стирания: нельзя перегрузить два метода, если их сигнатуры различаются только обобщёнными параметрами, потому что после стирания они имеют одинаковую сигнатуру:
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile errorЭто та же скрытая проблема, что и с переопределениями. Если два метода стираются до одной сигнатуры, компилятор откажется их компилировать. На уровне языка обходного пути нет — потребуются разные имена методов или параметр, который реально отличается после стирания.
Практический пример: стирание в действии
Программа демонстрирует то, что вытекает из стирания — равенство getClass во время выполнения, работающий instanceof в сыром виде и неявное приведение, которое байт-код выполняет за вас при каждом чтении.
Строки getClass() подтверждают, что во время выполнения нельзя отличить Box<Integer> от Box<String> — существует только один класс Box. Трюк с сырым List — это классическая история стирания: неправильный add(99) проскальзывает мимо проверки типов, а сбой проявляется при следующем чтении, потому что именно там находится вставленное компилятором приведение. Сообщение об исключении даже сообщает, каким было приведение: оно попыталось привести Integer к String.
Что дальше
Стирание — это не академическая деталь: именно оно стоит за почти каждым «этого нельзя сделать», которое компилятор выдаёт, когда вы пытаетесь написать хитрый обобщённый код. Финальная глава этой части каталогизирует полный список таких ограничений и объясняет каждое из них с точки зрения того, что предотвращает стирание. Продолжайте изучение: Java Generics Restrictions.