Чому працюють батути?


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)));

Моє найбільше питання - це я не знаю, чому це працює. У мене виникає ідея перезапустити функцію в циклі часу, а не використовувати рекурсивний цикл. За винятком того, що технічно моя базова функція вже має рекурсивний цикл. Я не виконую базову loopyфункцію, але я виконую функцію всередині неї. Що зупиняється foo = foo()від спричинення переповнення стека? І це foo = foo()технічно не мутує, чи я щось пропускаю? Можливо, це просто необхідне зло. Або якийсь синтаксис, якого я відсутній.

Чи є навіть спосіб це зрозуміти? Або це просто якийсь хакер, який якимось чином працює? Мені вдалося пройти через все інше, але цей мене здивував.


5
Так, але це все ще рекурсія. loopyне переповнює, тому що не називає себе .
tkausl

4
"Я думав, що ТСО було впроваджено, але, як виявилося, я помилявся". У більшості сценаріїв це було принаймні у V8. Ви можете використовувати його, наприклад, у будь-якій останній версії Node, повідомивши Node, щоб увімкнути його у V8: stackoverflow.com/a/30369729/157247 Chrome має його (за "експериментальним" прапором) з Chrome 51.
TJ Crowder

125
Кінетична енергія від користувача перетворюється на еластичну потенційну енергію, коли батут провисає, потім повертається до кінетичної енергії, коли вона відновлюється.
immibis

66
@immibis, від імені всіх, хто прийшов сюди, не перевіряючи, на якому це веб-сайті Stack Exchange, дякую.
користувач1717828

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(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, тому ми просто отримуємо х назад. Оскільки 10000000 також не є функцією, це також закінчує цикл while.


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

Це дійсно просто сума. Іноді відомий як варіант. Динамічні мови підтримують їх досить легко, оскільки кожне значення позначено тегами, тоді як для більш типово типових мов потрібно буде вказати, що функція повертає варіант. Наприклад, батути легко можливі в C ++ або Haskell.
GManNickG

2
@GManNickG: Так, саме це я мав на увазі під "набагато більше набравши текст". У C вам слід було б оголосити союз, оголосити структуру, що позначає союз, упакувати та розпакувати структуру на будь-якому кінці, упакувати та розпакувати об'єднання на будь-якому кінці, і (напевно) з'ясувати, кому належить пам'ять, якою живе структура . C ++ - це, ймовірно, менше коду, ніж це, але він концептуально не менш складний, ніж C, і він все-таки більш багатослівний, ніж Javascript в ОП.
Кевін

Звичайно, я це не оспорюю, я просто думаю, що акцент, який ти робиш на цьому, дивно або не має сенсу, трохи сильний. :)
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.

Трамполінінг долає це обмеження, вставляючи обгортку навколо 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функція відмовляється від прямого управління тим, що буде далі, замість того, щоб повернути функцію виклику, яка описує, що вона хотіла б відбутися далі. Потім функція батута приймає це і викликає, потім викликає будь-яку функцію, яка повертається, і так далі, поки не буде "наступного кроку". Це називається батутним тренуванням, оскільки потік управління "відскакує" між кожним рекурсивним викликом та реалізацією батута, замість того, щоб функція безпосередньо повторювалася. Відмовившись від контролю того, хто здійснює рекурсивний дзвінок, функція батута може забезпечити, щоб стек не став занадто великим. Побічна примітка: ця реалізація 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()від спричинення переповнення стека?

Це вже не називає себе. Натомість він повертає результат (в моєму виконанні, буквально а 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.