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

У чому різниця між `List<?>` і `List<Object>`

Головна відмінність між List та List полягає в типобезпеці та правилах поліморфізму (коваріантності та інваріантності):


🟢 Junior Level

Головна відмінність між List<?> та List<Object> полягає в типобезпеці та правилах поліморфізму (коваріантності та інваріантності):

  1. List<Object> — конкретний інваріантний список об’єктів:
    • Може зберігати будь-які посилальні типи (String, Integer, користувацькі класи).
    • У нього можна вільно додавати будь-які об’єкти через метод .add(new Object()).
    • Інваріантний: List<String> не є підтипом List<Object>. Передати List<String> у метод, що очікує List<Object>, не можна (помилка компіляції).
  2. List<?> (Unbounded Wildcard) — список невідомого типу:
    • Означає «список елементів якогось одного конкретного типу, але компілятору невідомо, якого саме».
    • Є супертипом для будь-якого параметризованого списку (List<String>, List<Integer>, List<Object>).
    • Забороняє запис елементів: компілятор не дозволяє додати в нього жодний об’єкт (за винятком літерала null), оскільки не може гарантувати відповідність типів.
    • Читання елементів повертає тип Object.
List<String> names = new ArrayList<>(List.of("Alice", "Bob"));

// ❌ Помилка компіляції: List<String> не можна привести до List<Object>
List<Object> objList = names; 

// ✅ Коректно: List<?> є супертипом для будь-якого List<T>
List<?> wildcardList = names; 

// ❌ Помилка компіляції: у List<?> не можна додати елемент
// wildcardList.add("Charlie"); 

// ✅ Дозволено: null допустимий для будь-якого посилального типу
wildcardList.add(null); 

// ✅ Читання завжди безпечне, повертає Object
Object item = wildcardList.get(0);

🟡 Middle Level

Чому List<String> не є List<Object> (Проблема коваріантності)

Якби компілятор Java дозволив присвоєння List<Object> list = new ArrayList<String>(), виникло б руйнування типобезпеки в runtime:

// Уявімо, що компілятор це дозволив:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // Припустимо, скомпілювалося...

objects.add(Integer.valueOf(42)); // Додали число в список об'єктів!

// У купі лежить список strings, що містить Integer!
// Наступний рядок викине ClassCastException:
String s = strings.get(0); // java.lang.Integer cannot be cast to java.lang.String

Саме тому дженерики в Java за замовчуванням інваріантні. Масиви в Java історично є коваріантними (Object[] arr = new String[5];), що призводить до викидання ArrayStoreException під час виконання при спробі покласти несумісний тип. Дженерики спроєктовані так, щоб ловити такі помилки на етапі компіляції.

Захоплення Wildcard та заборона запису

Синтаксис List<?> еквівалентний List<? extends Object>.
Коли компілятор зустрічає метод list.add(E element) у типу List<?>, тип E захоплюється як унікальна внутрішня анонімна змінна типу — capture of ? (або CAP#1 extends Object).

void addElement(List<?> list) {
    // Помилка компіляції:
    // incompatible types: String cannot be converted to capture#1 of ?
    // list.add("test"); 
}

Компілятор міркує так: «В аргумент list міг прийти List<Integer>. Спроба покласти туди String зруйнує купу. Оскільки я не знаю точного типу CAP#1, я забороняю передавати як аргумент будь-який об’єкт, крім null (оскільки null належить будь-якому посилальному типу)».

Порівняльна таблиця

Характеристика List (Raw type) List<Object> List<?>
Перевірка типів javac Вимкнена (Unchecked warning) Сувора типобезпека Сувора типобезпека
Додавання елементів Будь-який об’єкт (add(Object)) Будь-який об’єкт (add(Object)) Тільки null
Читання елементів Object (вимагає явного касту) Object Object
Сумісність присвоєння Приймає будь-який List Тільки List<Object> Приймає будь-який List<T>
Рекомендація щодо використання Заборонено в сучасному коді Для гетерогенних колекцій Для методів читання, що не залежать від T (size(), print())

🔴 Senior Level

Wildcard Capture Helper Pattern

Обмеження List<?> часто заважає реалізації узагальнених алгоритмів (наприклад, перестановки елементів місцями або реверсу списку), навіть якщо операція є абсолютно типобезпечною:

// Помилка компіляції: capture of ?
public void swap(List<?> list, int i, int j) {
    var temp = list.get(i); // temp має тип Object
    // list.set(i, list.get(j)); // ❌ set(int, capture#1 of ?) cannot be applied to (int, Object)
}

Для вирішення цієї проблеми стандартна бібліотека Java (включаючи java.util.Collections) використовує патерн допоміжного методу захоплення (Capture Helper):

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j); // Wildcard перезахоплюється в конкретний T
}

// Приватний generic-метод фіксує тип T на час виконання виклику:
private static <T> void swapHelper(List<T> list, int i, int j) {
    list.set(i, list.set(j, list.get(i))); // ✅ Повна типобезпека
}

