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

У чому різниця між extends T і super T

Різниця між та полягає в напрямку ієрархії наслідування та ролі контейнера (читання чи запис).


🟢 Junior Level

Різниця між <? extends T> та <? super T> полягає в напрямку ієрархії наслідування та ролі контейнера (читання чи запис).

Обидва синтаксиси являють собою підстановочні знаки з обмеженнями (Bounded Wildcards) і підпорядковуються золотому правилу PECS (Producer Extends, Consumer Super):

Wildcard Напрямок ієрархії Роль (PECS) Операції
<? extends T> Тип $T$ та всі, хто нижче нього (нащадки) Producer (постачальник) Можна читати як $T$. Запис заборонено (крім null).
<? super T> Тип $T$ та всі, хто вище нього (предки) Consumer (приймач) Можна записувати об’єкти типу $T$. Читати можна лише як Object.
// 1. <? extends T> — тільки читання (Producer)
List<? extends Number> producers = List.of(1, 2.5, 3L);
Number n = producers.get(0); // ✅ Читати безпечно: будь-який елемент гарантовано є Number
// producers.add(10);        // ❌ Помилка компіляції: запис заборонено!

// 2. <? super T> — тільки запис (Consumer)
List<? super Integer> consumers = new ArrayList<Number>();
consumers.add(42);           // ✅ Записувати безпечно: Integer завжди поміститься в Number або Object
// Integer val = consumers.get(0); // ❌ Помилка компіляції: читати можна тільки як Object
Object obj = consumers.get(0);     // ✅ Читати безпечно тільки як базовий Object

🟡 Middle Level

1. Чому <? extends T> забороняє запис елементів?

Розглянемо, чому компілятор блокує виклик .add():

List<Integer> intList = new ArrayList<>();
List<? extends Number> numbers = intList; // Допустимо: Integer extends Number

// Уявімо, що компілятор дозволив би запис:
// numbers.add(3.14); // ❌ Помилка!

Якби компілятор дозволив додати Double (3.14) до списку numbers, він фізично помістив би дробове число у вихідний список List<Integer>! При подальшому виклику Integer val = intList.get(0) програма впала б із фатальним ClassCastException.

Оскільки ? extends Number означає: «список деякого невідомого підтипу Number (це може бути List<Integer>, List<Double> або List<Long>)», компілятор повністю забороняє запис, захищаючи цілісність пам’яті. Єдине значення, яке дозволено передати в .add() — це літерал null, оскільки null належить будь-якому посилальному типу.

2. Чому <? super T> при читанні повертає лише Object?

У випадку List<? super Integer> фактичним списком у купі може бути:

  • List<Integer>
  • List<Number>
  • List<Object>

Якщо фактичним списком є List<Number>, у ньому вже можуть лежати елементи Double або Float. Тому компілятор не може гарантувати, що витягнутий елемент буде Integer. Єдиний гарантований спільний предок будь-якого суперкласу Integer — це java.lang.Object.

3. Класичний приклад із JDK: Collections.copy

Еталонна ілюстрація спільної роботи extends та super:

// src — постачальник даних (Producer -> extends)
// dest — приймач даних (Consumer -> super)
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i)); // Читаємо з src як T, пишемо в dest як T!
    }
}

🔴 Senior Level

Теорія типів: Коваріантність, Контраваріантність та Інваріантність

Дженерики в Java за замовчуванням суворо інваріантні: \(\text{Integer} \le \text{Number} \not\Rightarrow \text{List}\langle\text{Integer}\rangle \le \text{List}\langle\text{Number}\rangle\)

Підстановочні знаки (Wildcards) дозволяють послабити інваріантність у місцях використання (Use-Site Variance):

  1. Коваріантність (? extends T): зберігає напрямок наслідування в ієрархії типів. \(\text{Sub} \le \text{Super} \implies \text{List}\langle\text{Sub}\rangle \le \text{List}\langle?\text{ extends Super}\rangle\)
  2. Контраваріантність (? super T): обертає (інвертує) напрямок наслідування. \(\text{Sub} \le \text{Super} \implies \text{List}\langle\text{Super}\rangle \le \text{List}\langle?\text{ super Sub}\rangle\)

Захоплення підстановочного знака (Wildcard Capture) та патерн Helper

Спроба прочитати елемент із List<?> і записати його назад призводить до помилки Wildcard Capture:

public void swapFirstTwo(List<?> list) {
    // ❌ Помилка компіляції: 
    // capture of ? cannot be applied to capture of ?
    // list.set(0, list.get(0)); 
}

