Files
obsidian/Kotlin/Дженерики/Вариантность.md
T

18 KiB
Raw Blame History

title, description, tags
title description tags
Вариантность out и in в объявлении класса, ковариантность, контравариантность и правило PECS
kotlin
generics

Вариантность отвечает на один вопрос: если String - подтип Any, то является ли Box<String> подтипом Box<Any>?

По умолчанию - нет. Дженерики инвариантны:

class Box6<T>(val value: T)

fun main() {
    val str: Box6<String> = Box6("текст")
    // val any: Box6<Any> = str          // ошибка: несовместимые типы
    println(str.value)                    // текст
}

Причина видна, если допустить обратное на изменяемой коллекции:

fun main() {
    val strings = mutableListOf("a", "b")
    // val anys: MutableList<Any> = strings     // если бы компилировалось...
    // anys.add(42)                              // ...сюда положили бы Int
    // val s: String = strings[0]                // ...и здесь получили бы ClassCastException
    println(strings.size)                        // 2
}

Запрет инвариантности спасает от этой дыры. Но он же мешает там, где изменения нет.

out - ковариантность

out T означает, что T встречается только на выходе: в возвращаемых значениях, но не в параметрах.

class Producer<out T>(private val value: T) {
    fun get(): T = value                     // T на выходе — разрешено
    // fun set(v: T) { }                      // ошибка: T на входе запрещён
}

fun main() {
    val strings: Producer<String> = Producer("текст")
    val anys: Producer<Any> = strings        // работает!
    println(anys.get())                       // текст
}

Это безопасно: из Producer<String> можно только читать, а всякая прочитанная String является Any.

Именно так объявлен List:

fun printSize(items: List<Any>) = items.size

fun main() {
    println(printSize(listOf("a", "b")))         // 2
    println(printSize(listOf(1, 2, 3)))          // 3

    val numbers: List<Number> = listOf(1, 2.0, 3L)
    println(numbers.sumOf { it.toDouble() })     // 6.0
}

В stdlib: List<out E>, Set<out E>, Map<K, out V>, Iterable<out T>, Sequence<out T>, Pair<out A, out B>.

in - контравариантность

in T означает, что T встречается только на входе: в параметрах, но не в возвращаемых типах.

class Consumer<in T> {
    fun consume(value: T) { println("принял $value") }
    // fun produce(): T = TODO()               // ошибка: T на выходе запрещён
}

fun main() {
    val anyConsumer: Consumer<Any> = Consumer()
    val stringConsumer: Consumer<String> = anyConsumer     // работает!
    stringConsumer.consume("текст")                         // принял текст
}

Направление перевёрнуто: Consumer<Any> является подтипом Consumer<String>. Логика простая - тот, кто умеет принимать любой Any, справится и со String.

Классический пример - Comparator:

fun main() {
    val anyComparator = Comparator<Any> { a, b -> a.hashCode() - b.hashCode() }
    val strings = mutableListOf("bb", "a")
    strings.sortWith(anyComparator)            // Comparator<Any> для List<String>
    println(strings.size)                       // 2

    val byLength: Comparator<CharSequence> = compareBy { it.length }
    println(listOf("ccc", "a", "bb").sortedWith(byLength))    // [a, bb, ccc]
}

sortedWith принимает Comparator<in T>, поэтому компаратор по CharSequence годится для List<String>.

В stdlib: Comparator<in T>, Comparable<in T>, функциональные типы (in P) -> out R.

Сводка

Объявление Называется T может быть Направление подтипов
Box<T> инвариантность везде нет связи
Box<out T> ковариантность только возврат Box<String> -> Box<Any>
Box<in T> контравариантность только параметр Box<Any> -> Box<String>
graph TD
    subgraph co["out T: ковариантность"]
        CS["Producer&lt;String&gt;"] --> CA["Producer&lt;Any&gt;"]
    end
    subgraph contra["in T: контравариантность"]
        KA["Consumer&lt;Any&gt;"] --> KS["Consumer&lt;String&gt;"]
    end
    subgraph inv["T: инвариантность"]
        IS["Box&lt;String&gt;"]
        IA["Box&lt;Any&gt;"]
    end

Функциональные типы

Вариантность функциональных типов - самый частый случай, с которым работают, не замечая этого:

fun main() {
    val produceString: () -> String = { "текст" }
    val produceAny: () -> Any = produceString        // возврат ковариантен
    println(produceAny())                             // текст

    val acceptAny: (Any) -> Unit = { println("принял $it") }
    val acceptString: (String) -> Unit = acceptAny    // параметр контравариантен
    acceptString("строка")                             // принял строка
}

