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


В предыдущей статье «Дженерики для самых маленьких» мы рассказали о том, что такое дженерики (generics), зачем они нужны и как создавать дженерик-типы и методы. Там же говорили про ограничения (boundings) и wildcards. Без этих основ вам будет сложно разобраться с тем, что написано дальше. Поэтому освежите знания, если это необходимо.
Из этой статьи вы узнаете:
- почему ни один дженерик не доживает до выполнения программы;
- как создать наследника дженерик-класса;
- что не так с дженерик-типами классов-наследников;
- как переопределить метод с дженерик-типами;
- как wildcards с ограничениями «портят» коллекции и зачем нужен принцип PECS.
Почему ни один дженерик не доживает до выполнения программы
Воспользуемся примером из первой части рассказа о дженериках: там был класс 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[].

Как переопределить метод с дженерик-типами
Потренируемся сначала на простых типах и вспомним, что при переопределении методов необязательно полностью повторять сигнатуру родительского метода. Например, у таких методов могут различаться типы возвращаемых значений.
Переопределение будет правильным, если тип переопределённого метода — это подтип исходного метода. Например, так:
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
}
И даже картинку нарисовали:

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