Основите на 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 . Това е необходимо, дори ако методът се връща невалиден.

Както бе споменато по-горе, методът може да се справи с повече от един родов тип, където това е така, всички родови типове трябва да бъдат добавени към сигнатурата на метода, например, ако искаме да модифицираме горния метод, за да се справим с тип Т и тип 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 Collections T се използва за тип, K за ключ, V за стойност.

3.1. Обвързани генерици

Както бе споменато по-горе, параметрите на типа могат да бъдат ограничени. Bounded означава „ ограничен “, можем да ограничим типовете, които могат да бъдат приети от метод.

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

За да декларираме горен ограничен тип, използваме ключовата дума 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); }

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, тъй като прави работата на програмиста по-лесна и по-малко податлива на грешки. Generics налагат коректността на типа по време на компилиране и, най-важното, позволяват прилагането на общи алгоритми, без да причиняват допълнителни режийни разходи за нашите приложения.

Изходният код, придружаващ статията, е достъпен в GitHub.