JavaRush /Курси /Kotlin SELF /Проєкції типів: безпечні Ar...

Проєкції типів: безпечні Array<out/in> і проєкції для MutableList

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

1. Вступ

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

Проблема в тому, що деякі контейнери за своєю природою «і читають, і пишуть». Для такого контейнера не можна чесно поставити out або in в оголошенні класу — інакше він утратить половину сенсу. Класичний приклад — Array<T>, де є і читання за індексом, і запис за індексом. Kotlin прямо використовує цей приклад, пояснюючи, чому Array інваріантний і як це «лікується» проєкціями.

Type projections — це компроміс: ми не змінюємо клас Array, але в конкретній функції «урізаємо» його можливості так, щоб компілятор міг гарантувати безпеку.

2. Чому Array<T> інваріантний

Із масивами дуже легко потрапити в пастку «ну це ж контейнер, отже має поводитися як List». Але List у Kotlin — це інтерфейс «для читання», а Array — справжня «скринька з доступом за індексом», куди можна і заглядати, і класти щось назад.

Якщо спростити, то в Array<T> є такі операції:

operator fun get(index: Int): T
operator fun set(index: Int, value: T)

Саме тому Kotlin не може зробити Array коваріантним (як List), інакше зʼявилася б можливість записати «не той» тип. Ось чому просте «copy з Array<Int> у Array<Any>» не компілюється без додаткових прийомів.

Давайте відчуємо це на практиці.

fun copyNaive(from: Array<Any>, to: Array<Any>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val any: Array<Any> = arrayOf("x", "y", "z")

    // copyNaive(ints, any) // не компілюється: Array<Int> не є Array<Any>
}

І це добре: якби компілятор дозволив, усередині copyNaive теоретично можна було б зробити from[0] = "oops" (тобто записати String у масив Int). Kotlin блокує цей клас помилок наперед.

3. Проєкції для Array: out і in

Проєкція Array<out X>: масив-джерело

Коли ви пишете Array<out Any> у параметрі функції, ви кажете компілятору: «Я беру масив, який гарантовано містить елементи не гірші за Any, але обіцяю: я не буду записувати в нього». Тобто масив стає producer-обʼєктом — джерелом значень.

Важливо вловити логіку: Array<out Any> — це не новий тип масиву. Це той самий масив, але через це посилання ви не матимете доступу до небезпечних операцій запису (або вони стануть типово неможливими).

Kotlin в офіційному поясненні type projections використовує саме ідею «копіювання з масиву, який ми не маємо змінювати».

Пишемо безпечну функцію copyToAny

Зробімо акуратний copy, де from — лише для читання.

fun copyToAny(from: Array<out Any>, to: Array<Any>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val dest: Array<Any> = Array(3) { "?" }

    copyToAny(ints, dest)

    println(dest.joinToString()) // 1, 2, 3
}

Зверніть увагу на «магію», яка не виглядає магією: ми передали Array<Int> туди, де очікується Array<out Any>. Це стало можливим тому, що Kotlin тепер упевнений: ми не будемо записувати в from. А отже, не можна «зламати» масив Int, записавши туди String. Саме в цьому й полягає сенс use-site variance / type projection.

Що заборонено в Array<out X>

Якщо дуже хочеться випробувати компілятор на терпіння, спробуйте:

fun breakIt(from: Array<out Any>) {
    // from[0] = "boom" // не компілюється
}

І це не «Kotlin вередує». Kotlin просто чесно виконує контракт: out-проєкція означає «читаємо, але не пишемо».

Проєкція Array<in X>: масив-приймач

Тепер — дзеркальна ситуація. Іноді масив потрібен не як джерело, а як приймач: ми хочемо його заповнити. Тоді нам важлива операція set, а читання — другорядне.

Array<in String> читається так: «масив чогось супертипу String, у який я можу безпечно покласти рядок».

Заповнюємо масив рядками: fillWithOk

fun fillWithOk(dest: Array<in String>) {
    for (i in dest.indices) {
        dest[i] = "ok"
    }
}

fun main() {
    val anyArray: Array<Any> = Array(3) { 0 }
    fillWithOk(anyArray)

    println(anyArray.joinToString()) // ok, ok, ok
}

Чому це безпечно? Тому що Array<Any> справді може зберігати String: рядок — це Any.

Чому читання з Array<in X> стає біднішим

Ось важливий момент, який багатьох дратує (а потім ви звикаєте й починаєте дратуватися вже через інші речі).

Якщо у нас Array<in String>, то читати як String із нього не можна: адже фактично це може бути Array<Any>, і там можуть лежати не лише рядки. Тому безпечне читання зазвичай має тип Any?.

fun showFirst(dest: Array<in String>) {
    val x: Any? = dest[0]
    println("перший = $x") // перший = ...
}

fun main() {
    val anyArray: Array<Any> = arrayOf(10, 20, 30)
    showFirst(anyArray) // перший = 10
}

І знову: це не вередливість, а гарантія. Контракт in каже: «я можу писати String», але нічого не обіцяє про те, що там уже лежить і якого воно типу.

4. Проєкції для MutableList: out і in

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

  1. як джерело елементів (читаємо, але не змінюємо);
  2. як приймач (додаємо елементи).

І ось тут дуже допомагає use-site variance: MutableList<out T> і MutableList<in T>.

Важливо не переплутати: ми не робимо MutableList назавжди коваріантним. Ми кажемо: «у цій функції ставтеся до нього як до producer / consumer».

MutableList<out Number>: читаємо числа, не додаємо

Уявімо, що у нас є список MutableList<Int>, але функція хоче працювати з Number. Без проєкцій ви б уперлися в несумісність типів (інваріантність мутабельної колекції). Але з out-проєкцією все гаразд, якщо ви не додаєте елементи.

fun sumFirstTwo(list: MutableList<out Number>): Double {
    val a = list[0].toDouble()
    val b = list[1].toDouble()
    return a + b
    // list.add(1) // не компілюється: out-проєкція забороняє запис
}

fun main() {
    val ints: MutableList<Int> = mutableListOf(10, 20, 30)

    println(sumFirstTwo(ints)) // 30.0
}

Тут Kotlin захищає нас від тонкої помилки: якби запис був дозволений, ми могли б додати Double у список Int. І тоді все б «поїхало».

MutableList<in Int>: додаємо Int у відповідний список

Тепер навпаки: функція має додавати Int. Водночас зручно, якщо їй можна передати список більш загального типу, наприклад MutableList<Number>.

fun addOne(list: MutableList<in Int>) {
    list.add(1)
}

fun main() {
    val numbers: MutableList<Number> = mutableListOf(10, 2.5)

    addOne(numbers)

    println(numbers) // [10, 2.5, 1]
}

Сенс контракту такий: раз список гарантовано здатен прийняти Int, отже його елементний тип — Int або будь-який супертип (Number, Any). Це і є логіка consumer.

5. Шпаргалка щодо out і in

Коли починаєте працювати з проєкціями, мозок спершу протестує: «чому я не можу просто зробити, як хочу?» Щоб мозок не «звільнився», корисно тримати під рукою коротку таблицю.

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

Проєкція в параметрі Ідея Що зручно робити Що Kotlin спеціально ускладнює
Array<out T> / MutableList<out T>
джерело читати T записувати T
Array<in T> / MutableList<in T>
приймач записувати T читати як T (зазвичай читаємо як Any?)

Ця логіка буквально випливає з ідеї type projections (use-site variance) і з того, що Kotlin забороняє операції, які можуть зруйнувати типобезпечність.

6. Приклад: Finance Tracker і проєкції на MutableList

Щоб не залишати тему на рівні «абстрактні собаки й коти», давайте вбудуємо проєкції в шматок коду, схожий на реальність.

Уявімо, що у нас є консольний застосунок Finance Tracker, у якому ми зберігаємо фінансові операції. Ми вже вміємо зберігати колекції обʼєктів, писати функції й працювати зі спадкуванням. Тож зробімо базовий тип операції та два підтипи.

open class Operation(
    val amount: Double,
    val note: String,
)

class Expense(amount: Double, note: String) : Operation(amount, note)
class Income(amount: Double, note: String) : Operation(amount, note)

Друк операцій: producer MutableList<out Operation>

У цій функції нам важливо лише прочитати операції й вивести їх. Змінювати список не потрібно, інакше вона стане надто навʼязливою.

fun printOperations(ops: MutableList<out Operation>) {
    for (op in ops) {
        println("${op::class.simpleName}: ${op.amount} (${op.note})")
        // Приклад виводу: Expense: 12.5 (coffee)
    }
}

fun main() {
    val expenses: MutableList<Expense> = mutableListOf(
        Expense(12.5, "coffee"),
        Expense(40.0, "groceries"),
    )

    printOperations(expenses)
}

Чому це важливо? Тому що без out ви б не змогли передати MutableList<Expense> туди, де очікується «список операцій». А з out ви чесно фіксуєте: список використовується як джерело.

Додавання витрати: consumer MutableList<in Expense>

Тепер зворотне завдання: функція додає витрату. Зручно, якщо вона вміє додавати витрату не лише в MutableList<Expense>, а й у загальний журнал MutableList<Operation>.

fun addExpense(ops: MutableList<in Expense>, amount: Double, note: String) {
    ops.add(Expense(amount, note))
}

fun main() {
    val journal: MutableList<Operation> = mutableListOf(
        Income(1000.0, "salary"),
    )

    addExpense(journal, 12.5, "coffee")

    printOperations(journal)
    // Income: 1000.0 (salary)
    // Expense: 12.5 (coffee)
}

З погляду типів це звучить так: список MutableList<Operation> — чудовий приймач Expense, тому що Expense є Operation.

7. Проєкції обмежують API посилання, а не обʼєкт

Дуже поширена плутанина: здається, що Array<out Any> — це якийсь «особливий масив». Але насправді масив лишається тим самим. Змінюється лише те, що вам дозволено робити через цю змінну.

Можна уявити це як мінісхему:

flowchart LR
    A["Реальний обʼєкт: Array⟨Int⟩"]
    B["Посилання як Array⟨out Any⟩"]
    C["Посилання як Array⟨Int⟩"]

    A --> B
    A --> C

    B -->|"можна читати (Any)"| R["get(i)"]
    B -.->|"не можна писати"| W["set(i, ...)"]

    C -->|"можна читати (Int)"| R2["get(i)"]
    C -->|"можна писати (Int)"| W2["set(i, Int)"]

У цьому сенсі проєкції — дуже «інженерний» інструмент. Ви не переписуєте модель даних. Ви задаєте компілятору зрозумілі обмеження: «у цій функції я поводжуся пристойно».

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

Помилка №1: намагатися «дописати» в Array<out …> або MutableList<out …>.
Зазвичай це виглядає так: ви акуратно оголосили параметр як producer, а потім усередині функції прокидається бажання «ну я ж трішечки додам». Компілятор не дасть. І правильно: якщо ви змогли передати туди MutableList<Int> як MutableList<out Number>, то запис 2.5 був би логічною катастрофою.

Помилка №2: очікувати, що з Array<in …> можна читати як із Array<T>.
Якщо параметр оголошено як consumer, Kotlin свідомо робить читання «незручним»: безпечний тип читання виходить на кшталт Any?. Це нормально. Якщо вам потрібно і читати як T, і писати T, то це вже не consumer/producer-сценарій — і, можливо, проєкції в цьому місці не підходять.

Помилка №3: «лікувати» несумісність типів через as замість проєкцій.
Коли хочеться «щоб компілювалося», рука тягнеться до as. Але приведення типів у generics часто перетворює помилку компіляції на майбутній сюрприз під час виконання. Проєкції хороші тим, що не ховають проблему, а змушують вас чесно сформулювати контракт: «лише читаю» або «лише пишу».

Помилка №4: плутати проєкції з out/in в оголошенні типу.
out і in в оголошенні типу — це рішення дизайну вашого класу або інтерфейсу («цей тип за природою producer»). Проєкції в параметрах функцій — це точкове рішення («у цій функції я використовую контейнер як producer/consumer»). На практиці це різні рівні, і корисно прямо проговорювати собі: «я зараз проєктую тип чи просто роблю безпечну сигнатуру функції?»

Помилка №5: намагатися зробити універсальну функцію, яка і читає, і пише.
Іноді хочеться функцію «і копіювати, і трішки підправити», а параметри зробити максимально широкими. Але інваріантність якраз і каже: якщо ви хочете і читати, і писати — будьте ласкаві приймати точний тип (Array<T>, MutableList<T>). А якщо хочете приймати ширше — доведеться чимось пожертвувати (зазвичай можливістю запису або точним типом читання).

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