Як працюють дженерики з наслідуванням
У наслідуванні дженериків існує фундаментальне правило, яке часто збиває з пантелику розробників-початківців:
🟢 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
Дві осі наслідування: Тип контейнера проти Типу аргументу
Щоб не плутатися в дженериках, необхідно чітко розділяти дві незалежні осі поліморфізму:
- Вісь типу контейнера (Звичайне ООП-наслідування):
Якщо аргумент типу суворо зафіксований, звичайне наслідування між класами та інтерфейсами працює як завжди:// ✅ ArrayList<String> є підтипом List<String> List<String> list = new ArrayList<String>(); // ✅ List<String> є підтипом Collection<String> Collection<String> collection = list; - Вісь аргументу типу (Інваріантність):
Зміна параметра типу при однаковому контейнері створює непов’язані типи:// ❌ 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):
- Коваріантність (
? extends T): Дозволяє читати дані базового типу.// ✅ Тепер можна присвоїти список будь-якого підкласу Animal List<? extends Animal> animals = new ArrayList<Dog>(); Animal a = animals.get(0); // Читати можна безпечно // animals.add(new Dog()); // ❌ Запис заборонено (крім null) - Контраваріантність (
? 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>дозволено.