CodeGym /Kursy /Kotlin SELF /Podtypowosc i generics: dlaczego List sie „podnosi”, a Mu...

Podtypowosc i generics: dlaczego List sie „podnosi”, a MutableList — nie

Kotlin SELF
Poziom 36, Lekcja 0
Dostępny

1. Wprowadzenie

Gdy mowimy o „podtypowosci”, zwykle przypominamy sobie dziedziczenie: jeden typ rozszerza inny. I w zwyklym (niegenerycznym) zyciu wszystko jest dosc liniowe: jesli Expense dziedziczy po Transaction, to zmienna typu Transaction moze przechowywac referencje do Expense. To podstawa polimorfizmu: „mozna podstawic potomka zamiast klasy bazowej”.

Ustalmy to na malym fragmencie kodu z naszej umownej aplikacji do ewidencji finansow (niech nazywa sie BudgetTracker). Tutaj transakcja to typ bazowy, a wydatek to konkretny rodzaj transakcji:


open class Transaction(val amount: Int, val comment: String)

class Expense(amount: Int, comment: String, val category: String) :
    Transaction(amount, comment)

fun main() {
    val t: Transaction = Expense(500, "Kawa i rogalik", "Food")
    println(t.comment) // Kawa i rogalik
}

To wyglada logicznie: wydatek to transakcja, wiec zmienna „transakcja” moze na niego wskazywac. I wlasnie tutaj latwo zrobic nastepny „intuicyjny krok”… ktory nie zawsze okaze sie poprawny, gdy pojawia sie nawiasy katowe.

2. Dlaczego Box<Expense> nie jest Box<Transaction>

Gdy tylko bierzemy typ T i zaczynamy zawijac go w generyczny kontener Box<T>, pojawia sie nowa encja: nie T, ale „pudelko z T”. I tutaj Kotlin domyslnie wybiera maksymalnie ostrozne podejscie: typy generyczne domyslnie sa uznawane za inwariantne, czyli „nie obiecujemy zadnych automatycznych podstawien wynikajacych z dziedziczenia”.

Zrobmy najprostsze „pudelko”:

class Box<T>(var value: T)

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun main() {
    val expenseBox: Box<Expense> = Box(Expense(100))

    // Tak nie mozna (i to nie jest fanaberia):
    val txBox: Box<Transaction> = expenseBox
}

Z perspektywy czlowieka chce sie powiedziec: „No przeciez Expense jest potomkiem Transaction, wiec i pudelko z wydatkiem to pudelko z transakcja”. Ale kompilator sie nie zgadza. I nie czepia sie: z gory zapobiega sytuacji, w ktorej przez „pudelko transakcji” nagle wlozylibysmy tam nie wydatek, tylko cos innego (na przyklad przychod), i zepsulibysmy zalozenie, ze w srodku lezy wlasnie Expense.

W tym miejscu warto zapamietac krotka mysl: podtypowosc elementow nie musi przenosic sie na podtypowosc kontenerow. Dalej zobaczymy, dlaczego dla List Kotlin robi mile wyjatki — a dla MutableList nie.

3. Dlaczego List<Expense> mozna uzyc jako List<Transaction>

Teraz bedzie wazny moment, ktory w Kotlinie czesto jest mylony: List to nie „po prostu lista”, tylko interfejs tylko do odczytu. Daje operacje odczytu: pobrac rozmiar, wziac element po indeksie, przejsc petla, ale nie daje „dodac” ani „usunac”. Kotlin standardowo rozdziela kolekcje na interfejsy read-only i mutable: jest interfejs do odczytu i osobny interfejs do odczytu+zapisu.

I oto klucz: kolekcje tylko do odczytu w Kotlinie sa kowariantne, tzn. List<Rectangle> mozna uzyc tam, gdzie oczekuje sie List<Shape>, jesli Rectangle dziedziczy po Shape. Po ludzku: „jesli liste tylko czytamy, to liste bardziej konkretnych elementow mozna traktowac jak liste bardziej ogolnych”.

Przeniesmy to na nasze transakcje:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Kawa"),
        Expense(350, "Taksowka")
    )

    val transactions: List<Transaction> = expenses

    println(transactions[0].comment) // Kawa
}

Dlaczego to jest bezpieczne? Bo przez zmienna transactions nie mozesz zrobic transactions.add(...) — w List po prostu nie ma takiej operacji. Czyli nie da sie „wmieszac” do zrodlowej listy wydatkow jakiejs innej transakcji. Mozesz tylko czytac to, co juz tam lezy.