Компілятор виводить, що тип підстановочного знака ? відповідає типу T допоміжного методу, що дозволяє безпечно читати та записувати елементи одного й того самого списку.

Ініціалізація: new ArrayList<?> проти new ArrayList<Object>

Специфікація JLS §15.9 забороняє використання wildcard в операторі new:

// List<?> list1 = new ArrayList<?>(); // ❌ Помилка компіляції: wildcard cannot be instantiated
List<?> list2 = new ArrayList<Object>(); // ✅ Валідно
List<?> list3 = new ArrayList<String>(); // ✅ Валідно
List<?> list4 = new ArrayList<>();       // ✅ Валідно (diamond оператор виведе Object)

Оператор new вимагає конкретний reifiable або однозначно параметризований тип для алокації екземпляра в купі, тоді як підстановочний знак ? призначений виключно для використання на стороні оголошення змінних або параметрів методів (Use-site variance).


4 Tricky Questions

1. Чи є List<?> дійсно read-only колекцією?

Відповідь:
Ні, List<?> не є незмінною (immutable) або read-only колекцією.
З підстановочним знаком заборонені лише виклики методів, що приймають параметр типу (add(E), addAll(Collection<? extends E>), set(int, E)).
Проте методи, що не залежать від типу елемента, чудово працюють і змінюють список:

List<?> list = new ArrayList<>(List.of(1, 2, 3));
list.remove(0); // ✅ Видалить елемент за індексом
list.clear();    // ✅ Очистить усю колекцію
list.add(null);  // ✅ Додасть елемент null

Якщо потрібен справжній захист від модифікації, слід використовувати Collections.unmodifiableList(...) або List.copyOf(...).

2. У чому різниця між void process(List<?> list) та <T> void process(List<T> list)? Коли що використовувати?

Відповідь:

  • <T> void process(List<T> list) вводить змінну типу T. Це необхідно, якщо тип аргументу взаємопов’язаний з іншими аргументами методу, типом повернення або якщо всередині методу потрібно створювати нові екземпляри чи перекладати елементи з одного списку в інший.
  • void process(List<?> list) не вводить додаткових змінних типу. Відповідно до правила ефективного проєктування API (Effective Java), якщо параметр типу з’являється в сигнатурі методу лише один раз і не залежить від значення, що повертається, слід віддати перевагу підстановочному знаку ?, оскільки він робить інтерфейс методу чистішим і простішим для викликаючого коду.

3. Чи можна передати List<String> у метод void inspect(List<Object> list) за допомогою явного кастингу? Що станеться?

Відповідь:
Прямий каст (List<Object>) list призведе до помилки компіляції: incompatible types: List<String> cannot be converted to List<Object>.
Обійти компілятор можна лише через «подвійний каст» або raw type:

List<Object> dangerous = (List<Object>) (List<?>) list; // Unchecked warning
dangerous.add(100);

При спробі викликаючого коду прочитати цей елемент з оригінального List<String> виникне негайний ClassCastException у місці виклику методу get().

4. Чому компілятор забороняє List<?>[] arrayOfLists = new ArrayList<?>[10], але дозволяє сам тип посилання List<?>[]?

Відповідь:
Насправді new ArrayList<?>[10] дозволений!
Компілятор забороняє створення масивів дженериків з конкретними параметрами (наприклад, new ArrayList<String>[10]), оскільки через стирання типів масив List[] у runtime не здатний перевірити, що в нього кладуть саме ArrayList<String>, а не ArrayList<Integer>.
Але List<?> є необмеженим підстановочним знаком (unbounded wildcard type), який вважається reifiable-типом (стирання типу List<?> збігається з його представленням у байткоді). Тому масив List<?>[] створити через new ArrayList<?>[10] компілятор дозволяє безпечно.


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

  • Ключова відмінність:
    • List<Object> — конкретний тип, інваріантний, можна писати будь-які об’єкти, List<String> у нього передати не можна.
    • List<?> — супертип для будь-яких списків (List<String>, List<Integer>), писати не можна (тільки null), читати можна лише як Object.
  • Чому generics інваріантні: Для запобігання забрудненню купи (Heap Pollution). Якби List<String> був підтипом List<Object>, у список рядків можна було б покласти число через посилання на базовий тип.
  • List<?> не read-only: Можна викликати clear(), remove(i) та add(null). Заборонені лише операції, що приймають параметризований тип E.
  • Capture Helper Pattern: Щоб зробити мутуючу операцію над елементами List<?> (наприклад, swap), використовують приватний дженерик-метод <T> void helper(List<T> list).
  • Raw List vs List<?>: Raw type вимикає перевірку типів і є небезпечним; List<?> зберігає абсолютну типобезпеку.

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

☕ Java Interview Questions and Answers · Open Source Knowledge Portal

Built from the GitHub repository. Fully synced across English, Ukrainian, and Russian.