«out» в Kotlin: разбираем ковариантность на примерах и вопросах

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что скрывается за словом out в Kotlin? Отвечаем на популярные вопросы

Если вы когда-нибудь писали на Kotlin дженерики и сталкивались с непонятной ошибкой компиляции, скорее всего, вы встречали слово out. Этот маленький модификатор вызывает много споров даже у опытных разработчиков. Мы собрали самые частые вопросы с Stack Overflow и превратили их в понятное руководство. Сегодня вы узнаете, что такое out, зачем он нужен и как не запутаться в ковариантности.

Давайте разберемся раз и навсегда — без скучной теории, только живой код и практика.

Почему Kotlin придумал out? (Краткий экскурс в историю)

В Java есть wildcard — знак вопроса ? extends T. Он означает, что коллекция может содержать объекты типа T или его подтипов. Проблема в том, что Java не запрещает вам случайно положить в такую коллекцию неправильный объект — вы получите ошибку только в рантайме. Kotlin решил эту проблему на уровне типов.

Ключевое слово out в Kotlin — это декларация ковариантности. Если вы пишете Source<out T>, это значит: «Я могу читать объекты типа T из этого Source, но не могу в него писать». Компилятор следит за этим строго.

Вопрос 1: Чем out отличается от in?

Самый частый вопрос на Stack Overflow. Давайте представим коробку:

  • out — коробка, из которой можно только доставать вещи (производит).
  • in — коробка, в которую можно только складывать вещи (потребляет).

Пример:

// Ковариантность: можем читать, но не писать
interface Producer<out T> {
    fun produce(): T
}

// Контравариантность: можем писать, но не читать
interface Consumer<in T> {
    fun consume(item: T)
}

fun main() {
    val stringProducer: Producer<String> = object : Producer<String> {
        override fun produce(): String = "Hello"
    }
    
    val anyProducer: Producer<Any> = stringProducer // Работает! out позволяет
    println(anyProducer.produce())
}

Обратите внимание: Producer<out T> позволяет присвоить Producer<String> переменной типа Producer<Any>. Без out это было бы невозможно.

Вопрос 2: Почему Kotlin запрещает fun set(value: T) при out?

Это классическая ловушка. Если у вас объявлен out, компилятор не позволит иметь функции, которые принимают T в качестве параметра. Почему?

Представьте, что у вас есть MutableList<out Number>. Вы можете положить в него Int, но компилятор не знает, что это за список на самом деле. Может, это MutableList<Double>? Если бы разрешили запись, вы бы сломали типы. out гарантирует безопасность: вы можете только читать.

// Не скомпилируется!
interface BadList<out T> {
    fun get(index: Int): T  // OK
    fun add(item: T)        // Ошибка! T находится в in-позиции
}

Решение: если вам нужно и читать, и писать — используйте in и out по отдельности, либо инвариантный тип (без модификатора).

Вопрос 3: Как out связан с wildcard ? extends из Java?

Прямое соответствие: Source<out T> в Kotlin = Source<? extends T> в Java. Но есть важное отличие:

  • В Java wildcard — это использование типа (use-site variance).
  • В Kotlin out — это объявление типа (declaration-site variance).

Пример на Java:

// Java: используем wildcard при объявлении переменной
List<? extends Number> numbers = new ArrayList<Integer>();
numbers.add(42); // Ошибка компиляции! (но не всегда очевидная)

В Kotlin то же самое, но проще:

// Kotlin: объявляем ковариантность один раз в интерфейсе
interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // Это безопасно и разрешено
}

Kotlin избавляет вас от необходимости писать ? extends в каждом месте использования.

Вопрос 4: Можно ли использовать out с изменяемыми коллекциями?

Короткий ответ: да, но осторожно. Если вы объявите MutableList<out T>, вы не сможете вызывать add(), но сможете читать. На практике это используется редко, потому что теряется смысл изменяемой коллекции.

fun process(list: MutableList<out Number>) {
    // val item: Number = list[0] // OK
    // list.add(42)              // Ошибка!
}

Лучше использовать List<out T> (он уже ковариантен по умолчанию) или MutableList<T> без модификаторов.

Вопрос 5: Как out влияет на производительность?

Никак. out — это исключительно информация для компилятора. Во время выполнения (runtime) все дженерики стираются, и модификаторы не влияют на скорость работы. Единственное — вы получаете более безопасный код без лишних проверок типов.

Практический пример: ковариантный интерфейс для чтения данных

Допустим, вы пишете библиотеку для работы с разными источниками данных. Используйте out для интерфейса, который только читает:

interface DataReader<out T> {
    fun read(): T
    fun readAll(): List<T>
}

class StringReader : DataReader<String> {
    override fun read(): String = "kotlin"
    override fun readAll(): List<String> = listOf("kotlin", "java")
}

class IntReader : DataReader<Int> {
    override fun read(): Int = 42
    override fun readAll(): List<Int> = listOf(1, 2, 3)
}

fun printAll(reader: DataReader<Any>) {
    println(reader.read())
}

fun main() {
    printAll(StringReader()) // Работает благодаря out
    printAll(IntReader())    // И это тоже
}

Без out пришлось бы писать отдельные функции для каждого типа или использовать звездочку * (проекцию).

Типичные ошибки и как их избежать

  • Ошибка: «Type parameter T

Блоги

Рекомендаций по книгам