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

Як працюють дженерики з наслідуванням

У наслідуванні дженериків існує фундаментальне правило, яке часто збиває з пантелику розробників-початківців:


🟢 Junior Level

У наслідуванні дженериків існує фундаментальне правило, яке часто збиває з пантелику розробників-початківців:

Відношення наслідування між типами аргументів НЕ переноситься на параметризовані типи!

Навіть якщо клас Dog є прямим нащадком Animal, List<Dog> НЕ є нащадком List<Animal>. Дженерики в Java за замовчуванням інваріантні.

class Animal {}
class Dog extends Animal {}

List<Dog> dogs = new ArrayList<>();

// ❌ ПОМИЛКА КОМПІЛЯЦІЇ: Incompatible types!
// List<Animal> animals = dogs; 

Чому компілятор забороняє таке присвоєння?

Якби Java дозволила таке приведення, виникла б фатальна дірка в безпеці типів:

// Уявімо, що компілятор це дозволив:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // Припустимо, дозволено...

// Кладемо кішку в список тварин:
animals.add(new Cat()); 

// Але ж animals і dogs посилаються на один і той самий об'єкт у купі!
// Тепер у списку собак лежить кішка:
Dog myDog = dogs.get(0); // 💥 ClassCastException: Cat cannot be cast to Dog!

Компілятор Java блокує це присвоєння ще на етапі збирання, захищаючи програму від ClassCastException під час виконання.


🟡 Middle Level

Дві осі наслідування: Тип контейнера проти Типу аргументу

Щоб не плутатися в дженериках, необхідно чітко розділяти дві незалежні осі поліморфізму:

  1. Вісь типу контейнера (Звичайне ООП-наслідування):
    Якщо аргумент типу суворо зафіксований, звичайне наслідування між класами та інтерфейсами працює як завжди:
    // ✅ ArrayList<String> є підтипом List<String>
    List<String> list = new ArrayList<String>(); 
    // ✅ List<String> є підтипом Collection<String>
    Collection<String> collection = list; 
    
  2. Вісь аргументу типу (Інваріантність):
    Зміна параметра типу при однаковому контейнері створює непов’язані типи:
    // ❌ List<Dog> НЕ є підтипом List<Animal>
    // List<Animal> animalList = new ArrayList<Dog>(); 
    
       Collection<String>
              ▲
              │ (наслідування контейнерів)
          List<String>
              ▲
              │ (наслідування контейнерів)
       ArrayList<String>

----------------------------------------------------
  List<Animal>  ◀─── НЕ пов'язані! ───▶  List<Dog>

Як забезпечити поліморфізм: Механізм Wildcards (Use-site Variance)

Для зв’язку параметризованих типів у Java використовуються підстановочні знаки (Wildcards):

  1. Коваріантність (? extends T): Дозволяє читати дані базового типу.
    // ✅ Тепер можна присвоїти список будь-якого підкласу Animal
    List<? extends Animal> animals = new ArrayList<Dog>();
    Animal a = animals.get(0); // Читати можна безпечно
    // animals.add(new Dog()); // ❌ Запис заборонено (крім null)
    
  2. Контраваріантність (? super T): Дозволяє безпечно додавати дані.
    // ✅ Приймає список будь-якого супертипу Dog (Animal, Object)
    List<? super Dog> dogConsumers = new ArrayList<Animal>();
    dogConsumers.add(new Dog()); // ✅ Записувати безпечно
    

🔴 Senior Level

Історична помилка Java: Коваріантність масивів vs Інваріантність дженериків

У Java 1.0 дженериків не існувало. Щоб мати можливість писати узагальнені алгоритми (наприклад, Arrays.sort(Object[] a)), масиви були спроєктовані коваріантними:

// Масиви коваріантні (дозволено компілятором):
String[] strings = new String[5];
Object[] objects = strings; 

// Падає В РАНТАЙМІ, а не при компіляції!
// objects[0] = Integer.valueOf(42); // 💥 ArrayStoreException!

Віртуальна машина JVM змушена при кожному записі в масив перевіряти тип посилання в пам’яті (checkcast / ArrayStoreException), що створює накладні витрати. При проєктуванні Java 5 творці дженериків врахували цей гіркий досвід і зробили дженерики суворо інваріантними.

Наслідування класів з generic-параметрами

При наслідуванні від generic-класу або інтерфейсу у розробника є три шляхи:

1. Фіксація конкретного типу (Concrete Subclass)

public class StringRepository implements Repository<String, Long> {
    // Тип T замінено на String, ID на Long.
    // Байткод компілятора генерує synthetic bridge methods!
}