Aby utrwalic, warto porownac kontrakty:

Typ Mozna czytac elementy Mozna dodawac/usuwac
List<T>
tak nie
MutableList<T>
tak tak

I wlasnie dzieki temu, ze List jest interfejsem read-only, Kotlin pozwala „podnosic” typ po hierarchii.

Wazne: read-only to nie to samo co „niezmienialny na zawsze”. List nie obiecuje, ze dane nigdy nie zmienia sie gdzies indziej; obiecuje, ze przez ten konkretny typ referencji nie bedziesz w stanie mutowac kolekcji. Kotlin wprost opisuje taki model interfejsow read-only vs mutable.

4. Dlaczego MutableList<Expense> nie mozna uzyc jako MutableList<Transaction>

Teraz przechodzimy do najbardziej „bolesnej” czesci: dlaczego kompilator zabrania czegos, co wydaje sie tak logiczne. Wszystko rozbija sie o jedna rzecz: MutableList pozwala zapisywac.

Wyobrazmy sobie, ze Kotlin pozwolilby na taka linijke:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)
class Income(amount: Int) : Transaction(amount)

fun main() {
    val expenses: MutableList<Expense> = mutableListOf(Expense(100))

    // Gdyby to bylo dozwolone...
    val txs: MutableList<Transaction> = expenses
    txs.add(Income(5000)) // „do listy transakcji” dodano przychod

    // ...to teraz w expenses lezalby Income i to psuje typ!
}

Jesli zmienna txs to „lista transakcji”, to zgodnie z jej kontraktem dodanie Income(...) jest absolutnie legalne. Ale jesli pod spodem to ten sam obiekt co expenses (lista wydatkow), to wlasnie niepostrzezenie domieszalismy przychod do listy wydatkow. To narusza bezpieczenstwo typow: MutableList<Expense> przestaje byc „prawdziwa lista Expense”.

Wlasnie dlatego Kotlin wprowadza scisle prawo: mutowalne kolekcje nie sa kowariantne, bo inaczej prowadziloby to do problemow w runtime i naruszen typow.

W praktyce zobaczysz blad mniej wiecej o takim sensie (tekst moze sie nieco roznic, ale idea jest jedna):

Type mismatch: Required `MutableList<Transaction>`, found `MutableList<Expense>`

I to jest wlasnie ten przypadek, gdy kompilator nie „przeszkadza w pracy”, tylko realnie zapobiega przyszlemu uszkodzeniu danych.

5. Model mentalny: „wydawanie” vs „przyjmowanie”

Zamiast wkuwac wszystko jak zaklecie, warto trzymac w glowie prosta, codzienna analogie. Wyobraz sobie, ze jest magazyn i podchodzisz do okienka. W jednym przypadku wolno ci tylko odbierac towary („wydawanie”), w drugim — tylko przyjmowac towary („przyjmowanie”), a w trzecim — i odbierac, i przyjmowac (i wtedy zaczyna sie zabawa, bo ktos na pewno przyniesie cos nie takiego).

Read-only List to prawie „wydawanie”: bierzesz elementy i je odczytujesz. Dlatego „magazyn z wydatkami” mozna traktowac jak „magazyn z transakcjami”: kazdy wydatek jest transakcja, wiec czytanie jako transakcji jest bezpieczne.

MutableList to „i wydawanie, i przyjmowanie jednoczesnie”: mozna i pobrac element, i polozyc nowy. I tutaj podstawianie typow staje sie niebezpieczne: jesli dostales „przyjmowanie transakcji”, mozesz przyniesc tam przychod, a jesli tak naprawde bylo to „przyjmowanie wydatkow”, zrobi sie chaos.

Mala schematyczna podpowiedz, zeby mozg mial sie czego „zlapac”:

