1. Обзор
JDK 5.0 представил Java Generics с целью уменьшить количество ошибок и добавить дополнительный уровень абстракции над типами.
Этот учебник представляет собой краткое введение в обобщения в Java, их цель и то, как они могут улучшить качество нашего кода.
2. Потребность в дженериках
Давайте представим сценарий, в котором мы хотим создать список на Java для хранения Integer
.
Мы можем попробовать написать следующее:
List list = new LinkedList();
list.add(new Integer(1));
Integer i = list.iterator().next();
Удивительно, но компилятор будет жаловаться на последнюю строку. Он не знает, какой тип данных возвращается.
Компилятору потребуется явное приведение:
Integer i = (Integer) list.iterator.next();
Не существует контракта, который мог бы гарантировать, что возвращаемый тип списка является Integer
. Определенный список может содержать любой объект. Мы только знаем, что извлекаем список, проверяя контекст. При просмотре типов он может гарантировать только то, что это объект
, и поэтому требует явного приведения, чтобы гарантировать безопасность типа.
Это приведение может раздражать — мы знаем, что тип данных в этом списке — Integer
. Приведение также загромождает наш код. Это может вызвать ошибки времени выполнения, связанные с типом, если программист допустит ошибку с явным приведением типов.
Было бы намного проще, если бы программисты могли выражать свое намерение использовать определенные типы, а компилятор обеспечивал корректность таких типов. Это основная идея дженериков.
Давайте изменим первую строку предыдущего фрагмента кода:
List<Integer> list = new LinkedList<>();
Добавляя ромбовидный оператор <>, содержащий тип, мы сужаем специализацию этого списка только до целочисленного
типа. Другими словами, мы указываем тип, содержащийся в списке. Компилятор может применить тип во время компиляции.
В небольших программах это может показаться тривиальным дополнением. Но в больших программах это может существенно повысить надежность и сделать программу более удобной для чтения.
3. Общие методы
Мы пишем универсальные методы с одним объявлением метода, и мы можем вызывать их с аргументами разных типов. Компилятор гарантирует правильность любого типа, который мы используем.
Вот некоторые свойства универсальных методов:
- Универсальные методы имеют параметр типа (оператор ромба, заключающий тип) перед возвращаемым типом объявления метода.
- Параметры типа могут быть ограничены (об ограничениях мы расскажем далее в этой статье).
- Универсальные методы могут иметь параметры разных типов, разделенные запятыми в сигнатуре метода.
- Тело универсального метода такое же, как у обычного метода.
Вот пример определения универсального метода для преобразования массива в список:
public <T> List<T> fromArrayToList(T[] a) {
return Arrays.stream(a).collect(Collectors.toList());
}
<T>
в сигнатуре метода подразумевает, что метод будет иметь дело с универсальным типом T.
Это необходимо, даже если метод возвращает void.
Как уже упоминалось, метод может работать с более чем одним универсальным типом. В этом случае мы должны добавить все универсальные типы в сигнатуру метода.
Вот как мы можем изменить описанный выше метод для работы с типами T
и G
:
public static <T, G> List<G> fromArrayToList(T[] a, Function<T, G> mapperFunction) {
return Arrays.stream(a)
.map(mapperFunction)
.collect(Collectors.toList());
}
Мы передаем функцию, которая преобразует массив с элементами типа T в
список с элементами типа G.
Примером может быть преобразование Integer
в его строковое
представление:
@Test
public void givenArrayOfIntegers_thanListOfStringReturnedOK() {
Integer[] intArray = {1, 2, 3, 4, 5};
List<String> stringList
= Generics.fromArrayToList(intArray, Object::toString);
assertThat(stringList, hasItems("1", "2", "3", "4", "5"));
}
Обратите внимание, что Oracle рекомендует использовать заглавную букву для представления универсального типа и выбирать более описательную букву для представления формальных типов. В коллекциях Java мы используем T
для типа, K
для ключа и V
для значения.
3.1. Ограниченные дженерики
Помните, что параметры типа могут быть ограничены. Ограниченный означает «ограниченный», и мы можем ограничить типы, которые принимает метод.
Например, мы можем указать, что метод принимает тип и все его подклассы (верхняя граница) или тип и все его суперклассы (нижняя граница).
Чтобы объявить тип с верхней границей, мы используем ключевое слово extends
после типа, за которым следует верхняя граница, которую мы хотим использовать:
public <T extends Number> List<T> fromArrayToList(T[] a) {
...
}
Мы используем здесь ключевое слово extends
для обозначения того, что тип T
расширяет верхнюю границу в случае класса или реализует верхнюю границу в случае интерфейса.
3.2. Несколько границ
Тип также может иметь несколько верхних границ:
<T extends Number & Comparable>
Если один из типов, расширяемых T
, является классом (например , Number
), мы должны поставить его первым в списке границ. В противном случае это вызовет ошибку времени компиляции.
4. Использование подстановочных знаков с дженериками
Подстановочные знаки представлены вопросительным знаком ?
в Java, и мы используем их для ссылки на неизвестный тип. Подстановочные знаки особенно полезны с универсальными шаблонами и могут использоваться в качестве типа параметра.
Но прежде следует принять во внимание важное замечание. Мы знаем, что Object
— это супертип всех классов Java. Однако коллекция Object
не является супертипом какой-либо коллекции.
Например, List<Object>
не является супертипом List<String>
, и присвоение переменной типа List<Object>
переменной типа List<String>
вызовет ошибку компилятора. Это сделано для предотвращения возможных конфликтов, которые могут возникнуть, если мы добавим разнородные типы в одну и ту же коллекцию.
Это же правило применяется к любой коллекции типа и его подтипов.
Рассмотрим этот пример:
public static void paintAllBuildings(List<Building> buildings) {
buildings.forEach(Building::paint);
}
Если мы представим себе подтип Building
, например House
, мы не сможем использовать этот метод со списком House
, даже если House
является подтипом Building
.
Если нам нужно использовать этот метод с типом Building
и всеми его подтипами, ограниченный подстановочный знак может сотворить чудо:
public static void paintAllBuildings(List<? extends Building> buildings) {
...
}
Теперь этот метод будет работать с типом Building
и всеми его подтипами. Это называется подстановочным знаком с верхней границей, где тип Building
является верхней границей.
Мы также можем указать подстановочные знаки с нижней границей, где неизвестный тип должен быть супертипом указанного типа. Нижние границы можно указать с помощью ключевого слова super
, за которым следует конкретный тип. Например, <? super T>
означает неизвестный тип, который является суперклассом T
(= T и всех его родителей).
5. Введите стирание
В Java были добавлены дженерики для обеспечения безопасности типов. И чтобы гарантировать, что дженерики не вызовут накладных расходов во время выполнения, компилятор применяет процесс, называемый стиранием типов
, к дженерикам во время компиляции.
Стирание типа удаляет все параметры типа и заменяет их их границами или Object
, если параметр типа не ограничен. Таким образом, байт-код после компиляции содержит только обычные классы, интерфейсы и методы, что гарантирует отсутствие создания новых типов. Правильное приведение применяется также к типу Object
во время компиляции.
Это пример стирания типа:
public <T> List<T> genericMethod(List<T> list) {
return list.stream().collect(Collectors.toList());
}
При стирании типа неограниченный тип T
заменяется на Object
:
// for illustration
public List<Object> withErasure(List<Object> list) {
return list.stream().collect(Collectors.toList());
}
// which in practice results in
public List withErasure(List list) {
return list.stream().collect(Collectors.toList());
}
Если тип ограничен, тип будет заменен на связанный во время компиляции:
public <T extends Building> void genericMethod(T t) {
...
}
и изменится после компиляции:
public void genericMethod(Building t) {
...
}
6. Обобщенные и примитивные типы данных
Одно ограничение дженериков в Java состоит в том, что параметр типа не может быть примитивным типом.
Например, следующее не компилируется:
List<int> list = new ArrayList<>();
list.add(17);
Чтобы понять, почему примитивные типы данных не работают, давайте вспомним, что дженерики — это функция времени компиляции , то есть параметр типа стирается, а все дженерики реализуются как тип Object
.
Давайте посмотрим на метод добавления
списка:
List<Integer> list = new ArrayList<>();
list.add(17);
Сигнатура метода добавления
:
boolean add(E e);
и будет скомпилирован в:
boolean add(Object e);
Следовательно, параметры типа должны быть конвертируемы в Object
. Поскольку примитивные типы не расширяют Object
, мы не можем использовать их в качестве параметров типа.
Однако Java предоставляет упакованные типы для примитивов, а также автоупаковку и распаковку для их распаковки :
Integer a = 17;
int b = a;
Итак, если мы хотим создать список, который может содержать целые числа, мы можем использовать эту оболочку:
List<Integer> list = new ArrayList<>();
list.add(17);
int first = list.get(0);
Скомпилированный код будет эквивалентен следующему:
List list = new ArrayList<>();
list.add(Integer.valueOf(17));
int first = ((Integer) list.get(0)).intValue();
В будущих версиях Java могут быть разрешены примитивные типы данных для обобщений. Project Valhalla направлен на улучшение способа обработки дженериков. Идея состоит в том, чтобы реализовать специализацию дженериков, как описано в JEP 218 .
7. Заключение
Java Generics — мощное дополнение к языку Java, поскольку оно упрощает работу программиста и снижает вероятность ошибок. Обобщения обеспечивают правильность типов во время компиляции и, что наиболее важно, позволяют реализовать общие алгоритмы, не вызывая дополнительных накладных расходов для наших приложений.
Исходный код, прилагаемый к статье, доступен на GitHub .