JavaRush /Курси /Kotlin SELF /KSP замість runtime‑рефлексії: анотація → генерація → код...

KSP замість runtime‑рефлексії: анотація → генерація → код

Kotlin SELF
Рівень 61 , Лекція 2
Відкрита

1. Вступ

Якщо ви щойно звикли до думки «нічого собі, я можу знайти метод за іменем і викликати його», то цілком природно захотіти застосувати це всюди: автокоманди в CLI, автогенерація довідки, «підʼєднати плагіни», «обійтися без ручного реєстру функцій»… і так далі.

Проблема в тому, що runtime‑рефлексія — це як шукати потрібний предмет у кімнаті з вимкненим світлом: загалом можливо, але ви часто вдарятиметеся мізинцем об табуретку. Навіть якщо ви все написали акуратно, рефлексія майже завжди означає додаткову роботу в рантаймі: пошук, перевірки, пакування аргументів, оброблення помилок, складніше налагодження. До того ж ви неминуче починаєте завʼязуватися на рядки. А рядки, як відомо, не вміють «підсвічуватися червоним» в IDE, коли ви в них помилилися.

І тут зʼявляється гарне інженерне запитання: «А можна зробити так, щоб компʼютер виконав нудну роботу за нас — але ще до запуску програми?»

KSP: що це таке простими словами

KSP (Kotlin Symbol Processing) — це механізм, який дає змогу на етапі компіляції пройтися вашим Kotlin‑кодом, знайти потрібні елементи (класи, функції, властивості), зазвичай позначені анотаціями, і згенерувати новий Kotlin‑код (.kt-файли). Після генерації компілятор просто компілює ці файли разом із вашим проєктом — ніби ви написали їх вручну.

Тут важлива думка: KSP не «прискорює компіляцію заради компіляції» і не «підміняє Kotlin». Він додає окремий етап: виявили потрібні елементи → згенерували вихідні тексти → далі йде звичайна компіляція.

Якщо рефлексія схожа на ситуацію «ми запускаємо програму й уже в процесі питаємо: “Гей, обʼєкт, які в тебе є методи?”», то KSP — це «ми ще до запуску склали довідник: які методи є, а в рантаймі просто читаємо цей довідник».

2. Головний потік: «анотація → генерація → звичайний код»

Зараз буде важлива «картина світу». Сприймайте її як схему конвеєра: сировина на вході — готова деталь на виході.

Схема конвеєра

Нехай у нас є вихідні тексти проєкту, і ми хочемо, щоб частина коду «самопідʼєднувалася». Тоді загальний пайплайн виглядає приблизно так:

flowchart TD
    A["Ваші вихідні тексти (.kt)"] --> B["KSP: аналіз символів (класи/функції/анотації)"]
    B --> C["Згенеровані .kt файли (build/generated/...)"]
    C --> D["Звичайна компіляція Kotlin"]
    D --> E["Байткод / JAR"]
    E --> F["Запуск: звичайний Kotlin-код без рефлексії"]

Ключовий сенс: KSP робить роботу там, де компілятору «видно все», а рантайму «вже нічого шукати не треба».

Анотація — це просто мітка

Дуже поширена плутанина в новачків: «Я поставив анотацію — чому нічого не сталося?»

Тому що анотація сама по собі — це просто мітка. Вона починає «щось означати» лише тоді, коли є інструмент, який цю мітку читає й діє (у нашому випадку — KSP‑процесор/генератор).

Найпростіша анотація-маркер може виглядати так:

@Target(AnnotationTarget.FUNCTION)
annotation class CliCommand(val name: String)

Сама по собі вона не додасть команду у ваш застосунок. Але дасть генератору чіткий сигнал: «Оце — команда, оброби її».

4. Практичний приклад: CLI «вручну», «через рефлексію» і «через KSP»

Щоб KSP не залишився абстрактним словом зі світу збирання, привʼяжемо його до нашого навчального консольного застосунку. Припустімо, ми продовжуємо розвивати CLI‑застосунок (умовно назвемо його BudgetCli), де є команди на кшталт "add", "list", "help".

Реєстр команд вручну

Класичний «простий» спосіб — завести Map<String, (List<String>) -> Unit> і вручну реєструвати команди. Це працює, але виглядає як бухгалтерія: корисно, та щастя не додає.

typealias CommandHandler = (List<String>) -> Unit

val commands: Map<String, CommandHandler> = mapOf(
    "help" to ::cmdHelp,
    "list" to ::cmdList
)

Проблема тут не в рядках як таких, а в тому, що ви зобовʼязані памʼятати про реєстрацію. Додали cmdAdd() — не додали в Map — команда «ніби є», але «ніби нема».

Runtime‑рефлексія як авто‑сканер

Після лекції про рефлексію зʼявляється спокуса: «А давайте знайдемо всі функції з анотацією @CliCommand у рантаймі!»

