Введение: почему дженерики — это не страшно, а удобно
Представьте, что вы пишете класс «Коробка». В неё можно положить что угодно: книгу, яблоко, телефон. Но если вы сделаете её для 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 функциями. Для классов это неприменимо.