Код
#статьи

Дженерики в Java для тех, кто постарше: стирание типов, наследование и принцип PECS

Рассказываем, как в любой непонятной ситуации правильно сочетать дженерик-типы.

В предыдущей статье «Дженерики для самых маленьких» мы рассказали о том, что такое дженерики (generics), зачем они нужны и как создавать дженерик-типы и методы. Там же говорили про ограничения (boundings) и wildcards. Без этих основ вам будет сложно разобраться с тем, что написано дальше. Поэтому освежите знания, если это необходимо.

Из этой статьи вы узнаете:


Почему ни один дженерик не доживает до выполнения программы

Воспользуемся примером из первой части рассказа о дженериках: там был класс Box<T> — коробка для сбора мусора: можно было положить в неё или извлечь из неё только объект определённого типа:

class Box<T> { // обозначение типа - T
   // переменная с типом T
   private T item;
 
   public void putItem(T item) { // параметр метода типа T
      this.item = item;
   }

   public T getItem() { // возвращает объект типа T
     return item;
   }
}

Теперь создадим экземпляр такого класса и подставим вместо T конкретный тип: например, Paper — для коробки, в которую будем собирать бумагу:

class Paper {}
Box<Paper> boxForPaper = new Box<Paper>();

Можно предположить, что теперь мы имеем дело с таким классом:

class Box<Paper> {

   private Paper item;
 
   public void putItem(Paper item) {
      this.item = item;
   }

   public Paper getItem() {
     return item;
   }
}

Эта запись помогает понять, как класс будет работать, но она не имеет ничего общего с тем, во что превращается дженерик-класс или интерфейс в результате компиляции.

Компилятор не генерирует class-файл для каждого параметризованного типа. Он создаёт один class-файл для дженерик-типа.

Компилятор стирает информацию о типе, заменяя все параметры без ограничений (unbounded) типом Object, а параметры с границами (bounded) — на эти границы. Это называется type erasure.

Кроме стирания (иногда говорят «затирания») типов, компилятор может добавлять приведение (cast) к нужному типу и создавать переходные bridge-методы, чтобы сохранить полиморфизм в классах-наследниках.

Пример 1. Стирание типа для дженерика без границ

Все параметры типов заменяются на Object. Вот что получится для нашего класса-коробки:

class Box {

   private Object item;
 
   public void putItem(Object item) {
      this.item = item;
   }

   public Object getItem() {
     return item;
   }
}

Пример 2. Стирание типа для дженерика с границами

Объявим дженерик-интерфейс c ограничением сверху (upper bounding):

interface BoxMap<K extends Box, V>{
   void put(K key, V value);
   V get(K key);
}

Вот что от этого останется после компиляции:

interface BoxMap{
   void put(Box key, Object value);
   Object get(Box key);
}

Пример 3. Bridge-метод

Создадим класс-наследник коробки для бумаги и переопределим в нём метод putItem:

class CoolPaperBox extends Box<Paper>{
  
   public void putItem(Paper item) {
       super.putItem(item);
   }
}

Этому классу не всё равно, какого типа объекты приходят к нему в putItem, — нужно, чтобы они были типа Paper. Поэтому компилятору придётся немного докрутить класс — добавить в него bridge-метод с приведением типа:

class CoolPaperBox extends Box<Paper>{

   public void putItem(Paper item) {
       super.putItem(item);
   }
   
   // это и есть bridge-метод
   public void putItem(Object item) {
      putItem((Paper)item);
   }
}

А вот ещё несколько примеров дженерик-типов и того, что от них останется после компиляции:

До компиляцииПосле компиляции
<T extends Box<T>>Box
<? super Box>Box
List<Box>[]List[]

Из-за стирания типов при выполнении программы точно не известно, какой конкретно тип будет иметь экземпляр дженерик-класса. Единственное исключение — дженерик с wildcard без ограничений, например List<?>. Такой список будет считаться List<Object>.

Теперь, когда вы знаете про type erasure и его последствия, наверняка сможете ответить на вопрос, почему нельзя создать дженерик-Exception:

