web-dev-qa-db-ja.com

トランポリンはなぜ機能するのですか?

私はいくつかの機能的なJavaScriptを行っています。 Tail-Call Optimization が実装されていると思っていましたが、結局間違っていました。したがって、私は自分自身を教えなければなりませんでした トランポリン 。ここや他の場所で少し読んだ後、基本を理解し、最初のトランポリンを構築することができました。

_/*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()は技術的に変更されていませんか、それとも何か不足していますか?おそらくそれは単なる必要悪なのです。または、いくつかの構文が不足しています。

それを理解する方法さえありますか?それとも、何らかの方法で機能するのは単なるハックだけですか?私は他のすべてを自分の方法で行うことができましたが、これは私を困惑させています。

105
Ucenna

あなたの脳が関数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();
}
_

最初、fooloopy(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ループも終了します。

89
Kevin

ケビンは、この特定のコードスニペットがどのように機能するかを簡潔に指摘していますが、なぜそれがまったく理解できないのかについてですが、トランポリンin Generalの機能に関する情報を追加したいと思います。

末尾呼び出しの最適化(TCO)を使用しない場合、すべての関数呼び出しでstack frameが現在の実行スタックに追加されます。数のカウントダウンを出力する関数があるとします。

_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への再帰呼び出しはそれぞれtail position(呼び出しの結果を返す以外にすべきことは何もありません)なので、スタックフレームは割り当てられません。 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関数abandonsは次に何が起こるかを直接制御し、代わりにlikeに何を行うかを説明する関数を呼び出して返します次に起こります。次に、トランポリン関数がこれを受け取って呼び出し、次にthatが返す関数を呼び出し、以下同様に「次のステップ」がなくなるまで繰り返します。これは、関数が直接繰り返されるのではなく、制御のフローが各再帰呼び出しとトランポリン実装の間で「バウンス」するため、トランポリンと呼ばれます。makesの再帰呼び出しの制御を放棄することで、トランポリン関数はスタックが大きくなりすぎないようにすることができます。補足:このtrampolineの実装では、簡単にするために戻り値を省略しています。

これが良いアイデアかどうかを知るのは難しいかもしれません。各ステップで新しいクロージャーを割り当てるため、パフォーマンスが低下する可能性があります。巧妙な最適化はこれを実行可能にすることができますが、あなたは決して知りません。トランポリンは、言語の実装で最大コールスタックサイズを設定する場合など、ハード再帰の制限を回避するのに役立ちます。

173
Jack

(関数を悪用する代わりに)トランポリンを専用の戻り値の型で実装すると、理解しやすくなるかもしれません。

_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;
}
_

それでも、トランポリン機能がさらに優れているという考え方を示しています。

トランポリンのポイントはabstracting out再帰を使用して戻り値にしたい関数からの末尾再帰呼び出しであり、実際の再帰を1か所だけで実行します-trampoline関数を使用して、ループを使用するために1か所で最適化できます。

18
Bergi