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 просто немає такої операції. Отже, ви не зможете «підмішати» всередину початкового списку витрат якусь іншу транзакцію. Ви лише читаєте те, що там уже є.
Щоб закріпити, корисно порівняти контракти:
| Тип | Можна читати елементи | Можна додавати/видаляти |
|---|---|---|
|
так | ні |
|
так | так |
Саме через 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 прямо формулює цей мотив.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