JavaRush /Курси /Kotlin SELF /Рефлексивний доступ до властивостей і функцій у Kotlin

Рефлексивний доступ до властивостей і функцій у Kotlin

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

1. Вступ

Зазвичай ми викликаємо код «статично»: пишемо expense.amount або expense.label() — і компілятор гарантує, що такі члени існують, а типи збігаються. Але інколи хочеться зробити щось на кшталт «інспектора»: вивести всі поля об’єкта, написати універсальну налагоджувальну команду для CLI, підтримати плагіни або серіалізацію «без знання типів заздалегідь». У таких ситуаціях рефлексія — як ліхтарик у темній кімнаті: не потрібна щодня, але інколи дуже виручає.

Важливо одразу змиритися з думкою: рефлексія майже завжди крихкіша. Часто ви шукаєте властивість за рядком "amount" або метод за рядком "label", і будь-яка описка перетворюється не на помилку компіляції, а на ситуацію «чому воно під час виконання не знаходиться?». Тож сьогодні ми вчитимемося не лише «як знайти й викликати», а й як робити це передбачувано: із перевірками, зрозумілими резервними варіантами та нормальними повідомленнями про помилки.

2. Підключаємо kotlin-reflect

Якщо ви спробуєте використовувати «багату» рефлексію Kotlin (наприклад, memberProperties), то інколи натрапите на сюрприз: «імпорти є, а проєкт не збирається» або «під час виконання щось не знаходиться». Причина в тому, що повноцінна рефлексія — це окрема історія, і у світі JVM вона часто постачається як окрема бібліотека/артефакт. Історично навіть у CLI-команд Kotlin були опції, пов’язані з тим, чи додавати kotlin-reflect у classpath, чи ні, наприклад -no-reflect.

На практиці для нас це означає просте правило: якщо ви хочете користуватися API з kotlin.reflect.full.*, будьте готові під’єднати залежність kotlin-reflect у Gradle. Kotlin-проєкт як такий містить різні частини стандартної екосистеми, і kotlin-reflect — одна з бібліотек у цьому «наборі».

Мініприклад того, як виглядає підключення (синтаксис Gradle Kotlin DSL):

dependencies {
    implementation(kotlin("reflect"))
}

А в коді зазвичай знадобляться імпорти приблизно такого плану:

import kotlin.reflect.full.memberFunctions
import kotlin.reflect.full.memberProperties

Якщо ви бачите, що memberProperties «не знаходиться» або IDEA свариться на імпорти, перша думка має бути не «рефлексія зламалася», а «схоже, не під’єднали kotlin-reflect».

3. Властивості через memberProperties

Отримуємо список властивостей

Коли ми вже вміємо отримувати KClass, наступне логічне питання: «Гаразд, а які в цього класу є властивості?». У Kotlin це робиться через memberProperties. Тут немає магії: по суті, ви просите у KClass список описів властивостей, а далі працюєте з ним як зі звичайною колекцією — фільтруєте, шукаєте за ім’ям, виводите.

Оскільки це саме колекція, ви майже автоматично починаєте використовувати операції на кшталт map { it.name } — і це добре: код виходить коротшим і читабельнішим. До речі, такі перетворення колекцій — базова частина стандартної бібліотеки Kotlin.

Накидаємо мінімальний приклад на нашій «практичній» мінітемі — облік витрат. Припустімо, у нас є модель:

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

Тепер отримаємо імена властивостей:

import kotlin.reflect.full.memberProperties

fun main() {
    val names = Expense::class.memberProperties.map { it.name }
    println(names) // [amount, id, title] (порядок може відрізнятися)
}

Тут важливо звикнути до думки: порядок властивостей у виводі — не контракт вашого застосунку. Це метадані, і покладатися на «title завжди буде першим» — погана ідея. Якщо порядок важливий, ви явно сортуєте його самі (наприклад, sorted()), а не сподіваєтеся, що «в Kotlin так заведено».

Читаємо значення властивості через get(receiver)

Список властивостей сам по собі — це лише «описи». Щоб отримати значення, потрібен об’єкт (receiver): конкретний Expense, у якого ми читаємо title, amount тощо. У рефлексії це виглядає так: у кожної властивості є метод читання, і ви викликаєте property.get(obj).

Почнемо з простого: виведемо всі властивості та їхні значення. Це дуже схоже на «debug-print», який ми хочемо вбудувати в наш CLI-застосунок.

import kotlin.reflect.full.memberProperties

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

fun main() {
    val e = Expense(id = 1, title = "Кава", amount = 3.5)

    for (p in Expense::class.memberProperties) {
        println("${p.name} = ${p.get(e)}")
        // id = 1
        // title = Кава
        // amount = 3.5
    }
}

