1. Введение
Обычно мы вызываем код «статически»: пишем expense.amount или expense.label() — и компилятор гарантирует, что такие члены существуют и типы совпадают. Но иногда хочется сделать что-то вроде «инспектора»: вывести все поля объекта, написать универсальную отладочную команду в CLI, поддержать плагины или сериализацию «без знания типов заранее». В таких местах рефлексия — как фонарик в тёмной комнате: не обязателен всегда, но иногда очень выручает.
Важно заранее принять мысль: рефлексия почти всегда более хрупкая. Часто вы ищете свойство по строке "amount" или метод по строке "label", и любая опечатка превращается не в ошибку компиляции, а в ситуацию «почему оно в рантайме не находится?». Поэтому сегодня мы будем учиться не просто «как найти и вызвать», а как делать это предсказуемо: с проверками, понятными fallback-ами и нормальными сообщениями об ошибках.
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 = "Coffee", amount = 3.5)
for (p in Expense::class.memberProperties) {
println("${p.name} = ${p.get(e)}")
// id = 1
// title = Coffee
// amount = 3.5
}
}
Здесь ключевой момент: p.get(e) — именно «прочитай значение этого свойства у объекта e». Если вы забудете передать receiver, компилятор вам не позволит, и это хороший намёк: «без объекта ты читаешь не значение, а идею значения».
Поиск свойства по имени
Как только вы попробуете сделать «доступ по строке», у вас появится классический риск: свойства может не быть. А ещё оно может называться почти так же, но не так (например, "ammount" вместо "amount" — одна буква, и привет, баг).
Поэтому, когда мы ищем по имени, стиль «безопасного Kotlin» выглядит так: firstOrNull { ... }, затем ?., затем понятный fallback. Это ровно тот паттерн, который мы много раз применяли для строк, коллекций и 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, "Coffee", 3.5)
println(readPropertyOrNull(e, "title")) // Coffee
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, "Coffee", 3.5)
val fn = e::class.memberFunctions.firstOrNull { it.name == "label" }
val result = fn?.call(e)
println(result) // Coffee: 3.5
}
А теперь вызовем withTax(percent: Int):
import kotlin.reflect.full.memberFunctions
fun main() {
val e = Expense(1, "Coffee", 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, "Coffee", 3.5)
println(safeCallNoArgs(e, "label")) // Coffee: 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("type = ${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, "Coffee", 3.5),
Expense(2, "Lunch", 12.0)
)
val target = findExpenseById(expenses, 2) ?: return
print(dumpObject(target))
// type = Expense
// amount = 12.0
// id = 2
// title = Lunch
}
Этот кусочек легко встроить в ваш 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", вызови его», вы создаёте систему, где корректность зависит от строк, а не от типов. Обычно лучше держать список разрешённых имён, либо вообще предпочесть статический контракт (интерфейс/обычный вызов), а рефлексию оставить как вспомогательный инструмент.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