Использование Inline Функций в Kotlin. Вред и польза
Какое-то время назад на рабочем проекте столкнулся с тем, что небольшой класс, в котором происходит сетевой вызов и состоит из ~50 строк кода на Kotlin, компилируется в ~2500 строк на Java и примерно в такое же количество строк байт-кода. Причина была в особенностях работы inline функций. Довольно часто на собеседованиях спрашивают про встраиваемые (inline) функции, просят рассказать об их особенностях и какие потенциальные проблемы с такими функциями могут возникнуть. Все вроде бы понимают, что с использованием inline функций следует быть осторожным и использовать их тогда, когда действительно нужно. И что неосторожное их использование может приводить к раздуванию артефакта.
В данной заметке хочу вкратце рассказать про встраиваемые функции и рассказать, как их неправильное использование на проекте привело к раздуванию байт-кода.
Что такое inline функции?
Inline функции в Kotlin объявляются с использованием ключевого слова inline. Когда компилятор Kotlin видит inline функцию, он внедряет её тело напрямую в место вызова, что устраняет необходимость в вызове этой функции через стек вызовов.
Пример inline функции:
1
2
3
4
5
inline fun repeatAction(times: Int, action: (Int) -> Unit) {
for (i in 0 until times) {
action(i)
}
}
Кроме этого, у них есть побочное свойство: известно, что в Java generic-типы стираются и в runtime неизвестно, с каким именно типом мы работаем. Благодаря тому, что inline функция встраивается в вызывающий код, мы можем узнать в runtime дженерик тип. Делается это через ключевое слово reified
. Стоит заметить, что нельзя применять reified
к обычной, не inline функции.
Пример inline функции с reified
1
2
3
4
5
6
7
8
9
10
inline fun <reified T> List<*>.firstInstanceOfOrNull(): T? {
forEach { item ->
// благодаря reified generic тип T известен в runtime и мы можем делать, например, такую проверку
if (item is T) {
return item
}
}
return null
}
Когда следует использовать inline функции
- Для небольших функций: inline функции хорошо подходят для небольших, простых функций. Например, утилитных, как было в двух примерах выше;
- Для lambda-выражений: inline функции отлично работают с lambda-выражениями, уменьшая количество объектов и улучшая производительность;
- Для часто вызываемых небольших функций;
- Если требуется знать generic тип в Runtime: но и тут стоит быть осторожным и придерживаться правил выше.
Если создавать внутри inline функций какие-то классы, то скорей всего эффект от использования встраиваемых функций будет резко отрицательным. Назначение inline функций - улучшить производительность за счет уменьшения стека вызовов функций. А вместо этого у нас на каждый вызов функции будет создаваться класс.
Следует помнить, компилятор сам может производить оптимизации и даже обычную, не inline, функцию встраивать в код вызова, тем самым снижая накладные расходы на вызов функции. Поэтому не нужно помечать все функции, как inline. Кроме этого, компилятор сам будет подсвечивать нецелесообразность использования модификатора inline, если в ней нет необходимости.
Особенности встраивания функции
Возвращаемся к проблеме, с которой столкнулся на рабочем проекте. В общих чертах код примерно был таким:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
inline suspend fun <reified T> get(params: Set<Any>): Result<T> {
TODO("Формирование GET запроса и вызов process")
}
inline suspend fun <reified T> put(params: Set<Any>): Result<T> {
TODO("Формирование PUT запроса и вызов process")
}
// ...
inline suspend fun <reified T> process(json: Json, params: Any): Result<T> = suspendCoroutine { continuation ->
val callback = object : Callback {
fun onResponse(call: Call, request: Request, response: Response) {
if (!response.isSuccessfull) {
continuation.resume(Result.failure(Error("")))
}
when {
null is T -> continuation.resume(Result.success(null as T))
T::class == String::class -> continuation.resume(Result.success("" as T))
else -> {
val parsed: T = json.decodeFromString(response.body)
continuation.resume(Result.success(parsed))
}
}
}
fun onFailure(error: Throwable) {
continuation.resumeWithException(error)
}
}
TODO("Выполнение запроса")
}
В такой реализации компилятор встраивает каждую функцию, включая process с парсингом ответа для каждого из http методов в код вызова. К чему это приводит:
- Инлайнятся 5 функций для конкретного вызова http запроса (для GET, PATCH, PUT, POST, DELETE) в класс вызова. Таких классов могут быть десятки и сотни у нас в проекте
- 5 раз создается функция process для каждого метода
- 5 раз создается класс, реализующий Callback
В результате получаем такой overhead.
Я решил поэкспериментировать и разнести inline функции по разным классам. Получилось уменьшить на 10% байт-код, количество Java кода уменьшилось на 10-40% в зависимости от класса.
Если пойти чуть дальше, разделить выполнение запроса и парсинг ответа (для выполнения запроса при этом убрать inline), то байт-код уменьшится уже на 20-40% в зависимости от класса, а Java код еще сильнее: на 20-80% (чем меньше вызовов функций, тем сильнее эффект).
Способ | Класс с 1 функцией | Класс с 14 функциями |
---|---|---|
Текущий вариант | ~2800 Java, ~1800 bytecode | ~5600 Java, ~20000 bytecode |
Разбивка по файлам | ~800 Java, ~1600 bytecode | ~5000 Java, ~17400 bytecode |
Разбивка по файлам и вынос парсера в отдельную функцию | ~360 Java, ~1400 bytecode | ~2800 Java, ~13600 bytecode |
Заключение
Проблема такого раздувания байткода актуальна в проекте уже несколько лет. Часто за использованием абстракций и синтаксического сахара можно не знать, какой объем кода генерируется, а значит не увидеть потенциальных проблем.
Поэтому разработчикам следует чаще проверять сгенерированный код и профилировать приложение, находить проблемные места. В случае с Kotlin JVM следует оценивать по крайней мере сгенерированный Java код, попытаться оценить, насколько он оптимален, не содержит ли проблем с производительностью.
Неосторожное использование встраиваемых функций приводило к большему байт-коду, чем он мог бы быть. Как минимум это увеличивает время сборки на как на локальной машине, так и на CI. На объем потребляемой памяти на агентах, ведь большое потребление может приводить к ошибкам OOM.
Следующим этапом планируется собрать такие метрики, как объем потребляемой памяти и время выполнения функций, чтобы оценить текущее положение дел и проверить, как оптимизации повлияют на эти метрики.