Эффективный Котлин: рассмотрите встроенный модификатор для функций высшего порядка

Вы могли заметить, что все функции обработки коллекции встроены. Вы когда-нибудь спрашивали себя, почему они определены таким образом? Вот, например, упрощенная функция filter из Kotlin stdlib:

inline fun <T> Iterable<T>.filter(predicate: (T)->Boolean): List<T>{
    val destination = ArrayList<T>()
    for (element in this) 
        if (predicate(element))
            destination.add(element)
    return destination
}

Насколько важен этот модификатор inline? Допустим, у нас есть 5 000 товаров, и нам нужно просуммировать цены тех, что были куплены. Мы можем сделать это просто:

users.filter { it.bought }.sumByDouble { it.price }

На моей машине на вычисления в среднем уходит 38 мс. Сколько было бы, если бы этой функции не было inline? 42 мс в среднем на моей машине. Убедитесь сами. Это не выглядит большим, но вы можете заметить эту разницу ~ 10% каждый раз, когда используете методы для обработки коллекции.

Гораздо большую разницу можно наблюдать, когда мы изменяем локальные переменные в лямбда-выражении. Сравните функции ниже:

inline fun repeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}

fun noinlineRepeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}

Вы могли заметить, что кроме имени, единственная разница в том, что у первого есть модификатор inline, а у второго - нет. Использование тоже такое же:

var a = 0
repeat(100_000_000) {
    a += 1
}
var b = 0
noinlineRepeat(100_000_000) {
    b += 1
}

Здесь у нас большая разница во времени выполнения. inlineRepeat закончили в среднем за 0,335 нс, в то время как noinlineRepeat потребовалось в среднем 153 980 484,884 нс. Это в 466 тысяч раз больше! Убедитесь сами.

Почему это так важно? Есть ли какие-либо затраты на повышение производительности? Когда мы действительно должны использовать встроенный модификатор? Это очень важные вопросы, и мы постараемся на них ответить. Хотя все должно начинаться с гораздо более простого вопроса: что делает встроенный модификатор?

Что делает встроенный модификатор?

Мы знаем, как обычно вызываются функции. Выполнение переходит в тело функции, вызывает все операторы и затем возвращается к тому месту, где была вызвана функция.

Хотя когда функция помечена модификатором inline, компилятор обрабатывает ее по-другому. Во время компиляции кода он заменяет такой вызов функции своим телом. print - это inline функция:

public inline fun print(message: Int) {
    System.out.print(message)
}

Когда мы определяем следующее main:

fun main(args: Array<String>) {
    print(2)
    print(2)
}

После компиляции это будет выглядеть так:

fun main(args: Array<String>) {
    System.out.print(2)
    System.out.print(2)
}

Есть небольшая разница, связанная с тем, что нам не нужно переходить на другую функцию и обратно. Хотя этот эффект незначительный. Вот почему у вас будет следующее предупреждение в IDEA IntelliJ при самостоятельном определении такой встроенной функции:

Почему IntelliJ предлагает использовать inline, когда у нас есть лямбда-параметры? Потому что, когда мы встраиваем тело функции, нам не нужно создавать лямбды из аргументов, и вместо этого мы можем встраивать их в вызовы. Этот вызов вышеупомянутой функции repeat:

repeat(100) { println("A") }

После компиляции будет выглядеть так:

for (index in 0 until 1000) {
    println("A")
}

Как видите, тело лямбда-выражения заменяет вызовы встроенной функции. Давайте посмотрим на другой пример. Использование этой filter функции:

val users2 = users.filter { it.bought }

Будет заменено на:

val destination = ArrayList<T>()
for (element in this) 
    if (predicate(element))
        destination.add(element)
val users2 = destination

Это важное улучшение. Это потому, что JVM наивно не поддерживает лямбда-выражения. Довольно сложно объяснить, как компилируются лямбда-выражения, но в целом есть два варианта:

  • Анонимный класс
  • Отдельный класс

Давайте посмотрим на это на примере. У нас есть следующее лямбда-выражение:

val lambda: ()->Unit = {
    // body
}

Может оказаться, что это анонимный класс JVM:

