Основы Java Generics

1. Введение

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

Эта статья представляет собой краткое введение в 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();

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

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

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

Давайте изменим первую строку предыдущего фрагмента кода, чтобы:

List list = new LinkedList();

Добавляя ромбовидный оператор, содержащий тип, мы сужаем специализацию этого списка только до целочисленного типа, т. Е. Мы указываем тип, который будет храниться внутри списка. Компилятор может принудительно указать тип во время компиляции.

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

3. Общие методы

Универсальные методы - это те методы, которые написаны с одним объявлением метода и могут быть вызваны с аргументами разных типов. Компилятор обеспечит правильность любого используемого типа. Вот некоторые свойства универсальных методов:

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

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

public  List fromArrayToList(T[] a) { return Arrays.stream(a).collect(Collectors.toList()); }

В предыдущем примере в сигнатуре метода предполагает , что метод будет иметь дело с шаблонного типа T . Это необходимо, даже если метод возвращает void.

Как упоминалось выше, метод может работать с более чем одним универсальным типом, в этом случае все универсальные типы должны быть добавлены в сигнатуру метода, например, если мы хотим изменить вышеуказанный метод для работы с типом T и типом G , это должно быть написано так:

public static  List fromArrayToList(T[] a, Function 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 stringList = Generics.fromArrayToList(intArray, Object::toString); assertThat(stringList, hasItems("1", "2", "3", "4", "5")); }

Стоит отметить, что Oracle рекомендует использовать заглавную букву для представления универсального типа и выбирать более информативную букву для представления формальных типов, например в Java Collections T используется для типа, K для ключа, V для значения.

3.1. Ограниченные универсальные шаблоны

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

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

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

public  List fromArrayToList(T[] a) { ... } 

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

3.2. Множественные границы

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

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

4. Использование подстановочных знаков с обобщениями

Подстановочные знаки представлены в Java знаком вопроса « ? »И они используются для обозначения неизвестного типа. Подстановочные знаки особенно полезны при использовании универсальных шаблонов и могут использоваться в качестве типа параметра, но прежде всего следует принять во внимание важное замечание.

Известно, что Object является супертипом всех классов Java, однако коллекция Object не является супертипом какой-либо коллекции.

Например, List не является супертипом List, и присвоение переменной типа List переменной типа List вызовет ошибку компилятора. Это сделано для предотвращения возможных конфликтов, которые могут произойти, если мы добавим разнородные типы в одну и ту же коллекцию.

То же правило применяется к любой коллекции типа и его подтипов. Рассмотрим этот пример:

public static void paintAllBuildings(List buildings) { buildings.forEach(Building::paint); }

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

public static void paintAllBuildings(List buildings) { ... } 

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

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

5. Введите Erasure.

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

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

Это пример стирания типа:

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

With type erasure, the unbounded type T is replaced with Object as follows:

// for illustration public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } // which in practice results in public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } 

If the type is bounded, then the type will be replaced by the bound at compile time:

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

would change after compilation:

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

6. Generics and Primitive Data Types

A restriction of generics in Java is that the type parameter cannot be a primitive type.

For example, the following doesn't compile:

List list = new ArrayList(); list.add(17);

To understand why primitive data types don't work, let's remember that generics are a compile-time feature, meaning the type parameter is erased and all generic types are implemented as type Object.

As an example, let's look at the add method of a list:

List list = new ArrayList(); list.add(17);

The signature of the add method is:

boolean add(E e);

And will be compiled to:

boolean add(Object e);

Therefore, type parameters must be convertible to Object. Since primitive types don't extend Object, we can't use them as type parameters.

However, Java provides boxed types for primitives, along with autoboxing and unboxing to unwrap them:

Integer a = 17; int b = a; 

So, if we want to create a list which can hold integers, we can use the wrapper:

List list = new ArrayList(); list.add(17); int first = list.get(0); 

The compiled code will be the equivalent of:

List list = new ArrayList(); list.add(Integer.valueOf(17)); int first = ((Integer) list.get(0)).intValue(); 

Future versions of Java might allow primitive data types for generics. Project Valhalla aims at improving the way generics are handled. The idea is to implement generics specialization as described in JEP 218.

7. Conclusion

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

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