Files
obsidian/Kotlin/Дженерики/Проекции типов.md
T

16 KiB
Raw Blame History

title, description, tags
title description tags
Проекции типов Вариантность в месте использования, star-проекция и ограничения, которые она накладывает
kotlin
generics

Вариантность объявляется при определении класса. Но инвариантный класс изменить нельзя - ни свой MutableList, ни чужой из библиотеки. Для этого существуют проекции: вариантность в месте использования.

fun copy(from: MutableList<out Any>, to: MutableList<Any>) {
    for (item in from) to.add(item)
}

fun main() {
    val source = mutableListOf("a", "b")
    val target = mutableListOf<Any>(1)
    copy(source, target)
    println(target)        // [1, a, b]
}

Без out в сигнатуре MutableList<String> не подошёл бы под MutableList<Any>. С out компилятор принимает любой MutableList<T>, где T - подтип Any, но взамен запрещает использовать from на запись.

out-проекция

fun sumAll2(numbers: MutableList<out Number>): Double {
    // numbers.add(1)           // ошибка: add принимает Nothing
    return numbers.sumOf { it.toDouble() }
}

fun main() {
    println(sumAll2(mutableListOf(1, 2, 3)))         // 6.0
    println(sumAll2(mutableListOf(1.5, 2.5)))        // 4.0
}

Внутри функции MutableList<out Number> ведёт себя как List<Number>: читать можно, писать нельзя. Формально тип параметра add становится Nothing - значения такого типа не существует, поэтому вызвать его невозможно.

in-проекция

Зеркальный случай - нужно только писать:

fun fillWithOnes(target: MutableList<in Int>) {
    target.add(1)
    target.add(2)
    // val x: Int = target[0]        // ошибка: читается как Any?
    val any: Any? = target[0]
    println("первый: $any")
}

fun main() {
    val numbers = mutableListOf<Number>(0.5)
    fillWithOnes(numbers)
    println(numbers)                  // [0.5, 1, 2]

    val anys = mutableListOf<Any>("x")
    fillWithOnes(anys)
    println(anys)                      // [x, 1, 2]
}

MutableList<in Int> принимает MutableList<Int>, MutableList<Number>, MutableList<Any> - любой список, в который Int поместится. Чтение даёт Any?, потому что компилятор не знает фактический тип элементов.

Проекция Можно Нельзя Принимает
MutableList<out T> читать как T писать MutableList<подтип T>
MutableList<in T> писать T читать как T (только Any?) MutableList<супертип T>
MutableList<T> всё - только точно MutableList<T>

Это ровно PECS из Java: Producer - out, Consumer - in.

Копирование с обеими проекциями

fun <T> transfer(from: MutableList<out T>, to: MutableList<in T>) {
    for (item in from) to.add(item)
}

fun main() {
    val source = mutableListOf(1, 2, 3)
    val target = mutableListOf<Number>(0.0)
    transfer(source, target)
    println(target)        // [0.0, 1, 2, 3]
}

Здесь from - производитель, to - потребитель. Такая сигнатура максимально гибкая: подойдёт любая пара списков, где элементы источника помещаются в приёмник.

Star-проекция

Когда параметр типа не важен вообще, пишут *:

fun describeSize(collection: Collection<*>): String = "элементов: ${collection.size}"

fun main() {
    println(describeSize(listOf(1, 2)))                // элементов: 2
    println(describeSize(setOf("a")))                   // элементов: 1
    println(describeSize(mapOf("a" to 1).values))       // элементов: 1
}

* - это не «любой тип», а «неизвестный, но конкретный тип». Правила такие же, как у проекций, применённые сразу в обе стороны:

fun main() {
    val list: MutableList<*> = mutableListOf(1, 2)

    println(list.size)                 // 2 — не зависит от T
    val item: Any? = list[0]           // чтение даёт Any?
    println(item)                       // 1
    // list.add(3)                      // ошибка: параметр имеет тип Nothing
    list.removeAt(0)                    // не требует T — разрешено
    println(list)                       // [2]
}
Объявление * означает
Foo<out T : TUpper> чтение как TUpper
Foo<in T> запись как Nothing (невозможна)
Foo<T> чтение TUpper, запись Nothing
fun main() {
    val map: Map<*, *> = mapOf("a" to 1)
    println(map.size)                   // 1
    println(map.keys.first())            // a — тип Any?
    println(map.values.first())          // 1 — тип Any?

    val function: (Nothing) -> Any? = { "любая функция" }
    println(function::class.simpleName)             // null — у лямбды нет имени класса
}

