Недавно я прочитал статью об оптимизации хвостовой рекурсии в kotlin через ключевое слово tailrec. Мне стало интересно, как это реализовано под капотом и я решил провести небольшой эксперимент.

Tailrec и хвостовая рекурсия

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

Данный пример вычисления последовательности Фибоначчи очень хорошо демонстрирует принцип хвостовой рекурсии 

tailrec fun fibonacci(n: Int, a: BigInteger, b: BigInteger): BigInteger {
  return if (n == 0) b else fibonacci(n - 1, a + b, a)
}

Хвостовая рекурсия очень хорошо оптимизируется через итерацию в плоском цикле. Давайте посмотрим декомпилированный код, чтобы понять как tailrec оптимизирует нашу рекурсию

@NotNull
public final BigInteger fibonacci(
    int n, @NotNull BigInteger a, @NotNull BigInteger b
) {
    while(true) {
        if (n == 0) {
            return b;
        }

        n = n - 1;
        BigInteger var10001 = a.add(b);
        b = a;
        a = var10001;
    }
}

Как видите, все превратилось в обычный цикл и в коде не осталось никакого следа нашей рекурсии. Но это будет работать строго для случаев простой хвостовой рекурсии.

Рекурсия в деревьях

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

А вот ее простейшее решение через рекурсию.

fun ViewGroup.findViewRecursion(predicate: (View) -> Boolean): List<View> {
    val accumulator = mutableListOf<View>()
    if (predicate(this)) {
        accumulator.add(this)
    }
    children.forEach { child ->
        when {
            child is ViewGroup -> accumulator.addAll(
                child.findViewRecursion(predicate)
            )
            predicate(child) -> accumulator.add(child)
        }
    }
    return accumulator
}

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

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

fun ViewGroup.findViewRecursionOpt(predicate: (View) -> Boolean): List<View> {
    val accumulator = mutableListOf<View>()
    this.internalFindView(predicate, accumulator)
    return accumulator
 }

private fun ViewGroup.internalFindView(
    predicate: (View) -> Boolean,
    accumulator: MutableList<View> = mutableListOf()
) {
    if (predicate(this)) {
        accumulator.add(this)
    }
    children.forEach { child ->
        when {
            child is ViewGroup -> {
                child.internalFindView(predicate, accumulator)
            }
            predicate(child) -> accumulator.add(child)
        }
    }
}

Первое, что я попробовал сделать, это добавить ключевое слово tailrec для этой рекурсивной функции. Я был уверен, что это не сработает и я получу ошибку компиляции, но kotlin выдал мне всего лишь warning "A function is marked as tail-recursive but no tail calls are found". Лично я даже не сразу его заметил и многие разработчики могут посчитать, что удачно избавились от проблем рекурсии. 

tailrec fun ViewGroup.findViewRecursion(predicate: (View) -> Boolean): List<View> {
    val accumulator = mutableListOf<View>()
    if (predicate(this)) {
        accumulator.add(this)
    }
    children.forEach { child ->
        when {
            child is ViewGroup -> accumulator.addAll(
                child.findViewRecursion(predicate)
            )
            predicate(child) -> accumulator.add(child)
        }
    }
    return accumulator
}

Добавление tailrec к функции без хвостовой рекурсии скомпилируется, но не даст вам никакой оптимизации. Максимум что вы получите - это warning компилятора.

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

public final List findViewRecursion(ViewGroup $this, Function1 predicate) {
    List accumulator = new ArrayList<View>();
    if ((Boolean)predicate.invoke($this)) {
        accumulator.add($this);
    }

    Iterator iterator = ViewGroupKt.getChildren($this).iterator();

    while(iterator.hasNext()) {
        View child = (View)iterator.next();
        if (child instanceof ViewGroup) {
            accumulator.addAll(findViewRecursion(child, predicate));
        } else if ((Boolean)predicate.invoke(child)) {
            accumulator.add(child);
        }
    }

    return accumulator;
}

Честно говоря, я был обескуражен и пожалуй создам Issue в JetBrains, потому что на мой взгляд это состояние ошибки, а не warning. Но возможно у них будет какое то разумное объяснение.

Но как же нам избавиться от рекурсии в этом достаточно типовом способе обхода дерева?

Стандартный способ избавления от рекурсии через очередь 

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

Лучше всего этот принцип оптимизации демонстрирует сам код.

