JavaRush /Курси /Kotlin SELF /Підтипність і generics: чому List «піднімається», а Mutab...

Підтипність і generics: чому List «піднімається», а MutableList — ні

Kotlin SELF
Рівень 36 , Лекція 0
Відкрита

1. Вступ

Коли ми говоримо про «підтипність», то зазвичай згадуємо наслідування: один тип розширює інший. А в звичайному (неузагальненому) житті все доволі лінійно: якщо Expense наслідується від Transaction, то змінна типу Transaction може зберігати посилання на Expense. Це основа поліморфізму: «можна підставити нащадка замість базового класу».

Зафіксуймо це на невеликому фрагменті коду з нашого умовного застосунку для обліку фінансів (нехай він називається BudgetTracker). Тут транзакція — базовий тип, а витрата — конкретний різновид транзакції:


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, "Кава і круасан", "Food")
    println(t.comment) // Кава і круасан
}

Це виглядає логічно: витрата — це транзакція, отже змінна «транзакція» може на неї посилатися. І тут легко зробити наступний «інтуїтивний крок»… який виявиться не завжди правильним, щойно зʼявляться кутові дужки.

2. Чому Box<Expense> не є Box<Transaction>

Щойно ми беремо тип T і починаємо загортати його в узагальнений контейнер Box<T>, зʼявляється нова сутність: не T, а «коробка з T». І саме тут Kotlin за замовчуванням обирає максимально обережну позицію: узагальнені типи вважаються інваріантними, тобто «жодних автоматичних підстановок за наслідуванням не обіцяємо».

Зробімо найпростішу «коробку»:

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))

    // Так не можна (і це не примха):
    val txBox: Box<Transaction> = expenseBox
}

На людському рівні хочеться сказати: «Ну Expense же нащадок Transaction, отже й коробка з витратою — це коробка з транзакцією». Але компілятор не погоджується. І він не вередує: він заздалегідь запобігає ситуації, коли через «коробку транзакцій» ми раптом покладемо туди не витрату, а щось інше (наприклад, дохід) і зламаємо припущення, що всередині лежить саме Expense.

У цьому місці корисно запамʼятати коротку думку: підтипність елементів не зобовʼязана переноситися на підтипність контейнерів. Далі ми побачимо, чому для List Kotlin робить приємний виняток, а для MutableList — ні.

3. Чому List<Expense> можна використовувати як List<Transaction>

Зараз буде важливий момент, який у Kotlin часто плутають: List — це не «просто список», а read-only інтерфейс. Він дає операції читання: отримати розмір, узяти елемент за індексом, пройтися циклом. Але не дає змоги «додати» або «видалити». Kotlin стандартно розділяє колекції на read-only та mutable-інтерфейси: є інтерфейс для читання й окремий інтерфейс для читання та запису.

І ось ключ: read-only колекції в Kotlin коваріантні, тобто List<Rectangle> можна використовувати там, де очікують List<Shape>, якщо Rectangle наслідується від Shape. Людською мовою: «якщо список лише для читання, то список більш конкретних елементів можна сприймати як список більш загальних».

Перенесімо це на наші транзакції:

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, "Кава"),
        Expense(350, "Таксі")
    )

    val transactions: List<Transaction> = expenses

    println(transactions[0].comment) // Кава
}

Чому це безпечно? Тому що через змінну transactions ви не можете викликати transactions.add(...) — у List просто немає такої операції. Отже, ви не зможете «підмішати» всередину початкового списку витрат якусь іншу транзакцію. Ви лише читаєте те, що там уже є.

Щоб закріпити, корисно порівняти контракти:

Тип Можна читати елементи Можна додавати/видаляти
List<T>
так ні
MutableList<T>
так так

Саме через read-only інтерфейс List Kotlin дозволяє «піднімати» тип угору ієрархією.

Важливо: read-only — це не те саме, що «незмінний назавжди». List не обіцяє, що дані ніколи не зміняться десь в іншому місці. Він обіцяє, що через це конкретне посилання ви не зможете мутувати колекцію. Kotlin саме так і описує модель read-only vs mutable інтерфейсів.

4. Чому MutableList<Expense> не можна використовувати як MutableList<Transaction>

Зараз ми підходимо до най«болючішої» частини: чому компілятор забороняє те, що здається таким логічним. Тут усе впирається в одне: MutableList дозволяє записувати.