2. Передача generic-параметра далі (Pass-through)

public class AuditableRepository<T, ID> implements Repository<T, ID> {
    // Підклас залишається generic-класом
}

3. Звуження межі (Narrowing Bounds)

public class NumericBox<T extends Number> extends Box<T> {
    // T обмежений числовими типами
}

Коваріантне повернення generic-типів при перевизначенні (Method Overriding)

Починаючи з Java 5, метод підкласу може повертати підтип типу повернення батька (Covariant Return Type). Однак це підпорядковується правилу інваріантності дженериків:

class SuperService {
    public List<Animal> getAnimals() { return new ArrayList<>(); }
    public List<? extends Animal> getFlexibleAnimals() { return new ArrayList<>(); }
}

class SubService extends SuperService {
    // ❌ ПОМИЛКА КОМПІЛЯЦІЇ: List<Dog> не є підтипом List<Animal>
    // @Override
    // public List<Dog> getAnimals() { return new ArrayList<>(); } 

    // ✅ ДОЗВОЛЕНО: List<Dog> є підтипом List<? extends Animal>!
    @Override
    public List<Dog> getFlexibleAnimals() { return new ArrayList<>(); } 
}

[!TIP] Якщо в базовому сервісі оголосити тип повернення як List<? extends Animal>, підкласи отримують право легально перевизначати метод і повертати конкретний List<Dog>.


4 Tricky Questions

1. Чи може клас одночасно реалізувати Comparable<Parent> та Comparable<Child>?

Відповідь:
Ні, це сувора помилка компіляції (JLS §8.1.5).

class Parent implements Comparable<Parent> { /* ... */ }

// ❌ Помилка компіляції:
// 'Comparable' cannot be inherited with different type arguments: 'Parent' and 'Child'
// class Child extends Parent implements Comparable<Child> { ... }

Причина криється в Type Erasure. Обидва інтерфейси після стирання перетворюються на непараметризований Comparable з методом int compareTo(Object o). Клас не може мати два методи з однаковим ім’ям та аргументами, але різною логікою мостових методів (bridge methods clash).

2. Чи є List<String>[] підтипом Object[]?

Відповідь:
Так, оскільки масиви в Java коваріантні, а List<String> є посилальним типом (Object), тип List<String>[] формально є підтипом Object[].
Однак створити такий масив напряму через new List<String>[10] не можна (Generic Array Creation Error). Але якщо отримати посилання обхідним шляхом ((List<String>[]) new List<?>[10]), його можна покласти в Object[], що відкриває шлях до забруднення купи (Heap Pollution).

3. Чи дозволено оголошення class MyException<T> extends Exception?

Відповідь:
Ні, створення узагальнених класів винятків заборонено (JLS §8.1.2: Generic class cannot extend 'java.lang.Throwable').
Причина в тому, що механізм обробки винятків у JVM працює виключно в рантаймі (catch (MyException<String> e)). Через стирання типів JVM не може визначити в момент виконання, перехоплено MyException<String> чи MyException<Integer>.

4. Чому Collections.copy(List<? super T> dest, List<? extends T> src) використовує саме такі межі?

Відповідь:
Це класичне застосування принципу PECS (Producer Extends, Consumer Super):

  • src виступає в ролі постачальника (Producer) даних, ми тільки читаємо з нього елементи T, тому безпечна коваріантність ? extends T (можна скопіювати List<Dog> у список тварин).
  • dest виступає в ролі споживача (Consumer), ми записуємо в нього елементи T, тому безпечна контраваріантність ? super T (можна записувати собак у List<Animal> або List<Object>).

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

  • Фундаментальне правило: Дженерики інваріантні. Якщо Dog extends Animal, то List<Dog> НЕ є підтипом List<Animal>.
  • Навіщо інваріантність: Захист від Heap Pollution. Інакше через посилання на супертип у список можна було б покласти несумісний об’єкт (animals.add(new Cat())).
  • Дві осі наслідування:
    • Наслідування контейнерів працює: ArrayList<String> -> List<String>.
    • Наслідування типів елементів не робить контейнери родичами.
  • Масиви vs Дженерики: Масиви історично коваріантні (String[] <: Object[]), що небезпечно (ArrayStoreException в runtime). Дженерики інваріантні та безпечні (помилки ловляться в compile-time).
  • Як увімкнути поліморфізм типів: Використовувати Wildcards (List<? extends Animal> для читання, List<? super Dog> для запису).
  • Коваріантне повернення: Перевизначити List<Animal> поверненням List<Dog> не можна. Але якщо батько повертає List<? extends Animal>, повернути List<Dog> дозволено.

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