class GenericException<T> extends Exception { // не скомпилируется
}

Ответ:

В каждом блоке try catch проверяется тип исключения, так как разные типы исключений могут обрабатываться по-разному. Для дженерик-исключения определить конкретный тип было бы невозможно, а потому компилятор даже не даст его создать. Это правило относится к классу Throwable и его наследникам.

Как создать наследника дженерик-класса

Наследник дженерик-класса может быть дженериком или обычным классом. Это зависит от того, как обращаться с параметрами типа родителя. Разберём три примера со знакомым нам Box<T>.

Пример 1. Класс-наследник — не дженерик.

public class SuperNonGenericBox extends Box<Paper> {
}

Чтобы получить обычный, не дженерик-класс, мы должны вместо параметра T передать какой-то конкретный тип, что мы и сделали — передали Paper.

Пример 2. Класс-наследник и сам дженерик с тем же числом параметров.

public class SuperGenericBox<T> extends Box<T> {   
}

Параметры у Box и SuperGenericBox не обязаны обозначаться буквой T (от type) — можно брать любую. В этом примере важно, чтобы буквы были одинаковые, иначе компилятор не разберётся.

Пример 3. Класс-наследник — дженерик с другим числом параметров.

public class SuperDoubleGenericBox<T, V> extends Box<T> {

   public void newMethod(V param) {
       // здесь что-то происходит
   }
}

Здесь уже не один, а два параметра. Один передадим родителю, а второй используем как-нибудь ещё — например, напишем метод newMethod с параметром этого нового типа.

У наследника класса-дженерика может быть сколько угодно параметров, включая ноль (когда это вовсе не дженерик). Главное — помнить про все параметры типа родительского класса и передать для каждого параметра конкретный тип или какой-то из параметров класса-наследника.

Что не так с дженерик-типами классов-наследников

В Java можно присвоить объект одного типа объекту другого типа, если типы совместимы: реализуют один и тот же интерфейс или лежат в одной цепочке наследования.

Например, PaperBox — наследник Box, и пример ниже успешно компилируется:

class Box{}
class PaperBox extends Box{}
class Test{
   Box box = new PaperBox();
}

В терминах объектно-ориентированного программирования это называют отношением is a (является): бумажная коробка — это коробка (является коробкой). Или говорят, что PaperBox — это подтип (subtype) Box. При этом Box — супертип PaperBox.

Теперь возьмём не простую коробку, а её дженерик-вариант (Box<T>), в которую будем класть разные типы мусора: Paper, Glass и тому подобные типы — наследники Garbage:

class Garbage{}
class Paper extends Garbage{}
class Glass extends Garbage{}
class Box<T extends Garbage> {
// методы класса
}

В этом случае в качестве аргумента типа можно выбрать как Garbage, так и его подтип:

Box<Garbage> box = new Box<>();
box.putItem(new Garbage()); // успешно компилируется
box.putItem(new Paper()); // успешно компилируется

Но что, если Box<Garbage> станет типом параметра метода? Сможем ли мы в этом случае передать другой дженерик-тип? Напишем простой пример:

public void handle(Box<Garbage> box) {
   // что-то делаем с коробкой
}

public void test() {
   handle(new Box<Paper>()); // не скомпилируется
}

И убедимся, что замена тут не пройдёт. Несмотря на то что Paper — подтип Garbage, Box<Paper> — не подтип Box<Garbage>.

Дженерики инвариантны. Это означает, что, даже если A — подтип B, дженерик от A не является подтипом дженерика от B.

Для сравнения, массивы в Java ковариантны: если A — подтип B, A[] — подтип B[].

Несмотря на то что Paper — наследник Garbage, Box<Paper> — не наследник Box<Garbage>. Они оба наследники Object. Инфографика: Екатерина Степанова / Skillbox Media

Как переопределить метод с дженерик-типами

Потренируемся сначала на простых типах и вспомним, что при переопределении методов необязательно полностью повторять сигнатуру родительского метода. Например, у таких методов могут различаться типы возвращаемых значений.

