Generyki są jednym z tematów, z którymi spotkałaś się na naszym kursie, gdy pisaliśmy o kolekcjach, ale nie nazywaliśmy ich bezpośrednio. Dzisiaj uzupełnienie, które pozwoli Ci zrozumieć czym są i jakie jest ich zastosowanie.
Czym są generyki?
Generyki (generics) zostały dodane do Javy 1.5, po to, aby umożliwić parametryzowanie klasy, metody oraz interfejsu. Dzieje się tak poprzez podawanie typu (lub typów) argumentu, przyjmowanego przez metody danej klasy/interfejsu dopiero w momencie jej użycia w kodzie.
Ze względu na powyższy fakt, możemy wykorzystywać pewne części kodu, które nie są ściśle związane z konkretną implementacją wielokrotnie. Korzystamy wtedy ze ścisłego typowania w kontekście danej sytuacji. Jest to jeden ze sposobów na wcielenie polimorfizmu do naszego kodu.
Przykłady generyków
Na początek krótka wzmianka o korzyściach związanych z użyciem generyków. Jeśli poprosilibyśmy Cię o napisanie “uniwersalnej” klasy Walizka z polem przedmiot, które może być różnego typu, a Ty nie znałabyś generyków, to najprawdopodobniej stworzyłabyś coś takiego:
public class Walizka {
private Object przedmiot;
public void set(Object przedmiot) { this.przedmiot = przedmiot; }
public Object get() { return przedmiot; }
}
Takie rozwiązanie na pierwszy rzut oka wydaje się być prawidłowe. Mamy pole typu Object, które może ‘przechowywać’ dowolny typ obiektu, dziedziczący po Object (czyli wszystko poza prymitywami). istnieje możliwość stworzenia obiektów walizka, które będą miały wartość pola przedmiot jako Integer, String, czy też np. typu Kosmetyki (jeśli stworzymy taką klasę w ramach naszego programu). Sposób nadal wydaje się odpowiednim, ale poszukajmy w nim zagrożeń. Jeśli nasze pole może przyjąć wszystko, to jak uodpornić je na błędy związane z nieodpowiednim typem, w konkretnej sytuacji związanej z jego użyciem? Jak automatycznie sprawdzić, czy typ obiektu, który jest przypisywany do pola przedmiot, jest tym właściwym w danym momencie? W przypadku takiego rozwiązania, nie potrafimy w trakcie kompilacji “wyłapać” nieprawidłowego użycia. Przez to możemy wywołać metodę, która nie jest dla danego typu. Tym samym wygenerujemy błąd, bo Java jest w stanie sprawdzić tylko i wyłącznie, czy dany obiekt jest typu Object. Słowem, nie róbcie tego w domu! :)
Rozwiązaniem powyższego problemu jest stworzenie możliwości podania konkretnego typu naszego pola, w momencie zastosowania w kodzie. Wtedy kompilator będzie mógł zweryfikować, czy jest ono dobrze używane. W ten sposób metody mogą zwracać właściwy typ, a nie Object, który musimy rzutować w zależności od sytuacji. I tak właśnie działają generyki.
Nasza klasa w wydaniu generycznym będzie wyglądała następująco:
public class GenerycznaWalizka<T> {
private T przedmiot;
public void set(T przedmiot) { this.przedmiot = przedmiot; }
public T get() { return przedmiot; }
}
Jak widzimy, pojawiły się charakterystyczne nawiasy trójkątne, które zawierają w sobie T (zwyczajowo, jako akronim od type, ale możemy użyć dowolnej nazwy: K, V, ABC czy innej wybranej) Pole zamiast typu Object jest teraz właśnie typu T. Taki nawias na pewno kojarzysz już z pracy np. z ArrayListą, gdzie w nawiasach <> podawałaś typ przyjmowanych argumentów. I bardzo dobrze, że zauważyłaś powiązanie, bo kolekcje są właśnie przykładem generyków ;)
T może reprezentować wszystko, co nie jest prymitywem. Możemy dowolnie wybrać nazwę parametru. Warto jednak pamiętać, że istnieje pewna konwencja nazewnictwa i tak: E — Element, K — Key, N — Number, T — Type, V — Value, S,U,V etc. — drugi, trzeci, czwarty parametr).
Należy też wspomnieć, że klasa może przyjmować wiele parametrów. Podajemy je wtedy po przecinkach, np:
public class Mapa<K,V> {
private K klucz;
private V wartosc;
//...
}
Klasa ta przyjmuje dwa typy, które nazwaliśmy K oraz V.
Korzystanie z generyków w kodzie
Aby używać klasy generycznej w kodzie, powinniśmy skonkretyzować wszystkie parametry, tzn. podać konkretne klasy w miejsce każdego z nich (co ważne — nie jest to ściśle wymagane, niepodanie typu spowoduje wyświetlenie uwagi, ale kod się skompiluje). W naszym przypadku będzie to np:
Walizka<String> stringWalizka = ...;
Jeśli musimy skonkretyzować więcej parametrów, podajemy typy po przecinku, np:
Map<Integer, String> mapa = ...;
Generyków używamy wszędzie tam, gdzie nasz kod nie zależy od dokładnej implementacji obiektu, a jedynie ‘przesyła’ go. Dobrym przykładem są kolekcje (np. Lista przechowuje wszystko, nie ma znaczenia jakiego typu są to obiekty) czy bazowe serwisy (takie, po których dziedziczą serwisy specyficzne dla danego obiektu) lub obiekty typu DAO. Co więcej — typów możemy używać parametryzując także pola klasy czy metody abstrakcyjne, jak np. w poniższym przykładzie:
public abstract class BaseService<K, T> {
public abstract BaseDAO<K, T> getBaseDAO();
public T getById(K klucz) {
return getBaseDAO().getById(K);
}
//...
}
Ograniczenie górne (upper bound)
Ograniczenie górne pozwala nam ‘zawęzić’ potencjalnie używane typy tylko do tych, które dziedziczą po określonej klasie lub implementują określony interfejs. Rozpatrzmy przykład walizki z akapitu wyżej. Do walizki chcemy zapakować wyłącznie ubranie — nie jakieś konkretne, ale cokolwiek, co mieści się w pojęciu odzieży. Nasza deklaracja klasy będzie w tej sytuacji wyglądała następująco:
public class GenerycznaWalizka<T extends Ubranie> {
//...
}
Co najważniejsze, w dowolnym miejscu naszego kodu możemy używać wszystkich metod klasy bazowej (w tym wypadku Ubranie) dla dowolnej zmiennej, której typ określimy jako ‘T’. Np:
public class GenerycznaWalizka<T extends Ubranie> {
private T zawartosc;
public String opiszZawartosc() {
return zawartosc.getOpisZMetki();
}
//...
}
Oczywiście powyższy kod zadziała przy założeniu, że klasa (lub interfejs) Ubranie deklaruje metodę String getOpisZMetki().
Ograniczenie górne wielokrotne
Pewnym rozwinięciem tego konceptu są wielokrotne ograniczenia górne. Znajdują one zastosowanie w momentach, kiedy chcielibyśmy ograniczyć obiekty ‘przetwarzane’ przez naszą klasę tylko do takich, które implementują np. wiele interfejsów, czy też klasę oraz interfejs. Ponownie wracając do przykładu powyżej, załóżmy, że mamy dodatkowo interfejs ‘MoznaSkladac’. Chcemy tak opisać naszą walizkę, aby przyjmowała tylko ubranie, które można składać. W kodzie będzie to wyglądało następująco:
public class GenerycznaWalizka<T extends Ubranie & MoznaSkladac> {
//...
}
Równie dobrze możemy dodać kolejne interfejsy, jak np. MaKieszenie:
public class GenerycznaWalizka<T extends Ubranie & MoznaSkladac & MaKieszenie> {
//...
}
Związane są z tym pewne ograniczenia: możemy w powyższy sposób wymienić tylko jedną klasę (ponieważ Java nie obsługuje wielokrotnego dziedziczenia). Jeśli wymieniamy zarówno klasę, jak i interfejsy, klasa musi być wymieniona jako pierwsza.
Ograniczenie dolne (lower bound)
Drugim rodzajem ograniczeń, jakie można zastosować, są ograniczenia dolne. Ograniczamy w nich przyjmowane typy tylko do klas, po których dziedziczy określony typ. Tego rodzaju ograniczenie ma jednak bardzo specyficzne zastosowanie. Warto wiedzieć, że ono istnieje, najprawdopodobniej jednak nie spotkasz się z nim w praktyce.
Zainteresowanych przykładem użycia odsyłam do strony Oracle.
Metody generyczne
Czasem nie chcemy lub nie możemy uczynić całej klasy generycznej. Potrzebujemy za to, by metoda zwracała nam coś o typie takim jak argument (np. w przypadku walidacji, metod pomocnicznych builderów itp). Załóżmy, że mamy następujący interfejs:
public interface PosiadaId {
public String getId();
public void setId();
}
i chcemy stworzyć metodę pomocniczą, która dla wybranego obiektu skopiuje go, ustawi kopie losowe id oraz zwróci kopie. Metoda ta bez generyków wyglądałaby następująco:
public PosiadaId skopiujINadajId(PosiadaId obiekt) {
//...
}
Problemem jest to, że wyżej wymieniona metoda nie zwraca właściwego typu i każdorazowo musielibyśmy rzutować otrzymany obiekt na właściwy typ. Wersja generyczna tego sposobu miałaby postać następującą:
public T skopiujINadajId(T obiekt) {
//...
}
W tym wypadku otrzymany przez nas obiekt byłby właściwego typu. Jednocześnie nie musimy deklarować całej klasy jako generycznej, wykorzystując jeden obiekt dla wielu typów. Metoda ta może być również statyczna.
Generyki w praktyce…
… czyli: co to znaczy, że są one tylko i wyłącznie informacją dla kompilatora? Otóż wiadomość o typie parametrów klasy generycznej nie jest weryfikowana, czy wręcz dostępna w skompilowanej klasie. Oznacza to, że dla maszyny wirtualnej Javy ArrayList<Integer> to w praktyce po prostu ArrayList. Jeśli w ten sposób będziemy się odnosić do listy w kodzie, umożliwi nam to dodanie do niej np. Stringa! Oczywiście już w momencie próby odczytu otrzymamy wyjątek ClassCastException, ale używanie niesparametryzowanych typów może spowodować bardzo duże zamieszanie. Weźmy za przykład poniższy fragment:
Vector strings = new Vector(10);
Vector objects = strings; //typ niesparametryzowany!! Ale technicznie jest to prawidłowy kod, który się skompiluje
objects.add(new Object()); //poprawne, bo object nie ma parametrów - choć wskazuje na ten sam obiekt, co strings
Object anObject = strings.get(0); //to jest nadal poprawne, ponieważ pierwszy element w istocie jest obiektem
String aString = strings.get(0); //wyjątek, pierwszy element nie jest Stringiem
Nietrudno sobie wyobrazić, że druga i trzecia linijka są zaszyte w jakimś module i mozna ich prosto odnaleźć. Uruchamiamy aplikację i otrzymujemy wyjątek, że element pobrany z kolekcji Stringów nie jest Stringiem. Mylące, prawda?
Wynikają z tego różne ograniczenia — typów generycznych nie można np. rzucać czy łapać (throw i catch), nie można rzutować na typy parametryzowane, weryfikować warunków typu instanceof, czy też tworzyć tablic obiektów typów parametryzowanych. Bariery te są opisane szczegółowo na stronie Oracle, zajrzyj tam, jeśli interesują Cię powody tego stanu rzeczy.
Jeszcze krótkie wyjaśnienie: tego rodzaju wiedza nie jest oczekiwana od juniora — ale bardzo ważne, żebyś używała typów generycznych prawidłowo, w przeciwnym razie możesz przysporzyć sobie bólu głowy, szukając źródła problemu.
Generyki w API Javy
W samym API Javy znajdziemy wiele przykładów użycia typów generycznych — poniżej kilka najczęściej stosowanych oraz najbardziej znanych.
Kolekcje
Wszystkie kolekcje w Javie są parametryzowane — każda implementacja List, Map czy Set posiada parametry, które możemy (powinniśmy) określić.
Optional
Optional to typ, który pozwala na uniknięcie sprawdzania, czy dana wartość jest null’em oraz tych nieobsłużonych NullPointerException. Jest to klasa coraz częściej pojawiająca się w różnego rodzaju API, która służy do ‘pokazania’, że dany parametr jest opcjonalny.
Lambdy
Choć nie wprost, także lambdy są przykładem użycia typów generycznych. Wiele klas w pakiecie java.util.function jest parametryzowanych i reprezentuje np. elementy strumieni, czy bezpośrednio funkcje, które możemy zapisywać w postaci lambd
Podsumowanie
Typy generyczne to niezwykle przydatne usprawnienie w języku Java, które pozwala pisać bardziej czytelny i uniwersalny kod. Trzeba jednak uważać, by nie wprowadzić potencjalnie ryzykownych fragmentów kodu poprzez niewłaściwe ich użycie.
Generyki są również jednym z elementów języka, których najprawdopodobniej używałaś, zanim dowiedziałaś się o ich istnieniu ;)