Уявімо, що Kotlin дозволив би такий рядок:

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))

    // Якби це дозволили...
    val txs: MutableList<Transaction> = expenses
    txs.add(Income(5000)) // «у список транзакцій» додали дохід

    // ...то тепер всередині expenses лежав би Income, і це ламає тип!
}

Якщо змінна txs — це «список транзакцій», то за її контрактом додавання Income(...) абсолютно легальне. Але якщо «під капотом» це той самий обʼєкт, що й expenses (список витрат), то ми щойно непомітно підмішали дохід у список витрат. Це порушує типобезпечність: MutableList<Expense> перестає бути «справжнім списком Expense».

Саме тому Kotlin застосовує суворе правило: мутабельні колекції не є коваріантними. Інакше це призвело б до проблем під час виконання та порушення типів.

На практиці ви побачите помилку приблизно такого змісту (текст може трохи відрізнятися, але ідея одна):

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

І це якраз той випадок, коли компілятор не «заважає працювати», а справді запобігає майбутній поломці даних.

5. Ментальна модель: «видача» vs «приймання»

Щоб не завчати все як заклинання, корисно тримати в голові просту побутову аналогію. Уявіть, що є склад, і ви підійшли до віконця. В одному випадку вам дозволено лише отримувати товари («видача»), в іншому — лише приймати товари («приймання»), а в третьому — і отримувати, і приймати. І тоді починаються веселощі, бо хтось обовʼязково принесе не те.

Read-only List — це майже «видача»: ви берете елементи й читаєте їх. Тому «склад із витратами» можна розглядати як «склад із транзакціями»: будь-яка витрата — це транзакція, отже читати як транзакції безпечно.

MutableList — це «і видача, і приймання одночасно»: можна і взяти елемент, і покласти новий. І саме тут підстановка типів стає небезпечною: якщо вам дали «приймання транзакцій», ви можете принести туди дохід. А якщо насправді це було «приймання витрат» — вийде хаос.

Невелика схема, щоб легше було втримати це в голові:

flowchart LR
    A["List⟨Expense⟩
(лише читаємо)"] --> B["List⟨Transaction⟩
(лише читаємо)"] C["MutableList⟨Expense⟩
(читаємо+пишемо)"] -.-x D["MutableList⟨Transaction⟩ (читаємо+пишемо)"]

Суцільна стрілка — «безпечно підставити», пунктир із хрестиком — «небезпечно, тому заборонено».

6. Практика в BudgetTracker: як приймати «правильні» списки

У реальному коді біль від MutableList<Expense> зазвичай зʼявляється не в абстрактних прикладах, а в момент, коли ви пишете корисну функцію і хочете «просто прийняти список». Наприклад: порахувати суму, вивести в консоль, знайти максимум, відсортувати, згрупувати.

І ось головна практична порада, яка прямо випливає з моделі Kotlin: якщо функція не зобовʼязана змінювати колекцію — приймайте List, а не MutableList. Це одразу робить ваш API гнучкішим, і ви автоматично отримуєте те саме «підняття» типів.

Зробімо в нашому застосунку базовий тип і дві операції: друк і підрахунок суми. Обидві не мутують список.

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, "Кава"),
        Expense(350, "Таксі")
    )

    printTransactions(expenses)
    // 120 — Кава
    // 350 — Таксі
}

Зверніть увагу на «магію без магії»: printTransactions чекає List<Transaction>, а ми передали List<Expense>. І все добре — тому що List read-only і коваріантний.

Тепер приклад функції, яка хоче мутувати список — наприклад, додати транзакцію. Тут уже не можна «просто прийняти будь-що»:

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, "Тестова витрата"))
}

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

    println(txs.size) // 1
}

І ось важливе спостереження: якщо у вас десь зберігається MutableList<Expense>, ви не зможете передати її в addTestTransaction, тому що це було б потенційно небезпечно (ми вже обговорювали чому).

Одна з тимчасових «тактик виживання», поки ми не вивчаємо спеціальні інструменти типової системи (сьогодні їх ще не чіпаємо), — зробити копію, коли вам потрібно «тимчасово помутувати»:

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
}

Так, це копіювання. Так, воно коштує памʼяті й часу. Але зате воно чесне: ми не ламаємо початковий список expensesOnly, а працюємо з окремою мутабельною копією. Це нормальний «перехідний місток», поки ви ще не озброєні точнішими типовими контрактами.

