Перейти к содержанию

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

JDK 5.0 представил Java Generics с целью уменьшить количество ошибок и добавить дополнительный уровень абстракции над типами.

Это руководство представляет собой краткое введение в дженерики Java, их цель и то, как они могут улучшить качество кода.

Потребность в дженериках

Представим сценарий, в котором хотим создать список на Java для хранения целых чисел. Можно попробовать написать следующее:

List list = new LinkedList();
list.add(new Integer(1)); 
Integer i = list.iterator().next();

Удивительно, но компилятор будет жаловаться на последнюю строку. Он не знает, какой тип данных возвращается. Компилятору потребуется явное приведение:

Integer i = (Integer) list.iterator.next();

Не существует контракта, который мог бы гарантировать, что возвращаемый тип списка является Integer. Определенный список может содержать любой объект. Мы только знаем, что извлекаем список, проверяя контекст. При просмотре типов он может гарантировать только то, что это Object, и поэтому требует явного приведения, чтобы гарантировать безопасность типа.

Это приведение может раздражать, так как мы знаем, что тип данных в этом списке – Integer. Приведение также загромождает код. Это может вызвать ошибки во время выполнения, связанные с типом, если программист допустит ошибку с явным приведением типов.

Было бы намного проще, если бы программисты могли выражать свое намерение использовать определенные типы, а компилятор обеспечивал корректность таких типов. Это основная идея дженериков.

Изменим первую строку предыдущего фрагмента кода:

List<Integer> list = new LinkedList<>();

Добавляя ромбовидный оператор <>, содержащий тип, мы сужаем этот список до Integer. Другими словами, мы указываем тип, содержащийся в списке. Компилятор может применить тип во время компиляции.

В небольших программах это может показаться тривиальным дополнением. Но в больших программах это может существенно повысить надежность и сделать программу более удобной для чтения.

Универсальные методы

Мы пишем универсальные методы с одним объявлением метода и можем вызывать их с аргументами разных типов. Компилятор гарантирует правильность любого типа, который используем.

Вот некоторые свойства универсальных методов:

  • универсальные методы имеют параметр типа (оператор <>, заключающий тип) перед возвращаемым типом объявления метода;
  • параметры типа могут быть ограничены (об ограничениях расскажем далее в этой статье);
  • универсальные методы могут иметь параметры разных типов, разделенные запятыми в сигнатуре метода;
  • тело универсального метода такое же, как у обычного метода.

Ниже пример определения универсального метода для преобразования массива в список:

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 для значения.

Ограниченные дженерики

Параметры типа могут быть ограничены. Ограниченный означает «суженый». Можно ограничить типы, которые принимает метод.

Например, можно указать, что метод принимает тип и все его подклассы (верхняя граница) или тип и все его суперклассы (нижняя граница). Чтобы объявить тип с верхней границей, используем ключевое слово extends после типа, за которым следует верхняя граница, которую хотим использовать:

public <T extends Number> List<T> fromArrayToList(T[] a) {
    ...
}

Мы используем здесь ключевое слово extends для обозначения того, что тип T расширяет верхнюю границу в случае класса или реализует верхнюю границу в случае интерфейса.

Несколько границ

Тип также может иметь несколько верхних границ:

<T extends Number & Comparable>

Если один из типов, расширяемых T, является классом (например, Number), необходимо поставить его первым в списке границ. В противном случае это вызовет ошибку во время компиляции.

Использование подстановочных знаков с дженериками

Подстановочные знаки представлены знаком вопроса ? в 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 является подтипом Building.

Если нужно использовать этот метод с типом Building и всеми его подтипами, ограниченный подстановочный знак может сотворить чудо:

public static void paintAllBuildings(List<? extends Building> buildings) {
    ...
}

Теперь этот метод будет работать с типом Building и всеми его подтипами. Это называется подстановочным знаком с верхней границей, где тип Building является верхней границей.

Можно указать подстановочные знаки с нижней границей, где неизвестный тип должен быть супертипом указанного типа. Нижние границы можно указать с помощью ключевого слова super, за которым следует конкретный тип. Например, <? super T> означает неизвестный тип, который является суперклассом T (= T и всех его родителей).

Стирание типов

В Java были добавлены дженерики для обеспечения безопасности типов. И чтобы гарантировать, что дженерики не вызовут накладных расходов во время выполнения, компилятор во время компиляции применяет к дженерикам процесс, называемый стиранием типов.

Стирание типа удаляет все параметры типа и заменяет их границами или Object, если параметр типа не ограничен. Таким образом, байт-код после компиляции содержит только обычные классы, интерфейсы и методы, что гарантирует отсутствие создания новых типов. Правильное приведение применяется также к типу Object во время компиляции.

Ниже пример стирания типа:

public <T> List<T> genericMethod(List<T> list) {
    return list.stream().collect(Collectors.toList());
}

При стирании типа неограниченный тип T заменяется на Object:

// для иллюстрации
public List<Object> withErasure(List<Object> list) {
    return list.stream().collect(Collectors.toList());
}

// что на практике приводит к
public List withErasure(List list) {
    return list.stream().collect(Collectors.toList());
}

Если тип ограничен, тип будет заменен на связанный во время компиляции:

public <T extends Building> void genericMethod(T t) {
    ...
}

И изменится после компиляции:

public void genericMethod(Building t) {
    ...
}

Дженерики и примитивные типы данных

Одно ограничение дженериков в Java состоит в том, что параметр типа не может быть примитивным типом.

Например, следующий код не компилируется:

List<int> list = new ArrayList<>();
list.add(17);

Чтобы понять, почему примитивные типы данных не работают, вспомним, что дженерики – это функция, которая выполняется во время компиляции, то есть параметр типа стирается, а все универсальные типы реализуются как тип Object.

Посмотрим на метод add списка:

List<Integer> list = new ArrayList<>();
list.add(17);

Сигнатура метода add:

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 могут быть разрешены примитивные типы данных для дженериков. Проект Valhalla направлен на улучшение способа обработки дженериков. Идея состоит в том, чтобы реализовать специализацию дженериков, как описано в JEP 218.

Заключение

Дженерики Java Generics – мощное дополнение к языку Java, поскольку оно упрощает работу программиста и снижает вероятность ошибок. Дженерики обеспечивают правильность типов во время компиляции и, что наиболее важно, позволяют реализовать общие алгоритмы, не вызывая дополнительных накладных расходов для приложений.

Исходный код, прилагаемый к статье, доступен на GitHub.

Оригинал