Generic-и в Kotlin: как перестать бояться угловых скобок и писать чистый код

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

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

Начать курс

Введение: почему дженерики — это не страшно, а удобно

Представьте, что вы пишете класс «Коробка». В неё можно положить что угодно: книгу, яблоко, телефон. Но если вы сделаете её для Object, то при каждом доставании придётся делать as? Book и молиться, чтобы не упасть с ClassCastException. Знакомо?

Здесь на помощь приходят generics (дженерики) — мощный механизм типизации в Kotlin, который позволяет писать гибкий код, не жертвуя безопасностью типов. В этой статье мы разберём generics в Kotlin от простого к сложному: базовые классы, функции, variance (in/out), реифицированные типы и звёздные проекции. Вы узнаете, как сделать код чище, а компилятор — вашим главным союзником.

Статья подготовлена для образовательного портала pythonlib.ru — вашего проводника в мир современных языков программирования.

1. Основы: что такое generics в Kotlin?

Generics (обобщенные типы) позволяют параметризовать классы, интерфейсы и функции типом, который будет указан при использовании. Это избавляет от дублирования кода и даёт контроль над типами на этапе компиляции.

Взгляните на простейший пример:

class Box<T>(val item: T)

fun main() {
    val bookBox = Box<String>("Kotlin in Action")
    val intBox = Box<Int>(42)
    // Компилятор знает тип!
    val title: String = bookBox.item
}

Ключевые моменты:

  • <T> — параметр типа (может быть любая буква, но по соглашению T, R, E, K, V).
  • Тип фиксируется при создании объекта — никаких кастов в рантайме.
  • Дженерики работают только на этапе компиляции (type erasure), но Kotlin добавляет «реификацию» (об этом позже).

Generic-функции

Типизация в Kotlin работает и для функций. Например, функция, которая возвращает первый элемент списка:

fun <T> firstItem(list: List<T>): T? = list.firstOrNull()

fun main() {
    val numbers = listOf(1, 2, 3)
    val first: Int? = firstItem(numbers) // T = Int
    println(first) // 1
}

Обратите внимание: Kotlin умеет выводить тип из контекста — писать firstItem<Int>(numbers) не обязательно.

2. Variance: in и out — как не сломать типобезопасность

Самая сложная тема для новичков — variance (вариативность). Она отвечает на вопрос: может ли List<String> быть подтипом List<Any>?

В Java это решается wildcard'ами (? extends / ? super). В Kotlin — out и in.

out (ковариантность) — только читаем

Пометка out означает, что generic-тип может только «отдаваться» (возвращаться из методов). Пример — List<out T>.

interface Source<out T> {
    fun next(): T  // OK: T на выходе
    // fun add(item: T) // Ошибка! T на входе запрещён
}

fun demo(source: Source<String>) {
    val anySource: Source<Any> = source // out позволяет это
}

Почему это безопасно? Если мы можем только читать String из источника, то его можно считать источником Any.

in (контравариантность) — только пишем

in — наоборот: тип может быть только на входе. Классика — Comparable.

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(comp: Comparable<Number>) {
    val intComp: Comparable<Int> = comp // in позволяет это
}

Если Comparable<Number> умеет сравнивать любые числа, то он точно сравнит и Int.

in out (инвариантность) — по умолчанию

Если не указано ни in, ни out, то тип инвариантен. MutableList<String> не является подтипом MutableList<Any> — и это правильно, ведь мы можем как читать, так и писать.

3. Star-проекции: когда тип неизвестен

Иногда мы не знаем точный generic-параметр, но хотим работать с контейнером. Для этого существует star projection (звёздная проекция) — *.

fun printLength(list: List<*>) {
    println(list.size) // размер доступен всегда
    // val first: Any? = list[0] // но тип элемента — Any?
}

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

  • MutableList<*> = MutableList<out Any?> — можно читать, нельзя писать (кроме null).
  • Consumer<*> = Consumer<in Nothing> — нельзя вызывать с аргументом.

Star-проекции полезны, когда вы пишете универсальные утилиты или работаете с Java-библиотеками.

4. Reified generics: дженерики в рантайме

Главная боль Java/Kotlin — type erasure: во время выполнения программы информация о generic-типе стирается. Но Kotlin обходит это с помощью reified и inline функций.

inline fun <reified T> isA(value: Any): Boolean {
    return value is T  // работает! Без reified — ошибка
}

fun main() {
    println(isA<String>("Hello")) // true
    println(isA<Int>("Hello"))   // false
}

Как это работает? inline встраивает код функции в место вызова, а reified подставляет реальный тип. Без этого value is T было бы невозможно.

Ещё один крутой пример — получение класса через T::class:

inline fun <reified T> createInstance(): T? {
    return try {
        T::class.constructors.first().call()
    } catch (e: Exception) {
        null
    }
}

data class User(val name: String = "Default")
fun main() {
    val user = createInstance<User>()
    println(user) // User(name=Default)
}

Важно: reified работает только с inline функциями. Для классов это неприменимо.

5. Где применяются generics в реальных проектах?

Блоги

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