Тут ключовий момент: p.get(e) — це буквально «прочитай значення цієї властивості в об’єкта e». Якщо ви забудете передати receiver, компілятор не дозволить — і це хороший натяк: «без об’єкта ви читаєте не значення, а лише ідею значення».

Пошук властивості за ім’ям

Щойно ви спробуєте зробити «доступ за рядком», одразу з’являється класичний ризик: властивості може не бути. А ще вона може називатися майже так само, але не так (наприклад, "ammount" замість "amount" — одна літера, і привіт, баг).

Тому, коли ми шукаємо за ім’ям, стиль «безпечного Kotlin» виглядає так: firstOrNull { ... }, потім ?., а далі — зрозумілий резервний варіант. Це рівно той патерн, який ми багато разів застосовували для рядків, колекцій і nullable-типів.

Зробимо утиліту readPropertyOrNull: вона намагається прочитати властивість за ім’ям і повертає Any?, а якщо не вийшло — повертає null.

import kotlin.reflect.full.memberProperties

fun readPropertyOrNull(obj: Any, propName: String): Any? {
    val p = obj::class.memberProperties.firstOrNull { it.name == propName }
    return p?.get(obj)
}

Перевіримо:

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

fun main() {
    val e = Expense(1, "Кава", 3.5)

    println(readPropertyOrNull(e, "title"))   // Кава
    println(readPropertyOrNull(e, "oops"))    // null
}

Якщо ви зараз подумали: «А чому б не зробити first { ... }?» — тому що first { ... } падає з винятком, якщо елемент не знайдено. А відсутність властивості за рядком — зазвичай очікувана ситуація, а не «кінець світу». Нам потрібна керована поведінка, а не раптове аварійне завершення.

4. Функції через memberFunctions

Виклик методу через call(...)

Доступ до функцій влаштований аналогічно: у KClass можна взяти memberFunctions, знайти потрібну за ім’ям і викликати. У Kotlin-рефлексії виклик робиться через call(...). Звучить просто, але є два практичні нюанси, через які новачки найчастіше спотикаються.

Перший нюанс: для member-функції потрібен receiver (об’єкт, на якому ви викликаєте метод). Тобто call(obj, ...), а не просто call(...).

Другий нюанс: у функції є параметри, і рефлексія не буде за вас вгадувати, скільки аргументів треба передати. Тому перед call корисно перевіряти: «а скільки параметрів очікується?».

Додаймо до Expense пару методів:

data class Expense(val id: Int, val title: String, val amount: Double) {
    fun label(): String = "$title: $amount"
    fun withTax(percent: Int): Double = amount * (1 + percent / 100.0)
}

Тепер знайдемо й викличемо label():

import kotlin.reflect.full.memberFunctions

fun main() {
    val e = Expense(1, "Кава", 3.5)

    val fn = e::class.memberFunctions.firstOrNull { it.name == "label" }
    val result = fn?.call(e)

    println(result) // Кава: 3.5
}

А тепер викличемо withTax(percent: Int):

import kotlin.reflect.full.memberFunctions

fun main() {
    val e = Expense(1, "Кава", 3.5)

    val fn = e::class.memberFunctions.firstOrNull { it.name == "withTax" }
    val result = fn?.call(e, 20)

    println(result) // 4.2
}

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

Безпечні перевірки перед викликом

Коли ви починаєте викликати методи через рефлексію, помилки стають… творчими. Наприклад, ви передали не той тип аргументу або не ту кількість аргументів. У статичному коді компілятор зупинив би вас ще до запуску. У рефлексії ви дізнаєтеся про це під час виконання — і часто у вигляді винятку, який виглядає як «якась внутрішня штука».

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

Зробимо утиліту: «виклич метод без аргументів і поверни рядок результату або зрозумілий текст помилки».

import kotlin.reflect.full.memberFunctions

fun safeCallNoArgs(obj: Any, methodName: String): String {
    val fn = obj::class.memberFunctions.firstOrNull { it.name == methodName }
        ?: return "NO_METHOD($methodName)"

    return try {
        fn.call(obj)?.toString() ?: "null"
    } catch (e: Exception) {
        "CALL_FAILED($methodName): ${e::class.simpleName}"
    }
}

Використаємо:

fun main() {
    val e = Expense(1, "Кава", 3.5)

    println(safeCallNoArgs(e, "label"))   // Кава: 3.5
    println(safeCallNoArgs(e, "oops"))    // NO_METHOD(oops)
}

Зверніть увагу на «політику» результату: ми не падаємо й не друкуємо кілометровий stack trace, а повертаємо короткий діагностичний текст. Для CLI-утиліти це часто краще, ніж «підірвати процес».

5. Приклад для CLI: команда inspect

Час використати рефлексію не «у вакуумі», а вбудувати її як маленьку фічу в те, що в нас уже є: консольний застосунок, який зберігає витрати в колекції й уміє виконувати команди. Нехай це буде команда inspect <id>, яка друкує властивості вибраної витрати. Так, по суті це «налагодження», але саме в таких місцях рефлексія часто й живе.