Ідея звучить красиво, але в неї є ціна: у рантаймі треба шукати, фільтрувати, обробляти помилки. А якщо ви обираєте імʼя методу або анотації за рядком, то знову живете у світі рядків.

Ми тут не заглиблюємося в реалізацію такого сканера (це було на минулому занятті), але важливо зафіксувати: runtime‑рефлексія — це «автозбирання команд на місці», просто під час запуску.

KSP‑підхід: команди «збираються» до запуску

Тепер робимо те саме, але в стилі KSP: ми хочемо позначити функції команд анотацією, а далі — отримати згенерований реєстр.

Крок 1. Пишемо анотацію

@Target(AnnotationTarget.FUNCTION)
annotation class CliCommand(val name: String)

Крок 2. Позначаємо функції команд

Уявімо, що в нас є файл Commands.kt:

@CliCommand("help")
fun cmdHelp(args: List<String>) {
    println("Команди: help, list") // Команди: help, list
}

@CliCommand("list")
fun cmdList(args: List<String>) {
    println("Поки список порожній") // Поки список порожній
}

Крок 3. Що «умовно» генерує KSP

Важливий момент: ми не пишемо KSP‑процесор у цьому курсі. Але можемо розуміти, який код має вийти «на виході».

Наприклад, генератор може створити файл GeneratedCliRegistry.kt:

typealias CommandHandler = (List<String>) -> Unit

object GeneratedCliRegistry {
    val commands: Map<String, CommandHandler> = mapOf(
        "help" to ::cmdHelp,
        "list" to ::cmdList
    )
}

Зверніть увагу на приємну річ: ::cmdHelp — це звичайна callable reference, яку компілятор перевірить. Якщо ви перейменуєте cmdHelp, а генерація (або генератор) не відповідатиме новим іменам, ви отримаєте помилку компіляції, а не «команду не знайдено» в рантаймі. Це фундаментальна відмінність.

Крок 4. Використовуємо реєстр у main

fun runCommand(line: String) {
    val parts = line.trim().split(" ").filter { it.isNotBlank() }
    val name = parts.firstOrNull() ?: return
    val args = parts.drop(1)

    val handler = GeneratedCliRegistry.commands[name]
    handler?.invoke(args) ?: println("Невідома команда: $name")
}

І ось тут настає «момент істини»: у рантаймі ми більше не шукаємо функції через рефлексію. Ми просто дістали обробник із Map і викликали його. Швидко, передбачувано, типобезпечно (наскільки це можливо для Map<String, ...>).

5. Чому згенерований код — сильна ідея

У KSP легко «закохатися», бо він обіцяє автоматизацію без рантайм‑магії. Але краще відразу тримати в голові чесний баланс.

Плюси: швидкість і передбачуваність

Коли KSP генерує Kotlin‑код, далі працює звичайний компілятор: він перевіряє типи, видимість, сигнатури, існування функцій. Те, що в runtime‑рефлексії могло б «упасти на користувачеві», тут падає на етапі збирання — а це майже завжди дешевше й безпечніше.

Як приємний бонус ви можете генерувати не лише реєстри команд, а й довідку для help, таблиці серіалізації, мапінг полів, адаптери та інші нудні шматки, які людина зазвичай пише механічно.

Мінуси: збирання стає складнішим

KSP — це інструмент, який живе поруч із компілятором. Тому він чутливий до версій Kotlin, Gradle‑плагінів і оточення.

Це не означає «KSP поганий». Це означає: «KSP — частина екосистеми збирання», а збирання — теж частина продукту. Його потрібно підтримувати, обережно оновлювати й перевіряти.

6. Як обирати підхід і проєктувати генерацію

Дуже хочеться мати «універсальний молоток». Але в інженерії універсальний молоток швидко перетворюється на універсальну проблему.

Таблиця вибору

Задача Що краще Чому
Структура коду відома під час компіляції, потрібно «автозвʼязати» елементи (команди, мапінг, реєстри) KSP Можна згенерувати звичайний Kotlin‑код, отримати перевірку компілятора й швидкість у рантаймі
Структура невідома до запуску (наприклад, дані прийшли ззовні, плагіни підвантажуються за файлами) Runtime‑підходи (інколи рефлексія) Не можна заздалегідь згенерувати список того, чого ще немає
Потрібна проста логіка, 2–3 команди, проєкт навчальний «Вручну» Найдешевший за складністю варіант: менше магії збирання, простіше пояснити й підтримувати
Потрібні метадані про тип для діагностики/логів Міні‑рефлексія (::class) Іноді достатньо KClass.simpleName, і це не перетворюється на систему

Сенс таблиці не в тому, щоб «заборонити» щось. Сенс у тому, щоб розуміти: KSP — чудовий інструмент, коли ви справді виграєте від генерації. А якщо ви пишете один файл на 200 рядків, генератор може виявитися складнішим за саму задачу.

