📋 Розділ 20 · Питання #13

Що таке type erasure (стирання типів)

У виконуваному байткоді (.class-файлі) та під час роботи JVM дженериків практично не існує. Віртуальна машина оперує сирими типами (raw types) та вставленими компілятором інстру...


🟢 Junior Level

Type Erasure (стирання типів) — це архітектурний механізм компілятора Java, за якого вся інформація про аргументи дженерик-типів видаляється (стирається) на етапі компіляції.

У виконуваному байткоді (.class-файлі) та під час роботи JVM дженериків практично не існує. Віртуальна машина оперує сирими типами (raw types) та вставленими компілятором інструкціями приведення типів (checkcast).

// Що пише розробник:
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0);

// Що насправді компілюється в байткод:
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0); // Компілятор неявно вставив приведення типу!

Навіщо було обрано стирання типів

У 2004 році при релізі Java 5 стояло завдання забезпечити 100% зворотну сумісність з мільйонами рядків коду на Java 1.4. Завдяки стиранню типів, нові дженерик-бібліотеки могли прозоро працювати зі старими скомпільованими модулями без перезбирання всієї екосистеми.


🟡 Middle Level

1. Правила стирання типів (JLS §4.6)

Компілятор замінює змінну типу $T$ за суворим алгоритмом:

  1. Необмежений тип (<T>) стирається до java.lang.Object:
    public class Box<T> { private T val; } 
    // Перетворюється на:
    public class Box { private Object val; }
    
  2. Обмежений зверху тип (<T extends Number>) стирається до своєї першої межі (First Bound):
    public class NumberBox<T extends Number> { private T val; }
    // Перетворюється на:
    public class NumberBox { private Number val; }
    
  3. Множинні обмеження (<T extends Comparable<T> & Serializable>) стираються до найпершого типу в списку (в цьому випадку до Comparable).

2. Практичні обмеження, викликані стиранням типів

Через те, що в рантаймі інформація про конкретний $T$ відсутня, в Java заборонені наступні конструкції:

public class Restrictions<T> {
    // ❌ 1. Не можна створити екземпляр через new:
    // T obj = new T(); // Помилка: JVM не знає конструктор T

    // ❌ 2. Не можна створити масив дженерик-типу:
    // T[] arr = new T[10]; // Помилка: масиви reified, їм потрібен точний тип у рантаймі

    // ❌ 3. Не можна перевіряти точний дженерик-тип через instanceof:
    // if (x instanceof List<String>) // Помилка: в рантаймі це просто List

    // ❌ 4. Не можна використовувати T у статичному контексті:
    // private static T instance; // Помилка: static належить класу, а не екземпляру з типом T

    // ❌ 5. Не можна перевантажувати методи, якщо їхні типи збігаються після стирання:
    // void print(List<String> l) {}
    // void print(List<Integer> l) {} // Помилка: обидва стираються в print(List)
}

🔴 Senior Level

Анатомія байткоду: Мостові методи (Bridge Methods)

Головна архітектурна проблема стирання типів — загроза поліморфізму віртуальних викликів.

Розглянемо наслідування:

public class Node<T> {
    public void setData(T data) { /* ... */ }
}

public class StringNode extends Node<String> {
    @Override
    public void setData(String data) { /* ... */ }
}

Після компіляції та стирання типів:

  • У Node метод перетворюється на public void setData(Object data).
  • У StringNode оголошено метод public void setData(String data).
  • Для віртуальної машини setData(Object) та setData(String) — це два абсолютно різні перевантажені методи, а не перевизначення (override)! Поліморфізм ламається: при виклику через базове посилання Node n = new StringNode(); n.setData("test"); метод у StringNode не викликався б.

Рішення компілятора: Синтетичний Bridge Method

Компілятор автоматично генерує в нащадку прихований мостовий метод (ACC_BRIDGE | ACC_SYNTHETIC):

// Синтетичний метод, що генерується javac у StringNode.class:
public void setData(Object data) {
    // 1. Перевіряє тип аргументу через checkcast
    // 2. Делегує виклик користувацькому методу з правильною сигнатурою:
    this.setData((String) data);
}

Де дженерики зберігаються: Атрибут Signature

Хоча типи стираються з інструкцій виконання методів, метадані сигнатур зберігаються у файлі .class в атрибуті Signature:

  • Для полів класу (Field.getGenericType()).
  • Для параметрів та типів повернення методів (Method.getGenericReturnType()).
  • Для предків та інтерфейсів класу (Class.getGenericSuperclass()).
