Дженерики в Java для тех, кто постарше: стирание типов, наследование и принцип PECS
Рассказываем, как в любой непонятной ситуации правильно сочетать дженерик-типы.
В предыдущей статье «Дженерики для самых маленьких» мы рассказали о том, что такое дженерики (generics), зачем они нужны и как создавать дженерик-типы и методы. Там же говорили про ограничения (boundings) и wildcards. Без этих основ вам будет сложно разобраться с тем, что написано дальше. Поэтому освежите знания, если это необходимо.
Из этой статьи вы узнаете:
- почему ни один дженерик не доживает до выполнения программы;
- как создать наследника дженерик-класса;
- что не так с дженерик-типами классов-наследников;
- как переопределить метод с дженерик-типами;
- как wildcards с ограничениями «портят» коллекции и зачем нужен принцип PECS.
Почему ни один дженерик не доживает до выполнения программы
Воспользуемся примером из первой части рассказа о дженериках: там был класс Box<T> — коробка для сбора мусора: можно было положить в неё или извлечь из неё только объект определённого типа:
Теперь создадим экземпляр такого класса и подставим вместо T конкретный тип: например, Paper — для коробки, в которую будем собирать бумагу:
Можно предположить, что теперь мы имеем дело с таким классом:
Эта запись помогает понять, как класс будет работать, но она не имеет ничего общего с тем, во что превращается дженерик-класс или интерфейс в результате компиляции.
Компилятор не генерирует class-файл для каждого параметризованного типа. Он создаёт один class-файл для дженерик-типа.
Компилятор стирает информацию о типе, заменяя все параметры без ограничений (unbounded) типом Object, а параметры с границами (bounded) — на эти границы. Это называется type erasure.
Кроме стирания (иногда говорят «затирания») типов, компилятор может добавлять приведение (cast) к нужному типу и создавать переходные bridge-методы, чтобы сохранить полиморфизм в классах-наследниках.
Пример 1. Стирание типа для дженерика без границ
Все параметры типов заменяются на Object. Вот что получится для нашего класса-коробки:
Пример 2. Стирание типа для дженерика с границами
Объявим дженерик-интерфейс c ограничением сверху (upper bounding):
Вот что от этого останется после компиляции:
Пример 3. Bridge-метод
Создадим класс-наследник коробки для бумаги и переопределим в нём метод putItem:
Этому классу не всё равно, какого типа объекты приходят к нему в putItem, — нужно, чтобы они были типа Paper. Поэтому компилятору придётся немного докрутить класс — добавить в него bridge-метод с приведением типа:
А вот ещё несколько примеров дженерик-типов и того, что от них останется после компиляции:
До компиляции | После компиляции |
---|---|
<T extends Box<T>> | Box |
<? super Box> | Box |
List<Box>[] | List[] |
Из-за стирания типов при выполнении программы точно не известно, какой конкретно тип будет иметь экземпляр дженерик-класса. Единственное исключение — дженерик с wildcard без ограничений, например List<?>. Такой список будет считаться List<Object>.
Теперь, когда вы знаете про type erasure и его последствия, наверняка сможете ответить на вопрос, почему нельзя создать дженерик-Exception:
Ответ:
В каждом блоке try catch проверяется тип исключения, так как разные типы исключений могут обрабатываться по-разному. Для дженерик-исключения определить конкретный тип было бы невозможно, а потому компилятор даже не даст его создать. Это правило относится к классу Throwable и его наследникам.
Как создать наследника дженерик-класса
Наследник дженерик-класса может быть дженериком или обычным классом. Это зависит от того, как обращаться с параметрами типа родителя. Разберём три примера со знакомым нам Box<T>.
Пример 1. Класс-наследник — не дженерик.
Чтобы получить обычный, не дженерик-класс, мы должны вместо параметра T передать какой-то конкретный тип, что мы и сделали — передали Paper.
Пример 2. Класс-наследник и сам дженерик с тем же числом параметров.
Параметры у Box и SuperGenericBox не обязаны обозначаться буквой T (от type) — можно брать любую. В этом примере важно, чтобы буквы были одинаковые, иначе компилятор не разберётся.
Пример 3. Класс-наследник — дженерик с другим числом параметров.
Здесь уже не один, а два параметра. Один передадим родителю, а второй используем как-нибудь ещё — например, напишем метод newMethod с параметром этого нового типа.
У наследника класса-дженерика может быть сколько угодно параметров, включая ноль (когда это вовсе не дженерик). Главное — помнить про все параметры типа родительского класса и передать для каждого параметра конкретный тип или какой-то из параметров класса-наследника.
Что не так с дженерик-типами классов-наследников
В Java можно присвоить объект одного типа объекту другого типа, если типы совместимы: реализуют один и тот же интерфейс или лежат в одной цепочке наследования.
Например, PaperBox — наследник Box, и пример ниже успешно компилируется:
В терминах объектно-ориентированного программирования это называют отношением is a (является): бумажная коробка — это коробка (является коробкой). Или говорят, что PaperBox — это подтип (subtype) Box. При этом Box — супертип PaperBox.
Теперь возьмём не простую коробку, а её дженерик-вариант (Box<T>), в которую будем класть разные типы мусора: Paper, Glass и тому подобные типы — наследники Garbage:
В этом случае в качестве аргумента типа можно выбрать как Garbage, так и его подтип:
Но что, если Box<Garbage> станет типом параметра метода? Сможем ли мы в этом случае передать другой дженерик-тип? Напишем простой пример:
И убедимся, что замена тут не пройдёт. Несмотря на то что Paper — подтип Garbage, Box<Paper> — не подтип Box<Garbage>.
Дженерики инвариантны. Это означает, что, даже если A — подтип B, дженерик от A не является подтипом дженерика от B.
Для сравнения, массивы в Java ковариантны: если A — подтип B, A[] — подтип B[].
Как переопределить метод с дженерик-типами
Потренируемся сначала на простых типах и вспомним, что при переопределении методов необязательно полностью повторять сигнатуру родительского метода. Например, у таких методов могут различаться типы возвращаемых значений.
Переопределение будет правильным, если тип переопределённого метода — это подтип исходного метода. Например, так:
Добавим немного дженериков и применим то же правило:
Дженерики добавляют ещё пару возможностей для корректного переопределения. Оно будет верным, если:
- переопределённый метод возвращает значение сырого (raw) типа от дженерик-типа, возвращаемого исходным методом;
- переопределённый метод возвращает значение сырого (raw) типа от наследника дженерик-типа, возвращаемого исходным методом.
Звучит сложно, так что лучше взглянем на код:
Правда, в обоих случаях компилятор покажет предупреждение о небезопасном использовании типов (unchecked warning):
Note: GlassBox.java uses unchecked or unsafe operations.
Его можно понять: исходный метод требует, чтобы возвращался список объектов типа Garbage, а переопределённые хотят просто какой-то список. Там могут быть объекты типа Garbage, а могут и любые другие — вот компилятору и тревожно.
Зато если в исходном методе возвращаемый тип — с wildcard без ограничений, то при аналогичном переопределении предупреждений не будет:
При переопределении дженерик-методов с одинаковым числом параметров типа можно произвольно менять обозначения этих параметров:
В переопределённом методе параметр типа назван S, а не T, но переопределение остаётся корректным.
А вот ограничения для дженерика в переопределённом методе добавлять нельзя:
Получился не переопределённый метод, а просто метод с таким же названием.
Зато можно из дженерик-метода сделать обычный метод:
Компилятор спокоен, потому что метод в классе 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 нельзя читать? Вроде бы всё понятно, но давайте проверим:
Попробуем положить сюда экземпляр Paper — наследника Garbage:
Получим ошибку компиляции. Ладно, тогда, может, хотя бы объект типа Garbage подойдёт?
И снова нет. Принцип PECS не соврал — объект в такой список добавить нельзя. Единственное исключение — null. Вот так можно:
С первой частью принципа разобрались, теперь создадим коллекцию с ограничением снизу:
Добавим туда один объект типа Paper:
И попробуем его же прочитать. Если верить PECS, у нас это не должно получиться:
Но компилятору всё нравится — никаких ошибок нет. Проблемы, впрочем, начнутся, когда мы захотим сохранить полученное значение в переменной типа Paper или типа, совместимого с ним:
Вторая часть принципа PECS означает, что из коллекций, ограниченных снизу, нельзя без явного приведения типа (cast) прочитать объекты граничного класса, да и всех его родителей тоже. Единственное, что доступно, — тип Object:
К сожалению, принцип PECS ничего не говорит о том, какие объекты можно читать из producer, а какие добавлять в customer. Мы не придумали своего принципа, но сделали табличку, чтобы собрать вместе все правила:
Тип ограничения | Что можно читать | Что можно записывать |
---|---|---|
<? extends SomeType> | Объекты SomeType и всех его супертипов | Только null |
<? super SomeType> | Объекты типа Object | Объекты типа SomeType и всех его подтипов |
И сводный пример:
И даже картинку нарисовали:
Теперь точно не запутаетесь :)
Ещё больше хитростей дженериков и других особенностей Java — на курсе «Профессия Java-разработчик». Научим программировать на самом востребованном языке и поможем устроиться на работу.