Podstawy Java Generics

1. Wstęp

Java Generics zostały wprowadzone w JDK 5.0 w celu ograniczenia liczby błędów i dodania dodatkowej warstwy abstrakcji nad typami.

Ten artykuł to krótkie wprowadzenie do typów generycznych w Javie, ich celu oraz tego, jak można je wykorzystać do poprawy jakości naszego kodu.

2. Potrzeba generyków

Wyobraźmy sobie scenariusz, w którym chcemy utworzyć listę w Javie do przechowywania liczby całkowitej ; można pokusić się o napisanie:

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

Zaskakujące jest, że kompilator będzie narzekał na ostatnią linię. Nie wie, jaki typ danych jest zwracany. Kompilator będzie wymagał jawnego rzutowania:

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

Nie ma umowy, która mogłaby zagwarantować, że zwracanym typem listy jest liczba całkowita. Zdefiniowana lista może zawierać dowolny obiekt. Wiemy tylko, że pobieramy listę, sprawdzając kontekst. Patrząc na typy, może tylko zagwarantować, że jest to obiekt , dlatego wymaga jawnego rzutowania, aby upewnić się, że typ jest bezpieczny.

To rzutowanie może być denerwujące, wiemy, że typ danych na tej liście to liczba całkowita . Obsada również zaśmieca nasz kod. Może powodować błędy w czasie wykonywania związane z typem, jeśli programista popełni błąd podczas jawnego rzutowania.

Byłoby znacznie łatwiej, gdyby programiści mogli wyrazić zamiar używania określonych typów, a kompilator zapewniłby poprawność tego typu. To jest główna idea stojąca za lekami generycznymi.

Zmodyfikujmy pierwszy wiersz poprzedniego fragmentu kodu na:

List list = new LinkedList();

Dodając operator diamentowy zawierający typ, zawężamy specjalizację tej listy tylko do typu Integer, czyli określamy typ, który będzie przechowywany wewnątrz listy. Kompilator może wymusić typ w czasie kompilacji.

W małych programach może się to wydawać trywialnym dodatkiem, jednak w większych programach może to znacznie zwiększyć solidność i ułatwić czytanie programu.

3. Metody ogólne

Metody ogólne to te metody, które są napisane z jedną deklaracją metody i można je wywołać z argumentami różnych typów. Kompilator zapewni poprawność dowolnego używanego typu. Oto niektóre właściwości metod ogólnych:

  • Metody ogólne mają parametr typu (operator rombu otaczający typ) przed zwracanym typem deklaracji metody
  • Parametry typu można ograniczać (granice są wyjaśnione w dalszej części artykułu)
  • Metody ogólne mogą mieć różne parametry typu oddzielone przecinkami w sygnaturze metody
  • Treść metody dla metody ogólnej jest taka sama, jak normalna metoda

Przykład definiowania ogólnej metody konwersji tablicy na listę:

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

W poprzednim przykładzie w podpisie metoda zakłada, że metoda będzie mieć do czynienia ze standardowym typu T . Jest to potrzebne, nawet jeśli metoda zwraca void.

Jak wspomniano powyżej, metoda może obsługiwać więcej niż jeden typ ogólny, w takim przypadku wszystkie typy ogólne muszą zostać dodane do podpisu metody, na przykład, jeśli chcemy zmodyfikować powyższą metodę, aby radziła sobie z typem T i typem G , powinno być napisane tak:

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

Przekazujemy funkcję, która konwertuje tablicę z elementami typu T na listę z elementami typu G. Przykładem może być konwersja liczby całkowitej na jej reprezentację typu 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")); }

Warto zauważyć, że zaleceniem Oracle jest używanie dużej litery do reprezentowania typu ogólnego i wybranie bardziej opisowej litery do reprezentowania typów formalnych, na przykład w kolekcji Java T jest używane dla typu, K dla klucza, V dla wartości.

3.1. Bounded Generics

Jak wspomniano wcześniej, parametry typu można ograniczać. Ograniczony oznacza „ ograniczony ”, możemy ograniczyć typy, które można zaakceptować metodą.

Na przykład możemy określić, że metoda akceptuje typ i wszystkie jego podklasy (górna granica) lub typ wszystkie jego nadklasy (dolna granica).

Aby zadeklarować typ z górną granicą, używamy słowa kluczowego extends po typie, po którym następuje górna granica, której chcemy użyć. Na przykład:

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

Słowo kluczowe extends jest tutaj użyte w celu oznaczenia, że ​​typ T rozszerza górną granicę w przypadku klasy lub implementuje górną granicę w przypadku interfejsu.

3.2. Wiele granic

Typ może również mieć wiele górnych granic w następujący sposób:

Jeśli jednym z typów rozszerzonych przez T jest klasa (np. Liczba ), to należy go umieścić na pierwszym miejscu na liście granic. W przeciwnym razie spowoduje to błąd w czasie kompilacji.

4. Używanie symboli wieloznacznych w rodzajach ogólnych

Symbole wieloznaczne są reprezentowane przez znak zapytania w języku Java „ ? ”I są używane w odniesieniu do nieznanego typu. Symbole wieloznaczne są szczególnie przydatne w przypadku używania typów ogólnych i mogą być używane jako typ parametru, ale najpierw należy wziąć pod uwagę ważną uwagę.

Wiadomo, że Object jest nadtypem wszystkich klas Javy, jednak zbiór Object nie jest nadtypem żadnej kolekcji.

Na przykład List nie jest typem nadrzędnym listy, a przypisanie zmiennej typu List do zmiennej typu List spowoduje błąd kompilatora. Ma to na celu zapobieżenie możliwym konfliktom, które mogą wystąpić, jeśli dodamy heterogeniczne typy do tej samej kolekcji.

Ta sama zasada dotyczy dowolnej kolekcji typu i jego podtypów. Rozważmy ten przykład:

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 jest potężnym dodatkiem do języka Java, ponieważ sprawia, że ​​praca programisty jest łatwiejsza i mniej podatna na błędy. Generics wymuszają poprawność typów w czasie kompilacji i, co najważniejsze, umożliwiają wdrażanie algorytmów ogólnych bez powodowania dodatkowego obciążenia naszych aplikacji.

Kod źródłowy dołączony do artykułu jest dostępny w serwisie GitHub.