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

Що таке дженерики (Generics) в Java

До Java 5 усі колекції зберігали елементи як java.lang.Object. Це породжувало дві критичні проблеми:


🟢 Junior Level

Дженерики (Generics, узагальнення) — це механізм мови Java, що дозволяє параметризувати класи, інтерфейси та методи типами даних. Вони з’явилися в Java 5 (JSR 14).

До Java 5 усі колекції зберігали елементи як java.lang.Object. Це породжувало дві критичні проблеми:

  1. Необхідність явного ручного приведення типів (cast).
  2. Ризик отримати фатальну помилку ClassCastException у рантаймі, якщо в колекцію випадково поклали об’єкт іншого типу.
// До Java 5 (без дженериків):
List list = new ArrayList();
list.add("Hello");
list.add(42); // Помилки компіляції немає!

String s1 = (String) list.get(0); // Потрібен явний cast
String s2 = (String) list.get(1); // 💥 ClassCastException під час виконання!

// З дженериками (Java 5+):
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(42); // ❌ Помилка компіляції: несумісний тип Integer!

String s = list.get(0); // Без приведення типів, 100% безпечно

Три головні переваги дженериків

  1. Типобезпека на етапі компіляції (Compile-time Type Safety): помилки несумісності типів виявляються під час збирання проєкту, а не в продакшені.
  2. Усунення явного приведення типів (Elimination of Casts): компілятор сам знає тип елементів і вставляє перевірочні інструкції за вас.
  3. Перевикористання алгоритмів (Code Reusability): один і той самий алгоритм (сортування, стек, дерево, репозиторій) працює з будь-якими типами даних.

🟡 Middle Level

1. Анатомія параметрів типів (Type Parameters)

Під час оголошення дженерик-типів використовуються загальноприйняті конвенції іменування однобуквених плейсхолдерів:

  • T — Type (тип загального призначення).
  • E — Element (елемент колекції, наприклад List<E>).
  • K, V — Key, Value (ключ і значення в Map<K, V>).
  • N — Number (числовий тип).
  • R — Result (тип повернення функції).
// Параметризований клас:
public class Box<T> {
    private T value;

    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

// Параметризований інтерфейс:
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    T save(T entity);
}

// Узагальнений метод (Generic Method):
public static <T> List<T> fromArrayToList(T[] array) {
    return Arrays.stream(array).toList();
}

2. Обмеження типів (Bounded Type Parameters)

Ви можете обмежити діапазон допустимих типів зверху ключовим словом extends (працює і для класів, і для інтерфейсів):

// Дозволені тільки Number та його нащадки (Integer, Double, BigDecimal):
public class Stats<T extends Number> {
    private final List<T> numbers;

    public Stats(List<T> numbers) { this.numbers = numbers; }

    public double average() {
        double sum = 0.0;
        for (T n : numbers) {
            sum += n.doubleValue(); // Метод doubleValue() доступний завдяки bound!
        }
        return sum / numbers.size();
    }
}

3. Інваріантність дженериків (Invariance)

[!IMPORTANT] Якщо String є підтипом Object, це НЕ означає, що List<String> є підтипом List<Object>. Дженерики в Java суворо інваріантні.

List<String> strings = new ArrayList<>();
// List<Object> objects = strings; // ❌ Помилка компіляції!

Чому це заборонено? Якби таке присвоєння компілювалося, ви могли б через посилання objects виконати objects.add(12345);, і під час читання зі strings.get(0) стався б збій типів.

Для реалізації гнучкого поліморфізму в дженериках використовуються wildcards (? extends T та ? super T).


🔴 Senior Level

Стирання типів (Type Erasure) і чому JVM не знає про дженерики