Як читати повідомлення компілятора про List і MutableList

Повідомлення компілятора в таких місцях зазвичай короткі, але прикрі. І головна проблема для новачка — сприймати їх як «незрозумілу заборону», а не як підказку: що саме може піти не так.

Якщо ви бачите, що List<Expense> не підходить до List<Transaction>, то, найімовірніше, ви насправді десь використовуєте не List, а мутабельний тип (або очікуєте саме його). У Kotlin read-only колекції коваріантні, а mutable — ні. Тому перше, що варто зробити, — перевірити сигнатуру функції: чи справді там потрібен MutableList?

Якщо повідомлення каже, що потрібен MutableList<Transaction>, а у вас є MutableList<Expense>, це майже завжди означає: «я бачу, що ви хочете записувати в колекцію, а за такої підстановки це небезпечно». Далі у вас є вибір: або змінити дизайн так, щоб функція приймала List і не мутувала; або зберігати дані інакше; або явно скопіювати колекцію в більш загальний тип, як ми показували вище.

І ще одне: Kotlin чітко розділяє read-only та mutable інтерфейси колекцій. Іноді достатньо замінити MutableList на List у параметрі функції — і половина «дивних» проблем раптово зникає.

7. Аналогія з функціями: чому (Transaction) -> String підходить для (Expense) -> String

Є ще один цікавий шматочок, який допомагає звикнути до ідеї: «підстановка працює не завжди так, як здається». Це типи функцій.

У Kotlin функція — це теж значення, і вона має тип вигляду (A) -> B: приймає A, повертає B. Тут працює дуже логічна думка: якщо функція вміє приймати будь-яку транзакцію, то вона точно вміє приймати й витрату (адже витрата — окремий випадок транзакції).

З погляду типів це виглядає «навпаки» порівняно зі списками: за вхідним параметром функція поводиться так, ніби «дозволяє підстановку в інший бік».

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("Кава"))) // TX: Кава
}

Чому це безпечно? Тому що formatTransaction готова до будь-якого Transaction. Якщо їй дають Expense, вона не губиться: «ну гаразд, це теж транзакція». А от якби ми намагалися зробити навпаки (підставити функцію, яка вміє форматувати лише витрати, туди, де можуть прилітати будь-які транзакції), — це було б небезпечно. Їй могли б передати дохід, а вона «не обіцяла» з ним працювати.

Цей приклад потрібен не для того, щоб ви просто зараз вивчили слово «контраваріантність» і сказали його на співбесіді з розумним виглядом. Він потрібен, щоб ви прийняли ідею: підтипність в узагальнених конструкціях залежить від того, читаємо ми значення чи записуємо. У функцій «читання/запис» проявляється як «приймаємо аргумент / повертаємо результат».

8. Типові помилки

Помилка №1: «Раз це List, значить він незмінний».
Новачки часто думають, що List — це «immutable список». Насправді List — це read-only інтерфейс: ви не можете змінювати його через це посилання, але сама колекція може бути мутабельною десь іще. Kotlin саме так і описує модель: є read-only інтерфейс і є mutable інтерфейс, що розширює його операціями запису.

Помилка №2: Писати функції, які нічого не змінюють, але приймають MutableList.
Це не просто «стиль». Це практична пастка: ви втрачаєте можливість передати List<Expense> туди, де очікують MutableList<Transaction>, і отримуєте помилки типів. Якщо ви не збираєтеся додавати або видаляти елементи — приймайте List, і ви автоматично отримуєте безпечне «підняття» типів у read-only колекцій.

Помилка №3: Намагатися «обдурити» систему через приведення типів as.
Іноді зʼявляється спокуса зробити щось на кшталт val x = expenses as MutableList<Transaction>, щоб «скомпілювалося». Це рівно той шлях, який приводить до проблем під час виконання: ви вручну вимикаєте запобіжники компілятора. Якщо код справді небезпечний, ви просто відкладаєте падіння «на потім», роблячи його неприємнішим і менш очевидним.

Помилка №4: Плутати «піднімається» і «не піднімається» як випадкове правило.
Легко вивчити «List можна, MutableList не можна» як магічне заклинання. Але набагато корисніше тримати в голові причину: read-only колекції коваріантні, бо через них не можна записувати, а mutable — не коваріантні, інакше можна порушити тип аргументу (підмішати іншого нащадка). Kotlin прямо формулює цей мотив.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