Star-проекция и проверка типов

Главное применение * - проверки is, где из-за стирания типов иначе никак:

fun classify(value: Any): String = when (value) {
    is List<*> -> "список из ${value.size}"
    is Map<*, *> -> "карта из ${value.size}"
    is Set<*> -> "множество"
    is Array<*> -> "массив из ${value.size}"
    else -> "что-то ещё"
}

fun main() {
    println(classify(listOf(1, 2)))         // список из 2
    println(classify(mapOf("a" to 1)))      // карта из 1
    println(classify(arrayOf(1, 2, 3)))     // массив из 3
    println(classify(42))                    // что-то ещё
}

is List<Int> написать нельзя - компилятор откажется, потому что проверить это в рантайме невозможно. Подробнее о стирании: Параметрический полиморфизм

Безопасная работа с содержимым после такой проверки:

fun sumIfNumbers(value: Any): Double? {
    if (value !is List<*>) return null
    return value.filterIsInstance<Number>().sumOf { it.toDouble() }
}

fun main() {
    println(sumIfNumbers(listOf(1, 2.5)))            // 3.5
    println(sumIfNumbers(listOf(1, "не число")))     // 1.0 — лишнее отфильтровано
    println(sumIfNumbers("строка"))                   // null
}

filterIsInstance проверяет каждый элемент по отдельности - это единственный честный способ сузить тип элементов после стирания.

Когда проекция, когда параметр типа

fun printFirst(items: List<*>) = println(items.firstOrNull())

fun <T> firstOrNull3(items: List<T>): T? = items.firstOrNull()

fun main() {
    printFirst(listOf("a"))                   // a — но тип потерян
    val value: String? = firstOrNull3(listOf("a"))
    println(value?.length)                     // 1 — тип сохранён
}

Правило: если тип нужен в возвращаемом значении - объявляйте <T>. Проекция подходит, когда тип внутри функции действительно не используется.

fun <T> copyAll(from: List<T>, to: MutableList<in T>) {
    to.addAll(from)
}

fun countAll(collections: List<Collection<*>>): Int = collections.sumOf { it.size }

fun main() {
    val target = mutableListOf<Any>()
    copyAll(listOf(1, 2), target)
    println(target)                                            // [1, 2]
    println(countAll(listOf(listOf(1), setOf("a", "b"))))      // 3
}

Проекции в своих классах

class Container<T>(private val items: MutableList<T>) {
    fun add(item: T) { items.add(item) }
    fun get(index: Int): T = items[index]
    val size: Int get() = items.size

    fun addAllFrom(other: Container<out T>) {
        repeat(other.size) { items.add(other.get(it)) }
    }
}

fun main() {
    val numbers = Container<Number>(mutableListOf(0))
    val ints = Container(mutableListOf(1, 2))

    numbers.addAllFrom(ints)       // Container<Int> подошёл под Container<out Number>
    println(numbers.size)          // 3
    println(numbers.get(1))        // 1
}

Без out в addAllFrom пришлось бы либо делать класс ковариантным (невозможно - есть add), либо требовать точного совпадения типов.

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

Java Kotlin
List<? extends T> List<out T>
List<? super T> List<in T>
List<?> List<*>
Class<?> KClass<*>
wildcard нужен почти всегда проекция нужна редко
? extends на чтение, ? super на запись то же самое

Это тот же механизм, что wildcard в Java. MutableList<out Number> и List<? extends Number> означают одно и то же: неизвестный подтип, доступный только на чтение. Семантика, ограничения и правило PECS совпадают дословно.

Разница в том, как часто это нужно. В Java List инвариантен, поэтому wildcard приходится писать почти в каждой сигнатуре, принимающей коллекцию:

double sum(Collection<? extends Number> items) { ... }
void addAll(List<? super String> dest) { ... }

В Kotlin List<out E> уже ковариантен, и проекция нужна только для изменяемых коллекций и собственных инвариантных классов:

fun sum4(items: Collection<Number>) = items.sumOf { it.toDouble() }

fun main() { println(sum4(listOf(1, 2))) }     // 3.0

Практическое следствие: out/in в сигнатурах Kotlin встречаются на порядок реже, чем wildcard в Java. Если вы пишете проекцию - стоит сначала проверить, не подойдёт ли read-only List.

Синтаксис короче и симметричен объявлению. В Java extends/super в wildcard никак не связаны с объявлением класса. В Kotlin те же out/in, что и в declaration-site variance, что делает связь очевидной: проекция - это «применить вариантность здесь, раз её нет в классе».

List<*> и List<?> ведут себя одинаково, но * в Kotlin учитывает верхнюю границу параметра. Для class Box<T : Number> чтение из Box<*> даёт Number, а не Object:

class NumberBox<T : Number>(val value: T)

fun main() {
    val box: NumberBox<*> = NumberBox(42)
    val n: Number = box.value        // Number, а не Any?
    println(n.toDouble())             // 42.0
}

Java в этой ситуации тоже выводит границу, но менее последовательно - через capture conversion, которая не всегда видна в сообщениях об ошибках.

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

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

fun broken(items: MutableList<out Number>) {
    // items.add(1)          // ошибка даже для Int
    // items[0] = 1           // тоже ошибка
    items.removeAt(0)         // а это можно — не требует T
    println(items.size)
}

fun main() { broken(mutableListOf(1, 2)) }     // 1

Запрещены только методы, принимающие T. clear, removeAt, size работают - это иногда удивляет, потому что коллекция вроде бы «только для чтения».

in-проекция теряет тип при чтении:

fun readBack(items: MutableList<in String>) {
    items.add("новый")
    val value = items.last()       // Any?, не String
    println(value.toString().length)    // приходится приводить
}

fun main() { readBack(mutableListOf("a")) }      // 6

Проекция * не равна Any?:

fun main() {
    val stars: MutableList<*> = mutableListOf(1, 2)
    val anys: MutableList<Any?> = mutableListOf(1, 2)

    // stars.add(3)          // ошибка
    anys.add(3)               // работает
    println("${stars.size} ${anys.size}")      // 2 3
}

MutableList<Any?> - конкретный тип, в который можно класть что угодно. MutableList<*> - неизвестный тип, в который нельзя класть ничего.

Array<*> и Array<Any?> тоже разные - последний не примет Array<String>, потому что массивы инвариантны.

После is List<*> элементы имеют тип Any?:

fun process4(value: Any) {
    if (value is List<*>) {
        // value.sumOf { it }            // ошибка: it — Any?
        println(value.filterIsInstance<Int>().sum())
    }
}

fun main() { process4(listOf(1, 2, "x")) }      // 3

Умное приведение даёт только List<Any?>. Содержимое нужно проверять поэлементно.

is List<Int> не компилируется, а as List<Int> компилируется с предупреждением - и откладывает ClassCastException до момента использования:

fun main() {
    val any: Any = listOf("a", "b")
    @Suppress("UNCHECKED_CAST")
    val ints = any as List<Int>
    println(ints.size)                 // 2 — каст «прошёл»
    try {
        println(ints[0] + 1)
    } catch (e: ClassCastException) {
        println("упало при обращении, а не при касте")
    }
}

Проекцию легко применить там, где нужен параметр типа. fun first(items: List<*>): Any? теряет тип результата; fun <T> first(items: List<T>): T? его сохраняет. Проекция - для случаев, когда тип внутри функции не нужен совсем.

Проекция out на уже ковариантном типе бессмысленна. List<out String> компилируется, но ничего не добавляет к List<String>, потому что List уже объявлен как List<out E>. Компилятор выдаёт предупреждение о лишней проекции.

Внутри класса this не проецируется. Если метод принимает Container<out T>, то other.add(x) запрещён, но this.add(x) работает - проекция применена только к параметру.

Далее: Reified и inline