Творці Java (на чолі з Мартіном Одерські та Джеймсом Ґослінґом) під час впровадження дженериків у Java 5 постали перед дилемою:

  1. Реїфікація / Опредмечення (Monomorphization / Reification) (як у C++ або C#): віртуальна машина знає типи в рантаймі і створює окрему структуру метаданих під кожну інстанціацію (List<String> і List<Integer> — різні типи в пам’яті).
  2. Стирання типів (Type Erasure): компілятор перевіряє типи, а потім стирає їх, замінюючи на їхню базову межу (Object або верхній bound) та вставляючи байткод-інструкцію checkcast.

Було обрано підхід зі стиранням типів заради зворотної сумісності (Migration Compatibility): старий скомпільований код на Java 1.4 мав без проблем взаємодіяти з новим дженерик-кодом Java 5 у межах однієї JVM.

Байткод до і після стирання:

// Вихідний код:
public class Holder<T extends Number> {
    private T value;
    public T getValue() { return value; }
}

// Що бачить JVM після компіляції:
public class Holder {
    private Number value; // T стерто до Number!
    public Number getValue() { return value; }
}

Мостові методи (Bridge Methods)

Коли клас успадковується від параметризованого предка і фіксує конкретний тип, компілятор синтезує спеціальний мостовий метод (Bridge Method), щоб зберегти поліморфізм віртуальних викликів у таблиці методів (vtable):

public class StringNode extends Node<String> {
    public void setData(String data) { /* ... */ }
    
    // Синтетичний bridge-метод, що генерується компілятором:
    // public void setData(Object data) {
    //     setData((String) data); // checkcast + виклик цільового методу
    // }
}

Де стирання типів НЕ стирає інформацію (Reified Metadata)

Поширена помилкова думка: «У рантаймі про дженерики нічого не відомо».

Інформація про параметри стирається з виконуваного байткоду методів, але зберігається в сигнатурі класу (атрибут Signature у constant pool) для оголошень класів, полів та методів! Саме на цьому заснований патерн TypeToken (Jackson TypeReference, Spring ResolvableType, Gson TypeToken):

// Створюючи анонімний підклас, ми фіксуємо дженерик-тип у метаданих класу:
TypeReference<List<User>> typeRef = new TypeReference<>() {};
Type type = ((ParameterizedType) typeRef.getClass().getGenericSuperclass())
                .getActualTypeArguments()[0];
System.out.println(type); // java.util.List<User> — доступно в runtime через reflection!

4 Tricky Questions

1. Чому дженерики в Java реалізували через стирання типів (Type Erasure), а не через опредмечення (Reification), як у C# чи C++?

Відповідь: Головною причиною була вимога абсолютної бінарної зворотної сумісності (Migration Compatibility).

У 2004 році мільйони рядків корпоративного коду працювали на Java 1.4 із сирими типами (java.util.List). При реалізації через Reification (як у C#) старі бібліотеки перестали б працювати без повної перекомпіляції та переписування, а змішування старого й нового коду призводило б до помилок лінкування. Стирання типів дозволило запускати новий дженерик-код на старих версіях бібліотек і гарантувало, що List<String> та List<Integer> мають у рантаймі один і той самий List.class.

Зворотний бік цього рішення — неможливість створити new T(), new T[], виконати instanceof T та накладні витрати на автоупакування (autoboxing) примітивів.

2. Якщо типи стираються при компіляції, звідки Jackson, Spring та Gson знають, у який тип десеріалізувати List<User>?

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

Фреймворки використовують трюк Ніла Ґафтера (Super Type Token): створюється анонімний підклас абстрактного класу (наприклад, new TypeReference<List<User>>() {}). У цьому випадку параметризований тип List<User> стає предком анонімного класу і назавжди записується в метадані його класу. У рантаймі бібліотека викликає getClass().getGenericSuperclass(), приводить його до ParameterizedType і методом getActualTypeArguments() витягує вихідний клас User.

3. Чому масиви в Java коваріантні (String[] можна присвоїти Object[]), а колекції з дженериками інваріантні (List<String> не можна присвоїти List<Object>)?

Відповідь: Коваріантність масивів була введена в Java 1.0 (за 8 років до дженериків) як вимушений компроміс: без неї було неможливо написати універсальні утилітні методи сортування або копіювання масивів (Arrays.sort(Object[])). Однак коваріантність порушує безпеку типів: якщо в Object[] arr = new String[5] покласти arr[0] = 10;, станеться помилка часу виконання ArrayStoreException.

Для дженериків автори мови відмовилися від небезпечних рантайм-збоїв і зробили дженерики суворо інваріантними. Безпечна коваріантність і контраваріантність досягається через механізм wildcards: List<? extends Object> (коваріантність / читання) та List<? super String> (контраваріантність / запис).

4. Що таке Heap Pollution (забруднення купи) і до яких прихованих помилок воно призводить?

Відповідь: Heap Pollution (забруднення купи) — це ситуація в JVM, коли змінна параметризованого типу (List<String>) посилається на об’єкт у пам’яті, фактичний тип аргументів якого не відповідає оголошеному (наприклад, там лежить Integer).

Зазвичай виникає при змішуванні дженериків із сирими типами (raw types), некоректних приведеннях типів або при роботі з Generic Varargs (List<T>...). Небезпека Heap Pollution полягає в тому, що воно породжує відкладені баги: присвоєння проходить без помилок, а виняток ClassCastException викидається пізніше — у абсолютно безпечному на вигляд місці при звичайному читанні з колекції (String s = list.get(0)), де у вихідному коді навіть немає операції явного касту!


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

Головне про Generics в Java

  • З’явилися: Java 5 (JSR 14) для compile-time type safety та усунення явного приведення типів (cast).
  • Модель виконання: Type Erasure (Стирання типів). У рантаймі List<String> та List<Integer> перетворюються на один і той самий List.class, а тип $T$ стирається до своєї межі (верхнього bound або Object).
  • Інваріантність: List<String> не є підтипом List<Object>. Для варіативності використовуються wildcards (? extends / ? super).
  • Reified vs Non-Reified: Примітиви та масиви опредметнені (зберігають тип у заголовку), дженерики не опредметнені (non-reified).
  • Чого не можна:
    • new T() та new T[].
    • instanceof List<String> (дозволено тільки instanceof List<?>).
    • Використовувати примітиви (List<int> заборонено, потрібен List<Integer>).
    • Статичні поля з параметром типу класу (static T field).
  • Де типи зберігаються: Метадані полів, методів та суперкласів зберігаються в атрибуті Signature байткоду (Super Type Tokens / Jackson TypeReference).

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