1. Type erasure на JVM
Когда вы только начинаете писать обобщённые функции, возникает очень человеческое желание: «Сейчас я напишу fun <T> ..., а внутри проверю x is T и всё будет красиво». Это похоже на ситуацию, когда у вас есть коробка с надписью «внутри печеньки», и вы думаете, что в любой момент можно открыть её и убедиться, что там действительно печеньки, а не носки.
Но Kotlin (точнее, Kotlin на JVM) внезапно говорит: «Нет, так нельзя». И это не вредность компилятора, а защита от ложной уверенности. Если бы такая проверка была разрешена, она часто работала бы «как будто», а потом очень неприятно ломалась бы в самых странных местах.
Причина этому — стирание типов (type erasure).
Что такое type erasure и что именно «стирается»
Стирание типов — это правило мира JVM: параметры типов (то, что в <...>) в большинстве случаев живут только на этапе компиляции, а во время выполнения программа оперирует «обобщённой формой». Например, List<Int> и List<String> в рантайме выглядят просто как List (условно «список чего-то»). Kotlin честно предупреждает: экземпляры generic-типов не хранят информацию о своих фактических аргументах типа — «уголки» стираются.
Почему так сделано? Исторически JVM проектировалась так, чтобы Java generics не ломали совместимость со старым кодом. Поэтому обобщения — это в первую очередь проверки и гарантии компилятора, а не «волшебные метки» внутри объектов во время выполнения.
Наглядно это можно представить так:
flowchart LR
A["Исходник: List⟨Int⟩, List⟨String⟩"] --> B["Компилятор: проверил типобезопасность"]
B --> C["Байткод/JVM: в рантайме просто List"]
C --> D["Проверить ⟨Int⟩vs ⟨String⟩уже нельзя"]
Почему запрещены проверки is List<Int> и is T, и что разрешено вместо этого
Теперь понятнее, почему Kotlin запрещает такие проверки, как x is List<Int>: в рантайме нельзя достоверно отличить «список Int» от «списка String», потому что эти аргументы типа стёрты. Kotlin прямо говорит: из‑за type erasure нет общего способа проверить, с какими аргументами типа был создан generic-экземпляр, и поэтому is-проверки вида ints is List<Int> или list is T (где T — параметр типа) запрещены.
Зато разрешён безопасный компромисс: проверка на star-projection, например is List<*>. Это означает «это список чего-то, тип элемента неизвестен», и такая проверка честная: она ничего не обещает про элементы, поэтому компилятор её разрешает.
Пример — «что можно»:
fun main() {
val x: Any = listOf(1, 2, 3)
if (x is List<*>) {
println("Это список размера ${x.size}") // Это список размера 3
println("Первый элемент: ${x[0]}") // Первый элемент: 1
}
}
Обратите внимание: после x is List<*> элементы воспринимаются как Any?. Это логично: раз мы не знаем тип элемента, безопаснее всего считать его «чем-то».
«Сырой» тип без <...>: что значит if (list is ArrayList)
Есть ещё один полезный приём, который сначала выглядит как чит-код: иногда можно проверять только «внешний» класс, не трогая аргументы типа. Например, ArrayList — это конкретная реализация списка, и проверить «это ArrayList или нет» можно, даже если вы не знаете <String> там или <Int>. Kotlin разрешает такие проверки, но с важной деталью: угловые скобки вы обязаны опустить.
Пример:
fun main() {
val names: MutableList<String> = arrayListOf("Ann", "Bob")
if (names is ArrayList) {
println("Да, это ArrayList. size=${names.size}") // Да, это ArrayList. size=2
}
}
Здесь Kotlin может сделать smart cast к ArrayList<String> (потому что на этапе компиляции он и так знает, что names — это MutableList<String>). Но сам runtime-check был про «сырой» ArrayList без <...>.
3. inline и reified: как легально делать is T, as? T и T::class
Нулевая точка: «а если мне всё-таки нужно знать тип?»
В этот момент появляется честный инженерный вопрос: «Если тип стирается, как вообще писать универсальные штуки, которые зависят от типа?» И тут есть два базовых подхода. Первый — передавать «токен типа» явно (в Java это часто Class<T>, в Kotlin — KClass<T>). Второй — воспользоваться тем, что Kotlin умеет: inline + reified.
В этой лекции мы фокусируемся именно на втором способе, потому что он выглядит как магия, но на самом деле это аккуратно оформленная «подстановка кода компилятором». Kotlin подчёркивает: обычные generic-функции не могут использовать параметр типа для проверок is и приведений, а исключение — inline-функции с reified-параметрами типа.
inline: что означает «вклеить код на место вызова»
Слово inline буквально означает: «компилятор подставит тело функции прямо в место вызова». Это особенно полезно для функций высшего порядка (где есть лямбды), чтобы не создавать лишние объекты, но нам сейчас важнее другое: если тело функции подставляется на место вызова, то конкретный тип T становится известен компилятору прямо там.
Нам не нужно лезть в байткод, но полезно держать в голове простую картинку:
flowchart TD
A["Вызов: foo<String>(x)"] --> B["Компилятор видит T=String"]
B --> C["Встраивает тело foo прямо сюда"]
C --> D["Внутри тела можно писать x is String"]
reified: как сделать T «видимым» внутри функции
reified — это модификатор параметра типа, который разрешён только у inline fun. Он означает: «тип T будет доступен в рантайм-проверках внутри этой функции», как будто T — обычный класс. Kotlin прямо показывает идею: с reified можно писать x is T и x as? T без рефлексии и без передачи Class<T> вручную.
Важно запомнить короткое правило: reified бывает только вместе с inline.
Почему «обычная» generic-функция не компилируется
Вот код-идея, который очень хочется написать, но он не будет компилироваться (и это нормально):
// Идея (не компилируется):
// fun <T> isOfType(x: Any): Boolean = x is T
Компилятор запрещает, потому что T «стёрт» в рантайме.
Та же идея, но правильно: inline + reified
inline fun <reified T> isOfType(x: Any): Boolean = x is T
fun main() {
val a: Any = "hello"
println(isOfType<String>(a)) // true
println(isOfType<Int>(a)) // false
}
Здесь происходит важный фокус без кроликов: при вызове isOfType<String>(a) компилятор подставляет проверку a is String. А при isOfType<Int>(a) — a is Int.
as? T: безопасное приведение типа в обобщённых утилитах
Проверка is хороша, когда вам нужен Boolean. Но в прикладном коде чаще хочется «попробовать привести тип и, если не получилось, спокойно жить дальше». Для этого идеально подходит безопасное приведение as?, которое возвращает null, если привести нельзя.
Сделаем универсальную функцию castOrNull:
inline fun <reified T> castOrNull(x: Any): T? = x as? T
fun main() {
val x: Any = 42
val s: String? = castOrNull<String>(x)
val n: Int? = castOrNull<Int>(x)
println("s=$s") // s=null
println("n=$n") // n=42
}
А теперь добавим человеческое сообщение через Elvis-оператор ?: (который вы уже много раз использовали в null-safety):
inline fun <reified T> castOrDefault(x: Any, default: T): T {
return (x as? T) ?: default
}
fun main() {
val x: Any = "text"
val num = castOrDefault<Int>(x, default = -1)
println(num) // -1
}
Здесь важно, что мы не падаем с исключением, а выбираем запасной план.
T::class: «токен» класса для сообщений, логов и небольших проверок
Иногда вам не столько нужно привести тип, сколько нужно понятно объяснить, что вы ожидали. Для этого полезен T::class: он возвращает объект типа KClass, то есть «описание класса как значение». Kotlin показывает, что с reified можно обращаться к T::class.
Сделаем маленькую функцию, которая печатает имя ожидаемого типа:
inline fun <reified T> expectedTypeName(): String {
return T::class.simpleName ?: "<unknown>"
}
fun main() {
println(expectedTypeName<String>()) // String
println(expectedTypeName<List<*>>()) // List
}
И теперь — более практичная версия: «требую тип, иначе ошибка с нормальным сообщением». Здесь мы используем error(...), который возвращает Nothing (вы это уже проходили, когда обсуждали Nothing и «функция не возвращается»).
inline fun <reified T> requireType(x: Any): T {
return (x as? T) ?: error("Ожидали ${T::class}, получили ${x::class}")
}
fun main() {
val x: Any = "hi"
val s: String = requireType(x)
println(s) // hi
}
Такой helper — очень удобная штука для внутренних проверок в приложении, когда вы уверены в логике, но хотите, чтобы в случае ошибки сообщение было не «ClassCastException где-то там», а «ожидали одно, получили другое».
Ограничения reified: почему List<String> всё ещё скользкий лёд
Сейчас может появиться опасная мысль: «Раз reified делает тип T видимым, значит я могу делать x is List<String>». Увы, нет: аргументы типов внутри generic-типов тоже стираются. Kotlin прямо предупреждает, что даже с reified ограничения сохраняются: если arg сам является generic-типом, его аргументы типа всё равно стёрты.
Это означает, что проверка «это список строк» напрямую всё равно проблемная. Но можно сделать честную, логическую проверку в два шага: сначала убедиться, что это List<*>, а затем проверить каждый элемент.
fun main() {
val x: Any = listOf(1, 2, 3)
if (x is List<*>) {
val allStrings = x.all { it is String }
println(allStrings) // false
}
}
Да, это не так красиво, как x is List<String>, зато это реально работает и не врёт вам в лицо.
4. Практика: «журнал событий» и выбор элементов по типу
Представим, что в нашем учебном консольном приложении (которое мы развиваем по курсу) мы начали вести простой журнал событий. Иногда так делают даже в маленьких программах: не ради архитектуры, а ради отладки. Проблема в том, что события могут быть разными типами, и в самом простом варианте их складывают в List<Any>.
Тут reified становится очень практичным: можно написать утилиту «вытащи из журнала все события определённого типа» — без ручных as и без кучи if.
Сначала опишем два типа событий:
data class ExpenseAdded(val amount: Double)
data class NoteAdded(val text: String)
Теперь напишем функцию «выбрать все элементы типа T из списка Any»:
inline fun <reified T> pickAllOfType(items: List<Any>): List<T> {
val result = mutableListOf<T>()
for (item in items) {
if (item is T) result.add(item)
}
return result
}
И применим:
fun main() {
val log: List<Any> = listOf(
ExpenseAdded(10.0),
NoteAdded("coffee"),
ExpenseAdded(25.5),
)
val expenses = pickAllOfType<ExpenseAdded>(log)
println(expenses) // [ExpenseAdded(amount=10.0), ExpenseAdded(amount=25.5)]
}
Это хороший пример, где reified реально убирает «шум» из кода: вы не пишете руками циклы с as? каждый раз и не рискуете случайно сделать небезопасный as.
5. Типичные ошибки при работе с type erasure и inline/reified
Ошибка №1: ожидать, что fun <T> сможет делать x is T без reified.
Это частая логическая ловушка: кажется, что раз T написан в коде, то он «существует» и во время выполнения. На JVM он существует в основном для компилятора, а в рантайме стирается, поэтому Kotlin и запрещает такую проверку. Выход здесь один: либо передавать «токен типа» явно, либо использовать inline fun <reified T> — и помнить, что reified без inline не бывает.
Ошибка №2: пытаться проверять вложенные аргументы типа вроде x is List<String> и считать это надёжным.
Даже если очень хочется одним выражением доказать, что «это список строк», runtime этого не знает, потому что String внутри List<...> стёрт. Правильный стиль — проверять внешний тип через is List<*>, а затем уже логикой проверять элементы. Kotlin отдельно предупреждает, что generic-аргументы остаются стёртыми, даже когда вы используете reified.
Ошибка №3: использовать as в generic-утилитах вместо as?, получая падение «из ниоткуда».
as — это «верь мне, я знаю тип», и при ошибке он падает исключением. В утилитах, которые обрабатывают Any или данные «неизвестной природы», почти всегда разумнее начинать с as? и возвращать T?, а дальше уже решать через ?: (дать дефолт) или через явную ошибку с понятным сообщением.
Ошибка №4: воспринимать inline как бесплатную оптимизацию и писать inline «на всякий случай».
inline — это инструмент, который меняет способ генерации кода (тело копируется в места вызова). Он полезен, когда вам нужен reified, или когда вы действительно выигрываете на лямбдах, но это не «ускоритель всего». Иногда inline раздувает размер байткода и усложняет отладку. В этой лекции мы используем inline не ради скорости, а ради возможности сделать T доступным для проверок.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