JavaRush /Курсы /Kotlin SELF /Variance на месте объявления:

Variance на месте объявления: out и in в своих обобщённых типах

Kotlin SELF
36 уровень , 1 лекция
Открыта

1. Введение

Когда вы впервые сталкиваетесь с out и in, очень хочется думать так: «Ага, это какая-то хитрая магия, чтобы generics начали работать как наследование». Но правильнее думать наоборот: out/in — это способ не дать вам сделать небезопасную вещь, даже если вам очень хочется, даже если «ну я аккуратно».

Представьте, что вы делаете небольшой модуль для нашего учебного консольного приложения (пусть это будет трекер расходов). Мы хотим писать код, который будет работать и с Record (базовая запись), и с ExpenseRecord (расход), и с IncomeRecord (доход). Мы уже умеем наследование, и нам хочется, чтобы «контейнеры» тоже подстраивались.

Проблема в том, что контейнеры бывают разные. Одни контейнеры — это «коробка», из которой мы достаём значение. Другие — это «урна/приёмник», куда мы кладём значения. А некоторые — «и то, и другое», и там как раз обычно и начинается инвариантность и запреты.

Ключевая идея сегодняшней лекции: variance на месте объявления (declaration-site variance) — это когда вы в объявлении своего generic-типа честно говорите компилятору: «Смотри, я буду использовать T только так-то». Kotlin верит — и взамен разрешает более гибкую подтипность. Это описано в официальной документации как declaration-site variance и показывается на примере Source<out T>: если тип действительно только «отдаёт» T, его можно сделать ковариантным через out.

2. Declaration-site variance: как объявлять out/in и что такое позиции типа

Сейчас мы будем писать свои generic-типы, и это выглядит почти как «настройка доступа»: вы будто бы создаёте 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> — абсолютно нормально: «расход» — это тоже «запись».

Именно эта логика лежит в основе того, что read-only коллекции Kotlin ковариантны (они «источники значений»). Документация прямо отмечает, что read-only коллекции являются ковариантными.

Что запрещает 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

Приятный момент: вы уже пользовались variances, просто не замечали. Kotlin старается, чтобы «правильная» вариативность была в стандартной библиотеке из коробки, а вам не приходилось постоянно писать out/in вручную.

List<out T> как read-only producer

Read-only List — ковариантен, и поэтому List<ExpenseRecord> можно использовать как List<Record>. Это ровно та логика producer’а, которую мы обсуждали выше. В документации это формулируется так: read-only коллекции ковариантны.

Comparable<in T> как consumer

У Comparable параметр типа контравариантен: Comparable<in T>. В документации пример строится на том, что Comparable<Number> можно присвоить в Comparable<Double>, потому что объект, который умеет сравнивать Number, умеет сравнивать и Double.

Даже если вам пока не хочется глубоко разбираться в математике сравнений, полезно запомнить форму: сравниваемый тип стоит во входе compareTo(other: T), значит это consumer → in.

Типы функций: вход — это in, выход — это out

Тип функции (A) -> B — это очень наглядная модель variances: функция потребляет аргумент типа 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, а потом пытаться сделать «нормальный mutable-контейнер».
Очень частая ситуация: вы пишете 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()), и при этом тип останется ковариантным. Variance гарантирует только типобезопасность, а не отсутствие мутации — это разные понятия.

Ошибка №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?) или вовсе не хранить (делать «чистый обработчик»).

1
Задача
Kotlin SELF, 36 уровень, 1 лекция
Недоступна
Снимок записи
Снимок записи
1
Задача
Kotlin SELF, 36 уровень, 1 лекция
Недоступна
Форматтер отчёта
Форматтер отчёта
1
Задача
Kotlin SELF, 36 уровень, 1 лекция
Недоступна
Коробка чтения
Коробка чтения
1
Задача
Kotlin SELF, 36 уровень, 1 лекция
Недоступна
Сборщик строк
Сборщик строк
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