Files
obsidian/Kotlin/Дженерики/Параметрический полиморфизм.md
T

16 KiB
Raw Blame History

title, description, tags
title description tags
Параметрический полиморфизм Обобщённые функции и классы, ограничения параметров типа и вывод типов
kotlin
generics

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

fun <T> firstOrDefault(items: List<T>, default: T): T =
    if (items.isEmpty()) default else items[0]

fun main() {
    println(firstOrDefault(listOf(1, 2), 0))           // 1
    println(firstOrDefault(emptyList(), "нет"))        // нет
    // println(firstOrDefault(listOf(1), "нет"))       // ошибка: T не может быть и Int, и String
}

<T> объявляется перед именем функции. Компилятор выводит его из аргументов.

Обобщённые классы

class Box5<T>(private val value: T) {
    fun get(): T = value
    fun <R> map(f: (T) -> R): Box5<R> = Box5(f(value))
    override fun toString() = "Box($value)"
}

fun main() {
    val intBox = Box5(42)
    println(intBox.get() + 1)                      // 43
    println(intBox.map { it.toString() + "!" })    // Box(42!)

    val explicit = Box5<String>("явно")
    println(explicit.get().length)                  // 4
}

Метод map объявляет свой параметр типа R - он не связан с T класса.

Несколько параметров:

class Pair2<A, B>(val first: A, val second: B) {
    fun swap(): Pair2<B, A> = Pair2(second, first)
    override fun toString() = "($first, $second)"
}

fun main() {
    val p = Pair2("ключ", 1)
    println(p)                // (ключ, 1)
    println(p.swap())         // (1, ключ)
}

Ограничения параметра типа

Без ограничения у T доступны только методы Any?:

fun <T> describe(value: T): String = "значение: $value"     // только toString

fun <T : Comparable<T>> maxOf3(a: T, b: T): T = if (a > b) a else b

fun <T : Number> sum2(items: List<T>): Double = items.sumOf { it.toDouble() }

fun main() {
    println(describe(listOf(1)))              // значение: [1]
    println(maxOf3(3, 7))                      // 7
    println(maxOf3("abc", "abd"))              // abd
    println(sum2(listOf(1, 2.5, 3L)))          // 6.5
}

Верхняя граница после двоеточия говорит компилятору, какие методы доступны.

По умолчанию граница - Any?, то есть T может быть nullable:

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

fun <T : Any> requireFirst(items: List<T>): T = items.first()

fun main() {
    println(firstOrNull2(listOf<String?>(null)))     // null
    println(requireFirst(listOf(1, 2)))               // 1
}

T : Any - способ запретить nullable-аргумент типа.

Несколько ограничений

interface Named4 { val name: String }
interface Aged { val age: Int }

fun <T> describe2(value: T): String where T : Named4, T : Aged =
    "${value.name}, ${value.age}"

data class Person2(override val name: String, override val age: Int) : Named4, Aged

fun main() {
    println(describe2(Person2("Иван", 30)))      // Иван, 30
}

Больше одного ограничения задаётся через where после сигнатуры.

Обобщённые функции-расширения

fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null

fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
    zipWithNext().all { (a, b) -> a <= b }

fun main() {
    println(listOf(1, 2, 3).secondOrNull())       // 2
    println(listOf(1).secondOrNull())              // null
    println(listOf(1, 2, 3).isSorted())            // true
    println(listOf(3, 1).isSorted())               // false
}

Это основной способ добавить обобщённую операцию к существующему типу. Подробнее: Функции-расширения

Вывод типов

fun main() {
    val a = listOf(1, 2)                  // List<Int>
    val b = listOf(1, "a")                // List<Any>
    val c = listOf<Number>(1, 2.0)        // List<Number> — указали явно
    val d = mutableListOf<String>()       // без аргументов тип не вывести
    val e = emptyMap<String, Int>()

    println("${a::class.simpleName} ${b.size} ${c.size} ${d.size} ${e.size}")
    // ArrayList 2 2 0 0
}

Когда аргументов нет или нужен более широкий тип, параметр указывают явно.

Вывод работает и через возвращаемое значение:

