Решил сегодня пройтись по Generics с прицелом на вопросы, которые любят задавать на технических интервью. Вроде бы тема базовая, но когда доходит до ? extends и ? super, многие начинают плавать
1. Почему List<Cat> — это не List<Animal>?
Есть иерархия:
class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}
Казалось бы, раз Cat — это Animal, то список котов можно передать туда, где ждут список животных. Но Java не даёт:
List<Cat> cats = new ArrayList<>();
List<Animal> animals = cats; // Ошибка компиляции!
Почему? Представь, что это бы сработало. Тогда мы бы могли сделать:
animals.add(new Dog()); // Добавили собаку в список котов
Cat myCat = cats.get(0); // А тут нас ждёт сюрприз — это собака!
Компилятор не может гарантировать типобезопасность, поэтому Generics в Java инвариантны. List<Cat> и List<Animal> — совершенно разные типы, и точка.
И тут мы приходим к PECS, который часто стопорит на собеседовании:
2. Как читать данные из списка наследников?
Допустим, нам нужен метод, который печатает имена всех животных из списка. Мы хотим, чтобы он принимал и List<Cat>, и List<Dog>.
Наивная попытка:
void printNames(List<Animal> animals) { // не сработает для List<Cat>
for (Animal a : animals) System.out.println(a);
}
Метод не примет список котов. Исправляем с помощью wildcard:
void printNames(List<? extends Animal> animals) {
for (Animal a : animals) { // читать можно — тип Animal гарантирован
System.out.println(a);
}
}
Теперь можно вызвать:
List<Cat> cats = List.of(new Cat(), new Cat());
printNames(cats); // Ок
List<Dog> dogs = List.of(new Dog());
printNames(dogs); // Ок
Важно: внутрь такого списка мы не можем ничего добавить (кроме null):
animals.add(new Cat()); // Ошибка компиляции!
Потому что компилятор не знает, какой именно подтип Animal лежит внутри. Вдруг это List<Dog>? Поэтому ? extends — это Producer: он только отдаёт данные (читает).
3. Как записывать данные?
Допустим есть задача: мы хотим метод, который добавляет котов в переданную коллекцию. При этом коллекция может быть не только List<Cat>, но и List<Animal>, и даже List<Object>.
Пишем:
void addCats(List<? super Cat> list) {
list.add(new Cat()); // можно добавлять Cat и его наследников
list.add(new BengalCat()); // тоже ок, если есть такой класс
}
Вызываем:
List<Animal> animals = new ArrayList<>();
addCats(animals); // ок
List<Object> objects = new ArrayList<>();
addCats(objects); // тоже ок
List<Cat> cats = new ArrayList<>();
addCats(cats); // и даже так можно
А вот прочитать из такого списка что-то конкретное, кроме Object, уже нельзя:
Cat c = list.get(0); // Ошибка! Вдруг это List<Object>?
Можно только:
Object obj = list.get(0); // но это бесполезно, если мы ждали Cat
Поэтому ? super — это Consumer: он принимает данные (записывает).
Тут мы получаем золотое правило PECS (Producer Extends, Consumer Super)
Запомнить легко:
· Если метод читает из структуры — используй extends.
· Если метод пишет в структуру — используй super.
Пример из реальной жизни — метод Collections.copy(dest, src):
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
// dest — Consumer (мы туда пишем) → super
// src — Producer (мы оттуда читаем) → extends
}
Всё встаёт на свои места.
Generics — это не просто синтаксический сахар, а целая система, которая помогает отлавливать ошибки на этапе написания кода. А умение объяснить extends/super, PECS и рассказать про type erasure — это тот самый уровень, который выделяет разработчика на собеседовании.