Чому генерація часто робить extension‑функції

Є популярний стиль кодогенерації: KSP генерує extension‑функції, щоб ваш код виглядав «нативно», ніби це частина API.

Наприклад, замість GeneratedCliRegistry.formatHelp() можна згенерувати:

fun String.toCommandName(): String = trim().lowercase()

Або для моделі:

data class Expense(val title: String, val amount: Int)

fun Expense.toLogLine(): String = "Expense(title=$title, amount=$amount)"

Чому це зручно? Тому що extension‑функції в Kotlin розвʼязуються статично, на етапі компіляції: компілятор обирає, яку extension‑функцію викликати, виходячи з оголошеного типу, а не «в рантаймі дивом». Це один із чинників, чому згенерований код зазвичай швидкий і передбачуваний.

Ще один плюс extension‑стилю: він допомагає не городити гігантські GeneratedUtil123 класи. Ви отримуєте невеликі функції поруч із тими типами, для яких вони призначені, і IDE робить вам автодоповнення, як зазвичай.

Як «мислити KSP‑ом»: контракт важливіший за здогадки

Коли люди вперше проєктують генерацію, вони часто хочуть «зробити розумно»: щоб генератор сам здогадався, сам виправив, сам придумав імʼя, сам обробив усі випадки життя. Зазвичай це закінчується тим, що генератор починає бути схожим на другий компілятор… тільки гірший (бо ваш).

Здоровий підхід до KSP — ставитися до нього як до суворого конвеєра: на вхід подаємо зрозумілі правила (контракт), а на виході отримуємо максимально передбачуваний код.

Наприклад, для наших CLI‑команд можна заздалегідь домовитися про контракт:

Текст команди задається в @CliCommand("name"), функція зобовʼязана мати сигнатуру (List<String>) -> Unit, а всі команди збираються в GeneratedCliRegistry.commands. Усе.

Жодних «а якщо функція без аргументів, то я її теж візьму», жодних «а якщо два однакові імені, то я оберу останню». Такі «добрі здогадки» зазвичай перетворюються на лотерею, яку потім доводиться налагоджувати.

7. Типові помилки під час використання ідеї KSP

Помилка №1: очікування, що анотація «сама спрацює».
Дуже легко поставити @CliCommand над функцією й чекати, що команда зʼявиться. Але анотація — це просто мітка в коді. Поки в проєкт не додано обробник (генератор), нічого не відбувається — і це нормально. У голові потрібно тримати модель: «анотація — вхідні дані для інструмента».

Помилка №2: спроба зробити генератор заміною архітектури.
Іноді люди починають генерувати все підряд: бізнес‑логіку, алгоритми, половину застосунку. Це швидко призводить до того, що проєкт перетворюється на «збирання навколо генерації», а не на «генерацію навколо проєкту». Хороший генератор зазвичай автоматизує нудні звʼязки: реєстрацію, прості адаптери, однотипний код. Якщо генератор ухвалює за вас змістові рішення — це тривожний сигнал.

Помилка №3: надто «розумні» правила й неявні пріоритети.
Щойно в генерації зʼявляються правила на кшталт «якщо є два варіанти — обери найвідповідніший», вам доведеться пояснювати, що означає «відповідний», і чому сьогодні обралося одне, а завтра — інше. На практиці краще, коли генератор або робить рівно те, що ви сказали, або падає зі зрозумілою помилкою й просить виправити вхідні дані.

Помилка №4: відсутність fail‑fast поведінки під час конфлікту.
Якщо дві функції позначені @CliCommand("list"), генератор має не «якось розрулити», а зупинити збирання й пояснити конфлікт. Інакше ви отримаєте ситуацію: застосунок запускається, команда працює «не та», а ви пів дня думаєте, що у вас баг у логіці, хоча насправді баг у реєстрації.

Помилка №5: залежність від версій та ігнорування того, що збирання — частина продукту.
KSP живе поруч із компілятором і Gradle‑інфраструктурою, тому він чутливий до оновлень. Це не страшилка, а реальність інструментів такого рівня: якщо проєкт використовує генерацію, «оновити Kotlin навмання» — це вже не дрібниця, а подія, що потребує уважності.

Помилка №6: генерація API, яким неможливо користуватися без читання вихідних текстів генератора.
Згенерований код — це теж API. Якщо ви генеруєте дивні імена, неочікувані класи й заплутані сигнатури, то користуватися цим буде складно навіть вам за місяць. Хороший стиль — генерувати мінімальний, читабельний Kotlin, який не соромно показати людині.

Помилка №7: віра в те, що «згенерований код не можна чіпати».
Його справді не можна правити руками (бо він перегенерується), але його потрібно вміти читати. Коли щось зламалося, найпрактичніша навичка — відкрити згенеровані .kt-файли й подивитися: «А що саме вийшло?» Це часто швидше за будь-які припущення й здогадки.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