Рекурсия и стек вызовов

функции≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Здесь countdown(3) печатает тройку и зовёт countdown(2), тот печатает двойку и зовёт countdown(1), и так до нуля. Важно, что каждый новый вызов получает меньший аргумент — иначе спуск никогда не дойдёт до дна.

Такую функцию называют рекурсивной, а количество вложенных вызовов — глубиной рекурсии. У примера выше глубина равна четырём: вызовы с аргументами 3, 2, 1 и 0.

У любой рабочей рекурсии две части. Базовый случай — вариант, который решается сразу, без новых вызовов. Шаг рекурсии — сведение задачи к такой же, но меньшей, плюс вызов себя. Классика жанра — факториал: n! — это n, умноженное на (n - 1)!.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

Проверить рекурсию в голове проще всего доверием: считайте, что factorial(4) уже умеет возвращать 24. Тогда factorial(5) — это 5 * 24. Не нужно прослеживать всю цепочку, достаточно убедиться, что шаг корректен и что он приближает к базовому случаю.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Обратите внимание на порядок строк «выход»: он ровно обратный порядку «входов». Вызов sum(3) начался первым, а закончился последним — всё это время его кадр лежал в основании стека и держал в себе значение n, равное 3.

Кадр — это небольшая запись, которую движок создаёт на каждый вызов: значения параметров, локальные переменные функции, ссылка на её окружение и адрес возврата — место в коде, куда продолжить выполнение после return.

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

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Память под стек выделена заранее и невелика. Если кадры всё прибывают, движок останавливает программу ошибкой — в Chrome это RangeError: Maximum call stack size exceeded, в Firefox InternalError: too much recursion: ни вид такой ошибки, ни её текст стандартом не описаны. Чаще всего причина банальна: забыт базовый случай или шаг не приближает к нему.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Точный предел глубины не описан в стандарте: он зависит от движка, размера кадров и настроек браузера. Ориентир — единицы или десятки тысяч вызовов. Полагаться на конкретное число нельзя, а вот держать глубину заведомо небольшой — можно и нужно. Само переполнение — обычная ошибка времени выполнения, со своим видом и объектом: про них статья «Виды ошибок и объект Error».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Хвостовой называют рекурсию, где вызов себя — самое последнее действие: возвращать некуда, значит кадр можно было бы переиспользовать. Стандарт языка такую оптимизацию описывает, но реально её включил, по сути, только движок Safari. В V8 и SpiderMonkey кадры создаются как обычно, так что расчёт на неё — путь к тому же RangeError.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Любую рекурсию можно переписать циклом, и наоборот. Выбор делают не по красоте, а по форме данных: линейный перебор — цикл, вложенная структура — рекурсия. Цикл к тому же не тратит стек и обычно быстрее: вызов функции стоит дороже перехода на следующую итерацию.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Тот же счёт рекурсией потребовал бы ста тысяч кадров и упал бы с переполнением. Зато sumDeep из раздела выше циклом пишется заметно уродливее — придётся вручную заводить массив-хранилище и складывать в него необработанные ветки, то есть строить свой стек в обычной памяти.

ДанныеИнструментПочему
Список, диапазон чиселЦиклглубина не ограничена стеком, кадры не плодятся
Дерево, вложенные массивы и объектыРекурсиякод повторяет форму данных и читается почти как условие задачи
Очень глубокое дерево, тысячи уровнейЦикл со своим массивом-стекомхранилище живёт в куче, а не в стеке движка

  • Рекурсивная функция решает задачу, вызывая себя на данных поменьше; шаг обязан приближать к базовому случаю.
  • Базовый случай — вариант, который решается сразу, без новых вызовов. Без него рекурсия обрывается ошибкой переполнения стека.
  • Каждый незавершённый вызов занимает кадр в стеке вызовов; кадры снимаются в обратном порядке — кто зашёл последним, выходит первым.
  • Рекурсия выигрывает там, где данные вложены сами в себя: деревья, вложенные массивы и объекты неизвестной глубины.
  • Для линейного перебора в тысячи шагов надёжнее цикл: у стека есть предел, и он не описан стандартом.

1/3Что напечатает этот код? function f(n) { if (n === 0) return; f(n - 1); console.log(n); } f(3);

2/3Что произойдёт, если в рекурсивной функции забыть базовый случай?

3/3В каком случае цикл предпочтительнее рекурсии?

Возведение в степень через рекурсию

Напишите функцию pow(x, n), которая возводит x в натуральную степень n. Циклы использовать нельзя — только рекурсия. Подсказка: x в степени n — это x, умноженный на x в степени n - 1.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Базовый случай — нулевая степень: любое число в нулевой степени равно единице, и дальше спускаться незачем. Шаг рекурсии уменьшает n на единицу, поэтому спуск гарантированно доходит до базы.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Глубина здесь равна n, так что для больших степеней такой вариант опасен: pow(2, 100000) переполнит стек. В боевом коде степень считают циклом или встроенным оператором **.

Глубина вложенности массива

Напишите функцию depth(list), которая возвращает максимальную глубину вложенности массива. Плоский массив — это глубина 1, массив с массивом внутри — 2, и так далее.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Базовый случай отдельной строкой не написан — он возникает сам: если вложенных массивов нет, цикл ни разу не вызовет depth, и функция вернёт 0 + 1.