Menu

Typy generyczne w Javie: bezpieczne typowo klasy i metody

Czym są typy generyczne (generics) w Javie, jak pisać generyczne klasy i metody, parametry typu z ograniczeniami, wildcardy i dlaczego wymazywanie typów ma znaczenie.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Po co są typy generyczne

Typ generyczny pozwala napisać kod raz i używać go dla wielu typów bez rezygnacji z bezpieczeństwa typów. Zamiast pisać osobno IntBox, StringBox i UserBox, piszesz jeden Box<T>, gdzie T to miejsce na typ, które wypełnia wywołujący.

Typy generyczne pojawiły się już za każdym razem, gdy w kodzie było ArrayList<String> albo HashMap w rodzaju HashMap<String, Integer>. Część <...> to argument typu. Ta strona pokazuje, jak pisać własne.

Alternatywa, czyli przechowywanie wszystkiego jako Object, wyrzuca informację o typie i wymusza brzydkie, podatne na błędy rzutowania:

Rzutowanie w ostatnim wierszu rzuca ClassCastException w czasie działania programu, czyli dokładnie taki wyjątek, który typy generyczne mają uniemożliwić.

Klasa generyczna

Parametr typu deklarujesz w nawiasach ostrych po nazwie klasy. Zgodnie z konwencją to pojedyncza wielka litera: T od „type”, E od „element”, K/V od klucza i wartości.

Wewnątrz Box każde T staje się typem podanym przez wywołującego. Box<String> to pudełko, które przechowuje i zwraca tylko String. Kompilator odrzuca name.set(99), zanim program w ogóle się uruchomi.

Puste <> po prawej stronie (operator diamentu) pozwala kompilatorowi wywnioskować argument typu z lewej strony, więc nie powtarzasz <String> dwa razy.

Metody generyczne

Pojedyncza metoda może mieć własny parametr typu, niezależny od klasy. Umieść parametr <T> przed typem zwracanym:

Nigdy nie przekazujesz T jawnie: kompilator wnioskuje go z argumentu. Dzięki metodom generycznym narzędzia takie jak Collections.sort czy List.of pozostają bezpieczne typowo dla dowolnego typu elementów.

Parametry typu z ograniczeniem

Czasem typ generyczny ma sens tylko dla niektórych typów. extends ogranicza parametr, dzięki czemu możesz wywoływać metody typu ograniczającego. Tutaj T extends Number oznacza, że T to Number albo dowolna jego podklasa (Integer, Double, ...), więc dostępne jest doubleValue():

Zauważ, że extends oznacza tu „jest podtypem” i działa zarówno dla klas, jak i interfejsów: <T extends Comparable<T>> to bardzo częsty zapis, gdy trzeba porównywać elementy.

Wildcardy: ? extends i ? super

Subtelna pułapka: List<Integer> nie jest List<Number>, choć Integer jest Number. Typy generyczne są inwariantne. Wildcardy (symbole wieloznaczne) łagodzą to, gdy potrzebujesz tylko czytać albo tylko zapisywać.

Używaj ? extends T dla producenta, z którego czytasz, i ? super T dla konsumenta, do którego zapisujesz (zasada „PECS”: Producer Extends, Consumer Super):

Lista ? extends Number pozwala czytać elementy jako Number, ale nie pozwala do niej dodawać (kompilator nie zna dokładnego typu elementów). Lista ? super Integer pozwala dodawać Integer, ale odczytane wartości mają typ Object. Dobierz wildcard do kierunku przepływu danych.

Wymazywanie typów i jego ograniczenia

Typy generyczne to mechanizm czasu kompilacji. Po kompilacji parametr typu zostaje wymazany: w czasie działania Box<String> i Box<Integer> to po prostu Box. Dzięki temu typy generyczne są zgodne wstecz ze starym kodem, ale wiąże się to z realnymi ograniczeniami.

// Żadne z tych się nie skompiluje: parametr typu nie istnieje w czasie działania:
T value = new T();          // nie można utworzyć instancji parametru typu
T[] array = new T[10];      // nie można utworzyć tablicy generycznej
if (list instanceof List<String>) { } // nie można sprawdzić argumentu typu

Ponieważ typu w czasie działania już nie ma, nie zapytasz przez refleksję „czym było T?” i nie przeciążysz metod, które różnią się tylko argumentem generycznym (foo(List<String>) i foo(List<Integer>) po wymazaniu mają tę samą sygnaturę). Gdy naprawdę potrzebujesz typu w czasie działania, przekaż token Class<T> jako parametr konstruktora lub metody.

Dalej: wyrażenia lambda

Jak widać, metoda generyczna przyjmuje typ jako parametr. Kolejny krok to traktowanie zachowania jako parametru. Wyrażenia lambda pozwalają przekazać do metody fragment kodu, czyli funkcję. Właśnie tak będziesz sortować, filtrować i przekształcać kolekcje generyczne, które potrafisz już bezpiecznie typować.

Najczęściej zadawane pytania

Czym są typy generyczne w Javie?

Typy generyczne pozwalają napisać klasę lub metodę, która działa z typem podanym później, zamiast wiązać ją z jednym konkretnym typem. Deklarujesz parametr typu w nawiasach ostrych, class Box<T>, a wywołujący podaje faktyczny typ, Box<String>. Kompilator pilnuje potem tego typu wszędzie, więc niezgodności wychodzą na etapie kompilacji, a ręczne rzutowania są zbędne.

Dlaczego używać typów generycznych zamiast Object?

Użycie Object gubi całą informację o typie: kompilator nie powstrzyma cię przed włożeniem czegoś niewłaściwego, a każdą wyjmowaną wartość musisz rzutować (ryzykując ClassCastException w czasie działania). Typy generyczne przenoszą to sprawdzanie na etap kompilacji. List<String> po prostu nie przyjmie Integer, a get() od razu zwraca String: bez rzutowania i bez niespodzianek w czasie działania.

Czym jest wymazywanie typów (type erasure) w Javie?

Wymazywanie typów oznacza, że informacja o typie generycznym istnieje tylko w czasie kompilacji. Po kompilacji List<String> i List<Integer> to w czasie działania po prostu List: parametr typu zostaje wymazany. Dlatego nie możesz napisać new T[10], wywołać list instanceof List<String> ani odczytać parametru typu przez refleksję. Typy generyczne dają bezpieczeństwo na etapie kompilacji, a nie dane o typach w czasie działania.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