Переопределение будет правильным, если тип переопределённого метода — это подтип исходного метода. Например, так:

class Box {
   public Garbage doSomething() {
       return new Garbage();
   }
}

class PaperBox extends Box {
   
   // корректное переопределение, т. к. Paper — подтип Garbage
   @Override
   public Paper doSomething() {
       return (Paper) super.doSomething();
   }
}

Добавим немного дженериков и применим то же правило:

class Box {
   public List<Garbage> doSomething() {
       return Collections.emptyList();
   }
}

class PaperBox extends Box {
   
   // корректное переопределение, 
   // т. к. ArrayList<Garbage> — подтип List<Garbage>
   @Override
   public ArrayList<Garbage> doSomething() {
       return (ArrayList<Garbage>) super.doSomething();
   }
}

Дженерики добавляют ещё пару возможностей для корректного переопределения. Оно будет верным, если:

  • переопределённый метод возвращает значение сырого (raw) типа от дженерик-типа, возвращаемого исходным методом;
  • переопределённый метод возвращает значение сырого (raw) типа от наследника дженерик-типа, возвращаемого исходным методом.

Звучит сложно, так что лучше взглянем на код:

class Box {
   public List<Garbage> doSomething() {
       return Collections.emptyList();
   }
}

class PaperBox extends Box {

   // корректное переопределение,
   // т. к. мы взяли raw-type от List<Garbage>
   @Override
   public List doSomething() {
       return super.doSomething();
   }
}

class GlassBox extends Box {
  
   // корректное переопределение, т. к. мы взяли 
   // raw-type от ArrayList<Garbage> - наследника List<Garbage>
   @Override
   public ArrayList doSomething() {
       return (ArrayList) super.doSomething();
   }
}

Правда, в обоих случаях компилятор покажет предупреждение о небезопасном использовании типов (unchecked warning):

Note: GlassBox.java uses unchecked or unsafe operations.

Его можно понять: исходный метод требует, чтобы возвращался список объектов типа Garbage, а переопределённые хотят просто какой-то список. Там могут быть объекты типа Garbage, а могут и любые другие — вот компилятору и тревожно.

Зато если в исходном методе возвращаемый тип — с wildcard без ограничений, то при аналогичном переопределении предупреждений не будет:

class Box {
   public List<?> doSomething() {
       return Collections.emptyList();
   }
}

class PaperBox extends Box {

   // корректное переопределение,
   // и никаких unchecked warnings
   @Override  
   public List doSomething() {
       return super.doSomething();
   }
}

При переопределении дженерик-методов с одинаковым числом параметров типа можно произвольно менять обозначения этих параметров:

class Box {
   public <T> void doSomething(T item) {
       // здесь что-то происходит
   }
}

class SuperBox extends Box {
  
   @Override
   public <S> void doSomething(S item) {
       // здесь что-то происходит
   }
}

В переопределённом методе параметр типа назван S, а не T, но переопределение остаётся корректным.

А вот ограничения для дженерика в переопределённом методе добавлять нельзя:

class SuperBox extends Box {

   @Override // не скомпилируется, так как это НЕ переопределение
   public <S extends Paper> void genericMethod(S item) {
       // здесь что-то происходит
   }
}

Получился не переопределённый метод, а просто метод с таким же названием.

Зато можно из дженерик-метода сделать обычный метод:

class SuperBox extends Box {

   @Override
   public void genericMethod(Object item) {
       // здесь что-то происходит
   }
}

Компилятор спокоен, потому что метод в классе Box станет именно таким после type erasure — параметр типа будет заменён на Object.

Переопределение дженерик-метода будет корректно, если:

  • сигнатуры методов в классе-родителе и классе-наследнике совпадают или различаются с точностью до обозначений параметров типа;
  • тип результата переопределённого метода — подтип для типа результата исходного метода;
  • переопределённый метод возвращает raw-type типа результата исходного метода или его подтип;
  • сигнатуры методов будут совпадать после type erasure.

Как wildcards с ограничениями «портят» коллекции и зачем нужен принцип PECS

Если нужно что-то сделать с коллекциями объектов нескольких подтипов, удобны wildcards с ограничениями.

