Введение в Generics в Kotlin
Generics (обобщения) — одна из ключевых фишек языка Kotlin, которая позволяет писать гибкий, типобезопасный и переиспользуемый код. Если вы когда-нибудь писали List<String> или Map<Int, User>, вы уже использовали дженерики, даже не задумываясь. Но что стоит за этими угловыми скобками и как выжать из них максимум? В этой статье мы разберем, зачем нужны Generics в Kotlin, как их применять на практике и какие продвинутые фичи (variance, reified, where) помогут вам писать еще более чистый и безопасный код.
Эта статья предназначена для разработчиков, которые уже знакомы с основами Kotlin и хотят углубить свои знания. Мы рассмотрим всё: от базового синтаксиса до реальных примеров из production-кода.
Зачем нужны Generics? Проблема без них
Представьте, что вам нужно написать функцию, которая принимает список любых элементов и возвращает первый элемент. Без дженериков вы бы написали так:
fun getFirst(list: List<Any>): Any {
return list[0]
}
Теперь, если вы передадите List<String>, то получите Any на выходе. Чтобы работать с ним как со строкой, придется делать явное приведение типов:
val first = getFirst(listOf("Kotlin", "Java")) as String
first.length // Опасно! Может упасть ClassCastException
Это неудобно и небезопасно. Ошибка проявится только в рантайме. Generics решают эту проблему, сохраняя информацию о типе на этапе компиляции.
Основные преимущества Generics:
- Типобезопасность — компилятор проверяет типы на этапе компиляции, а не в рантайме.
- Устранение явных приведений — код становится чище и читаемее.
- Переиспользуемость — один класс или функция работают с разными типами.
Базовый синтаксис: классы, функции и ограничения
Обобщенные классы
Самый простой способ начать — объявить класс с одним или несколькими параметрами типа. Параметры типа указываются в угловых скобках <T> (буква может быть любой, но традиционно используют T, E, K, V):
class Box<T>(val value: T)
fun main() {
val stringBox = Box("Kotlin") // Тип выводится автоматически
val intBox = Box(42) // Box<Int>
println(stringBox.value.length) // Работает без приведения!
println(intBox.value + 1) // 43
}
Kotlin умеет выводить тип, поэтому Box<String> писать не обязательно — достаточно Box("Kotlin").
Обобщенные функции
Функции тоже могут быть обобщенными. Параметр типа указывается перед именем функции:
fun <T> getMiddleElement(list: List<T>): T? {
if (list.isEmpty()) return null
return list[list.size / 2]
}
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val middle = getMiddleElement(numbers) // Тип Int? выводится автоматически
println(middle) // 3
}
Ограничения (where / upper bound)
Иногда нужно ограничить, какие типы можно использовать в дженерике. Например, чтобы вызывать метод .compareTo(), тип должен реализовывать интерфейс Comparable:
fun <T : Comparable<T>> maxOfTwo(a: T, b: T): T {
return if (a >= b) a else b
}
fun main() {
println(maxOfTwo(10, 20)) // 20
println(maxOfTwo("A", "B")) // B
// maxOfTwo(10, "A") // Ошибка компиляции: типы не совпадают
}
Если нужно несколько ограничений, используйте ключевое слово where:
interface Printable {
fun print()
}
fun <T> process(item: T) where T : Comparable<T>, T : Printable {
if (item > something) item.print()
}
Вариативность (Variance): in, out и проекции
Одна из самых сложных тем в дженериках — вариативность. Она отвечает на вопрос: если Box<Cat> — это подтип Box<Animal>? В Kotlin ответ зависит от того, как объявлен параметр типа.
Ковариантность (out)
Если вы только читаете данные из класса (производите их), используйте out. Это делает класс ковариантным: Box<Cat> становится подтипом Box<Animal>.
class Producer<out T>(private val value: T) {
fun get(): T = value
}
fun main() {
val catProducer: Producer<Cat> = Producer(Cat())
val animalProducer: Producer<Animal> = catProducer // Корректно!
val animal: Animal = animalProducer.get()
}
Жизненный пример: List<T> в Kotlin — ковариантен (объявлен как List<out T>). Поэтому вы можете присвоить List<String> переменной типа List<Any>.
Контравариантность (in)
Если вы только записываете данные в класс (потребляете их), используйте in. Это делает класс контравариантным: Box<Animal> становится подтипом Box<Cat>.
class Consumer<in T> {
fun consume(value: T) {
println("Consumed: $value")
}
}
fun main() {
val animalConsumer: Consumer<Animal> = Consumer()
val catConsumer: Consumer<Cat> = animalConsumer // Корректно!
catConsumer.consume(Cat())
}
Жизненный пример: Comparable<T> — контравариантен (объявлен как Comparable<in T>). Если