flowchart LR
    A["List⟨Expense⟩
(tylko odczyt)"] --> B["List⟨Transaction⟩
(tylko odczyt)"] C["MutableList⟨Expense⟩
(odczyt+zapis)"] -.-x D["MutableList⟨Transaction⟩ (odczyt+zapis)"]

Ciagla strzalka oznacza „bezpiecznie podstawic”, a przerywana z krzyzykiem — „niebezpieczne, wiec zabronione”.

6. Praktyka w BudgetTracker: jak przyjmowac „wlasciwe” listy

W prawdziwym kodzie bol zwiazany z MutableList<Expense> zwykle pojawia sie nie w abstrakcyjnych przykladach, tylko w momencie, gdy piszesz uzyteczna funkcje i chcesz „po prostu przyjac liste”. Na przyklad: policzyc sume, wypisac na konsoli, znalezc maksimum, posortowac, zgrupowac.

I tutaj najwazniejsza praktyczna rada, ktora wprost wynika z modelu Kotlina: jesli funkcja nie musi zmieniac kolekcji — przyjmuj List, a nie MutableList. To od razu czyni twoje API bardziej elastycznym i automatycznie daje to „podnoszenie” typow.

Zrobmy w naszej aplikacji typ bazowy i dwie operacje: wypisanie oraz policzenie sumy. Obie nie mutuja listy.

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun printTransactions(items: List<Transaction>) {
    for (t in items) {
        println("${t.amount} — ${t.comment}")
    }
}

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Kawa"),
        Expense(350, "Taksowka")
    )

    printTransactions(expenses)
    // 120 — Kawa
    // 350 — Taksowka
}

Zwracaj uwage na „magie bez magii”: printTransactions oczekuje List<Transaction>, a my przekazalismy List<Expense>, i wszystko dziala — bo List jest read-only i jest kowariantny.

Teraz przyklad funkcji, ktora chce mutowac liste — na przyklad dodac transakcje. Tutaj nie da sie juz „po prostu przyjac czegokolwiek”:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun addTestTransaction(storage: MutableList<Transaction>) {
    storage.add(Expense(10, "Testowy wydatek"))
}

fun main() {
    val txs: MutableList<Transaction> = mutableListOf()
    addTestTransaction(txs)

    println(txs.size) // 1
}

I oto wazna obserwacja: jesli gdzies przechowujesz MutableList<Expense>, nie bedziesz mogl przekazac jej do addTestTransaction, bo byloby to potencjalnie niebezpieczne (omawialismy juz dlaczego).

Jedna z tymczasowych „taktyk przetrwania”, dopoki nie poznamy specjalnych narzedzi systemu typow (dzis jeszcze ich nie ruszamy), jest robienie kopii, gdy potrzebujesz „tymczasowo pomutowac”:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun addSomething(storage: MutableList<Transaction>) {
    storage.add(Expense(999))
}

fun main() {
    val expensesOnly: List<Expense> = listOf(Expense(100))

    val copyAsTransactions: MutableList<Transaction> = expensesOnly.toMutableList()
    addSomething(copyAsTransactions)

    println(copyAsTransactions.size) // 2
}

Tak, to kopiowanie. Tak, kosztuje pamiec i czas. Ale jest uczciwe: nie psujemy oryginalnej listy expensesOnly, tylko pracujemy na osobnej mutowalnej kopii. To normalny „most przejsciowy”, dopoki nie uzbroisz sie w bardziej precyzyjne kontrakty typow.

Jak czytac komunikaty kompilatora o List i MutableList

Komunikaty kompilatora w takich miejscach zwykle sa krotkie, ale frustrujace. Najwiekszy problem poczatkujacego polega na tym, ze odbiera je jako „niezrozumialy zakaz”, a nie jako podpowiedz, co konkretnie moze pojsc nie tak.

Jesli widzisz, ze List<Expense> nie pasuje do List<Transaction>, to najprawdopodobniej gdzies tak naprawde uzywasz nie List, tylko typu mutowalnego (albo go oczekujesz). W Kotlinie kolekcje read-only sa kowariantne, a mutable — nie. Dlatego pierwsza rzecza, jaka warto zrobic, jest sprawdzenie sygnatury funkcji: czy na pewno potrzebny jest tam MutableList?

Jesli komunikat mowi, ze wymagane jest MutableList<Transaction>, a ty masz MutableList<Expense>, to prawie zawsze oznacza: „widze, ze chcesz zapisywac do kolekcji, a to jest niebezpieczne przy takim podstawieniu”. I dalej masz wybor: albo zmienic projekt tak, by funkcja przyjmowala List i nie mutowala; albo przechowywac dane inaczej; albo jawnie skopiowac kolekcje do bardziej ogolnego typu, jak pokazalismy wyzej.

I osobno warto pamietac: Kotlin wyraznie rozdziela interfejsy kolekcji read-only i mutable. Czasem wystarczy zmienic MutableList na List w parametrze funkcji — i polowa „dziwnych” problemow nagle znika.

7. Analogia z funkcjami: dlaczego (Transaction) -> String pasuje do (Expense) -> String

Jest jeszcze jeden ciekawy fragment, ktory pomaga przyzwyczaic glowe do mysli: „podstawienie nie zawsze dziala tak, jak sie wydaje”. To typy funkcji.

