Generics
Znakomicie. Wchodzimy w zaawansowany, ale niezwykle ważny temat, który rozwiązuje jeden z największych bólów głowy związanych z generykami w Javie. Jeśli zrozumiesz in i out, zrozumiesz, dlaczego kolekcje w Kotlinie są tak bezpieczne i elastyczne.
21. Generyki: in i out (Wariancja na poziomie deklaracji)
Wariancja określa, jak typy generyczne (np. List<String>) odnoszą się do siebie w hierarchii dziedziczenia (np. w stosunku do List<Any>). Kotlin i Java podchodzą do tego problemu w fundamentalnie różny sposób.
Problem w Javie: Wariancja na poziomie użycia (wildcards)
W Javie typy generyczne są inwariantne. Oznacza to, że List<String> nie jest podtypem List<Object>, mimo że String jest podtypem Object.
// TO SIĘ NIE SKOMPILUJE!
// Incompatible types. Found: 'java.util.ArrayList<java.lang.String>', required: 'java.util.List<java.lang.Object>'
// List<Object> list = new ArrayList<String>();
Dlaczego? Bo gdyby to było dozwolone, moglibyśmy zepsuć listę:
// Gdyby powyższa linia była dozwolona...
// list.add(123); // ...moglibyśmy dodać Integera do listy Stringów!
Aby to obejść, Java wprowadziła wildcards (? extends, ? super), czyli wariancję na poziomie użycia (use-site variance). Musisz określać wariancję za każdym razem, gdy używasz typu generycznego jako parametru.
To prowadzi do słynnej i skomplikowanej zasady PECS (Producer Extends, Consumer Super).
// Producer: Ta funkcja tylko PRODUKUJE (czyta) obiekty z listy.
// Możesz jej przekazać List<String>, List<Integer> itd.
void printList(List<? extends Object> list) {
for (Object obj : list) {
System.out.println(obj);
// list.add(...); // BŁĄD KOMPILACJI: Nie można nic dodać do listy `? extends`
}
}
// Consumer: Ta funkcja tylko KONSUMUJE (zapisuje) obiekty do listy.
void addStrings(List<? super String> list) {
list.add("Hello");
// Object obj = list.get(0); // Zwraca tylko Object, nie wiadomo co jest w środku
}
Jest to potężne, ale bardzo skomplikowane i rozwlekłe. Musisz o tym pamiętać przy każdej sygnaturze metody.
Rozwiązanie w Kotlinie: Wariancja na poziomie deklaracji (in, out)
Kotlin upraszcza to, pozwalając na zdefiniowanie wariancji raz, w momencie deklaracji klasy lub interfejsu generycznego. To jest wariancja na poziomie deklaracji (declaration-site variance).
Do tego służą dwa słowa kluczowe: out (dla kowariancji) i in (dla kontrawariancji).
1. out - Kowariancja (Producer)
Słowo kluczowe out przed parametrem generycznym (<out T>) oznacza, że ta klasa/interfejs jest kowariantna względem typu T.
Oznacza to, że:
MyClass<String>jest podtypemMyClass<Any>.- Typ
Tmoże być używany tylko w pozycji wyjściowej (out), czyli jako typ zwracany przez metody. - Nie można używać
Tjako typu parametru metody (pozycjain).
W skrócie: out T = Producer T (klasa, która tylko "produkuje" / zwraca T).
Przykład: Interfejs List w Kotlinie
Oto uproszczona definicja List z biblioteki standardowej Kotlina:
interface List<out E> : Collection<E> {
// ...
// E jest używane tylko w pozycji `out` (jako typ zwracany)
fun get(index: Int): E
// ... inne metody odczytu
// fun add(element: E) // BŁĄD KOMPILACJI: Typ `E` jest `out`, nie może być parametrem.
}
Dzięki temu, że List jest zadeklarowany jako List<out E>, ten kod w Kotlinie działa bez problemu:
val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings // To jest w 100% bezpieczne i legalne!
// Możemy bezpiecznie czytać z listy `anys`, bo wiemy, że dostaniemy co najmniej `Any`.
println(anys.get(0))
// Nie możemy nic dodać, bo `List` nie ma metody `add()`.
// Kompilator nas chroni.
2. in - Kontrawariancja (Consumer)
Słowo kluczowe in (<in T>) oznacza, że klasa jest kontrawariantna względem T.
Oznacza to, że:
MyClass<Any>jest podtypemMyClass<String>. (Hierarchia jest odwrócona!)- Typ
Tmoże być używany tylko w pozycji wejściowej (in), czyli jako typ parametru metody. - Nie można używać
Tjako typu zwracanego.
W skrócie: in T = Consumer T (klasa, która tylko "konsumuje" / przyjmuje T).
Przykład: Interfejs Comparable
Wyobraźmy sobie interfejs do porównywania:
interface MyComparable<in T> {
fun compareTo(other: T): Int
// fun get(): T // BŁĄD KOMPILACJI: Typ `T` jest `in`, nie może być zwracany.
}
fun demo(comparer: MyComparable<String>) {
comparer.compareTo("test")
}
// Możemy przekazać komparator, który umie porównywać cokolwiek (`Any` lub `CharSequence`),
// bo na pewno poradzi sobie też ze `String`.
val anyComparer: MyComparable<Any> = ...
demo(anyComparer) // Działa! Bo MyComparable<Any> jest podtypem MyComparable<String>.
3. Inwariancja (Brak in / out)
Jeśli nie użyjesz ani in, ani out, typ generyczny jest inwariantny, tak jak w Javie domyślnie. Oznacza to, że może on zarówno przyjmować, jak i zwracać typ T.
Przykład: MutableList<T>
MutableList musi być inwariantna, bo ma zarówno metody "produkujące" T (get), jak i "konsumujące" T (add).
interface MutableList<E> : List<E>, MutableCollection<E> {
// Konsumuje E
override fun add(element: E): Boolean
// Produkuje E
override fun get(index: Int): E
}
val strings: MutableList<String> = mutableListOf("a")
// val anys: MutableList<Any> = strings // BŁĄD KOMPILACJI, bo MutableList jest inwariantna.
Podsumowanie dla programisty Javy
| Koncepcja | Java (wildcards) | Kotlin (in/out) |
|---|---|---|
| Gdzie definiowana jest wariancja? | Na poziomie użycia (List<? extends T>) | Na poziomie deklaracji (interface List<out T>) |
| Kowariancja (Producer) | ? extends T | out T |
| Kontrawariancja (Consumer) | ? super T | in T |
| Złożoność | Wysoka (zasada PECS, trzeba pamiętać przy każdej metodzie) | Niska (definiujesz raz, kompilator pilnuje reszty) |
| Czytelność | Niska (List<? super MyType>) | Wysoka (List<MyType>) |
Wariancja na poziomie deklaracji w Kotlinie to gigantyczny krok naprzód. Przenosi ona skomplikowaną logikę z programisty na kompilator. W 95% przypadków, pracując z kolekcjami, nie musisz w ogóle myśleć o wariancji, bo twórcy biblioteki standardowej zrobili to za Ciebie, a Ty dostajesz prosty i bezpieczny kod.