// Java
Function0 lambda = new Function0() {
   public Object invoke() {
      // code
   }
};

Или это может оказаться нормальный класс, определенный в отдельном файле:

// Java
// Additional class in separate file
public class TestInlineKt$lambda implements Function0 {
   public Object invoke() {
      // code
   }
}
// Usage
Function0 lambda = new TestInlineKt$lambda()

Второй вариант более быстрый и используется везде, где это возможно. Первый вариант (анонимный класс) необходим, когда нам нужно использовать локальные переменные.

Это ответ, почему у нас такая разница между repeat и noinlineRepeat, когда мы изменяем локальные переменные. Лямбда в не встроенной функции должна быть скомпилирована в анонимный класс. Это огромные затраты, потому что их создание и использование медленнее. Когда мы используем встроенную функцию, нам вообще не нужно создавать какой-либо дополнительный класс. Убедитесь сами. Скомпилируйте и декомпилируйте в Java этот код:

fun main(args: Array<String>) {
    var a = 0
    repeat(100_000_000) {
        a += 1
    }
    var b = 0
    noinlineRepeat(100_000_000) {
        b += 1
    }
}

Вы найдете что-то похожее на это:

// Java
public static final void main(@NotNull String[] args) {
   int a = 0;
   int times$iv = 100000000;
   int var3 = 0;

   for(int var4 = times$iv; var3 < var4; ++var3) {
      ++a;
   }

   final IntRef b = new IntRef();
   b.element = 0;
   noinlineRepeat(100000000, (Function1)(new Function1() {
      public Object invoke(Object var1) {
         ++b.element;
         return Unit.INSTANCE;
      }
   }));
}

В filter примере улучшение не так заметно, потому что лямбда-выражение в не встроенной версии скомпилировано в обычный класс. Его создание и использование происходит быстро, но все же есть затраты, поэтому разница составляет ~ 10%.

Обработка потока коллекции по сравнению с классическим способом

Встроенный модификатор - это ключевой элемент, делающий потоковую обработку коллекции столь же эффективной, как и классическую обработку, основанную на циклах. Он тестировался снова и снова, и всегда классическая обработка требует огромных затрат с точки зрения читаемости кода и небольшого улучшения производительности с точки зрения производительности. Например, под кодом:

return data.filter { filterLoad(it) }.map { mapLoad(it) }

Работает так же и имеет такое же время выполнения, как и этот:

val list = ArrayList<String>()
for (it in data) {
    if (filterLoad(it)) {
        val value = mapLoad(it)
        list.add(value)
    }
}
return list

Конкретные результаты эталонных измерений (код здесь):

Benchmark           (size) Mode  Cnt        Score    Error  Units
filterAndMap           10  avgt  200      561.249 ±      1  ns/op
filterAndMap         1000  avgt  200    29803.183 ±    127  ns/op
filterAndMap       100000  avgt  200  3859008.234 ±  50022  ns/op

filterAndMapManual     10  avgt  200      526.825 ±      1  ns/op
filterAndMapManual   1000  avgt  200    28420.161 ±     94  ns/op
filterAndMapManual 100000  avgt  200  3831213.798 ±  34858  ns/op

С программной точки зрения эти две функции почти равны. Хотя с точки зрения удобочитаемости первый вариант намного лучше. Вот почему мы всегда должны предпочесть использовать функции обработки интеллектуальной коллекции вместо того, чтобы реализовывать всю обработку самостоятельно. Также, если нам нужна какая-то другая функция обработки коллекции, отличная от stdlib, не стесняйтесь написать свою собственную функцию. Это, например, функция, которую я добавил в свой последний проект, когда мне нужно было переставить список списков:

fun <E> List<List<E>>.transpose(): List<List<E>> {
    if (isEmpty()) return this

    val width = first().size
    if (any { it.size != width }) {
        throw IllegalArgumentException("All nested lists must have the same size, but sizes were ${map { it.size }}")
    }

    return (0 until width).map { col ->
        (0 until size).map { row -> this[row][col] }
    }
}

Просто не забудьте написать несколько модульных тестов:

class TransposeTest {

    private val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6))

    @Test
    fun `Transposition of transposition is identity`() {
        Assert.assertEquals(list, list.transpose().transpose())
    }

    @Test
    fun `Simple transposition test`() {
        val transposed = listOf(listOf(1, 4), listOf(2, 5), listOf(3, 6))
        assertEquals(transposed, list.transpose())
    }
}

Стоимость встроенного модификатора

Inline не следует использовать слишком часто, потому что это также имеет свою цену. Скажем, мне очень нравится печать 2. Сначала я определил следующую функцию:

inline fun twoPrintTwo() {
    print(2)
    print(2)
}

Мне этого показалось мало, поэтому я добавил такую ​​функцию:

inline fun twoTwoPrintTwo() {
    twoPrintTwo()
    twoPrintTwo()
}

Все еще не удовлетворен. Я определил следующие функции:

inline fun twoTwoTwoPrintTwo() {
    twoTwoPrintTwo()
    twoTwoPrintTwo()
}

fun twoTwoTwoTwoPrintTwo() {
    twoTwoTwoPrintTwo()
    twoTwoTwoPrintTwo()
}

Затем я решил проверить, что у меня есть в скомпилированном коде, поэтому я скомпилировал его в байт-код JVM и декомпилировал в Java. twoTwoPrintTwo был уже довольно большим:

public static final void twoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
}

Но twoTwoTwoTwoPrintTwo было действительно страшно:

public static final void twoTwoTwoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print();
}

Это показывает основную проблему встроенных функций: код очень быстро растет, когда мы злоупотребляем им. На самом деле это причина, по которой IntelliJ выдает предупреждение, когда мы их используем, и улучшение маловероятно.

Различные аспекты использования встроенных модификаторов

Встроенный модификатор из-за своего характера меняет гораздо больше, чем то, что мы видели в этой статье. Это позволяет нелокальный возврат и овеществленный универсальный тип. У него также есть некоторые ограничения. Хотя это не связано с серией Effective Kotlin и это материал для отдельной статьи. Если хотите, чтобы я это написал, напишите об этом в Твиттере или в комментарии.

Когда вообще следует использовать встроенный модификатор?

Самый важный случай, когда мы используем модификатор inline, - это когда мы определяем утилитарные функции с функциями параметров. Коллекция или обработка строк (например, filter, map или joinToString) или просто автономные функции (например, repeat) являются прекрасным примером.

Вот почему модификатор inline - важная оптимизация для разработчиков библиотек. Они должны знать, как это работает, каковы его улучшения и затраты. Мы будем использовать модификатор inline в наших проектах, когда мы определяем наши собственные служебные функции с параметрами типа функции.

Когда у нас нет параметра типа функции, параметра повторного типа и нам не нужен нелокальный возврат, то нам, скорее всего, не следует использовать модификатор inline. Вот почему у нас будет предупреждение в Android Studio или IDEA IntelliJ.

Другие случаи более сложные, и нам нужно опираться на интуицию. Позже в этой серии мы увидим, что иногда неясно, какая оптимизация лучше. В таких случаях нам нужно опираться на измерения или чей-то опыт.

Эффективный Котлин

Это первая статья об Effective Kotlin. Когда увидим интерес, опубликуем следующие части. В Кот. Academy мы также работаем над книгой на эту тему:



Он будет охватывать гораздо более широкий круг тем и углубляться в каждую из них. Он также будет включать в себя лучшие практики, опубликованные командой Kotlin и Google, опыт членов команды Kotlin, с которой мы сотрудничаем, и темы, затронутые в серии Эффективная Java в Kotlin. Чтобы поддержать это и ускорить публикацию, воспользуйтесь этой ссылкой и подпишитесь.

Вам нужна мастерская Kotlin? Посетите наш сайт, чтобы узнать, что мы можем для вас сделать.

Чтобы быть в курсе отличных новостей о Kt. Academy, подписывайтесь на рассылку новостей, следите за Твиттером и следите за нами в среде.

Чтобы сослаться на меня в Twitter, используйте @MarcinMoskala.

Если вам это нравится, не забудьте хлопать. Обратите внимание: если вы удерживаете кнопку хлопка, вы можете оставить больше хлопков.

Хочу поблагодарить Илью Рыженкова за исправления и важные предложения.