Тип (P) -> R объявлен как Function1<in P, out R>: функция, принимающая больше и возвращающая меньше, подходит везде, где ожидается более узкая.

Практическое следствие для API:

class EventBus {
    private val handlers = mutableListOf<(Any) -> Unit>()

    fun subscribe(handler: (Any) -> Unit) { handlers.add(handler) }
    fun publish(event: Any) = handlers.forEach { it(event) }
}

fun main() {
    val bus = EventBus()
    bus.subscribe { println("получено: $it") }
    bus.publish("событие")       // получено: событие
    bus.publish(42)              // получено: 42
}

Когда помечать свой класс

Правило простое: если параметр типа используется только на выходе - ставьте out, только на входе - in. Компилятор проверит и подскажет.

interface Reader<out T> {
    fun read(): T
    fun readAll(count: Int): List<T>          // List<T> — тоже выход, ок
}

interface Writer<in T> {
    fun write(value: T)
    fun writeAll(values: List<T>)             // List<T> на входе, ок
}

interface Storage<T> : Reader<T>, Writer<T>   // и то и другое — инвариантен

class MemoryStorage<T>(private var value: T) : Storage<T> {
    override fun read(): T = value
    override fun readAll(count: Int) = List(count) { value }
    override fun write(value: T) { this.value = value }
    override fun writeAll(values: List<T>) { values.lastOrNull()?.let { value = it } }
}

fun main() {
    val storage = MemoryStorage("начальное")
    val reader: Reader<Any> = storage          // ковариантность работает
    storage.write("новое")
    println(reader.read())                      // новое
    println(reader.readAll(2))                  // [новое, новое]
}

Разделение на Reader и Writer - типичный приём: инвариантный класс реализует оба интерфейса, а клиентам отдаётся вариантный.

Nothing и вариантность

Nothing - подтип всех типов, поэтому List<Nothing> подходит под любой List<T>:

fun main() {
    val empty: List<Nothing> = emptyList()
    val ints: List<Int> = empty               // работает благодаря out
    val strings: List<String> = empty
    println("${ints.size} ${strings.size}")    // 0 0
}

Именно поэтому emptyList() один на все типы - это синглтон EmptyList : List<Nothing>. Подробнее: Any, Unit, Nothing

Тот же приём в sealed-иерархиях:

sealed interface Outcome<out T> {
    data class Ok<T>(val value: T) : Outcome<T>
    data object Empty : Outcome<Nothing>
}

fun describe4(o: Outcome<Int>): String = when (o) {
    is Outcome.Ok -> "значение ${o.value}"
    Outcome.Empty -> "пусто"
}

fun main() {
    println(describe4(Outcome.Ok(1)))      // значение 1
    println(describe4(Outcome.Empty))       // пусто
}

Empty может быть object, а не обобщённым классом, только потому что Outcome ковариантен. Подробнее: Sealed class и interface

Ограничение out: нельзя принимать T

Иногда ковариантный класс всё-таки должен принять T. Для этого параметр объявляют с более широкой границей:

class ImmutableBox<out T>(private val value: T) {
    fun get(): T = value

    fun <R> withDefault(default: R): R where R : Any = default

    fun <U> plus(other: U): List<Any?> = listOf(value, other)
}

fun main() {
    val box: ImmutableBox<String> = ImmutableBox("a")
    val any: ImmutableBox<Any> = box
    println(any.get())                 // a
    println(box.plus(1))               // [a, 1]
}

В stdlib этот приём виден в List.plus:

fun main() {
    val strings: List<String> = listOf("a")
    val mixed: List<Any> = strings + 1      // plus объявлен как <T> List<T>.plus(element: T)
    println(mixed)                           // [a, 1]
}

Тип T здесь выводится как Any, а не как String, поэтому ковариантность не нарушается.

[!info] Отличия от Java

Java Kotlin
List<? extends T> в каждой сигнатуре List<out E> один раз в объявлении
List<? super T> MutableList<in E> или in в объявлении
Collection<?> Collection<*>
нет declaration-site variance out/in при объявлении класса
PECS - правило для программиста компилятор проверяет сам

Главное отличие - где объявляется вариантность. Java знает только use-site variance: интерфейс List<E> инвариантен, и каждый метод, принимающий «список чего-то производного», обязан писать wildcard:

void printAll(Collection<? extends Object> items) { ... }
double sum(List<? extends Number> numbers) { ... }
void addAll(List<? super Integer> dest, List<? extends Integer> src) { ... }