class Box7<T>(private val value: T?) {
    fun get(): T? = value
}

fun <T> emptyBox(): Box7<T> = Box7(null)

fun main() {
    val box: Box7<String> = emptyBox()      // T выведен из типа переменной
    println(box.get())                       // null
}

Обобщённые интерфейсы

interface Repository6<T, ID> {
    fun find(id: ID): T?
    fun save(item: T): ID
    fun all(): List<T>
}

data class User9(val id: Int, val name: String)

class InMemoryUserRepository : Repository6<User9, Int> {
    private val storage = mutableMapOf<Int, User9>()

    override fun find(id: Int) = storage[id]
    override fun save(item: User9): Int { storage[item.id] = item; return item.id }
    override fun all() = storage.values.toList()
}

fun main() {
    val repo = InMemoryUserRepository()
    repo.save(User9(1, "Иван"))
    println(repo.find(1)?.name)      // Иван
    println(repo.find(2))             // null
    println(repo.all().size)          // 1
}

Реализация может зафиксировать параметры типа (как здесь) или оставить их обобщёнными:

interface Repository7<T, ID> {
    fun find(id: ID): T?
    fun save(item: T): ID
    fun all(): List<T>
}

data class User10(val id: Int, val name: String)

class InMemoryRepository<T : Any, ID : Any>(
    private val idOf: (T) -> ID,
) : Repository7<T, ID> {
    private val storage = mutableMapOf<ID, T>()

    override fun find(id: ID) = storage[id]
    override fun save(item: T): ID {
        val id = idOf(item)
        storage[id] = item
        return id
    }
    override fun all() = storage.values.toList()
}

fun main() {
    val repo = InMemoryRepository<User10, Int> { it.id }
    repo.save(User10(1, "Иван"))
    println(repo.find(1)?.name)      // Иван
}

Стирание типов

Параметры типа существуют только при компиляции. В рантайме их нет:

fun main() {
    val ints = listOf(1, 2)
    val strings = listOf("a", "b")

    println(ints.javaClass == strings.javaClass)     // true — один класс
    // println(ints is List<Int>)                     // ошибка: нельзя проверить
    println(ints is List<*>)                          // true — только «какой-то List»
}

Это наследие JVM: дженерики появились в Java 5 и реализованы стиранием ради совместимости. Следствия:

fun main() {
    // 1. Нельзя создать массив параметра типа напрямую
    // fun <T> makeArray(): Array<T> = arrayOf()     // ошибка

    // 2. Нельзя перегрузить по параметру типа
    // fun show(x: List<String>) = "строки"
    // fun show(x: List<Int>) = "числа"               // та же JVM-сигнатура

    // 3. Непроверяемый каст компилируется с предупреждением
    val any: Any = listOf(1, 2)
    @Suppress("UNCHECKED_CAST")
    val back = any as List<String>                    // не упадёт здесь
    try {
        println(back[0].length)                        // упадёт здесь
    } catch (e: ClassCastException) {
        println("ClassCastException при обращении")
    }
}

Обойти стирание в Kotlin можно для inline-функций через reified. Подробнее: Reified и inline

Параметры типа и значения по умолчанию

class Cache<K, V>(
    private val maxSize: Int = 100,
    private val storage: MutableMap<K, V> = LinkedHashMap(),
) {
    fun put(key: K, value: V) {
        if (storage.size >= maxSize) storage.remove(storage.keys.first())
        storage[key] = value
    }

    fun get(key: K): V? = storage[key]
    val size: Int get() = storage.size
}

fun main() {
    val cache = Cache<String, Int>(maxSize = 2)
    cache.put("a", 1)
    cache.put("b", 2)
    cache.put("c", 3)             // "a" вытеснен
    println("${cache.get("a")} ${cache.get("c")} ${cache.size}")   // null 3 2
}

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

Java Kotlin
<T> T first(List<T> x) fun <T> first(x: List<T>): T
<T extends Number> <T : Number>
<T extends A & B> where T : A, T : B
new Box<>(1) (ромб) Box(1) - вывод
List<?> List<*>
List<? extends T> List<out T>
List<? super T> List<in T>
стирание типов стирание типов (то же)
нет аналога reified в inline-функциях

