Привет, Хабр!
Kotlin радует лаконичным синтаксисом и мощными фичами, но некоторые из них остаются недооценёнными. Сегодня поговорим про две джедайские техники Kotlin, о которых многие слышали, но не все используют в полной мере: это inline-функции и reified-типы.
Inline-функции: что это и зачем нужно?
Начнём с inline. В Kotlin ключевое слово inline можно поставить перед объявлением функции, обычно функции высшего порядка (то есть функции, принимающей другую функцию-лямбду в параметрах). Чтобы понять зачем это всё вспомним, как обычно работают лямбды на JVM.
Когда мы передаём в функцию лямбда-выражение, Kotlin создаёт объект функционального интерфейса и упаковывает в него наш код.
Это приводит к некоторым расходам: во-первых, выделяется новый объект в куче, во-вторых, при каждом вызове лямбды выполняется вызов метода invoke этого объекта. В Java начиная с 8 версии подобные вещи оптимизируются через invokedynamic, но Kotlin ради совместимости с Java 6/7 этого не делает. В результате, каждая переданная лямбда = один лишний объект + один лишний виртуальный вызов метода. А если лямбда замыкает переменные из внешней функции, то для каждого вызова создаётся новый объект (не просто singleton), и ещё сохраняются копии переменных, накладные расходы растут.
Inline-функции призваны устранить эти расходы. Когда мы помечаем функцию как inline, компилятор Kotlin встраивает её тело на место вызова. Это касается и самой функции, и передаваемых в неё лямбд. Происходит что-то вроде макроподстановки, только безопасно.
Представим, есть inline-функция:
inline fun <T> twoTimes(action: () -> T): T {
// выполняем лямбду два раза и возвращаем второй результат
action()
return action()
}И мы вызываем её так:
val result = twoTimes { heavyCalculation() }Без inline это работало бы примерно так, создаётся объект функции (()->T) для лямбды { heavyCalculation() }, затем twoTimes вызывается (обычный вызов), внутри неё два раза вызывается action.invoke(). С inline всё иначе, компилятор развернёт вызов, и итоговый байткод будет вести себя словно мы написали вручную:
var tmp: T
tmp = heavyCalculation()
tmp = heavyCalculation()
val result = tmpТо есть никаких объектов лямбд, никаких виртуальных вызовов, логика функции twoTimes просто вставлена вместо её вызова. В нашем примере выигрыш двойной, мы избавились от создания объекта функции и выполнили heavyCalculation() дважды напрямую. Конечно, inline-развертывание делает код больше, поэтому не стоит метить inline абсолютно всё. Обычно inline используют для небольших функций-обёрток, часто принимающих лямбды. Стандартная библиотека Kotlin наполовину состоит из таких: let, apply, use, всякие filter, map у коллекций, они объявлены как inline, благодаря чему мы платим ноль лишних затрат за красоту функционального стиля.
Ещё одна фича, которая стала возможна благодаря inline – нелокальные возвраты. Обычно return внутри лямбды прерывает только саму лямбду. Но если лямбда передана в inline-функцию, то return внутри неё может вернуть из окружающей функции. Это похоже на слово yield break в других языках или на выход из цикла: мы как бы выходим наружу из нескольких вложенных контекстов. Пример:
fun findZero(list: List<Int>): Boolean {
list.forEach {
if (it == 0) return true // возврат из функции findZero!
}
return false
}Этот код компилируется, потому что forEach объявлена как inline. return true внутри лямбды означает немедленно вернуть true из функции findZero. Если попробовать такое с обычной (не inline) функцией высшего порядка, компилятор не позволит. Так что inline даёт и такие приятные возможности управления потоком.
Реальность JVM: type erasure и ограничение дженериков
Теперь перейдём к reified-типам. Чтобы понять их ценность, нужен небольшой экскурс в то, как устроены generics на JVM. Java и Kotlin используют стирание типов (type erasure) для обобщённых типов. Это значит, что информация о параметрах типа не сохраняется в рантайме. Скажем, есть List<Int> и List<String>, во время выполнения это просто List и List, без различий. JVM не знает, что за T был у списка, и в байткоде нет этих данных.
Обычно это не беспокоит, потому что проверки типа работают на компиляции, а в рантайме в большинстве случаев конкретный тип не нужен. Но иногда возникает задача: узнать или использовать тип параметра во время выполнения. Например, хотим написать функцию fun <T> countElementsOfType(list: List<Any>): Int, которая будет считать, сколько в списке элементов типа T. На уровне Kotlin мы знаем T, но в рантайме у каждого элемента свой конкретный класс, а информацию, что мы имели в виду именно T, JVM от нас скрыла. Если попробовать сделать вот так:
fun <T> countElementsOfType(list: List<Any>): Int {
var count = 0
for (elem in list) {
if (elem is T) { // ОШИБКА: тип T стерся
count++
}
}
return count
}Компилятор скажет, что невозможно проверить elem is T: тип T не известен во время выполнения. Примерно то же самое произойдёт, если попытаться сделать T::class или T::class.java, Kotlin не даст, ведь класса T не существует в runtime (не считая случаев, когда T ограничен конкретным классом, но сейчас речь про обобщённый параметр).
В старые добрые времена Java проблему решали через передачу явного описателя типа, например, добавить параметр clazz: Class<T> в функцию, и вызывать if (clazz.isInstance(elem)). Либо использовать рефлексию. Решение рабочее, но топорное, каждый раз прокидывать Class<T> или использовать громоздкий isInstance. Хотелось бы как-то заставить Kotlin знать тип T на этапе выполнения...
Reified
Вот тут поможет reified. Оно означает “материализованный” или “превращённый в нечто реальное”. В контексте Kotlin просим компилятор сохранить информацию о типе T, чтобы её можно было использовать в runtime. Но есть одно условие, reified-тип возможен только в inline-функции. Логично, ведь если функция встраивается, то для каждого вызова компилятор подставит конкретный тип вместо T. Проще говоря, при inline у нас нет универсального T, вместо него каждый раз подставляется реальный тип вызова, и можно генерировать специализированный код под него.
Перепишем нашу задачу с подсчётом элементов типа T, используя inline и reified:
inline fun <reified T> countElementsOfType(list: List<Any>): Int {
var count = 0
for (elem in list) {
if (elem is T) { // теперь можно!
count++
}
}
return count
}Объявили функцию как inline и пометили параметр типа T ключевым словом reified. Внутри функции теперь разрешено писать elem is T, компилятор не ругается. Более того, на этапе компиляции каждый вызов countElementsOfType<SomeType>(list) превратится в обычный цикл с конкретным проверяемым типом: if (elem is SomeType). То есть, фактически, мы получаем специализированную версию функции для каждого используемого типа T без потери производительности. Никакой рефлексии, только прямой оператор is. Кстати, можно и elem as T использовать, и T::class , всё, что душе угодно, ведь в сгенерированном коде вместо T будет конкретный класс.
Обычные функции не могут иметь reified-парам��тров. Это ограничение сделано намеренно, без inline невозможно сделать подстановку реального типа, а Kotlin не поддерживает сохранение generic-типов в runtime иначе, чем через inline.
Приведу пример с подсчётом типов, весьма показательный. reified часто применяется, чтобы упростить API, где раньше приходилось передавать Class/KClass. Возьмём библиотеку для JSON-сериализации. В Java/Kotlin, чтобы парсер понял, объект какого класса создавать из JSON, обычно передают Class<T> или TypeToken. С reified можно сделать красивее:
inline fun <reified T> String.fromJson(): T {
val jsonStr = this
return gson.fromJson(jsonStr, T::class.java)
}Здесь gson условно наш JSON-парсер (можно представить Gson или Moshi). Мы вызываем jsonString.fromJson<MyData>() без явного указания класса MyData::class.java. Компилятор сам подставит нужный класс при вызове, и внутри fromJson() окажется конкретный T::class.java. Без reified такой фокус бы не прошёл, пришлось бы явно передавать класс. В Kotlin-экосистеме многие библиотеки используют этот приём. Например, популярный HTTP-клиент Retrofit позволяет получать ответ сразу в виде нужного типа, хотя под капотом Java этого не умеет – там тоже применены inline reified generic-функции.
Да и в стандартной библиотеке Kotlin есть отличный пример: функция filterIsInstance<T>(). Её задача отфильтровать из коллекции элементы заданного типа. Реализация упрощённо такая:
inline fun <reified R> Iterable<*>.filterIsInstance(): List<R> {
val destination = mutableListOf<R>()
for (element in this) {
if (element is R) destination.add(element)
}
return destination
}Мы видим знакомый паттерн: inline + reified, и внутри element is R. Благодаря этому, можно легко написать list.filterIsInstance<String>() и получить список только из строк, и при этом ни Class<R> не передавать, ни рефлексию не дергать, всё выполнится через обычные is проверки на нужный класс. Красота и удобство!
Немного под капотом
Стоит понять, что делает компилятор, когда встречает такую функцию. По сути, он генерирует отдельную версию функции для каждого типа T, который вы используете при вызове. Например, вы где-то в коде пишете:
printTypeName<Int>()
printTypeName<String>()Пусть printTypeName это inline fun <reified T> printTypeName() = println(T::class.simpleName). Компилятор увидит два вызова с разными типами и развернёт их, словно мы написали две конкретные функции:
// Развёрнутый код для printTypeName<Int>()
println(Int::class.simpleName)
// Развёрнутый код для printTypeName<String>()
println(String::class.simpleName)Поэтому в байткоде не будет самой универсальной функции printTypeName, а сразу два блока кода. Это и хорошо (нет затрат на вызов), и плохо (дубликация кода). Если таких вызовов много и функция большая, размер байткода растёт. Потому будьте разумны: не делайте inline громоздкие функции, иначе потеряете на размере больше, чем выиграете на производительности.
Ещё момент: reified работает только в Kotlin-коде. Если вызвать inline-функцию с reified из Java, ничего не выйдет, потому что для Java она как будто не существует (её нет как обычной функции).
Напишем небольшую утилиту с inline и reified
Для закрепления реализуем что-нибудь полезное, применяя оба подхода – и inline, и reified. Сделаем универсальный отладочный хелпер: функцию, которая принимает лямбду, замеряет время её выполнения и выводит имя типа возвращаемого значения. Благодаря inline накладных расходов не будет, а благодаря reified мы узнаем тип.
inline fun <reified R> measureExecution(block: () -> R): R {
val start = System.nanoTime()
val result: R = block() // выполняем лямбду
val duration = System.nanoTime() - start
println("Время выполнения: $duration нс, тип результата: ${R::class.simpleName}")
return result
}Мы объявили функцию measureExecution как inline, с reified-типом R. Она запускает переданный блок, измеряет время и печатает результат. Обратите внимание, R::class.simpleName, здесь мы пользуемся reified: получаем класс типа R и берем его имя. Если бы R не был reified, так сделать было бы нельзя.
Теперь воспользуемся нашей функцией:
fun computeSum(): Long {
// просто для примера посчитаем сумму первых 100 млн чисел
return (1..100_000_000).sumOf { it.toLong() }
}
// Замеряем время и узнаем тип результата
val total = measureExecution { computeSum() }Вывод может получиться, например:
Время выполнения: 243846300 нс, тип результата: LongТо есть примерно 0.24 секунды, результат типа Long. Конечно, measureExecution здесь больше для демонстрации, Kotlin имеет средства профилирования, а определять тип возвращаемого значения можно и во время компиляции. Но представьте, что наша функция делала что-то полезное с типом R: например, логировала его или выбирала разные алгоритмы в зависимости от категории типа. С reified это возможно.
Выводы
Важно применять эти возможности осознанно. Inline стоит использовать для небольших функций, особенно тех, что принимают лямбды, или когда нужны нелокальные возвраты. Reified ��огда действительно необходимо пробросить информацию о типе в runtime: проверки типа, создание экземпляров, получение KClass/Class и т.д. Если таких требований нет, обычные generics справятся и без reified.
Попрактикуйтесь на небольших утилитах, и очень скоро все это войдет в привычку.
Если хочется не просто пользоваться inline и reified, а в целом лучше понимать, что происходит под капотом Android-стека, логичный шаг — вывести это знание на уровень продакшн-архитектуры. В OTUS на курсе Android Developer. Professional действующие Android-разработчики углубляются в SDK и архитектуру, CI/CD, тестирование и современный стек (Dagger 2, RxJava, корутины, Gradle-оптимизации), параллельно собирая проект уровня реального приложения. Готовы к серьезному обучению? Пройдите вступительный тест.
А если хотите понять формат обучения — записывайтесь на бесплатные демо-уроки от преподавателей курса:
8 декабря: Собственный оператор Flow. Записаться
18 декабря: Создаем оптимизированный список на Compose. Записаться