Причина: Компілятор призначає кожному входженню ? свіжу внутрішню анонімну змінну типу (наприклад, capture#1 of ? при читанні та capture#2 of ? при записі). Оскільки компілятор не може довести, що два анонімні типи ідентичні, операція блокується.

Патерн рішення: Допоміжний generic-метод (Private Helper)

public void swapFirstTwo(List<?> list) {
    swapHelper(list); // Захоплення wildcard у явний тип T
}

private <T> void swapHelper(List<T> list) {
    list.set(0, list.get(0)); // ✅ Успішно: тип T однозначно визначено!
}

Use-Site Variance (Java) vs Declaration-Site Variance (Kotlin / C#)

  • Declaration-Site Variance (Kotlin, Scala, C#): тип оголошується коваріантним або контраваріантним безпосередньо в заголовку класу: class Producer<out T>. Клієнтський код позбавлений необхідності писати wildcards при кожному виклику.
  • Use-Site Variance (Java): клас List<E> завжди інваріантний, а варіативність оголошується розробником за місцем використання в аргументах методів. Це вимагає суворого дотримання правила PECS в API, але забезпечує максимальну гнучкість: одну й ту саму колекцію можна в одному методі використовувати як Producer, а в іншому — як Consumer.

4 Tricky Questions

1. Чому в List<? extends Number> не можна покласти навіть об’єкт Integer, хоча Integer гарантовано є Number?

Відповідь: Тому що тип List<? extends Number> означає: «список деякого конкретного, але невідомого компілятору підтипу Number».

Фактичним екземпляром у пам’яті може виявитися List<Double> або List<Long>. Якби компілятор дозволив покласти туди Integer, ми б порушили цілісність списку List<Double>, викликавши відкладений ClassCastException при читанні. Єдиний об’єкт, який гарантовано сумісний з будь-яким невідомим посилальним типом — це null.

2. Що повертає метод list.get(0), якщо list має тип List<? super Integer>, і чому саме цей тип?

Відповідь: Метод повертає значення типу java.lang.Object.

Тип ? super Integer означає «список деякого невідомого супертипу Integer» (наприклад, List<Number> або List<Object>). У такий список крім цілих чисел у рантаймі можуть бути законно додані Double, Float або будь-які інші об’єкти супертипу. Компілятор не має права стверджувати, що значення, що повертається, є Integer чи Number. Єдиним предком, загальнозастосовним до всіх елементів будь-якого можливого суперкласу в Java, є Object.

3. Що таке помилка Wildcard Capture (наприклад, capture#1 of ?) і як її вирішити?

Відповідь: Помилка Wildcard Capture виникає, коли компілятор зустрічає підстановочний знак ? і призначає йому унікальний внутрішній ідентифікатор типу (наприклад, capture of ?). Якщо спробувати прочитати елемент із такого списку через get() і тут же записати його в той самий список через set(), компілятор відмовить у компіляції: він сприймає тип витягнутого елемента і тип параметра методу set() як два незалежні несумісні захоплення типу.

Рішенням є Патерн Helper Method: публічний метод приймає List<?> і відразу ж викликає приватний узагальнений допоміжний метод <T> void helper(List<T> list). Допоміжний метод матеріалізує анонімний wildcard у конкретну змінну типу $T$, зв’язуючи читання та запис у межах однієї сигнатури.

4. Чи можна оголосити локальну змінну як List<? super T>, і чи має це практичний сенс?

Відповідь: Синтаксично це повністю допустимо:

List<? super Integer> list = new ArrayList<Number>();
list.add(10);

Однак у реальному коді оголошувати локальні змінні з wildcards (? extends або ? super) практично ніколи не має сенсу і вважається антипатерном. Wildcards створені для сигнатур публічних інтерфейсів та аргументів методів (Use-Site Variance), де вони забезпечують поліморфізм вхідних параметрів. Всередині тіла методу розробник знає конкретний тип структури і повинен оголошувати точний параметризований тип (List<Number> або List<Integer>), уникаючи втрати зручності роботи з методами читання/запису.


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

Головне про Bounded Wildcards

  • Формула PECS: Producer Extends, Consumer Super.
  • <? extends T> (Producer):
    • Читання: повертає T.
    • Запис: заборонено (дозволено лише null).
    • Коваріантність: зберігає напрямок ієрархії.
  • <? super T> (Consumer):
    • Читання: повертає лише Object.
    • Запис: дозволено для об’єктів типу $T$ та його підтипів.
    • Контраваріантність: інвертує напрямок ієрархії.
  • Якщо потрібно І читати, І писати: Не використовуйте wildcards! Використовуйте точний інваріантний тип <T>.
  • Wildcard Capture: Помилка невідповідності типів при роботі з ?. Вирішується патерном приватного generic-хелпера <T> void helper(List<T> list).

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