Перейти к основному содержимому

Основы дженериков Java

· 8 мин. чтения

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 .