Основи 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();

Додаючи алмазний оператор, що містить тип, ми звужуємо спеціалізацію цього списку лише до типу Integer, тобто ми вказуємо тип, який буде міститися в списку. Компілятор може застосовувати тип під час компіляції.

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

3. Загальні методи

Загальні методи - це ті методи, які записуються з єдиною декларацією методу і можуть бути викликані з аргументами різних типів. Компілятор забезпечить правильність використання будь-якого типу. Ось деякі властивості загальних методів:

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

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

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

У попередньому прикладі в сигнатурі методу передбачає , що метод буде мати справу з шаблонного типу T . Це потрібно, навіть якщо метод повертає недійсним.

Як згадувалося вище, метод може мати справу з декількома загальними типами, де це так, усі загальні типи повинні бути додані до підпису методу, наприклад, якщо ми хочемо змінити вищезазначений метод для роботи з типом T і типом G , це слід писати так:

public static  List fromArrayToList(T[] a, Function mapperFunction) { return Arrays.stream(a) .map(mapperFunction) .collect(Collectors.toList()); }

Ми передаємо функцію, яка перетворює масив з елементами типу T у список з елементами типу G. Прикладом може бути перетворення Integer у його подання String :

@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 T використовується для типу, K для ключа, V для значення.

3.1. Обмежені дженерики

Як зазначалося раніше, параметри типу можуть бути обмежені. Обмежений означає " обмежений ", ми можемо обмежити типи, які можуть бути прийняті методом.

Наприклад, ми можемо вказати, що метод приймає тип і всі його підкласи (верхня межа) або тип усі його суперкласи (нижня межа).

Щоб оголосити верхній обмежений тип, ми використовуємо ключове слово extends після типу, за яким слідує верхня межа, яку ми хочемо використовувати. Наприклад:

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

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

3.2. Кілька меж

Тип також може мати кілька верхніх меж, як показано нижче:

Якщо одним із типів, які розширено за допомогою T, є клас (тобто число ), його потрібно поставити першим у списку меж. В іншому випадку це спричинить помилку під час компіляції.

4. Використання узагальнюючих знаків із дженериками

Узагальнюючі символи представлені знаком питання на Java “ ? ”, І вони використовуються для позначення невідомого типу. Узагальнюючі символи особливо корисні при використанні дженериків і можуть бути використані як тип параметра, але спочатку слід врахувати важливу примітку.

Відомо, що Object є супертипом усіх класів Java, однак колекція Object не є надтипом жодної колекції.

Наприклад, List не є надтипом List, і присвоєння змінної типу List змінній типу List призведе до помилки компілятора. Це для запобігання можливим конфліктам, які можуть статися, якщо ми додамо різнорідні типи до однієї колекції.

Те саме правило застосовується до будь-якої колекції типу та його підтипів. Розглянемо цей приклад:

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

if we imagine a subtype of Building, for example, a House, we can't use this method with a list of House, even though House is a subtype of Building. If we need to use this method with type Building and all its subtypes, then the bounded wildcard can do the magic:

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

Now, this method will work with type Building and all its subtypes. This is called an upper bounded wildcard where type Building is the upper bound.

Wildcards can also be specified with a lower bound, where the unknown type has to be a supertype of the specified type. Lower bounds can be specified using the super keyword followed by the specific type, for example, means unknown type that is a superclass of T (= T and all its parents).

5. Type Erasure

Generics were added to Java to ensure type safety and to ensure that generics wouldn't cause overhead at runtime, the compiler applies a process called type erasure on generics at compile time.

Type erasure removes all type parameters and replaces it with their bounds or with Object if the type parameter is unbounded. Thus the bytecode after compilation contains only normal classes, interfaces and methods thus ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

This is an example of type erasure:

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.