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 в одному з двох режимів:
- як джерело елементів (читаємо, але не змінюємо);
- як приймач (додаємо елементи).
І ось тут дуже допомагає 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 спеціально ускладнює |
|---|---|---|---|
|
джерело | читати T | записувати 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>). А якщо хочете приймати ширше — доведеться чимось пожертвувати (зазвичай можливістю запису або точним типом читання).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