Синтаксис почти совпадает - это сознательное решение, чтобы перенос кода был механическим. Два отличия: двоеточие вместо extends и where для нескольких границ.

Вариантность объявляется в месте определения. В Java List<? extends Number> пишется каждый раз в сигнатуре метода (use-site variance). В Kotlin List<out E> объявлено один раз в самом интерфейсе, поэтому List<String> является List<Any> автоматически:

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

fun main() {
    println(printAll(listOf("a", "b")))      // 2, без всяких ? extends
}

В Java пришлось бы объявить параметр как List<? extends Object>. Подробнее: Вариантность

reified решает главную проблему стирания. В Java чтобы узнать тип в рантайме, передают Class<T>:

<T> T parse(String json, Class<T> type) { ... }
parse(json, User.class);

В Kotlin inline-функция может объявить reified T и использовать T::class напрямую. Это убирает параметр-костыль из сигнатуры.

Массивы дженериков одинаково проблемны. new T[] запрещён в Java, arrayOf<T>() - в Kotlin. Обход - Array<Any?> с кастом или ArrayList.

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

[!warning] Подводные камни Типы стираются - проверить is List<Int> нельзя:

fun describe3(x: Any): String = when (x) {
    is List<*> -> "список из ${x.size}"       // только так
    is Map<*, *> -> "карта"
    else -> "другое"
}

fun main() {
    println(describe3(listOf(1, 2)))          // список из 2
    println(describe3(listOf("a")))            // список из 2? нет — список из 1
}

Отличить List<Int> от List<String> в рантайме невозможно.

Непроверяемый каст откладывает ошибку:

@Suppress("UNCHECKED_CAST")
fun <T> castAll(items: List<Any>): List<T> = items as List<T>

fun main() {
    val wrong: List<String> = castAll(listOf(1, 2))
    println(wrong.size)                        // 2 — каст «прошёл»
    try {
        println(wrong[0].length)
    } catch (e: ClassCastException) {
        println("упало только при использовании")
    }
}

ClassCastException возникает не в месте каста, а там, где значение используется - отлаживать это тяжело.

Параметр типа без ограничения почти бесполезен внутри функции. У T доступны только toString, equals, hashCode. Если нужны операции - нужна граница.

T по умолчанию nullable:

fun <T> process3(value: T): String = value.toString()

fun main() {
    println(process3(null))         // null — T выведен как Nothing?
}

Чтобы запретить это, нужно <T : Any>.

Перегрузка по параметру типа невозможна - после стирания сигнатуры совпадают. Обход - @JvmName:

@JvmName("sumInts")
fun sumAll(items: List<Int>) = items.sum()
fun sumAll(items: List<String>) = items.sumOf { it.length }

fun main() { println(sumAll(listOf("ab", "c"))) }     // 3

Массив параметра типа создать нельзя:

class Buffer<T>(size: Int) {
    @Suppress("UNCHECKED_CAST")
    private val items = arrayOfNulls<Any?>(size) as Array<T?>
    operator fun set(i: Int, v: T) { items[i] = v }
    operator fun get(i: Int): T? = items[i]
}

fun main() {
    val b = Buffer<String>(2)
    b[0] = "a"
    println(b[0])          // a
}

Это стандартный обход, он безопасен, пока массив не утекает наружу как Array<T>.

Вывод типа может дать неожиданно широкий тип:

fun main() {
    val mixed = listOf(1, "a")              // List<Any>
    val nullable = listOf(1, null)           // List<Int?>
    println("${mixed.size} ${nullable.size}")   // 2 2
    // mixed[0] + 1                           // ошибка: Any не число
}

Ограничение T : Comparable<T> не покрывает смешанные типы. maxOf3(1, 2.0) не скомпилируется, потому что Int и Double не сравнимы между собой.

Обобщённое исключение объявить нельзя:

// class MyException<T> : Exception()       // ошибка: generic class cannot extend Throwable
class MyException(val payload: Any?) : Exception()

fun main() { println(MyException(1).payload) }     // 1

Причина - стирание: catch не смог бы различить типы параметров.

Далее: Вариантность