Generyki w Kotlinie
To jest rozdział z książki Kotlin Essentials.
Java została pierwotnie zaprojektowana tak, że wszystkie listy miały ten sam typ List
, zamiast konkretnych list z określonymi parametrami typów, takimi jak List<String>
czy List<Int>
. Typ List
w Javie akceptuje wszystkie rodzaje wartości; gdy prosisz o wartość na określonej pozycji, typem wyniku jest Object
(który w Javie jest nadtypem wszystkich typów).
// Java
List names = new ArrayList();
names.add("Alex");
names.add("Ben");
names.add(123); // to jest niepoprawne, ale się kompiluje
for(int i = 0; i < names.size(); i++){
String name= (String) names.get(i); // wyjątek przy i==2
System.out.println(name.toUpperCase());
}
Takie listy używa się dość trudno. Jako programiści, wolimy mieć listy z określonymi typami elementów. Dopiero wtedy możemy być pewni, że zawierają one elementy właściwego typu i dopiero wtedy nie musimy jawnie rzutować tych elementów, gdy pobieramy je z listy. Umożliwienie tego było jednym z głównych powodów wprowadzenia typów generycznych do Javy 5. W Kotlinie nie mamy tego problemu, ponieważ został on zaprojektowany z obsługą typów generycznych od samego początku, a wszystkie listy są generyczne, więc trzeba z góry określić rodzaj elementów, jaki akceptują. Typy generyczne są ważną cechą większości nowoczesnych języków programowania i w tym rozdziale omówimy, czym są i jak używamy ich w Kotlinie.
W Kotlinie mamy trzy rodzaje elementów generycznych:
- funkcje generyczne,
- klasy generyczne,
- interfejsy generyczne.
Omówmy je kolejno.
Funkcje generyczne
Tak jak możemy przekazać wartość jako argument do parametru, możemy przekazać typ jako argument typu do parametru typu. Aby to zrobić, funkcja musi zdefiniować co najmniej jeden parametr typu w nawiasach ostrokątnych zaraz po słowie kluczowym fun
. Zgodnie z tą konwencją nazwy parametrów typu pisane są wielkimi literami. Gdy funkcja definiuje parametr typu, jej wywołanie musi określić argument typu. Parametr typu jest symbolem zastępczym dla konkretnego typu; argument typu to rzeczywisty typ używany podczas wywoływania funkcji. Aby jawnie określić argumenty typu, używamy również nawiasów ostrokątnych.
Istnieje popularna praktyka, że pojedynczy argument typu nazywa się T
(od "type"); jeśli jest kilka argumentów typu, nazywają się T
z kolejnymi liczbami. Ta praktyka jednak nie jest sztywną regułą i istnieje wiele innych konwencji nazewnictwa parametrów typu.
Gdy wywołujemy funkcję generyczną, wszystkie jej argumenty typu muszą być jasne dla kompilatora Kotlin. Możemy je albo jawnie określić, albo ich wartości mogą być wnioskowane przez kompilator.
W jaki sposób parametry typu mogą być przydatne? Używamy ich głównie do określenia związku między typami argumentów oraz typem wyniku. Dla przykładu możemy wyrazić, że typ wyniku jest taki sam jak typ argumentu lub że oczekujemy dwóch argumentów tego samego typu.
Parametry typu w funkcji są przydatne dla kompilatora, ponieważ pozwalają mu sprawdzać i poprawnie wnioskować typy; sprawia to, że nasze programy są bezpieczniejsze, a programowanie staje się przyjemniejsze dla programistów. Chronią nas one przed używaniem niedozwolonych operacji i pozwalają naszemu IDE dawać lepsze sugestie.
W książce Funkcyjny Kotlin zobaczysz wiele przykładów funkcji generycznych, zwłaszcza do przetwarzania kolekcji. Takie funkcje są naprawdę ważne i przydatne. Na razie wróćmy do początkowej motywacji wprowadzenia generyków: porozmawiajmy o klasach generycznych.
Klasy generyczne
Klasy możemy uczynić generycznymi, określając parametr typu po nazwie klasy. Taki parametr typu można używać w ciele klasy, zwłaszcza do określania właściwości, parametrów i typów wyników. Parametr typu jest określany, gdy definiujemy instancję, po czym pozostaje niezmieniony. Dzięki temu, gdy zadeklarujesz ValueWithHistory<String>
i następnie wywołasz setValue
w poniższym przykładzie, musisz użyć obiektu typu String
; gdy wywołasz currentValue
, obiekt wynikowy będzie miał typ String
; a gdy wywołasz history
, jego wynik będzie typu List<String>
. To samo dotyczy wszystkich innych możliwych argumentów typu.
Argument typu konstruktora może być wnioskowany. W powyższym przykładzie określiliśmy go jawnie, ale nie musieliśmy tego robić. Ten typ może być wnioskowany na podstawie typu argumentu.
Argumenty typu mogą być również wnioskowane z typów zmiennych. Powiedzmy, że chcemy użyć Any
jako argumentu typu. Możemy to określić typ zmiennej letter
jako ValueWithHistory<Any>
. Wtedy argument typu będzie wnioskowany jako Any
.
Jak wspomniałem we wstępie do tego rozdziału, najważniejszym powodem wprowadzenia typów generycznych było stworzenie kolekcji z określonymi typami elementów. Weźmy pod uwagę klasę ArrayList
z biblioteki standardowej (stdlib). Jest ona generyczna, więc gdy tworzymy instancję z tej klasy, musimy określić typy elementów. Dzięki temu Kotlin chroni nas, oczekując tylko wartości z akceptowanymi typami, które mają być dodane do listy, jak również typ ten jest określony, gdy zwracamy lub operujemy na elementach listy.
Klasy generyczne a nullowalność
Zauważ, że argumenty typu mogą być nullowalne, więc możemy stworzyć ValueWithHistory<String?>
. W takim przypadku wartość null
jest zupełnie prawidłową opcją.
Gdy używasz parametrów typu w klasach lub funkcjach, możesz uczynić je nullowalnymi, dodając znak zapytania. Zobacz poniższy przykład. Typ T
może, ale nie musi być nullowalny, w zależności od argumentu typu, ale typ T?
zawsze jest nullowalny. Możemy przypisać wartość null
do zmiennych typu T?
. Nullowalny parametr typu T?
musi zostać rozpakowany przed użyciem go jako T
.
Można wyrazić również przeciwność. Ponieważ parametr typu może reprezentować typ nullowalny, możemy określić zdecydowanie nienullowalną wersję tego typu, dodając & Any
po parametrze typu. W poniższym przykładzie metoda orThrow
może być wywoływana dla dowolnej wartości, ale nawet jeśli jej argument typu jest nullowalny, zawsze typ zwracany będzie nienullowalny.
Generyczne interfejsy
Interfejsy również mogą być generyczne, co ma podobne konsekwencje jak dla klas: określone parametry typu można użyć wewnątrz ciała interfejsu jako typy właściwości, parametrów i wyników. Dobrym przykładem jest interfejs List
.
Modyfikator
out
oraz adnotacjęUnsafeVariance
wyjaśniam w książce Zaawansowany Kotlin.
List<String>
metody takie jak contains
oczekują argumentu typu String
, a metody takie jak get
deklarują String
jako typ wyniku.List<String>
, metody takie jak filter
mogą wywnioskować String
jako parametr wyrażenia lambda.Generyczne interfejsy mogą być implementowane zarówno przez zwykłe, jak i generyczne klasy. Powiedzmy, że mamy klasę Dog
, która implementuje Consumer<DogFood>
, jak pokazano w poniższym fragmencie. Interfejs Consumer
oczekuje metody consume
z parametrem typu T
. Oznacza to, że nasz Dog
musi nadpisać metodę consume
z argumentem typu DogFood
. Musi to być DogFood
, ponieważ implementujemy Consumer<DogFood>
i typ parametru metody consume
musi pasować do użytego argumentu typu DogFood
. Ponieważ Dog
implementuje Consumer<DogFood>
, instancja Dog
może być rzutowana do Consumer<DogFood>
.
Parametry typu i dziedziczenie
Klasy mogą dziedziczyć z otwartych klas generycznych lub implementować generyczne interfejsy; w obu przypadkach muszą wyraźnie określić argument typu. Rozważ poniższy fragment. Klasa A
dziedziczy z C<Int>
i implementuje I<String>
.
Spójrz jeszcze na bardziej praktyczny przykład klasy niegenerycznej MessageListAdapter
, dziedziczącej po klasie generycznej ArrayAdapter<String>
.
Jeszcze bardziej powszechnym przypadkiem jest, gdy jedna klasa/interfejs generyczny dziedziczy po innej klasie/interfejsie generycznym i używa swojego parametru typu jako argumentu typu klasy, po której dziedziczy. W poniższym fragmencie klasa A
jest generyczna i używa swojego parametru typu T
jako argumentu dla zarówno C
, jak i I
. Oznacza to, że jeśli utworzysz A<Int>
, będziesz mógł rzutować ją do C<Int>
lub I<Int>
. Jeśli zaś utworzysz A<String>
, będziesz mógł rzutować ją do C<String>
lub do I<String>
.
Dobrym przykładem jest hierarchia kolekcji. Obiekt typu MutableList<Int>
implementuje List<Int>
, który implementuje Collection<Int>
, który implementuje Iterable<Int>
.
Jednak klasa nie musi używać swojego parametru typu podczas dziedziczenia z klasy ogólnej lub implementacji ogólnego interfejsu. Parametry typu klas nadrzędnych i podrzędnych są niezależne od siebie i nie należy ich mylić, nawet jeśli mają taką samą nazwę.
Type erasure
Typy generyczne zostały dodane do Javy dla wygody programistów, ale nigdy nie zostały wbudowane w platformę JVM. Wszystkie argumenty typu są tracone, gdy kompilujemy Kotlin do bajtkodu JVM1. W praktyce oznacza to, że List<String>
staje się List
, a emptyList<Double>
staje się emptyList
. Proces utraty argumentów typu nazywany jest type erasure. Z powodu tego procesu parametry typu mają pewne ograniczenia w porównaniu z normalnymi typami. Nie można ich używać dla sprawdzeń is
; nie można używać ich do określania referencji do klasy2; ani nie można używać ich jako argument typu z modyfikatorem reified
3.
Kotlin może jednak pokonać te ograniczenia dzięki użyciu funkcji inline z argumentami typu oznaczonymi modyfikatorem reified
. Ten temat jest omówiony szczegółowo w rozdziale Funkcje inline w książce Funkcyjny Kotlin.
Ograniczenia generyczne
Ważną funkcjonalnością parametrów typu jest to, że można je ograniczyć, aby były podtypem konkretnego typu. Ustalamy ograniczenie, umieszczając oczekiwany nadtyp po dwukropku. Na przykład, powiedzmy, że implementujesz funkcję maxOf
, która zwraca największy z jej argumentów. Aby znaleźć największą wartość, argumenty muszą być porównywalne. Więc obok parametru typu T
możemy określić, że akceptujemy tylko typ będący podtypem Comparable<T>
.
Ograniczenia parametrów typu są również używane przez klasy generyczne. Rozważ poniższą klasę ListAdapter
, która oczekuje argumentu typu będącego podtypem ItemAdaper
.
Ważnym wynikiem posiadania ograniczenia jest to, że instancje tego typu mogą korzystać ze wszystkich metod oferowanych przez określony nadtyp. W ten sposób, gdy T
jest ograniczone jako podtyp Iterable<Int>
, wiemy, że możemy iterować po instancjach typu T
, a elementy zwracane przez iterator będą typu Int
. Kiedy instancje mają typ generyczny T
ograniczony jako podtyp Comparable<T>
, wiemy, że możemy je ze sobą porównywać. Innym popularnym wyborem dla ograniczenia jest Any
, co oznacza, że typ może być dowolnym typem nienullowalnym.
W rzadkich przypadkach możemy potrzebować więcej niż jednego ograniczenia górnego dla typu generycznego. Wtedy możemy użyć where
po nazwie klasy lub funkcji, aby ustawić dowolną liczbą ograniczeń (oddzielamy je przecinkiem).
Star projection
W niektórych przypadkach nie chcemy podać konkretnego argumentu typu. W takich sytuacjach możemy użyć star projection, czyli znaku *
, który zastępuje argument typu i akceptuje dowolny typ. Istnieją dwie typowe sytuacje, w których jest to przydatne. Pierwsza to sprawdzenie, czy zmienna jest listą. W tym przypadku należy użyć sprawdzenia is List<*>
. Użycie konkretnego argumentu typu, jak List<Int>
, byłoby i tak kompilowane do List
, ze względu na type erasure. Oznacza to, że lista stringów przeszłaby sprawdzenie is List<Int>
. Takie sprawdzenie byłoby mylące i jest niedozwolone w Kotlinie. Zamiast tego musisz użyć is List<*>
. Podobnie jest z innymi typami generycznymi.
Star projection można również stosować, określając typy zmiennych. Możesz użyć List<*>
, gdy chcesz wyrazić, że oczekujesz listy bez względu na typ jej elementów. Gdy pobierasz elementy z takiej listy, mają one typ Any?
, który jest nadrzędnym typem wszystkich typów.
Nie należy mylić star projection z argumentem typu Any?
. Aby zrozumieć różnicę, porównajmy MutableList<Any?>
i MutableList<*>
. Oba te typy deklarują Any?
jako generyczny typ wyników z metod. Jednak gdy dodawane są elementy, MutableList<Any?>
akceptuje wszystko (Any?
), ale MutableList<*>
akceptuje Nothing
, więc nie akceptuje żadnych wartości.
Typ określony przez star projection jest interpretowany jako Any?
we wszystkich pozycjach wyjściowych (typy wyników) oraz jako Nothing
we wszystkich pozycjach wejściowych (typy parametrów).
Podsumowanie
Dla wielu programistów generyki wydają się trudne i przerażające, ale w rzeczywistości są dość proste i intuicyjne. Możemy uczynić element generycznym poprzez dodanie parametrów typu. Takie parametry typu mogą być używane wewnątrz tego elementu. Ten mechanizm pozwala na uogólnienie algorytmów i klas, tak aby mogły być używane z różnymi typami. Dobrze jest rozumieć, jak działają generyki, dlatego ten rozdział przedstawił prawie wszystkie ich kluczowe aspekty. Jednakże są jeszcze inne, takie jak modyfikatory wariancji (out
i in
), ale do nich wrócimy w książce Zaawansowany Kotlin.
Używam JVM jako odniesienia, ponieważ jest to najbardziej popularny target dla Kotlina, ale także dlatego, że Kotlin był pierwotnie projektowany pod tę platformę. Warto zaznaczyć, że jeśli chodzi o brak wsparcia dla argumentów typu na niskim poziomie, inne platformy nie są lepsze. Na przykład JavaScript nie obsługuje w ogóle typów generycznych.
Odwołania do klasy i typu są wyjaśnione w książce Zaawansowany Kotlin.
Modyfikator reified
jest wyjaśniony w książce Funkcyjny Kotlin.