JavaRush /Курси /Kotlin SELF /Star-projection *: List<*>

Star-projection *: List<*>

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

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». Ні. Іноді за поведінкою під час читання це справді схоже, але сенс інший.

Зафіксуймо різницю в невеличкій таблиці (і говоритимемо простими словами, без занурення у формальну теорію типів):

Тип Що обіцяє коду, який викликає Що можна читати Що можна додавати
List<*>
«це список, але тип елементів невідомий»
Any?
(не застосовно,
List
read-only)
List<Any?>
«це список, елементи якого точно Any?»
Any?
(не застосовно)
MutableList<*>
«це змінюваний список, але тип елементів невідомий»
Any?
нічого (заборонено компілятором)
MutableList<Any?>
«це змінюваний список, який зберігає 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) або будувати інший контракт функції, де тип відомий заздалегідь.

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