Дженерики в Java для тех, кто постарше: стирание типов, наследование и принцип PECS
2026-02-21 00:46 Diff

#статьи

  • 17 ноя 2021
  • 0

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

Фулстек-разработчик. Любимый стек: Java + Angular, но в хорошей компании готова писать хоть на языке Ада.

В предыдущей статье «Дженерики для самых маленьких» мы рассказали о том, что такое дженерики (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>BoxList<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 с ограничениями.

Например: 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-разработчик». Научим программировать на самом востребованном языке и поможем устроиться на работу.

Бесплатный курс по Python ➞
Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу