CodeGym /Kursy /Kotlin SELF /Star-projection *: List<*>

Star-projection *: List<*>

Kotlin SELF
Poziom 36, Lekcja 3
Dostępny

1. Zapoznanie sie z List<*>

Wyobraz sobie prawdziwe zycie (i prawdziwy projekt): piszesz funkcje logowania albo debugowego wypisywania. Dla niej kompletnie nie ma znaczenia, z czego sklada sie lista — Expense, String czy Int. Ważne jest, zeby przejsc po elementach i je wydrukowac. Albo na przyklad pokazac rozmiar, pierwsze kilka elementow, a pozniej taktownie zamilknac, zeby nie spamowac konsoli.

Jesli sprobujesz zrobic „uniwersalna” funkcje przez List<Any?>, dosc szybko trafisz na to, ze tak, List jest kowariantny, ale gdy tylko zechcesz obsluzyc kolekcje modyfikowalne (MutableList) albo kontenery z bardziej zlozonymi parametrami typow, zaczna sie nieprzyjemne ograniczenia. Dlatego Kotlin daje osobna skladnie: List<*>, co czyta sie jako:

„To lista elementow jakiegos typu, ale jakiego dokladnie — nie wiadomo”.

I to „nie wiadomo” to nie filozofia, tylko konkretne reguly kompilatora: co mozna z taka lista robic, a czego nie.

2. Co mozna robic z List<*>

Jesli mamy List<*>, mozemy robic wszystko, co jest „czytaniem”: size, isEmpty(), przechodzic w for, brac elementy po indeksie. Ale jest cena: typ elementu staje sie maksymalnie bezpieczny — Any?.

Mini-przyklad „wydrukuj wszystko”:

fun printAll(xs: List<*>) {
    for (x in xs) {
        println(x) // x: Any?
    }
}

fun main() {
    printAll(listOf(1, 2, 3))
    // 1
    // 2
    // 3

    printAll(listOf("a", "b"))
    // a
    // b
}

Dlaczego Any?, a nie Any? Bo Kotlin dopuszcza listy, w ktorych elementy moga byc null (na przyklad List<String?>). Gdy typ jest nieznany, kompilator musi pamietac rowniez o takim wariancie.

Teraz wazna praktyczna mysl: jesli wyciagnales element z List<*>, to dalej pracujesz z nim jak z Any?. Chcesz wywolywac metody napisow — najpierw sprawdzenie typu.

fun printStringLengths(xs: List<*>) {
    for (x in xs) {
        if (x is String) {
            println("${x} -> len=${x.length}") // smart-cast do String
        }
    }
}

fun main() {
    printStringLengths(listOf("Kotlin", "is", "fun"))
    // Kotlin -> len=6
    // is -> len=2
    // fun -> len=3
}

Tutaj Kotlin zachowuje sie bardzo przyjaznie: po if (x is String) robi smart cast i x.length staje sie legalne.

3. Dlaczego nie mozna dodawac do MutableList<*>

Z List<*> wszystko wyglada prawie jak „uniwersalna lista do odczytu”. I tu nowicjusze czesto robia nastepujacy skok logiczny:

„OK, skoro List<*> to lista nieznanych elementow, to MutableList<*> to modyfikowalna lista nieznanych elementow. To przeciez moge cos do niej dodac… chociazby null…”

I wlasnie tutaj Kotlin wlacza tryb „zly ochroniarz w klubie”: dodawac nie wolno.

fun tryToAdd(xs: MutableList<*>) {
    // xs.add(1)    // Blad kompilacji
    // xs.add("hi") // Blad kompilacji
    // xs.add(null) // Tez blad kompilacji
    println(xs.size)
}

Dlaczego az tak twardo? Bo rzeczywista lista moze byc na przyklad MutableList<String>. Gdyby kompilator pozwolil ci dodac Int, zepsulbys kontrakt listy napisow. A gdyby pozwolil dodac null, zepsulbys MutableList<String> (gdzie null jest zabronione).

Na poziomie formalnego modelu Kotlin opisuje star-projection tak: jesli parametr typu jest nieznany, to odczyt staje sie „jak out ...”, a zapis — „jak in Nothing” (czyli „niczego nie da sie bezpiecznie zapisac”). Jest to opisane wprost w zasadach star-projection: dla inwariantnego parametru Foo<T> gwiazdka zachowuje sie jak bezpieczny odczyt gornej granicy i jak zakaz zapisu przez Nothing.

Intuicyjnie mozna to zapamietac tak: MutableList<*> to „pudelko z napisem: NIE WIADOMO CO”. Jesli zaczniesz tam wkladac przedmioty, ryzykujesz, ze dostaniesz „pudelko na lyzki”, w ktorym nagle znalazl sie srubokret. Pudelko to przetrwa, ale twoj kontrakt typow — nie.

Porownanie: * to nie „Any”

Bardzo czesta pomylka: „gwiazdka to Any”. Nie. Czasem zachowaniem przy odczycie jest podobnie, ale sens jest inny.

Ustalmy roznice na malej tabeli (i bedziemy mowic prostymi slowami, bez wchodzenia w formalna teorie typow):

Typ Co obiecuje kodowi wywolujacemu Co mozna czytac Co mozna dodawac
List<*>
„to lista, ale typ elementow jest nieznany”
Any?
(nie dotyczy,
List
jest tylko do odczytu)
List<Any?>
„to lista, ktorej elementy na pewno sa typu Any?”
Any?
(nie dotyczy)
MutableList<*>
„to modyfikowalna lista, ale typ elementow jest nieznany”
Any?
nic (zabronione przez kompilator)
MutableList<Any?>
„to modyfikowalna lista, ktora przechowuje Any?”
Any?
mozna dodawac Any? (w tym null)

Czyli MutableList<Any?> to „kontener na wszystko”. A MutableList<*> to „kontener nie wiadomo na co” — i wlasnie dlatego zapis jest zabroniony.

Sprawdzmy na krotkim przykladzie, dlaczego MutableList<*> jest wygodne jako parametr wejsciowy, gdy nie zamierzasz nic zapisywac:

fun printFirst(xs: MutableList<*>) {
    val first = xs[0]
    println("first=$first") // first: Any?
}

fun main() {
    val names: MutableList<String> = mutableListOf("Ann", "Bob")
    val nums: MutableList<Int> = mutableListOf(10, 20)

    printFirst(names) // first=Ann
    printFirst(nums)  // first=10
}

Tutaj MutableList<*> jest przydatne: funkcja moze przyjac dowolna MutableList<T>, ale sama obiecuje „ja tylko czytam”. A kompilator gwarantuje, ze przypadkiem nie zaczniesz dodawac tam „czegos nie tego”.

Co by sie zepsulo bez zakazu add

Wyobrazmy sobie na chwile, ze Kotlin pozwalalby robic tak:

fun breakWorld(xs: MutableList<*>) {
    xs.add(123) // zalozmy, ze to jest dozwolone (w rzeczywistosci — nie)
}

Teraz wywolujemy:

val names: MutableList<String> = mutableListOf("Ann", "Bob")
breakWorld(names)
println(names[2].length) // i co tutaj?

Gdyby add(123) bylo dozwolone, to names przestaloby byc „lista napisow” w praktyce. I dalej dostalbys albo wywalenie w runtime (na przyklad ClassCastException gdzies daleko), albo co gorsza — cicha logiczna pomylke.

Wlasnie dlatego Kotlin woli „niech sie nie kompiluje, niz ma sie zepsuc u uzytkownika”. A MutableList<*> dokladnie to znaczy: „typ jest nieznany, nie mamy prawa nic zapisywac”.

4. Praktyka: debugowe wypisywanie listy w Expense Tracker

Zeby nie zostalo to czysta teoria, wbudujmy pomysl List<*> w nasz konsolowy Expense Tracker (ten sam, w ktorym przechowujemy wydatki i pokazujemy raporty). Co jakis czas chcemy szybko sprawdzic „co teraz lezy w kolekcji”, nie tworzac dziesieciu roznych funkcji.

Zrobmy mala utilke do „drukowania listy” (zwykla funkcja, bez rozszerzen):

data class Expense(val title: String, val amount: Double)

fun printList(label: String, xs: List<*>) {
    println("== $label (size=${xs.size}) ==")
    for (x in xs) println(x)
}

fun main() {
    val expenses = listOf(
        Expense("Coffee", 3.5),
        Expense("Book", 12.0)
    )
    val tags = listOf("food", "education")

    printList("Expenses", expenses)
    // == Expenses (size=2) ==
    // Expense(title=Coffee, amount=3.5)
    // Expense(title=Book, amount=12.0)

    printList("Tags", tags)
    // == Tags (size=2) ==
    // food
    // education
}

Zwroc uwage na wazny efekt: printList nie moze „wiedziec” nic o typie elementu — i to jest OK. Nie udaje, ze robi logike biznesowa; to narzedzie do wypisywania. W takich miejscach List<*> trafia idealnie.

Podglad: drukowanie pierwszych N elementow

Teraz dodajmy troche wiecej pozytku: drukowac pierwsze N elementow, zeby ogromna lista nie „zabila” konsoli.

fun printPreview(label: String, xs: List<*>, limit: Int) {
    val n = if (xs.size < limit) xs.size else limit
    println("== $label (showing $n of ${xs.size}) ==")
    for (i in 0 until n) println(xs[i])
}

fun main() {
    val nums = listOf(1, 2, 3, 4, 5)
    printPreview("Numbers", nums, limit = 3)
    // == Numbers (showing 3 of 5) ==
    // 1
    // 2
    // 3
}

Zauwaz, jak naturalnie to sie czyta: nie znamy typu elementow — i nie musimy go znac, bo zadanie „debugowe wypisywanie” nie wymaga zadnych operacji poza drukowaniem.

5. Sprawdzenie is List<*> i analiza elementow

W prawdziwym kodzie czasem przylatuje Any (np. z bardzo ogolnego API, z drzewa JSON, z Map<String, Any?>, z zewnetrznej biblioteki). I chcesz zrozumiec: to jest lista czy nie?

Kotlin pozwala na sprawdzenie w stylu x is List<*>. To wazny moment: sprawdzenie „to lista nieznanych elementow” jest dopuszczalne, i Kotlin wprost podaje taki przyklad, zaznaczajac, ze elementy maja wtedy typ Any?.

Zrobmy mini-funkcje:

fun describeValue(x: Any?) {
    if (x is List<*>) {
        println("This is a list, size=${x.size}") // ok
    } else {
        println("This is not a list")
    }
}

fun main() {
    describeValue(listOf(1, 2, 3)) // This is a list, size=3
    describeValue("hello")         // This is not a list
}

A teraz typowy kolejny krok: „skoro to lista, to sprobujmy wyciagnac z niej napisy”. Tutaj trzeba pamietac, ze elementy to Any?, i bedzie trzeba filtrowac przez sprawdzenia typow.

fun printOnlyStrings(x: Any?) {
    if (x !is List<*>) return

    for (item in x) {
        if (item is String) {
            println(item.uppercase())
        }
    }
}

fun main() {
    printOnlyStrings(listOf("a", 1, "b", null))
    // A
    // B
}

Wyglada to troche bardziej rozwlekle niz „magiczne rzutowanie”, ale dziala przewidywalnie i nie zamienia kodu w pole minowe.

6. Projekcje out/in i *

Bardzo przydaje sie trzymac w glowie roznice miedzy sytuacjami „wiem, jakie to elementy, ale chce ograniczyc operacje” a „w ogole nie wiem, jakie to elementy”.

W MutableList<out Number> wiesz, ze to „lista jakichs liczb”, i dlatego mozesz bezpiecznie czytac jako Number, ale nie mozesz dodawac (bo „jakie dokladnie liczby” jest nieznane). W MutableList<in Int> wiesz, ze bezpiecznie jest dodawac Int, ale czytac bedziesz tylko maksymalnie ogolnie (zwykle jako Any?).

A MutableList<*> to „nic nie wiem”: ani gornej granicy poza Any?, ani dolnej granicy do zapisu. Dlatego jest maksymalnie restrykcyjny w zapisie.

Można to sobie wyobrazic jako taki „suwak informacji”:

flowchart LR
    A["MutableList⟨Any?⟩
(wszystko wiem: moge czytac Any? i pisac Any?)"] --> B["MutableList⟨out Number⟩
(znam gorna granice: czytam Number, nie pisze)"] --> C["MutableList⟨*⟩
(prawie nic nie wiem: czytam Any?, nie pisze)"]

Tak, to uproszczenie (jak wszystkie dobre wyjasnienia edukacyjne), ale swietnie dziala jako intuicja: im mniej wiesz o typie — tym mniej Kotlin pozwala ci robic.

7. Typowe bledy przy pracy ze star-projection *

Blad nr 1: myslec, ze List<*> to „lista Any”, i od razu wywolywac metody na elementach.
Gdy piszesz val x: Any? = xs[0], a potem probujesz zrobic x.length, kompilator calkiem slusznie nie rozumie, skad wiesz, ze to napis. Poprawna droga to sprawdzenie typu (if (x is String)) i dopiero potem praca z wlasciwosciami napisow.

Blad nr 2: probowac dodawac elementy do MutableList<*> i dziwic sie, ze nie wolno nawet null.
To jeden z najbardziej „bolesnych” momentow dla poczatkujacego, bo wydaje sie, ze null jest uniwersalne. Ale MutableList<*> znaczy, ze na zapis dziala tam logika poziomu „in Nothing”, czyli nie da sie zapisac nic typowo-bezpiecznego (a null tez sie nie nadaje). Jesli musisz dodawac elementy, to kontrakt funkcji powinien miec konkretny typ „wejsciowy”, np. MutableList<in Int> albo MutableList<Any?>, a nie *.

Blad nr 3: mylic MutableList<*> z MutableList<Any?> i wybierac gwiazdke „na wszelki wypadek”.
Gwiazdka nie znaczy „bardziej uniwersalne”, tylko „bardziej restrykcyjne”. Idealnie pasuje do scenariuszy „przeczytac/przejsc/wydrukowac”, ale jest niewygodna tam, gdzie chcesz cos wkladac do srodka. Jesli twoja funkcja z definicji ma wypelniac liste — to nie jest swiat *, tylko swiat in.

Blad nr 4: robic niebezpieczne rzutowania (as MutableList<String>) zamiast gwiazdki, zeby „po prostu zadzialalo”.
Czasem bardzo chce sie „docisnac kompilator”: napisac as i isc dalej. Ale w generics czesto konczy sie to tym, ze program sie kompiluje, a potem wywala w runtime w niespodziewanym miejscu. List<*> i MutableList<*> sa o tyle przydatne, ze pozwalaja uczciwie powiedziec: „nie znam typu” — i kompilator sam nie pozwoli ci wykonac niebezpiecznych operacji.

Blad nr 5: oczekiwac, ze sprawdzenie x is List<*> automatycznie da ci wiedze o typie elementow.
To sprawdzenie odpowiada tylko na pytanie „to jest lista czy nie”. Po nim elementy i tak beda typu Any? — i to jest normalne. Jesli musisz zrozumiec, czy w srodku sa napisy, liczby czy twoje Expense, musisz sprawdzac elementy (is String, is Int, is Expense) albo zbudowac inny kontrakt funkcji, w ktorym typ jest znany z gory.

1
Zadanie
Kotlin SELF, poziom 36, lekcja 3
Niedostępne
Wydruk diagnostyczny
Wydruk diagnostyczny
1
Zadanie
Kotlin SELF, poziom 36, lekcja 3
Niedostępne
Szybki podgląd
Szybki podgląd
1
Zadanie
Kotlin SELF, poziom 36, lekcja 3
Niedostępne
Długości komentarzy
Długości komentarzy
1
Zadanie
Kotlin SELF, poziom 36, lekcja 3
Niedostępne
Szybki opis
Szybki opis
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION