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


104

私はいくつかの機能的な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()、技術的に変異していないのですか、何か不足していますか?おそらく、それは単なる必要悪です。または私が欠落しているいくつかの構文。

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


5
はい、それでも再帰です。loopyそれはそれ自身を呼び出さないので、オーバーフローしません
tkausl

4
「TCOが実装されたと思っていましたが、結局は間違っていました。」ほとんどのscenarosでは、少なくともV8で使用されています。あなたはV8で、それを有効にするには、ノードを伝えることにより、ノードのいずれかの最新バージョンでは、インスタンスのためにそれを使用することができます。stackoverflow.com/a/30369729/157247 Chromeのは、クロム51以降(「実験」フラグの後ろに)それを持っていた
TJクラウダー

125
ユーザーからの運動エネルギーは、トランポリンが弛むと弾性ポテンシャルエネルギーに変換され、跳ね返ると運動エネルギーに戻ります。
イミビス

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よりも複雑ではなく、OPのJavascriptよりも冗長です。
ケビン

確かに、私はそれについて異議を唱えているわけではありません。あなたがそれをおかしいか、または意味をなさないことを強調するのは少し強いと思います。:)
GManNickG

173

Kevinは、この特定のコードスニペットがどのように機能するかを(簡潔に理解できない理由も含めて)簡潔に指摘していますが、一般的なトランポリンの動作に関する情報を追加したいと思いました。

末尾呼び出し最適化(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再帰を続行するか、ブレークアウトするかを伝える結果(私の実装では、文字通りa )を返します。

そして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;
}

それでも、トランポリン機能がさらに優れていることのアイデアを示しています。

トランポリンのポイントは、再帰を使用したい関数から末尾再帰呼び出しを戻り値に抽象化し、実際の再帰を1か所でのみ行うtrampolineことです。ループ。


foo = foo()ローカル状態を変更するという意味での突然変異ですが、一般的には、基になる関数オブジェクトを実際に変更するのではなく、それが返す関数(または値)に置き換えているため、再割り当てを検討します。
JAB

@JABはい、foo含まれる値を変更することを意味するものではなく、変数のみが変更されます。whileあなたはそれを終了したい場合はループが、この場合、変数には、いくつかの変更可能な状態を必要としますfoox
ベルギ

テールコールの最適化、トランポリンなどに関するStack Overflowの質問に対するこの回答で、しばらく前にこのようなことをしました。
Joshua Taylor

2
突然変異のないあなたのバージョンは、の再帰呼び出しに変換しているfnの再帰呼び出しにtrampoline-私はそれが改善だか分かりません。
マイケルアンダーソン

1
@MichaelAnderson抽象化を実証することのみを目的としています。もちろん、再帰的なトランポリンは役に立ちません。
ベルギ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.