1. Вступ
Коли ви вперше стикаєтеся з out і in, дуже хочеться подумати так: «Ага, це якась хитра магія, щоб узагальнення почали працювати як успадкування». Але правильніше мислити навпаки: out/in — це спосіб не дати вам зробити небезпечну річ. Навіть якщо дуже хочеться. Навіть якщо «та я обережно».
Уявіть, що ви робите невеликий модуль для нашого навчального консольного застосунку (нехай це буде трекер витрат). Ми хочемо писати код, який працюватиме і з Record (базовий запис), і з ExpenseRecord (витрата), і з IncomeRecord (дохід). Успадкування ми вже вміємо, тож природно хотілося б, щоб «контейнери» теж підлаштовувалися.
Проблема в тому, що контейнери бувають різні. Одні контейнери — це «коробка», з якої ми дістаємо значення. Інші — це «приймач», куди ми кладемо значення. А деякі — «і те, і інше». Саме там зазвичай і починаються інваріантність і заборони.
Ключова ідея сьогоднішньої лекції: variance у місці оголошення (declaration-site variance) — це коли ви в оголошенні свого узагальненого типу чесно кажете компілятору: «Дивись, я використовуватиму T тільки ось так». Kotlin вам вірить — і натомість дозволяє гнучкішу підтипність. В офіційній документації це описано як declaration-site variance і показано на прикладі Source<out T>: якщо тип справді лише «віддає» T, його можна зробити коваріантним через out.
2. Declaration-site variance: як оголошувати out/in і що таке позиції типу
Зараз ми писатимемо власні узагальнені типи. Це схоже на «налаштування доступу»: ви ніби створюєте API, у якому наперед забороняєте небезпечні операції.
Важливий момент: out і in пишуться у місці оголошення параметра типу, тобто в заголовку класу/інтерфейсу/функціонального типу, наприклад class Box<out T> ... або class Sink<in T> .... Це не «разовий трюк для однієї функції», а правило для всього типу:
- якщо T оголошено як out, Kotlin суворо стежитиме, щоб T ніде не використовувався як «вхід» (у параметрах методів, сетерах тощо);
- якщо T оголошено як in, Kotlin стежитиме, щоб ви не намагалися «видати назовні» значення типу T як точний тип.
Чому так суворо? Бо інакше варіативність зламалася б під час виконання (як це колись сталося з масивами в Java — і всім досі трохи болить). Kotlin обирає підхід: «краще не скомпілююся, ніж дам вам шанс улаштувати типову катастрофу».
Корисно розуміти, як компілятор визначає, де T — це «in», а де — «out». На практиці йдеться про те, що відбувається зі значенням: ви віддаєте його назовні чи приймаєте всередину.
Нижче — невелика таблиця-шпаргалка. Вона не замінює розуміння, але дуже допомагає, коли ви дивитеся на помилку компілятора й думаєте: «Чого йому знову треба?».
| Місце, де зустрічається T | Це T як… | Приклад | Дозволено за out T | Дозволено за in T |
|---|---|---|---|---|
| Тип, що повертається з функції | out (віддаємо назовні) | |
так | ні |
| Параметр функції | in (приймаємо всередину) | |
ні | так |
| val-властивість типу T | out (getter) | |
так | ні |
| var-властивість типу T | і out, і in | |
ні | ні (у загальному випадку) |
Тут важливе «у загальному випадку»: інколи заборону можна обійти, якщо змінити дизайн. Наприклад, consumer може зберігати значення як Any?, тому що Any? не обіцяє тип T. Producer може приймати не T, а щось інше, що не повʼязане з параметром типу (наприклад, «скинути стан»).
3. out T: коваріантність і producer
Зараз ми розберемо out на практиці — і це буде найінтуїтивніший варіант, бо він схожий на List, із яким ви вже мали справу. Після цього стане простіше зрозуміти in, який зазвичай збиває з пантелику трохи сильніше.
Міні-домен для прикладів
Щоб приклади були не «про котів і собак» (хоча коти й собаки дуже стараються), зробімо типи, схожі на те, що може жити в нашому застосунку для обліку фінансів:
open class Record(val id: Int, val comment: String)
class ExpenseRecord(
id: Int,
comment: String,
val amount: Int
) : Record(id, comment)
Тут ExpenseRecord — підтип Record. Отже, ExpenseRecord можна використовувати там, де очікують Record (це звичайне успадкування — воно в нас уже було).
«Знімок» як producer: Snapshot<out T>
Тепер створімо тип, який лише зберігає значення й віддає його. Це дуже схоже на «знімок результату обчислення», «відповідь сервера», «готове значення».
class Snapshot<out T>(private val value: T) {
fun get(): T = value
}
Зверніть увагу: T тут використовується лише в out-позиції — повертається з get(). Це рівно та обіцянка, яку out і вимагає.
Перевіримо підтипність:
fun main() {
val expenseSnapshot: Snapshot<ExpenseRecord> =
Snapshot(ExpenseRecord(1, "Coffee", 250))
val recordSnapshot: Snapshot<Record> = expenseSnapshot
println(recordSnapshot.get().comment) // Coffee
}
Чому це безпечно? Тому що через recordSnapshot ми не можемо запхати всередину «неправильний Record». Ми можемо лише читати. А читати Record із Snapshot<ExpenseRecord> — абсолютно нормально: «витрата» — це теж «запис».
Саме ця логіка лежить в основі того, що колекції Kotlin лише для читання коваріантні (вони «джерела значень»). У документації прямо зазначено, що колекції лише для читання є коваріантними.
Що забороняє out: не можна приймати T всередину
А тепер спробуймо «поліпшити» Snapshot: додаймо метод set(newValue: T), щоб можна було змінювати значення.
class Snapshot<out T>(private var value: T) {
fun get(): T = value
// Так не можна — приклад ідеї, не компілюється:
// fun set(newValue: T) { value = newValue }
}
Kotlin заборонить це (і це добре). Логіка така: якщо Snapshot<ExpenseRecord> можна присвоїти в Snapshot<Record>, а потім через змінну типу Snapshot<Record> можна було б викликати set(Record(...)), то ми б запхали «звичайний Record» усередину обʼєкта, який насправді зберігає ExpenseRecord. Типобезпечність розвалилася б.
Отже, out — це ціна за гнучкість: ви отримуєте «піднімання» по ієрархії типів, але втрачаєте можливість безпечно приймати T як вхід.
Пастка: var майже завжди ламає out
Один із найчастіших сюрпризів у новачків: ви оголосили out T, а потім зробили властивість var value: T — і раптом компілятор невдоволений. Причина в тому, що var — це і getter, і setter. Getter повертає T (out-позиція), setter приймає T (in-позиція). А out вимагає, щоб T використовувався лише як out.
Подивіться на приклад-ідею:
// Не компілюється — показано як пастка:
// class Box<out T>(var value: T)
Якщо вам потрібен «producer», зазвичай робіть val (лише читання) або взагалі ховайте зберігання всередину й віддавайте значення через методи.
4. in T: контраваріантність і consumer
Якщо out зазвичай відчувається як «логічно», то in спочатку може здаватися так, ніби «компілятор знущається». Зараз розберемося так, щоб компілятор виглядав не лиходієм, а охоронцем у клубі типобезпечності.
Ідея consumer: ми лише приймаємо T
Зробімо тип, який приймає значення T, але сам нічого типу T назовні не віддає.
Наприклад, «форматер»: він отримує обʼєкт і робить із нього рядок. Це дуже прикладна річ для нашого консольного застосунку: ми постійно щось друкуємо.
class Formatter<in T>(private val toText: (T) -> String) {
fun format(value: T): String = toText(value)
}
Зверніть увагу: T тут стоїть у параметрі format(value: T) — це in-позиція. Також T стоїть на вході функції (T) -> String, а вхід функції теж поводиться як «in».
Чому підтипність «розвертається»
Тепер найцікавіше: що є підтипом чого?
Якщо ExpenseRecord — підтип Record, то Formatter<Record> є підтипом Formatter<ExpenseRecord>, тому що Formatter — consumer (контраваріантний).
Звучить дивно, але логіка проста: якщо обʼєкт уміє форматувати будь-який Record, то він точно вміє форматувати ExpenseRecord (адже витрата — це окремий випадок запису).
fun main() {
val recordFormatter: Formatter<Record> =
Formatter { r -> "#${r.id}: ${r.comment}" }
val expenseFormatter: Formatter<ExpenseRecord> = recordFormatter
val e = ExpenseRecord(10, "Taxi", 900)
println(expenseFormatter.format(e)) // #10: Taxi
}
Ми зробили присвоювання «у зворотний бік» порівняно з out. І це нормально: для consumer усе дзеркально.
Що забороняє in: не можна віддавати T назовні як точний тип
Спробуймо зробити consumer, який ще й «зберігає останній прийнятий елемент» і повертає його назовні:
class Sink<in T> {
// Ідея, яка не скомпілюється, якщо намагатися повернути T:
// private var last: T? = null
// fun put(x: T) { last = x }
// fun last(): T? = last
}
Чому не можна? Бо якщо у вас є Sink<Record> і ви присвоюєте його в змінну Sink<ExpenseRecord> (що дозволено за in), то хтось почне думати, що last() поверне ExpenseRecord?. Але реальний обʼєкт же Sink<Record>: туди могли покласти будь-який Record, не обовʼязково витрату.
Тому Kotlin дозволяє consumer-типам приймати T, але не дозволяє «обіцяти» назовні точний T.
5. Де out/in уже траплялися в Kotlin
Приємний момент: ви вже користувалися варіативністю, просто не помічали цього. Kotlin намагається, щоб «правильна» варіативність була в стандартній бібліотеці з коробки, а вам не доводилося постійно писати out/in вручну.
List<out T> як producer лише для читання
List лише для читання — коваріантний, і тому List<ExpenseRecord> можна використовувати як List<Record>. Це рівно та логіка producer, яку ми обговорювали вище. У документації це формулюється так: колекції лише для читання коваріантні.
Comparable<in T> як consumer
У Comparable параметр типу контраваріантний: Comparable<in T>. У документації приклад побудовано на тому, що Comparable<Number> можна присвоїти в Comparable<Double>, тому що обʼєкт, який уміє порівнювати Number, уміє порівнювати і Double.
Навіть якщо вам поки що не хочеться глибоко занурюватися в «математику порівнянь», корисно запамʼятати форму: тип, який ми порівнюємо, стоїть на вході compareTo(other: T), отже це consumer → in.
Типи функцій: вхід — це in, вихід — це out
Тип функції (A) -> B — це дуже наочна модель варіативності: функція споживає аргумент типу A і виробляє результат типу B. Тому вхід поводиться як in, а вихід — як out.
Практична думка: якщо у вас є функція, яка вміє приймати Record, то вона автоматично вміє приймати і ExpenseRecord (тому що витрата — це запис). Тому таку функцію можна використовувати там, де очікують обробник витрат:
fun main() {
val handleRecord: (Record) -> String = { r -> "Handled: ${r.comment}" }
val handleExpense: (ExpenseRecord) -> String = handleRecord
println(handleExpense(ExpenseRecord(7, "Lunch", 600))) // Handled: Lunch
}
Якщо ви зараз подумали: «Це схоже на приклад із минулої лекції», — так і є. Просто тепер ви бачите, що це не випадковість: це той самий принцип consumer/producer, тільки у вигляді функцій.
6. Прикладний рефакторинг: джерело даних і обробник виведення
Щоб сьогоднішня тема не залишилася «у вакуумі», давайте акуратно вбудуємо її в логіку консольного застосунку. Ми не будемо будувати архітектуру «на віки» (це окрема дисципліна), але зробимо одну маленьку й зрозумілу річ: відокремимо отримання даних від форматування виведення через out/in.
Producer звітних даних: ReportSource<out T>
Зробімо джерело, яке віддає елементи для звіту. Це producer, отже out.
class ReportSource<out T>(private val items: List<T>) {
fun firstOrNull(): T? = items.firstOrNull()
}
Використаємо:
fun main() {
val expenses = listOf(
ExpenseRecord(1, "Coffee", 250),
ExpenseRecord(2, "Book", 1200)
)
val expenseSource: ReportSource<ExpenseRecord> = ReportSource(expenses)
val recordSource: ReportSource<Record> = expenseSource
println(recordSource.firstOrNull()?.comment) // Coffee
}
Тут усе логічно: джерело витрат можна використовувати як джерело записів, бо воно лише «віддає».
Consumer виведення: Printer<in T>
Тепер зробімо обʼєкт «для друку», який приймає T і виводить у консоль. Це consumer, отже in.
class Printer<in T>(private val toLine: (T) -> String) {
fun print(value: T) {
println(toLine(value))
}
}
І перевіримо підстановку типів:
fun main() {
val printRecord = Printer<Record> { r -> "Record: ${r.comment}" }
val printExpense: Printer<ExpenseRecord> = printRecord
printExpense.print(ExpenseRecord(3, "Taxi", 900)) // Record: Taxi
}
Обʼєкт, який уміє друкувати «будь-який Record», підходить там, де потрібно друкувати «лише ExpenseRecord». Це і є контраваріантність in.
Зʼєднуємо producer і consumer в одну функцію
Тепер можна написати функцію, яка бере «джерело» і «принтер» та друкує перший елемент. Тут корисно бачити, як out і in починають дружити в одному місці:
fun printFirst(
source: ReportSource<Record>,
printer: Printer<Record>
) {
val first = source.firstOrNull()
if (first != null) {
printer.print(first)
}
}
А тепер використаємо це, підставляючи «більш конкретні» і «більш загальні» речі там, де це безпечно:
fun main() {
val expenses = listOf(ExpenseRecord(1, "Coffee", 250))
val sourceExpenses = ReportSource(expenses) // ReportSource<ExpenseRecord>
val printerRecords = Printer<Record> { "-> ${it.comment}" }
printFirst(sourceExpenses, printerRecords) // -> Coffee
}
Чому це взагалі компілюється, якщо printFirst очікує ReportSource<Record>, а ми передаємо ReportSource<ExpenseRecord>? Тому що ReportSource оголошено як out, тобто він коваріантний. Чому компілюється принтер? Тому що Printer оголошено як in, тобто він контраваріантний.
7. Типові помилки під час проєктування out і in
Помилка №1: ставити out, а потім намагатися зробити «нормальний змінюваний контейнер».
Дуже часта ситуація: ви пишете class Box<out T>, а потім додаєте fun set(x: T) або робите var value: T. Компілятор лається — і здається, що він «ламає вам життя». Насправді він захищає вас від логічної дірки: коваріантний тип не можна безпечно використовувати як приймач T. Якщо вам потрібна змінюваність, то або робіть тип інваріантним (class Box<T>), або розділяйте ролі producer і consumer різними типами.
Помилка №2: ставити in, а потім намагатися повертати T назовні.
У consumer-типу часто виникає бажання «повернути останній прийнятий елемент». Але щойно тип стає контраваріантним, повернене T перетворюється на небезпечну обіцянку: ззовні очікуватимуть конкретніший тип, ніж той, який реально міг потрапити всередину. У таких випадках або повертайте щось загальніше (Any?), або змінюйте дизайн: consumer не зобовʼязаний уміти «діставати» те, що він прийняв.
Помилка №3: думати, що out робить обʼєкт незмінюваним.
out не означає «immutable». Він означає: «у моєму API параметр типу використовується лише на виході». Ви цілком можете мати методи без T, які змінюють внутрішній стан (наприклад, clear()), і водночас тип залишиться коваріантним. Варіативність гарантує лише типобезпечність, а не відсутність мутації — це різні поняття.
Помилка №4: намагатися вибрати out або in «навмання», аби скомпілювалося.
out і in — це не «ліки від помилок типів», а опис сенсу. Хороший спосіб перестати вгадувати — щоразу проговорювати: мій тип виробляє T чи споживає T? Якщо виробляє (читаємо, отримуємо, повертаємо) — out. Якщо споживає (приймає, додає, обробляє вхід) — in. Якщо і те, і інше — найімовірніше, це інваріантність, і доведеться проєктувати інакше.
Помилка №5: дивуватися, що var ламає і out, і in.
Властивість var x: T одночасно і віддає T (getter), і приймає T (setter). Тому в типі з out T вона заборонена через setter, а в типі з in T — заборонена через getter. Якщо ви хочете зберігати значення, але зберегти варіативність, зазвичай допомагає: для out використовувати val, а для in зберігати всередині не T, а загальніший тип (Any?) або взагалі не зберігати (робити «чистий обробник»).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