В Kotlin List<out E> объявлен один раз в stdlib, и дальше всё работает само:

fun sum3(numbers: List<Number>) = numbers.sumOf { it.toDouble() }

fun main() {
    println(sum3(listOf(1, 2, 3)))        // 6.0 — List<Int> подошёл
}

Это убирает wildcard из подавляющего большинства сигнатур. Use-site variance в Kotlin тоже есть - это проекции типов, но нужны они редко.

PECS из правила превратился в проверку компилятора. В Java «Producer Extends, Consumer Super» - мнемоника, которую программист применяет вручную; забыл - и метод неудобен в использовании, но компилируется. В Kotlin вы помечаете класс out, и компилятор откажется принять T в параметр: ошибка возникает при объявлении, а не у пользователя API.

Comparator в Kotlin контравариантен автоматически. В Java Comparator<T> инвариантен, и методы вроде Collections.sort объявляют Comparator<? super T>. В Kotlin stdlib Comparator уже in T, поэтому sortedWith(comparator) принимает компаратор по любому супертипу без дополнительных обозначений.

Разделение List/MutableList делает out возможным. В Java List содержит add, поэтому ковариантным он быть не может в принципе - только через wildcard в месте использования. Kotlin вынес мутаторы в MutableList<E> (инвариантный), и List<out E> стал безопасным по построению.

Сравнить: Параметрический полиморфизм в Java, Полиморфизм

[!warning] Подводные камни out запрещает T в параметрах - включая неочевидные места:

class Holder<out T>(private val value: T) {
    fun get(): T = value
    // fun contains(other: T): Boolean = value == other     // ошибка
    fun contains(other: Any?): Boolean = value == other      // так можно
}

fun main() {
    println(Holder("a").contains("a"))       // true
}

Расширение границы до Any? - стандартный обход, но оно теряет проверку типов: contains(42) тоже скомпилируется.

private функции могут нарушать вариантность:

class Mutable<out T>(private var value: T) {
    fun get(): T = value
    private fun set(v: T) { value = v }      // private — проверка не применяется
}

fun main() { println(Mutable("a").get()) }    // a

Компилятор разрешает T на входе в private членах, потому что снаружи они недоступны. Но private var value: T - всё ещё изменяемое состояние; если его изменить изнутри класса, ковариантный «снимок» окажется нестабильным.

MutableList инвариантен, и это не ошибка:

fun main() {
    val strings = mutableListOf("a")
    // val anys: MutableList<Any> = strings      // не скомпилируется — и правильно
    val readOnly: List<Any> = strings             // а так можно
    println(readOnly.size)                         // 1
}

Если нужно передать изменяемую коллекцию «как список чего угодно» - передавайте List, а не MutableList.

Массивы инвариантны в Kotlin и ковариантны в Java:

fun main() {
    val strings = arrayOf("a", "b")
    // val anys: Array<Any> = strings        // ошибка компиляции
    println(strings.size)                     // 2
}

В Java String[] является Object[], поэтому Object[] o = new String[1]; o[0] = 1; компилируется и бросает ArrayStoreException в рантайме. Kotlin закрыл эту дыру ценой того, что Array<Any> не принимает Array<String>. Для чтения используйте Array<out T>.

Вариантность не распространяется на вложенные параметры автоматически:

fun main() {
    val nested: List<List<String>> = listOf(listOf("a"))
    val widened: List<List<Any>> = nested      // работает: оба List ковариантны
    println(widened.size)                       // 1

    val maps: List<MutableList<String>> = listOf(mutableListOf("a"))
    // val wide: List<MutableList<Any>> = maps  // не работает: MutableList инвариантен
    println(maps.size)                           // 1
}

in и out легко перепутать направление. Мнемоника: out - значение выходит из объекта (producer, можно только читать), in - значение входит в объект (consumer, можно только писать). Если нужны обе операции, вариантность невозможна.

Map<K, out V> ковариантен только по значению:

fun main() {
    val m: Map<String, Int> = mapOf("a" to 1)
    val widened: Map<String, Any> = m           // ок — V ковариантен
    // val keys: Map<Any, Int> = m               // ошибка — K инвариантен
    println(widened.size)                        // 1
}

Ключ инвариантен, потому что он используется на входе в get(key).

Ковариантный класс с var-полем типа T объявить нельзя - сеттер принимает T. Если нужно изменяемое состояние, либо инвариантность, либо приватный сеттер с осознанием последствий.

Далее: Проекции типов