Почему батуты работают?


104

Я делал некоторый функциональный JavaScript. Я думал, что оптимизация Tail-Call была реализована, но, как оказалось, я ошибался. Таким образом, я должен был научить себя прыжкам на батуте . Немного почитав здесь и в других местах, я смог освоить основы и сконструировал свой первый батут:

/*not the fanciest, it's just meant to
reenforce that I know what I'm doing.*/

function loopy(x){
    if (x<10000000){ 
        return function(){
            return loopy(x+1)
        }
    }else{
        return x;
    }
};

function trampoline(foo){
    while(foo && typeof foo === 'function'){
        foo = foo();
    }
    return foo;
/*I've seen trampolines without this,
mine wouldn't return anything unless
I had it though. Just goes to show I
only half know what I'm doing.*/
};

alert(trampoline(loopy(0)));

Моя самая большая проблема, я не знаю, почему это работает. У меня появилась идея перезапустить функцию в цикле while вместо использования рекурсивного цикла. Кроме того, технически моя базовая функция уже имеет рекурсивный цикл. Я не запускаю базовую loopyфункцию, но я запускаю функцию внутри нее. Что мешает foo = foo()вызвать переполнение стека? И foo = foo()технически не мутирует, или я что-то упускаю? Возможно, это просто необходимое зло. Или какой-то синтаксис мне не хватает.

Есть ли способ понять это? Или это просто какой-то хак, который как-то работает? Я был в состоянии пройти через все остальное, но это меня озадачило.


5
Да, но это все еще рекурсия. loopyне переполняется, потому что не вызывает себя .
tkausl

4
«Я думал, что TCO был реализован, но, как оказалось, я был неправ». Это было по крайней мере в V8 в большинстве сценариев. Вы можете использовать его, например, в любой недавней версии Node, сказав Node, чтобы включить его в V8: stackoverflow.com/a/30369729/157247 У Chrome было это (за «экспериментальным» флагом) начиная с Chrome 51.
TJ Crowder

125
Кинетическая энергия пользователя преобразуется в упругую потенциальную энергию, когда батут провисает, а затем возвращается к кинетической энергии, когда она восстанавливается.
user253751

66
@immibis, от имени всех, кто пришел сюда без проверки, какой это был сайт Stack Exchange, спасибо.
user1717828

4
@jpaugh ты имел ввиду "прыгающий"? ;-)
Халк

Ответы:


89

Причина, по которой ваш мозг восстает против этой функции, loopy()заключается в том, что она имеет противоречивый тип :

function loopy(x){
    if (x<10000000){ 
        return function(){ // On this line it returns a function...
            // (This is not part of loopy(), this is the function we are returning.)
            return loopy(x+1)
        }
    }else{
        return x; // ...but on this line it returns an integer!
    }
};

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

Итак, давайте внимательно пройдемся по циклу while:

while(foo && typeof foo === 'function'){
    foo = foo();
}

Первоначально fooравно loopy(0). Что такое loopy(0)? Ну, это меньше, чем 10000000, так что мы получаем function(){return loopy(1)}. Это истинное значение, и это функция, поэтому цикл продолжается.

Теперь мы пришли к foo = foo(). foo()так же, как loopy(1). Так как 1 все еще меньше 10000000, возвращается function(){return loopy(2)}, который мы затем назначаем foo.

fooвсе еще функция, поэтому мы продолжаем ... пока в конечном итоге foo не будет равен function(){return loopy(10000000)}. Это функция, поэтому мы делаем еще foo = foo()один раз, но на этот раз, когда мы вызываем loopy(10000000), x не меньше 10000000, поэтому мы просто возвращаем x. Поскольку 10000000 также не является функцией, это также завершает цикл while.


1
Комментарии не для расширенного обсуждения; этот разговор был перемещен в чат .
Яннис

Это действительно просто тип суммы. Иногда известен как вариант. Динамические языки поддерживают их довольно легко, потому что каждое значение помечено тегами, в то время как более статически типизированные языки потребуют, чтобы вы указали функцию, возвращающую вариант. Батуты легко возможны в C ++ или Haskell, например.
GManNickG

2
@GManNickG: Да, именно это я имел в виду под «намного большим набором текста». В C вам нужно было бы объявить объединение, объявить структуру, которая помечает объединение, упаковать и распаковать структуру на любом конце, упаковать и распаковать объединение на любом конце, и (возможно) выяснить, кому принадлежит память, которую населяет структура , C ++, скорее всего, меньше кода, чем это, но концептуально он не менее сложен, чем C, и он все еще более многословен, чем Javascript OP.
Кевин

Конечно, я не оспариваю это, я просто думаю, что акцент, который вы делаете на это странно или не имеет смысла, немного сильный. :)
GManNickG

173

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

Без оптимизации хвостового вызова (TCO) каждый вызов функции добавляет кадр стека к текущему стеку выполнения. Предположим, у нас есть функция для вывода обратного отсчета чисел:

function countdown(n) {
  if (n === 0) {
    console.log("Blastoff!");
  } else {
    console.log("Launch in " + n);
    countdown(n - 1);
  }
}

