1. Type erasure на JVM
Коли ви тільки починаєте писати узагальнені функції, виникає дуже людське бажання: «Зараз я напишу fun <T> ..., а всередині перевірю x is T — і все буде гарно». Це схоже на ситуацію, коли у вас є коробка з написом «усередині печиво», і ви думаєте, що будь-якої миті можна відкрити її та переконатися: там і справді печиво, а не шкарпетки.
Але Kotlin (точніше, Kotlin на JVM) раптом каже: «Ні, так не можна». І це не шкідливість компілятора, а захист від хибної впевненості. Якби така перевірка була дозволена, вона часто працювала б «ніби», а потім дуже неприємно ламалася б в найнесподіваніших місцях.
Причина цього — стирання типів (type erasure).
Що таке type erasure і що саме «стирається»
Стирання типів — це правило світу JVM: параметри типів (те, що в <...>) у більшості випадків «живуть» лише на етапі компіляції, а під час виконання програма оперує «узагальненою формою». Наприклад, List<Int> і List<String> у рантаймі виглядають просто як List (умовно «список чогось»). Kotlin чесно попереджає: екземпляри узагальнених типів не зберігають інформації про свої фактичні аргументи типу — «кутові дужки» стираються.
Чому так зроблено? Історично 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 немає загального способу перевірити, з якими аргументами типу був створений узагальнений екземпляр. Тому 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-перевірка стосувалася «сирого» ArrayList без <...>.
3. inline і reified: як легально робити is T, as? T і T::class
Нульова точка: «а якщо мені все-таки потрібно знати тип?»
У цей момент виникає чесне інженерне запитання: «Якщо тип стирається, то як узагалі писати універсальні штуки, що залежать від типу?» І тут є два базові підходи. Перший — передавати «токен типу» явно (у Java це часто Class<T>, у Kotlin — KClass<T>). Другий — скористатися тим, що вміє Kotlin: inline + reified.
У цій лекції ми фокусуємося саме на другому способі. Він виглядає як магія, але насправді це акуратно оформлена «підстановка коду компілятором». Kotlin підкреслює: звичайні узагальнені функції не можуть використовувати параметр типу для перевірок 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
}
Такий помічник — дуже зручна річ для внутрішніх перевірок у застосунку, коли ви впевнені в логіці, але хочете, щоб у разі помилки повідомлення було не «ClassCastException десь там», а «очікували одне, отримали інше».
Обмеження reified: чому List<String> усе ще слизький лід
Зараз може зʼявитися небезпечна думка: «Раз reified робить тип T видимим, то я можу писати x is List<String>». На жаль, ні: аргументи типів усередині узагальнених типів теж стираються. Kotlin прямо попереджає, що навіть із reified обмеження нікуди не зникають: якщо arg сам є узагальненим типом, його аргументи типу все одно стерті.
Це означає, що перевірка «це список рядків» напряму все одно проблемна. Але можна зробити чесну, логічну перевірку у два кроки: спочатку переконатися, що це 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> і вважати це надійним.
Навіть якщо дуже хочеться одним виразом довести, що «це список рядків», рантайм цього не знає, бо String усередині List<...> стертий. Правильний стиль — перевіряти зовнішній тип через is List<*>, а потім уже окремою логікою перевіряти елементи. Kotlin окремо попереджає, що узагальнені аргументи залишаються стертими, навіть коли ви використовуєте reified.
Помилка №3: використовувати as в узагальнених утилітах замість as?, через що програма падає «нізвідки».
as — це «вірте мені, я знаю тип», і у разі помилки він завершується винятком. В утилітах, які обробляють Any або дані «невідомої природи», майже завжди розумніше починати з as? і повертати T?. А далі вже вирішувати через ?: (дати значення за замовчуванням) або через явну помилку зі зрозумілим повідомленням.
Помилка №4: сприймати inline як безкоштовну оптимізацію та писати inline «про всяк випадок».
inline — це інструмент, який змінює спосіб генерації коду (тіло копіюється в місця виклику). Він корисний, коли вам потрібен reified, або коли ви справді виграєте на лямбдах. Але це не «прискорювач усього». Іноді inline роздуває розмір байткода й ускладнює налагодження. У цій лекції ми використовуємо inline не заради швидкості, а заради можливості зробити T доступним для перевірок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