Пост

Использование 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.

Следующим этапом планируется собрать такие метрики, как объем потребляемой памяти и время выполнения функций, чтобы оценить текущее положение дел и проверить, как оптимизации повлияют на эти метрики.

Авторский пост защищен лицензией CC BY 4.0 .

© Marche1os. Некоторые права защищены.

Использует тему Chirpy для Jekyll

Популярные теги