Если мы позвоним countdown(3), давайте проанализируем, как будет выглядеть стек вызовов без TCO.

> countdown(3);
// stack: countdown(3)
Launch in 3
// stack: countdown(3), countdown(2)
Launch in 2
// stack: countdown(3), countdown(2), countdown(1)
Launch in 1
// stack: countdown(3), countdown(2), countdown(1), countdown(0)
Blastoff!
// returns, stack: countdown(3), countdown(2), countdown(1)
// returns, stack: countdown(3), countdown(2)
// returns, stack: countdown(3)
// returns, stack is empty

При использовании TCO каждый рекурсивный вызов countdownнаходится в хвостовой позиции (ничего не остается, кроме как вернуть результат вызова), поэтому стековый кадр не выделяется. Без TCO стек взрывается даже при небольшом увеличении n.

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

function trampoline(firstHop) {
  nextHop = firstHop();
  while (nextHop) {
    nextHop = nextHop()
  }
}

function countdown(n) {
  trampoline(() => countdownHop(n));
}

function countdownHop(n) {
  if (n === 0) {
    console.log("Blastoff!");
  } else {
    console.log("Launch in " + n);
    return () => countdownHop(n-1);
  }
}

Чтобы лучше понять, как это работает, давайте посмотрим на стек вызовов:

> countdown(3);
// stack: countdown(3)
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(3)
Launch in 3
// return next hop from countdownHop(3)
// stack: countdown(3), trampoline
// trampoline sees hop returned another hop function, calls it
// stack: countdown(3), trampoline, countdownHop(2)
Launch in 2
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(1)
Launch in 1
// stack: countdown(3), trampoline
// stack: countdown(3), trampoline, countdownHop(0)
Blastoff!
// stack: countdown(3), trampoline
// stack: countdown(3)
// stack is empty

На каждом шаге countdownHopфункция отказывается от прямого контроля за тем, что происходит дальше, вместо этого возвращая функцию для вызова, которая описывает, что она хотела бы, чтобы происходило дальше. Функция батута затем берет это и вызывает его, затем вызывает любую функцию, которая возвращает, и так далее, пока не будет «следующего шага». Это называется trampolining, потому что поток управления «подпрыгивает» между каждым рекурсивным вызовом и реализацией trampoline, а не функцией, непосредственно повторяющейся. Оставляя контроль над тем, кто делает рекурсивный вызов, функция батута может гарантировать, что стек не станет слишком большим. Примечание: эта реализация trampolineопускает возвращаемые значения для простоты.

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


18

Возможно, станет легче понять, если батут реализован с выделенным типом возврата (вместо злоупотребления функцией):

class Result {}
// poor man's case classes
class Recurse extends Result {
    constructor(a) { this.arg = a; }
}
class Return extends Result {
    constructor(v) { this.value = v; }
}

function loopy(x) {
    if (x<10000000)
        return new Recurse(x+1);
    else
        return new Return(x);
}

function trampoline(fn, x) {
    while (true) {
        const res = fn(x);
        if (res instanceof Recurse)
            x = res.arg;
        else if (res instanceof Return)
            return res.value;
    }
}

alert(trampoline(loopy, 0));

Сравните это с вашей версией trampoline, где случай рекурсии - это когда функция возвращает другую функцию, а базовый случай - когда она возвращает что-то еще.

Что мешает foo = foo()вызвать переполнение стека?

Он больше не называет себя. Вместо этого он возвращает результат (в моей реализации, буквально a Result), который передает, продолжить ли рекурсию или выйти из нее .

И foo = foo()технически не мутирует, или я что-то упускаю? Возможно, это просто необходимое зло.

Да, это точно необходимое зло цикла. Можно было бы написать и trampolineбез мутации, но это потребовало бы рекурсии снова:

function trampoline(fn, x) {
    const res = fn(x);
    if (res instanceof Recurse)
        return trampoline(fn, res.arg);
    else if (res instanceof Return)
        return res.value;
}

Тем не менее, это показывает идею того, что функция батута делает еще лучше.

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


foo = foo()является мутацией в смысле изменения локального состояния, но я бы обычно рассмотрел это переназначение, так как вы фактически не модифицируете базовый объект функции, вы заменяете его функцией (или значением), которую он возвращает.
JAB

@JAB Да, я не хотел подразумевать изменение значения, которое fooсодержит, только измененная переменная. whileПетля требует некоторого изменяемого состояния , если вы хотите, чтобы это прекратить, в этом случае переменной fooили x.
Берги

Некоторое время назад я сделал что-то подобное в этом ответе на вопрос о переполнении стека об оптимизации хвостовых вызовов, батутах и ​​т. Д.
Джошуа Тейлор,

2
Ваша версия без мутаций превратила рекурсивный вызов fnв рекурсивный вызов trampoline- я не уверен, что это улучшение.
Майкл Андерсон

1
@MichaelAnderson Это предназначено только для демонстрации абстракции. Конечно, рекурсивный батут бесполезен.
Берги
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.