ClassFile {
    ...
    fields {
        field_info {
            u2 name_index;
            ...
            attributes {
                Signature_attribute { // <--- Зберігає Ljava/util/List<Ljava/lang/String;>;
                    u2 attribute_name_index;
                    u2 signature_index;
                }
            }
        }
    }
}

Саме тому патерн TypeToken (Jackson TypeReference, Spring ResolvableType) працює: створюючи анонімний підклас new TypeReference<List<User>>() {}, ми змушуємо компілятор зберегти сигнатуру предка в метаданих класу, звідки рефлексія витягує параметри типів у runtime!


4 Tricky Questions

1. Якщо у типу кілька обмежень (<T extends Comparable<T> & Serializable>), до якого типу компілятор зітре T?

Відповідь: Компілятор завжди стирає тип $T$ до першої оголошеної межі (First Bound).

У виразі <T extends Comparable<T> & Serializable> першою межею є Comparable, тому в байткоді поле або метод перетвориться на Comparable. В усіх місцях, де вимагається використання другого інтерфейсу (Serializable), компілятор генеруватиме додаткові явні інструкції приведення типів checkcast.

Саме тому специфікація Java вимагає, щоб клас (якщо він є серед bounds) завжди стояв на першому місці: <T extends Number & Runnable> допустимо, а <T extends Runnable & Number> викличе помилку компіляції.

2. Чи зберігається інформація про дженерики для локальних змінних методів всередині байткоду?

Відповідь: Ні, для локальних змінних метадані дженериків не зберігаються.

Атрибут Signature генерується компілятором лише для елементів постійної структури класу: для оголошень полів, заголовків методів та інтерфейсів. Локальні змінні всередині тіл методів стираються повністю та остаточно: List<String> list = new ArrayList<>() у байткоді стає локальною змінною типу java.util.List. Жодна рефлексія в рантаймі не здатна дізнатися, який тип було вказано в локальній змінній.

3. Чи можна оголосити два перевантажені методи, що відрізняються лише аргументами дженериків (void sort(List<String> l) та void sort(List<Integer> l))?

Відповідь: Ні, це викличе помилку компіляції: «both methods have the same erasure».

Після стирання типів обидва методи отримають абсолютно ідентичну сигнатуру на рівні JVM: void sort(List l). Наявність двох методів з однаковим ім’ям та однаковим дескриптором параметрів в одному класі заборонена специфікацією байткоду Java (JVMS).

Обхідні шляхи: або дати методам різні імена (sortStrings, sortIntegers), або використовувати фіктивний dummy-параметр.

4. Чому в Java дозволено перевантаження методів, що відрізняються типом повернення на рівні байткоду (коваріантні типи повернення), хоча у вихідному Java-коді це заборонено?

Відповідь: У специфікації мови Java (JLS) сигнатура методу складається лише з його імені та типів параметрів (тип повернення до сигнатури не входить). Тому в Java-коді не можна оголосити два методи з однаковим ім’ям та аргументами, але різним типом повернення.

Однак у специфікації віртуальної машини (JVMS) дескриптор методу включає в себе і тип повернення: (Ljava/lang/Object;)Ljava/lang/String; та (Ljava/lang/Object;)Ljava/lang/Object; для JVM є двома різними методами. Саме на цьому трюку компілятор будує коваріантні типи повернення та мостові методи (Bridge Methods).


🎯 Шпаргалка для інтерв’ю

Головне про Type Erasure за 1 хвилину

  • Що це: Процес видалення параметрів дженериків при компіляції в байткод.
  • У що стираються типи:
    • Без обмежень: T $\rightarrow$ Object.
    • З обмеженням: T extends Number $\rightarrow$ Number (перший bound).
  • Головна мета: 100% бінарна зворотна сумісність коду Java 5 зі старим кодом Java 1.4.
  • Наслідки стирання:
    • Не можна: new T(), new T[], x instanceof List<String>, class Ex<T> extends Exception.
    • Не можна перевантажувати методи з однаковим стиранням: foo(List<String>) та foo(List<Integer>).
  • Bridge Methods: Синтетичні методи, що генеруються компілятором для збереження віртуального поліморфізму в байткоді.
  • Збереження метаданих: В атрибуті Signature class-файлу (для полів, методів та класів). Використовується патерном TypeToken у Jackson/Spring.

Пов’язані теми