Например: List<? extends Paper> означает, что список может состоять из объектов типа Paper и всех его подтипов, а в List<? super Paper> могут быть объекты типа Paper и всех супертипов — например, Garbage или Object.

С wildcards и коллекциями есть маленькая проблема — коллекции вроде тех, что в примере выше, нельзя использовать на полную катушку: свободно читать из них и записывать новые данные. Чтобы запомнить это ограничение, даже придумали принцип — принцип PECS.

PECS — Producer Extends, Consumer Super. Его суть:

  • Коллекции с wildcards и ключевым словом extends — это producers (производители, генераторы), они лишь предоставляют данные.
  • Коллекции с wildcards и ключевым словом super — это consumers (потребители), они принимают данные, но не отдают их.

Получается, в коллекцию с extends нельзя добавлять, а из коллекции с super нельзя читать? Вроде бы всё понятно, но давайте проверим:

List<? super Garbage>> list = new ArrayList<>();

Попробуем положить сюда экземпляр Paper — наследника Garbage:

list.add(new Paper()); // не скомпилируется

Получим ошибку компиляции. Ладно, тогда, может, хотя бы объект типа Garbage подойдёт?

list.add(new Garbage()); // не скомпилируется

И снова нет. Принцип PECS не соврал — объект в такой список добавить нельзя. Единственное исключение — null. Вот так можно:

list.add(null); // OK

С первой частью принципа разобрались, теперь создадим коллекцию с ограничением снизу:

List<? super Paper> list = new ArrayList<>();

Добавим туда один объект типа Paper:

list.add(new Paper());

И попробуем его же прочитать. Если верить PECS, у нас это не должно получиться:

list.get(0); //OK

Но компилятору всё нравится — никаких ошибок нет. Проблемы, впрочем, начнутся, когда мы захотим сохранить полученное значение в переменной типа Paper или типа, совместимого с ним:

Paper p = list.get(0); // не скомпилируется

Вторая часть принципа PECS означает, что из коллекций, ограниченных снизу, нельзя без явного приведения типа (cast) прочитать объекты граничного класса, да и всех его родителей тоже. Единственное, что доступно, — тип Object:

Object p = list.get(0); // OK

К сожалению, принцип PECS ничего не говорит о том, какие объекты можно читать из producer, а какие добавлять в customer. Мы не придумали своего принципа, но сделали табличку, чтобы собрать вместе все правила:

Тип ограниченияЧто можно читатьЧто можно записывать
<? extends SomeType>Объекты SomeType и всех его супертиповТолько null
<? super SomeType>Объекты типа ObjectОбъекты типа SomeType и всех его подтипов

И сводный пример:

class Garbage {}
class Paper extends Garbage {}
class CoolPaper extends Paper{}

public void testUpperBounding(List<? extends Paper> list){
   Paper p = list.get(0); // OK
   Garbage g = list.get(1); // OK
   CoolPaper sp = list.get(2); // не скомпилируется
   list.add(new Paper()); // не скомпилируется
   list.add(null); // OK
}

public void testLowBounding(List<? super Paper> list){
   Paper p = list.get(0); // не скомпилируется
   Garbage g = list.get(1); // не скомпилируется
   Object o = list.get(2); // OK
   list.add(new Garbage()); // не скомпилируется
   list.add(new Paper()); // OK
   list.add(new CoolPaper()); // OK
}

И даже картинку нарисовали:

Какие типы можно читать из коллекции с ограниченными wildcards и записывать в неё.
Инфографика: Екатерина Степанова / Skillbox Media

Теперь точно не запутаетесь :)

Ещё больше хитростей дженериков и других особенностей Java — на курсе «Профессия Java-разработчик». Научим программировать на самом востребованном языке и поможем устроиться на работу.

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Освойте топовые нейросети за три дня. Бесплатно
Знакомимся с ChatGPT-4, DALLE-3, Midjourney, Stable Diffusion, Gen-2 и нейросетями для создания музыки. Практика в реальном времени. Подробности — по клику.
Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована