JavaRush /Курси /Kotlin SELF /Variance у місці оголошення:

Variance у місці оголошення: out і in у власних узагальнених типах

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

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 (віддаємо назовні)
fun get(): T
так ні
Параметр функції in (приймаємо всередину)
fun put(x: T)
ні так
val-властивість типу T out (getter)
val current: T
так ні
var-властивість типу T і out, і in
var current: T
ні ні (у загальному випадку)

Тут важливе «у загальному випадку»: інколи заборону можна обійти, якщо змінити дизайн. Наприклад, 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?) або взагалі не зберігати (робити «чистий обробник»).

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