Привет, Хабр!

Сегодня мы продолжим наши изыскания на тему функционального программирования в разрезе EcmaScript, на спецификации которого основан JavaScript. В предыдущих статьях цикла были рассмотрены следующие темы:

  1. Чистые функции, лямбды, имутабельность
  2. Композиция, каррирование, частичное применение

Рекурсия


Как всегда, википедия помогает нам с поиском определения:
Реку?рсия — определение, описание, изображение какого-либо объекта или процесса внутри самого этого объекта или процесса, то есть ситуация, когда объект является частью самого себя. Термин «рекурсия» используется в различных специальных областях знаний — от лингвистики до логики, но наиболее применение находит в математике и информатике.
Применительно к программированию под рекурсией подразумевают процессы, которые вызывают сами себя в своём теле. Рекурсивная функция имеет несколько обязательных составляющих:

  • Терминальное условие — условие прекращения выполнения
  • Правило по которому осуществляется движение в глубь по рекурсии

Рассмотрим самый популярный пример рекурсии — вычисление факториала.

const factorial = (n) => {
  if (n === 0) {
    return 1;
  }

  return n * factorial(n - 1);
}

Выделим характерные составляющие рекурсивной функции. Терминальное условие

  if (n === 0) {
    return 1;
  }

и правило движения по рекурсии

return n * factorial(n - 1);

Важно осознавать, что рекурсия это не какая-то специфическая фича JS, а техника очень распространённая в программировании.

Рекурсивный и итеративный процессы


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

Рекурсивный процесс мы с вами уже видели:

const factorial = (n) => {
  if (n === 0) {
    return 1;
  }

  return n * factorial(n - 1);
}

Итеративное решение задачи о факториале выглядело бы так:

const factorial = (n) => {
  const iter = (counter, acc) => {
    if (counter === 1) {
      return acc;
    }
    return iter(counter - 1, counter * acc);
  };

  return iter(n, 1);
};

Оба этих варианта это рекурсия. В обоих решениях есть характерные для рекурсии черты: терминальное условие и правило движения по рекурсии. Давайте разберём их отличия.

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

Выглядит это примерно так:

factorial(3);        //для 6ти писать много, поэтому ограничусь факториалом 3ки
3 * factorial(2);       
3 * 2 * factorial(1);  
3 * 2 * 1;              
3 * 2;
6;

Как видите, основная идея рекурсивного процесса — откладывание вычисления до конца.
Такой процесс порождает изменяемое во времени состояние, которое хранится «где-то» снаружи текущего вызова функции.

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

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

Выглядит это так:

iter(3, 1); // iter(3 - 1, 1 * 3); //обратите внимание, что тут мы записали факториал 6ти, 
iter(2, 3); // iter(2 - 1, 2 * 3);//но количество строк меньше, чем в предыдущем примере для 3ки
iter(1, 6); // counter === 1, return 6
6;

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

Древовидная рекурсия


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

Чтобы лучше понять древовидную рекурсию разберём простой и популярный пример — числа Фибоначчи.

Чи?сла Фибона?ччи — элементы числовой последовательности 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, … (последовательность A000045 в OEIS), в которой первые два числа равны либо 1 и 1, либо 0 и 1, а каждое последующее число равно сумме двух предыдущих чисел. Названы в честь средневекового математика Леонардо Пизанского (известного как Фибоначчи).

Математически довольно просто сформулировать описание (а ведь декларативное программирование и есть описание) данной последовательности:

fib(n) = [
равно 0 если n = 0,//(1)
равно 1 если n = 1,//(2)
fib(n-1) + fib(n-2) во всех остальных случаях
]

Теперь давайте перейдём от математики к логическим рассуждениям(нам ведь нужно программную логику написать). Для вычисления fib(5) нам придётся вычислить fib(4) и fib(3). Для вычисления fib(4) нам придётся вычислить fib(3) и fib(2). Для вычисления fib(3) нам придётся вычислить fib(2) и так до тех пор пока мы не дойдём до известных значений (1) и (2) в нашей математической модели.

На какие мысли нас должны навести наши рассуждения? Очевидно, мы должны использовать рекурсию. Термальное условие можно сформулировать как n <= 1. Однако, вместо одной ветви рекурсии(как в предыдущих примерах) у нас будет две ветви: fib(n-1), fib(n-2).

const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));

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

Заключение


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

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


  1. dom1n1k
    27.12.2019 12:58

    1. Термальное (встречается в тексте 4 раза, то есть не опечатка) или всё же терминальное?
    2. Зачем реализовывать «итеративный» вариант через рекурсию?


    1. Alex_Shcherbackov Автор
      27.12.2019 13:54

      Да, правильно терминальное, но я, увы, привык говорить термальное и это отразилось в статье.

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


  1. hrie
    27.12.2019 13:54
    +1

    Про хвостовую рекурсию забыли рассказать )

    Термальное условие

    Разве не терминальное? Термальное — это же что-то курортное.


    1. Alex_Shcherbackov Автор
      27.12.2019 13:55

      Да, правильно терминальное, но я, увы, привык говорить термальное и это отразилось в статье.


    1. chapuza
      27.12.2019 18:31

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

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


      1. GlukKazan
        27.12.2019 20:35

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


        1. chapuza
          27.12.2019 20:37

          Я думал, первая буква дважды упомянутой аббревиатуры TCO как бы намекает на это :)


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


          1. GlukKazan
            28.12.2019 00:09

            Ну если вы посмотрите на код, прямо над той фразой которую прокомментировали у автора, то как раз и увидите ту самую хвостовую рекурсию:

            которую автор называет итеративным процессом
            const factorial = (n) => {
              const iter = (counter, acc) => {
                if (counter === 1) {
                  return acc;
                }
                return iter(counter - 1, counter * acc);
              };
            
              return iter(n, 1);
            };


            1. chapuza
              28.12.2019 08:21

              В JS нет TCO, так что код выше помрет на сто-с-чем-то итерации от переполнения стека.


              1. GlukKazan
                28.12.2019 09:52

                Ага, я знаю. А ещё я знаю, что не любую рекурсию можно выразить через хвостовую, так что с Фибоначчи можете не трудиться.


                1. chapuza
                  28.12.2019 10:25

                  Если вы думаете, что Фибоначчи не выразить через хвостовую рекурсию, то это не так.


                  defmodule Fib do
                    def calc(count), do: calc(1, 1, count - 1)
                  
                    def calc(_p, acc, 0), do: acc
                    def calc(p, acc, count), do: calc(acc, acc + p, count - 1)
                  
                    def test do
                      Enum.map(1..20, &calc/1) 
                    end
                  end
                  #? [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 
                  #   377, 610, 987, 1597, 2584, 4181, 6765, 10946]

                  Elixir поддерживает TCO, поэтому:


                  1_000_000 |> Fib.calc() |> to_string() |> String.length()
                  #? 208988


                  1. GlukKazan
                    28.12.2019 10:48

                    Я не знаком с Elixir, но похоже понял вашу мысль. Да, о таком варианте я не подумал, но не хотите же вы сказать, что любую рекурсию можно выразить через хвостовую? Ваше утверждение:

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


          1. GlukKazan
            28.12.2019 00:15

            Ну и кстати, было бы интересно посмотреть на ваш вариант хвостовой рекурсии для чисел Фибоначчи. Вы ведь достаточно шишек набили, чтобы автоматически получилось?