fun ViewGroup.findViewQueue(predicate: (View) -> Boolean): List<View> {
    val accumulator = mutableListOf<View>()
    val queue: Queue<View> = LinkedList()

    // add self as a first element of queue
    queue.add(this) 
    while (queue.isNotEmpty()) {
        // get and remove next item from queue
        val view = queue.poll() 

        if (predicate(view)) {
            accumulator.add(view)
        }

        // add to queue all childs for current view
        if (view is ViewGroup) { 
            view.children.forEach { queue.add(it) }
        }
    }

    return accumulator
}

Сначала мы добавляем в очередь текущий элемент. Затем в цикле, пока в нашей очереди есть элементы, мы забираем из очереди очередной элемент и обрабатываем его. Если этот элемент имеет дочерние элементы, то мы добавляем их в нашу очередь. 

Таким образом, мы легко обработаем все наши вложенные View без использования рекурсии. Этот способ является простым и изящным решением, позволяющим легко заменить практически любую рекурсию.

Способ избавления от рекурсии через итератор 

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

Я решил сделать универсальный, ленивый итератор для обхода иерархии в деревьях. Такой итератор можно будет легко встраивать в стандартные цепочки обработки sequence и циклы. Смысл в том, что мы передаем итератору корневой элемент и лямбду, которая для текущего элемента возвращает итератор его дочерних элементов. Таким образом, мы обходим иерархию дерева лениво и не имеем вложенной рекурсии.

/**
 * Lazy iterator for iterate by abstract hierarchy
 * @param rootIterator Iterator for root elements of hierarchy
 * @param getChildIterator function which get child iterator for current item
 * if current item has child return child iterator else return null
 *
 * Example of using for ViewGroup hierarchy
 * TreeIterator<View>(viewGroup.children.iterator) { (it as? ViewGroup)?.children?.iterator() }
 *
 * @author Сидоров Максим on 15.12.2023
 */
class TreeIterator<T>(
    rootIterator: Iterator<T>,
    private val getChildIterator: ((T) -> Iterator<T>?)
) : Iterator<T> {
    private val stack = mutableListOf<Iterator<T>>()

    private var iterator: Iterator<T> = rootIterator

    override fun hasNext(): Boolean {
        return iterator.hasNext()
    }

    override fun next(): T {
        val item = iterator.next()
        prepareNextIterator(item)
        return item
    }

    /**
     * calculate next iterator for [item]
     * if current item has child then get child iterator and save current iterator to stack
     * else if current iterator hasn't more elements then restore parent iterator from stack
     */
    private fun prepareNextIterator(item: T) {
        val childIterator = getChildIterator(item)
        if (childIterator != null && childIterator.hasNext()) {
            stack.add(iterator)
            iterator = childIterator
        } else {
            while (!iterator.hasNext() && stack.isNotEmpty()) {
                iterator = stack.last()
                stack.removeLast()
            }
        }
    }
}

Реализация нашего поиска View с помощью данного итератора становится тривиальной задачей. И такой итератор легко встраивать в любые ленивые цепочки обработки данных через sequence.

fun ViewGroup.findViewTreeIterator(predicate: (View) -> Boolean): Sequence<View> {
    val treeIterator = TreeIterator(children.iterator()) { view ->
        (view as? ViewGroup)?.children?.iterator() 
    } 

    return sequenceOf(this)
        .plus(treeIterator.asSequence())
        .filter { predicate(it) }
}

Способ избавления от рекурсии через sequence.yield 

Но существует и другой интересный способ избавления от рекурсии, о котором хочется здесь рассказать. В sequence существует механизм ленивых операторов yield, о которых я раньше не знал. Собственно, я обнаружил их, когда писал эту статью и изучал код стандартных расширений androidx.core.view для ViewGroup. 

public val ViewGroup.descendants: Sequence<View>
    get() = sequence {
        forEach { child ->
            yield(child)
            if (child is ViewGroup) {
                yieldAll(child.descendants)
            }
        }
    }

Данная функция создает ленивую последовательность, которая позволяет линейно итерироваться по всей иерархии дочерних View. Ключевыми здесь являются функции yield и yieldAll, которые лениво подставляют в общий итератор последовательности новые элементы. 

Таким образом, добавление дочерних элементов будет происходить лениво, а не сразу. То есть дочерние элементы следующего уровня иерархии будут добавлены в итератор последовательности ровно в тот момент, когда мы дойдем до него во время обработки нашей последовательности. И каждый раз добавляться будут элементы только одного уровня иерархии. То есть здесь не будет рекурсии, но для обработки каждого уровня иерархии будут создаваться новые обертки над итераторами (что не бесплатно).

Концептуально, данный подход очень похож на принцип работы моего итератора для деревьев, но тут это сделано на уровне поддержки ленивого оператора yield в sequence. 

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

И наконец, сама наша функция поиска View, которая в случае с yield тоже стала тривиальной.

fun ViewGroup.findViewYield(predicate: (View) -> Boolean): Sequence<View> {
    return sequenceOf(this)
        .plus(this.descendants)
        .filter { predicate(it) }
}

Результаты измерений

Мне стало интересно, какой из способов отказа от рекурсии даст наибольший выигрыш в производительности.

Я создал иерархию View с разной глубиной вложенности и написал тесты, которые производят поиск в этой иерархии всеми рассмотренными способами. Для замеров я использовал библиотеку kotlinx.benchmark

Признаться честно, результаты меня удивили.

function

performance (ops / sec)

depth 1000

depth 2000

depth 3000

depth 5000

findViewRecursion

4 951

1 144

stackOverflow

stackOverflow

findViewRecursionOpt

45 540

20 677

13 956

stackOverflow

findViewQueue

19 856

10 133

6 728

4 223

findViewTreeIterator

12 273

6 665

4 331

2 530

findViewYield

69

15

7

2

Как видите, обычная рекурсия с копированием списков дала ошибку уже на 3000 уровней вложенности. Оптимизированная рекурсия с аккумулятором дала ошибку на глубине в 5000 уровней.

Если говорить о скорости, то оптимизированная рекурсия работает в два раза быстрее очереди. То есть передача параметров через стек при вызове рекурсии работает значительно быстрее, чем добавление и удаление элемента из очереди. Если ваше дерево не предполагает большого числа уровней вложенности, то рекурсия лучший вариант по скорости.

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

Но самое большое разочарование, это оператор yield в sequence. Я предполагал, что скорость работы sequence и ленивое расширение последовательности через вызовы yield может быть низкой, но не ожидал что настолько. Оно работает в сотни раз медленней, чем мое решение через итератор, хотя принцип их работы похож. 

Возможно, я займусь изучением и оптимизацией yield в sequence и когда нибудь напишу об этом отдельную большую статью. 

Исходники тестов и всех функций: https://github.com/maxssoft/yield_recursion

Выводы

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

Более того, все попытки отказа от рекурсии приводят к снижению производительности, так как за все надо платить. Но в том случае, когда глубина вашего дерева может превышать максимальный размер стека, то лучшим способом будет оптимизация через очередь. Но наиболее гибким и удобным решением будет использование TreeIterator.

И еще один важный момент. Любые оптимизации необходимо замерять, так как очень часто бывает, что логичный и оптимизированный код (по мнению разработчика) начинает работать медленнее простого и не оптимизированного. То что кажется нам логичным и оптимальным не всегда логично и оптимально для компилятора.

Пожалуй, я создам issue в google, чтобы заменить реализацию обхода иерархии View в функции ViewGroup.descendants на мое решение с ленивым итератором. Это стандартная функция и ее используют многие разработчики, не догадываясь, что производительность обработки иерархии через эту функцию падает в сотни раз.

Issue в JetBrains (буду признателен за лайки в issue и в статье)

Комментарии (15)


  1. GospodinKolhoznik
    13.12.2023 06:24

    Добавление tailrec к функции без хвостовой рекурсии скомпилируется, но не даст вам никакой оптимизации.

    Тогда зачем вообще нужно это волшебное слово tailrec? Если компилятор может оптимизировать рекурсию, так пусть оптимизирует, ну а если не может, так не может. Зачем принуждают писать tailrec?


    1. aamonster
      13.12.2023 06:24

      Стало интересно, нагуглил две причины:

      1. В Kotlin и Java не заложена хвостовая рекурсия по умолчанию, т.к. она ломает стек фреймы (удобство отладки? Стоит ли того?)

      2. Якобы tailrec – в первую очередь для программиста, чтобы он был уверен, что тут цикл (тогда непонятно, почему не tail call – это warning, а не error)

      3. Кстати, ещё наткнулся на интересный вопрос – почему это модификатор функции, а не атрибут для вызова. Аннотация на уровне вызова подошла бы для вещей типа первой оптимизации наивной реализации qiucksort (когда для меньшей части массива мы делаем рекурсивный вызов, а для большей – итеративный), а также для случая взаимной рекурсии.


    1. MaxSidorov Автор
      13.12.2023 06:24

      Я и сам задаюсь этим вопросом. Посмотрим что ответят в JetBrains


    1. vba
      13.12.2023 06:24

      От части с вами согласен, но разработчику жизненно важно знать имела ли оптимизация место или нет. В данном случае компилятор должен работать как, например, в Scala:

      Добавление tailrec к функции без хвостовой рекурсии скомпилируется, Если оптимизация не возможна или жирное предупреждение, или ошибка компиляции.


      1. Anarchist
        13.12.2023 06:24

        Ошибка компиляции в Scala :) Любое нецклевое использование: если нет рекурсии вообще или есть нехвостовая.


  1. konsoletyper
    13.12.2023 06:24

    Вот никогда не понимал этого. Я ещё понимаю, во всяких OCaml, где никаких циклов не заложено by design, там хвостовая рекурсия - это единственный способ писать оптимальный код. Но Kotlin - нормальный императивный язык, нет вообще ни одной причины тот код, который легко и читабельно представим циклами, переписывать в хвостовую рекурсию. Но вот взяли и потратили силы на то, чтобы это реализовать. Чтобы что? Поставить галочку напротив "ещё одна фича из функциональных языков"? Ради читабельности? Не думаю, что большинству разработчиков удобнее спарсить глазами того же Фибоначчи в "декларативном" стиле, чем через старый добрый цикл. Кроме того, часто такие элегантные математические определения из учебника не ложатся в хвостовую рекурсию и нужно их переписать, так что они уже не будут выглядеть такими же элегантными.


    1. aamonster
      13.12.2023 06:24

      Иногда бывает, что рекурсия читается проще, чем если разворачивать её в цикл, и в этом случае хочется, чтобы компилятор соптимизировал, если есть возможность.

      А в функциональщине, кстати, любят вместо хвостовой рекурсии в явном виде использовать fold и тому подобное.


  1. Beholder
    13.12.2023 06:24

    От варианта с генератором sequence можно было бы ожидать более низкой производительности, так как там действительно работают suspend функции, но не настолько же. Всё как-то подозрительно.

    Вы опубликовали бы исходники бенчмарка.


    1. MaxSidorov Автор
      13.12.2023 06:24

      Я создаю следующую иерархию вью

      root
        view
        viewGroup
        view    |
                view
                viewGrpoup
                view     |
                        view
                        viewGroup
                        view

      и так на заданный уровень глубины (1000, 3000, 5000)

      Затем я выполняю тест, который находит все view, чей id делится нацело на 10 (то есть примерно 10% всех вьюшек). Идишники вьюшек генерируются автоинкрементом

      И вот код самого теста, но он тривиальный

          val rootView = createHierarchy(10000)
          
          @Benchmark
          fun yieldBenchmark(): List<View> {
              return rootView.findViewYield { it.id % 10 == 0  }.toList()
          }
      
          fun ViewGroup.findViewYield(predicate: (View) -> Boolean): Sequence<View> {
              return sequenceOf(this)
                  .plus(this.descendants)
                  .filter { predicate(it) }
          }    

      Аналогичные тесты и для всех остальных функций


      1. MaxSidorov Автор
        13.12.2023 06:24

        На выходных оформлю код в github, его надо еще причесать) и отправлю issue в JetBrains и возможно в Google и тогда скину ссылку на github в статью. Просто я статью написал за два дня и еще не успел все оформить как следует.


    1. MaxSidorov Автор
      13.12.2023 06:24

      Я опубликовал исходники тестов и всех функций: https://github.com/maxssoft/yield_recursion
      И обновил статью. Перепроверил все результаты. Yield работает в сотни раз медленнее всех остальных подходов.


  1. Spinoza0
    13.12.2023 06:24

    Любопытно было прочитать, спасибо


  1. auddu_k
    13.12.2023 06:24

    Классная статья, спасибо ???? Всегда интересны новые подходы к стандартным задачам.

    Есть вопрос -) Правильно ж я вижу, что в итераторе при обходе не учитывает root, мы сразу перескаетваем к деткам?


    1. MaxSidorov Автор
      13.12.2023 06:24

      Да, я тоже заметил это. На выходных поправлю и обновлю статью. Заодно выложу ссылку на исходники тестов и всех функций


  1. Kajfolom
    13.12.2023 06:24

    ...с мягким знаком) простите...