1. Знайомство з List<*>
Уявіть реальну ситуацію (і реальний проєкт): ви пишете функцію логування або відлагоджувального виведення. Їй зовсім не важливо, з чого складається список — Expense, String чи Int. Важливо лише пробігтися елементами й надрукувати їх. Або, наприклад, показати розмір і перші кілька елементів, а далі акуратно промовчати, щоб не засмічувати консоль.
Якщо ви спробуєте зробити «універсальну» функцію через List<Any?>, то доволі швидко упретеся в те, що так, List коваріантний. Але щойно ви захочете підтримати змінювані колекції (MutableList) або контейнери зі складнішими параметрами типів, почнуться неприємні обмеження. Тому в Kotlin є окремий синтаксис: List<*>, який читається так:
«Це список елементів якогось типу, але якого саме — невідомо».
І ось це «невідомо» — не філософія, а цілком конкретні правила компілятора: що можна робити з таким списком, а чого — не можна.
2. Що можна робити з List<*>
Якщо в нас List<*>, ми можемо робити все «читальне»: size, isEmpty(), проходити for, брати елементи за індексом. Але за це доводиться платити: тип елемента стає максимально безпечним — Any?.
Мініприклад «друк усього»:
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
}
Чому Any?, а не Any? Тому що Kotlin допускає списки, у яких елементи можуть бути null (наприклад, List<String?>). Коли тип невідомий, компілятор зобовʼязаний ураховувати й такий варіант.
Ось практичний висновок: якщо ви дістали елемент із List<*>, далі ви працюєте з ним як із Any?. Хочете звертатися до рядкових методів — спершу перевірте тип.
fun printStringLengths(xs: List<*>) {
for (x in xs) {
if (x is String) {
println("${x} -> len=${x.length}") // smart cast до String
}
}
}
fun main() {
printStringLengths(listOf("Kotlin", "is", "fun"))
// Kotlin -> len=6
// is -> len=2
// fun -> len=3
}
Тут Kotlin поводиться дуже дружньо: після if (x is String) він робить smart cast, і x.length стає легальним.
3. Чому не можна додавати в MutableList<*>
Із List<*> усе виглядає майже як «універсальний список для читання». І тут новачки часто роблять такий логічний стрибок:
«Гаразд, якщо List<*> — це список невідомих елементів, то MutableList<*> — це змінюваний список невідомих елементів. То я ж можу туди щось додати… хоча б null…»
І ось тут Kotlin вмикає режим «суворого охоронця клубу»: додавати не можна.
fun tryToAdd(xs: MutableList<*>) {
// xs.add(1) // Помилка компіляції
// xs.add("hi") // Помилка компіляції
// xs.add(null) // Теж помилка компіляції
println(xs.size)
}
Чому так жорстко? Бо реальний список може бути, наприклад, MutableList<String>. Якби компілятор дозволив вам додати Int, ви б зламали контракт списку рядків. А якби дозволив додати null, то ви б зламали MutableList<String> (де null заборонено).
На рівні формальної моделі Kotlin описує star-projection так: якщо параметр типу невідомий, то читання стає «як out …», а запис — «як in Nothing» (тобто «нічого не можна безпечно записати»). Це прямо випливає з правил star-projection: для інваріантного параметра Foo<T> зірочка дає безпечне читання верхньої межі та забороняє запис через Nothing.
Інтуїтивно це можна запамʼятати так: MutableList<*> — це «коробка, на якій написано: НЕВІДОМО ЩО». Якщо ви почнете туди класти предмети, то ризикуєте отримати «коробку для ложок», у якій раптом опинилася викрутка. Коробка, може, й переживе, а от ваш типовий контракт — ні.
Порівняння: * — це не «Any»
Дуже поширена плутанина: «зірочка — це Any». Ні. Іноді за поведінкою під час читання це справді схоже, але сенс інший.
Зафіксуймо різницю в невеличкій таблиці (і говоритимемо простими словами, без занурення у формальну теорію типів):
| Тип | Що обіцяє коду, який викликає | Що можна читати | Що можна додавати |
|---|---|---|---|
|
«це список, але тип елементів невідомий» | |
(не застосовно, read-only) |
|
«це список, елементи якого точно Any?» | |
(не застосовно) |
|
«це змінюваний список, але тип елементів невідомий» | |
нічого (заборонено компілятором) |
|
«це змінюваний список, який зберігає Any?» | |
можна додавати Any? (зокрема null) |
Отже, MutableList<Any?> — це «контейнер для всього підряд». А MutableList<*> — це «контейнер невідомо для чого», і саме тому запис у нього заборонено.
Перевірмо коротким прикладом, чому MutableList<*> зручний як вхідний параметр, коли ви не збираєтеся в нього нічого записувати:
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
}
Тут MutableList<*> справді корисний: функція може прийняти будь-яку MutableList<T>, але сама обіцяє «я лише читаю». А компілятор гарантує, що ви випадково не почнете додавати туди «не те».
Що зламалося б без заборони на add
Уявімо на секунду, що Kotlin дозволяв би робити так:
fun breakWorld(xs: MutableList<*>) {
xs.add(123) // припустімо, це дозволено (насправді — ні)
}
Тепер викликаємо:
val names: MutableList<String> = mutableListOf("Ann", "Bob")
breakWorld(names)
println(names[2].length) // а що тут?
Якби add(123) було дозволено, то names фактично перестав би бути «списком рядків». А далі ви б отримали або збій у середовищі виконання (наприклад, ClassCastException десь далеко), або — ще гірше — тиху логічну помилку.
Саме тому Kotlin віддає перевагу принципу «краще не скомпілюється, ніж зламається в користувача». А MutableList<*> якраз і означає: «тип невідомий, ми не маємо права писати».
4. Практика: відлагоджувальне виведення списку в Expense Tracker
Щоб це не залишилося чистою теорією, вбудуймо ідею List<*> у наш консольний Expense Tracker (той самий, де ми зберігаємо витрати й показуємо звіти). Час від часу хочеться швидко подивитися, «що зараз лежить у колекції», не створюючи десять різних функцій.
Зробімо невеличку утиліту «друк списку» (звичайну функцію, без розширень):
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
}
Зверніть увагу на важливий ефект: printList не може «знати» нічого про тип елемента — і це нормально. Вона не претендує на бізнес-логіку: це утиліта для виведення. У таких місцях List<*> — ідеальне рішення.
Попередній перегляд: друкувати перші N елементів
Тепер додамо трохи більше користі: друкуватимемо перші N елементів, щоб величезний список не «вбив» консоль.
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
}
Зауважте, як природно це читається: ми не знаємо тип елементів — і нам не потрібно його знати, бо завдання «відлагоджувальне виведення» не вимагає жодних операцій, окрім друку.
5. Перевірка is List<*> і розбір елементів
У реальному коді інколи «прилітає» Any (наприклад, із дуже загального API, із JSON-дерева, із Map<String, Any?>, із зовнішньої бібліотеки). І ви хочете зрозуміти: це список чи ні?
Kotlin дозволяє перевірку виду x is List<*>. Це важливий момент: перевірка «це список невідомих елементів» допустима, і Kotlin прямо наводить такий приклад, зазначаючи, що елементи при цьому мають тип Any?.
Зробімо мініфункцію:
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
}
А тепер типовий наступний крок: «якщо це список, давайте спробуємо дістати з нього рядки». Тут важливо памʼятати, що елементи — Any?, тож доведеться відфільтровувати їх через перевірки типів.
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
}
Це виглядає трохи багатослівніше, ніж «магічне приведення типів», зате працює передбачувано й не перетворює ваш код на мінне поле.
6. out/in-проєкції та *
Дуже корисно тримати в голові різницю між ситуаціями «я знаю, що це за елементи, але хочу обмежити операції» та «я взагалі не знаю, що це за елементи».
У MutableList<out Number> ви знаєте, що це «список якихось чисел», і тому можете безпечно читати як Number, але не можете додавати (бо «які саме числа» невідомо). У MutableList<in Int> ви знаєте, що туди безпечно додавати Int, але читати зможете лише максимально загально (зазвичай як Any?).
А MutableList<*> — це «я не знаю нічого»: ні верхньої межі, окрім Any?, ні нижньої межі для запису. Тому він максимально суворий до запису.
Можна уявити це як такий «повзунок інформації»:
flowchart LR
A["MutableList⟨Any?⟩
(усе знаю: можу читати Any? і писати Any?)"] -->
B["MutableList⟨out Number⟩
(знаю верхню межу: читаю Number, не пишу)"] -->
C["MutableList⟨*⟩
(майже нічого не знаю: читаю Any?, не пишу)"]
Так, це спрощення (як і всі хороші навчальні пояснення), але воно чудово працює як інтуїція: що менше ви знаєте про тип, то менше Kotlin вам дозволяє робити.
7. Типові помилки під час роботи зі star-projection *
Помилка №1: думати, що List<*> — це «список Any», і одразу звертатися до методів елементів.
Коли ви пишете val x: Any? = xs[0], а потім намагаєтеся зробити x.length, компілятор цілком справедливо не розуміє, чому ви вирішили, що це рядок. Правильний шлях — перевірка типу (if (x is String)) і лише потім робота з рядковими властивостями.
Помилка №2: намагатися додавати елементи в MutableList<*> і дивуватися, що не можна навіть null.
Це один із найприкріших моментів для новачка, бо здається, що null — універсальний. Але MutableList<*> означає, що для запису там діє логіка рівня «in Nothing», тобто не можна записати нічого типобезпечного (і null теж не підходить). Якщо вам потрібно додавати елементи, значить у контракті функції має бути конкретний «вхідний» тип, наприклад MutableList<in Int> або MutableList<Any?>, а не *.
Помилка №3: плутати MutableList<*> і MutableList<Any?> та обирати зірочку «про всяк випадок».
Зірочка — це не «універсальніше», а «суворіше». Вона ідеально підходить для сценаріїв «прочитати/перебрати/надрукувати», але незручна там, де ви хочете щось складати всередину. Якщо ваша функція за змістом має наповнювати список, значить ви не у світі *, а у світі in.
Помилка №4: робити небезпечні приведення типу (as MutableList<String>) замість зірочки, щоб «просто запрацювало».
Іноді дуже хочеться «продавити компілятор»: написати as — і піти далі. Але в generics це часто закінчується тим, що застосунок компілюється, а потім падає в середовищі виконання в несподіваному місці. List<*> і MutableList<*> корисні тим, що дозволяють чесно сказати: «я не знаю тип» — і компілятор сам не дасть вам виконати небезпечні операції.
Помилка №5: очікувати, що перевірка x is List<*> автоматично дасть вам знання про тип елементів.
Ця перевірка відповідає лише на запитання «це список чи ні». Після неї елементи все одно будуть Any?, і це нормально. Якщо вам потрібно зрозуміти, чи всередині рядки, числа або ваші Expense, доведеться перевіряти елементи (is String, is Int, is Expense) або будувати інший контракт функції, де тип відомий заздалегідь.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