📋 Раздел 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 каверзных вопроса

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> разрешено.

Связанные темы