W Kotlinie funkcja to tez wartosc i ma typ w postaci (A) -> B: przyjmuje A, zwraca B. Dziala tu bardzo logiczna idea: jesli funkcja umie przyjac dowolna transakcje, to na pewno umie przyjac i wydatek (bo wydatek jest szczegolnym przypadkiem transakcji).

Z perspektywy typow wyglada to „na odwrot” w porownaniu do list: po parametrze wejsciowym funkcja zachowuje sie tak, jakby „pozwalala na podstawienie w druga strone”.

open class Transaction(val comment: String)
class Expense(comment: String) : Transaction(comment)

fun main() {
    val formatTransaction: (Transaction) -> String = { t ->
        "TX: ${t.comment}"
    }

    val formatExpense: (Expense) -> String = formatTransaction

    println(formatExpense(Expense("Kawa"))) // TX: Kawa
}

Dlaczego to jest bezpieczne? Bo formatTransaction jest gotowa na dowolny Transaction. Jesli dostaje Expense, nie gubi sie: „no OK, to tez transakcja”. A gdybysmy probowali zrobic odwrotnie (podstawic funkcje, ktora umie formatowac tylko wydatki, tam, gdzie moga przychodzic dowolne transakcje) — byloby to niebezpieczne: mogliby przekazac jej przychod, a ona „nie obiecywala”, ze z nim zadziala.

Ten przyklad nie jest po to, zeby od razu nauczyc sie slowa „kontrawariantnosc” i rzucac nim na rozmowie kwalifikacyjnej z madrzejsza mina. Jest po to, zeby zaakceptowac idee: podtypowosc w konstrukcjach generycznych zalezy od tego, czy wartosci odczytujemy, czy zapisujemy. W funkcjach „odczyt/zapis” przejawia sie jako „przyjmujemy argument / zwracamy wynik”.

8. Typowe bledy

Blad nr 1: „Skoro to List, to jest niezmienialny”.
Poczatkujacy czesto mysla, ze List to „immutable lista”. W rzeczywistosci List to interfejs read-only: nie mozesz jej zmieniac przez ten typ referencji, ale sama kolekcja moze byc mutowalna gdzies indziej. Kotlin wlasnie tak opisuje model: jest interfejs read-only i jest interfejs mutable, ktory go rozszerza o operacje zapisu.

Blad nr 2: Pisac funkcje, ktore niczego nie zmieniaja, ale przyjmuja MutableList.
To nie jest tylko „styl”. To praktyczna pulapka: tracisz mozliwosc przekazania List<Expense> tam, gdzie oczekuje sie MutableList<Transaction>, i dostajesz bledy typow. Jesli nie zamierzasz dodawac/usuwac elementow — przyjmuj List, a automatycznie dostajesz bezpieczne „podnoszenie” typow dla kolekcji read-only.

Blad nr 3: Probowac „oszukac” system przez rzutowanie typow as.
Czasem kusi, zeby zrobic cos w rodzaju val x = expenses as MutableList<Transaction>, zeby „sie skompilowalo”. To dokladnie ta droga, ktora prowadzi do problemow w runtime: recznie wylaczasz zabezpieczenie kompilatora. Jesli kod naprawde jest niebezpieczny, to tylko odkladasz awarie na pozniej, robiac ja bardziej nieprzyjemna i mniej oczywista.

Blad nr 4: Traktowac „podnosi sie” i „nie podnosi sie” jak przypadkowa zasade.
Latwo nauczyc sie „List mozna, MutableList nie mozna” jak magicznego zaklecia. Ale o wiele bardziej przydatne jest trzymanie sie przyczyny: kolekcje read-only sa kowariantne, bo nie da sie przez nie zapisywac, a mutable — nie sa kowariantne, bo inaczej mozna by naruszyc typ argumentu (wmieszac innego potomka). Kotlin wprost formuluje ta motywacje.

1
Zadanie
Kotlin SELF, poziom 36, lekcja 0
Niedostępne
Lista schroniska
Lista schroniska
1
Zadanie
Kotlin SELF, poziom 36, lekcja 0
Niedostępne
Katalog figur
Katalog figur
1
Zadanie
Kotlin SELF, poziom 36, lekcja 0
Niedostępne
Pudełko transakcji
Pudełko transakcji
1
Zadanie
Kotlin SELF, poziom 36, lekcja 0
Niedostępne
Kopia dziennika
Kopia dziennika
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION