Иногда для решении задачи приходится использовать Рекурсию, в которой есть свои плюсы и минусы. Я столкнулся с проблемой переполнения стека.
Максимальная глубина рекурсии ограничена движком JavaScript. Точно можно рассчитывать на 10000 вложенных вызовов, некоторые интерпретаторы допускают и больше, но для большинства из них 100000 вызовов – за пределами возможностей. Существуют автоматические оптимизации, помогающие избежать переполнения стека вызовов («оптимизация хвостовой рекурсии»), но они ещё не поддерживаются везде и работают только для простых случаев.
Пример рекурсивной функции:
function sum(n) {
return n === 0 ? 0 : n + sum(n - 1)
}
sum(5) // 1 + 2 + 3 + 4 + 5 = 15
sum(100000) // Error: Maximum call stack size exceeded.
Хвостовая рекурсия позволяет оптимизировать вызовы компилятором и уже есть в стандарте ES6, но поддержка браузерами оставляет желать лучшего.
Пример хвостовой рекурсивной функции:
function sum(number, s = 0){
return number === 0 ? s : sum(number - 1, s + number)
}
Но, без поддержки браузерами мы столкнемся с той же проблемой — переполнения стека. Можем попробовать использовать вместе с Trampolining.
Напишем декоратор, который будет принимать измененную рекурсивную функцию(следующий шаг) и исполнять ее в цикле, без увеличения глубины вызовов.
function trampoline(fn) {
return function(...args) {
let result = fn.apply(fn.context, args)
while (typeof result === 'function') {
result = result()
}
return result
}
}
Теперь наша рекурсивная функция должна возвращать функцию, которая будет сразу исполняться декоратором. Таким способом, мы условно сделаем массив функций и исполним их по очереди. Но поскольку мы каждый раз возвращаем новую анонимную функцию, этот код будет работать несколько медленнее.
function sum(number, s = 0) {
return number === 0 ? s : () => sum(number - 1, s + number)
}
Используем:
const trampolineSum = trampoline(sum)
trampolineSum(100000) // 5000050000
Это моя первая статья. Я старался не пересказывать понятия и указывал ссылку на источники. Тема не уникальная и давно существует на англоязычных сайтах, к сожалению на русском не нашел.
Спасибо, за внимание. Хорошего дня :)
Update
Производительность:
#1 Рекурсия
function sum(n) {
return n === 0 ? 0 : n + sum(n - 1)
}
console.time("run")
sum(1000)
console.timeEnd("run")
[Debug] run: 0.281ms
[Debug] run: 0.305ms
[Debug] run: 0.315ms
[Debug] run: 0.319ms
[Debug] run: 0.231ms
[Debug] run: 0.255ms
[Debug] run: 0.334ms
[Debug] run: 0.370ms
[Debug] run: 0.274ms
[Debug] run: 0.260ms
run: 0.101806640625ms
run: 0.099853515625ms
run: 0.10205078125ms
run: 0.10302734375ms
run: 0.099853515625ms
run: 0.106201171875ms
run: 0.103759765625ms
run: 0.105224609375ms
run: 0.262939453125ms
run: 0.136962890625ms
run: 0.10107421875ms
run: 0.10302734375ms
#2 Итерация
function sum(n) {
let sum = 0
for (let i = 1; i <= n; i++) {
sum += i
}
return sum
}
console.time("run")
sum(1000)
console.timeEnd("run")
[Debug] run: 0.552ms
[Debug] run: 0.502ms
[Debug] run: 0.527ms
[Debug] run: 0.434ms
[Debug] run: 0.462ms
[Debug] run: 0.511ms
[Debug] run: 0.528ms
[Debug] run: 0.549ms
[Debug] run: 0.475ms
[Debug] run: 0.530ms
[Debug] run: 0.494ms
run: 0.751953125ms
run: 0.4580078125ms
run: 0.678955078125ms
run: 0.424072265625ms
run: 0.505859375ms
run: 0.563720703125ms
run: 0.404052734375ms
run: 0.411865234375ms
run: 0.634033203125ms
run: 0.4169921875ms
run: 0.390869140625ms
run: 0.464111328125ms
#3 Хвостовая рекурсия и «батут»
function trampoline(fn) {
return function(...args) {
let result = fn.apply(fn.context, args)
while (typeof result === 'function') {
result = result()
}
return result
}
}
function sum(number, s = 0) {
return number === 0 ? s : () => sum(number - 1, s + number)
}
const trampolineSum = trampoline(sum)
console.time("run")
trampolineSum(1000)
console.timeEnd("run")
[Debug] run: 0.792ms
[Debug] run: 0.882ms
[Debug] run: 0.826ms
[Debug] run: 0.968ms
[Debug] run: 0.818ms
[Debug] run: 1.681ms
[Debug] run: 0.777ms
[Debug] run: 1.109ms
[Debug] run: 0.832ms
[Debug] run: 0.826ms
run: 0.989990234375ms
run: 0.567138671875ms
run: 0.56005859375ms
run: 1.0087890625ms
run: 0.5400390625ms
run: 0.578125ms
run: 0.541015625ms
run: 0.557861328125ms
run: 1.97607421875ms
run: 0.570068359375ms
run: 0.593017578125ms
run: 0.530029296875ms
run: 0.89794921875ms
run: 0.590087890625ms
#4 Хвостовая рекурсия и «батут» (большое число)
function trampoline(fn) {
return function(...args) {
let result = fn.apply(fn.context, args)
while (typeof result === 'function') {
result = result()
}
return result
}
}
function sum(number, s = 0) {
return number === 0 ? s : () => sum(number - 1, s + number)
}
const trampolineSum = trampoline(sum)
console.time("run")
trampolineSum(100000)
console.timeEnd("run")
[Debug] run: 24.564ms
[Debug] run: 25.313ms
[Debug] run: 23.262ms
[Debug] run: 24.848ms
[Debug] run: 23.909ms
[Debug] run: 24.248ms
[Debug] run: 32.416ms
[Debug] run: 24.090ms
[Debug] run: 23.986ms
run: 33.955078125ms
run: 40.907958984375ms
run: 37.693115234375ms
run: 28.929931640625ms
run: 30.7548828125ms
run: 29.720947265625ms
run: 40.8310546875ms
run: 31.5830078125ms
run: 30.712890625ms
run: 30.162841796875ms
run: 31.56103515625ms
По результатам мы видим, что наш декоратор хоть и позволяет избежать ошибки переполнения стека, но работает он медленнее чем рекурсивный и итеративный вариант. Так что данный способ стоит использовать только если вы не можете заменить рекурсию на итерацию или боитесь переполнения стека при выполнении вашей рекурсивной функции.
Комментарии (31)
Focushift
25.08.2019 13:17+1И? остановились на самом главном.
Почему декоратор? что он делает и т.п.?
Я вижу только увеличение глубины вызовов функции.MikeMoore Автор
25.08.2019 13:29Декоратор возвращает функцию, которая в цикле выполняет нашу функцию, без увеличения глубины вызовов как при рекурсии. В функцию передаются параметры и она сразу может быть вызвана вернув результат или другую функцию, которую мы будем вызывать на следующей итерации. Если кратно, мы просто в цикле выполняем якобы рекурсивную функцию, просто подставляя результат прошлой итерации.
CoolCmd
25.08.2019 13:30+1данные хранятся не в стеке, а в куче в виде неименованной функции.
на каждой итерации создается новая функция, так что производительность скорее всего будет так себе.
Gibrrr
25.08.2019 13:37+1Спасибо за статью, но хотелось бы чуть более развернутых объяснений, что в показанном подходе позволило избавиться от переполнения стека, какие недостатки есть у данного подхода, как он по скорости, другие альтернативы и т.п.
MikeMoore Автор
25.08.2019 13:41Спасибо за комментарии, вижу уже несколько недостатков статьи, поэтому постараюсь ближайшем времени дополнить информацию и провести тесты производительности.
python273
25.08.2019 13:40Только это не будет работать если нужно сделать больше одного рекурсивного вызова в функции
Как-то делал обход глубины стека через генераторы на python:
https://github.com/python273/precursionMingun
25.08.2019 13:47С концептуальной точки зрения будет, только нужно еще немного руками функцию доработать (а хотелось бы, конечно, чтобы это делал движок): просто вместо одного параметра-аккумулятора (
s = 0
в статье) функция должна принимать вектор и сама стать векторной, на каждой итерации вычисляя сразу пачку значений. Тогда для основного алгоритма разворачивания рекурсии в цикл у нас остается всего один хвостовой вызов
xGromMx
25.08.2019 13:44Умеющий искать да найдет
raganwald.com/2013/03/28/trampolines-in-javascript.html
blog.logrocket.com/using-trampolines-to-manage-large-recursive-loops-in-javascript-d8c9db095ae3
stackoverflow.com/a/27704484/3042847
marmelab.com/blog/2018/02/12/understanding-recursion.html
github.com/getify/Functional-Light-JS/blob/master/manuscript/ch8.md
sqrtt.pro/trampolines-in-javascript-ru
blog.csssr.ru/2018/09/06/recursion
purescript имеет TCO из коробки
Rsa97
Если мне не изменяет склероз, то хвостовую рекурсию практически всегда можно преобразовать в итерацию. Возможно, что запись функции при этом станет не такой красивой, но переполнения стека из-за рекурсии не будет.
Pand5461
Не изменяет, причём не практически всегда, а всегда.
Насчёт того, что запись станет некрасивой — вряд ли. Преобразование к хвостовой рекурсии само по себе "портит" внешний вид. Даже сравнить примеры из статьи — в первом очевидно, что делает рекурсия, во втором мысленно всё равно нужно развернуть её в цикл, чтобы понять, что происходит.
Zenitchik
Пример из статьи — очень упрощённый.
Практически интересен пример, когда функций несколько, и они могут вызывать друг друга, причём. Я обычно такое преобразовываю в возврат предыдущей функцией ссылки на следующую, а саму функцию выполняет цикл. ИМХО, настоящая хвостовая рекурсия — была бы красивее.