Зробимо мінімальний начерк «сховища»:

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

fun findExpenseById(items: List<Expense>, id: Int): Expense? =
    items.firstOrNull { it.id == id }

Тепер зробимо «вивід» об’єкта через властивості. Щоб зібрати гарний багаторядковий текст, зручно використовувати StringBuilder та його методи додавання рядків. У Kotlin багато таких методів реалізовано як extension-функції.

import kotlin.reflect.full.memberProperties

fun dumpObject(obj: Any): String {
    val sb = StringBuilder()
    sb.appendLine("тип = ${obj::class.simpleName}")

    for (p in obj::class.memberProperties) {
        sb.appendLine("${p.name} = ${p.get(obj)}")
    }
    return sb.toString()
}

І приклад використання в «командному» стилі:

fun main() {
    val expenses = listOf(
        Expense(1, "Кава", 3.5),
        Expense(2, "Обід", 12.0)
    )

    val target = findExpenseById(expenses, 2) ?: return
    print(dumpObject(target))
    // тип = Expense
    // amount = 12.0
    // id = 2
    // title = Обід
}

Цей шматочок легко вбудувати у ваш when (command) із попередніх днів: якщо команда inspect, парсимо id, шукаємо об’єкт і друкуємо dumpObject(...).

6. Пайплайн безпечного рефлексивного доступу

Перш ніж завершити, корисно зафіксувати в голові один стабільний пайплайн. Він майже завжди однаковий: спочатку ви знаходите метадані, потім перевіряєте, далі виконуєте, а тоді перетворюєте результат на текст/значення для подальшої логіки. Це схоже на «прочитав → підготував → розпарсив», тільки у світі рефлексії.

flowchart TD
    A["Є об’єкт obj: Any"] --> B["Потрібно: властивість або метод за ім’ям String"]
    B --> C["Пошук: firstOrNull { it.name == ... }"]
    C -->|не знайшли| D["Fallback: null / NO_METHOD / повідомлення"]
    C -->|знайшли| E["Перевірки: чи підходить за очікуваннями?"]
    E -->|ні| F["Fallback: BAD_SIGNATURE / повідомлення"]
    E -->|так| G["Виконання: get(obj) / call(obj, args...)"]
    G --> H["try/catch: перехоплення винятків"]
    H --> I["Нормалізуємо результат: toString / null / повідомлення"]

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

7. Типові помилки під час рефлексивного доступу до властивостей і функцій

Помилка № 1: забути про kotlin-reflect і дивуватися, що «чогось немає».
Найчастіший сценарій: ви бачите в прикладах kotlin.reflect.full.memberProperties, копіюєте імпорт, а IDE підсвічує червоним. Зазвичай причина не в тому, що Kotlin «зламався», а в тому, що повноцінна рефлексія живе в окремій бібліотеці. Історично це навіть відображалося в CLI-опціях, де можна було вимикати reflect-jar (-no-reflect). Тож перше, що перевіряємо, — залежності.

Помилка № 2: використовувати first { ... } там, де елемент може бути відсутнім.
У динамічному пошуку за рядком відсутність члена — нормальна ситуація. Якщо ви робите first { it.name == "oops" }, то за відсутності отримаєте виняток і аварійну поведінку. firstOrNull + ?: return або ?: "NO_METHOD" робить поведінку керованою та читабельною.

Помилка № 3: забувати про receiver під час get(...) і call(...).
У рефлексії ви працюєте не зі «значенням поля», а з «описом поля». Щоб перетворити опис на значення, потрібен об’єкт. Якщо намагатися мислити «ніби це звичайний доступ через крапку», легко загубити receiver і заплутатися. Тримайте в голові формулу: «опис + об’єкт → значення».

Помилка № 4: не обгортати рефлексивний виклик у try/catch.
Навіть якщо ви все знайшли правильно, викликаний метод може сам кинути виняток, або ви можете помилитися з типом аргументу. У статичному коді це часто ловиться раніше. У рефлексії — пізніше. Тому рефлексивні виклики майже завжди мають бути «вузько» обгорнуті в try/catch із зрозумілою деградацією: повідомлення, null, результат-помилка.

Помилка № 5: будувати бізнес-логіку на рядкових іменах членів.
Рефлексія чудово підходить для діагностики, налагодження, адмінських команд і «інфраструктурних» задач. Але якщо ви починаєте робити критичну бізнес-логіку на кшталт «якщо є метод "process", виклич його», ви створюєте систему, де коректність залежить від рядків, а не від типів. Зазвичай краще тримати список дозволених імен або взагалі віддати перевагу статичному контракту (інтерфейс/звичайний виклик), а рефлексію залишити як допоміжний інструмент.

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