web-dev-qa-db-ja.com

継続とコールバックの違いは何ですか?

私は継続についてEnlightenmentを求めてウェブ上でブラウジングしてきましたが、私のようなJavaScriptプログラマーを最も単純な説明がどれほど完全に混乱させることができるかを気にかけています。これは、ほとんどの記事がSchemeのコードの継続について説明している場合、またはモナドを使用している場合に特に当てはまります。

ようやく継続の本質が理解できたと思うので、自分が知っていることが実際に真実かどうかを知りたかったのです。私が本当だと思うことが実際に真実でないなら、それは無知であり、啓発ではありません。

だから、ここで私が知っていることです:

ほとんどすべての言語で、関数は呼び出し元に明示的に値(および制御)を返します。例えば:

var sum = add(2, 3);

console.log(sum);

function add(x, y) {
    return x + y;
}

これで、ファーストクラス関数を備えた言語では、呼び出し元に明示的に返す代わりに、コントロールと戻り値をコールバックに渡すことができます。

add(2, 3, function (sum) {
    console.log(sum);
});

function add(x, y, cont) {
    cont(x + y);
}

したがって、関数から値を返す代わりに、別の関数を使用しています。したがって、この関数は最初の関数の継続と呼ばれます。

それでは、継続とコールバックの違いは何ですか?

124
Aadit M Shah

継続はコールバックの特殊なケースだと思います。関数は、何回でも何度でもコールバックできます。例えば:

var array = [1, 2, 3];

forEach(array, function (element, array, index) {
    array[index] = 2 * element;
});

console.log(array);

function forEach(array, callback) {
    var length = array.length;
    for (var i = 0; i < length; i++)
        callback(array[i], array, i);
}

ただし、ある関数が別の関数を最後に呼び出す場合、2番目の関数は最初の関数の継続と呼ばれます。例えば:

var array = [1, 2, 3];

forEach(array, function (element, array, index) {
    array[index] = 2 * element;
});

console.log(array);

function forEach(array, callback) {
    var length = array.length;

    // This is the last thing forEach does
    // cont is a continuation of forEach
    cont(0);

    function cont(index) {
        if (index < length) {
            callback(array[index], array, index);
            // This is the last thing cont does
            // cont is a continuation of itself
            cont(++index);
        }
    }
}

関数が最後に別の関数を呼び出す場合、それは末尾呼び出しと呼ばれます。 Schemeなどの一部の言語は、末尾呼び出しの最適化を実行します。これは、末尾呼び出しが関数呼び出しの完全なオーバーヘッドを被らないことを意味します。代わりに、単純なgotoとして実装されます(呼び出し元の関数のスタックフレームを末尾呼び出しのスタックフレームに置き換えます)。

ボーナス:継続渡しスタイルに進みます。次のプログラムを検討してください。

console.log(pythagoras(3, 4));

function pythagoras(x, y) {
    return x * x + y * y;
}

すべての操作(加算、乗算などを含む)が関数の形式で記述された場合、次のようになります。

console.log(pythagoras(3, 4));

function pythagoras(x, y) {
    return add(square(x), square(y));
}

function square(x) {
    return multiply(x, x);
}

function multiply(x, y) {
    return x * y;
}

function add(x, y) {
    return x + y;
}

さらに、値を返すことが許可されていない場合、次のように継続を使用する必要があります。

pythagoras(3, 4, console.log);

function pythagoras(x, y, cont) {
    square(x, function (x_squared) {
        square(y, function (y_squared) {
            add(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply(x, x, cont);
}

function multiply(x, y, cont) {
    cont(x * y);
}

function add(x, y, cont) {
    cont(x + y);
}

値を返すことが許可されていない(したがって、継続を渡すことに頼らなければならない)このプログラミングスタイルは、継続渡しスタイルと呼ばれます。

ただし、継続渡しスタイルには2つの問題があります。

  1. 継続を渡すと、呼び出しスタックのサイズが大きくなります。テールコールを排除するSchemeのような言語を使用していない限り、スタック領域が不足する危険があります。
  2. ネストされた関数を書くのは苦痛です。

最初の問題は、継続を非同期的に呼び出すことでJavaScriptで簡単に解決できます。継続を非同期的に呼び出すことにより、関数は継続が呼び出される前に戻ります。したがって、呼び出しスタックのサイズは増加しません。

Function.prototype.async = async;

pythagoras.async(3, 4, console.log);

function pythagoras(x, y, cont) {
    square.async(x, function (x_squared) {
        square.async(y, function (y_squared) {
            add.async(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply.async(x, x, cont);
}

function multiply(x, y, cont) {
    cont.async(x * y);
}

function add(x, y, cont) {
    cont.async(x + y);
}

function async() {
    setTimeout.bind(null, this, 0).apply(null, arguments);
}

通常、2番目の問題は、call-with-current-continuationという関数を使用して解決されます。この関数は、多くの場合callccと省略されます。残念ながらcallccをJavaScriptで完全に実装することはできませんが、ほとんどのユースケースに代わる関数を書くことができます。

pythagoras(3, 4, console.log);

function pythagoras(x, y, cont) {
    var x_squared = callcc(square.bind(null, x));
    var y_squared = callcc(square.bind(null, y));
    add(x_squared, y_squared, cont);
}

function square(x, cont) {
    multiply(x, x, cont);
}

function multiply(x, y, cont) {
    cont(x * y);
}

function add(x, y, cont) {
    cont(x + y);
}

function callcc(f) {
    var cc = function (x) {
        cc = x;
    };

    f(cc);

    return cc;
}

callcc関数は、関数fを取り、それをcurrent-continuationccと省略)に適用します。 current-continuationは、callccの呼び出し後、関数本体の残りをラップする継続関数です。

関数pythagorasの本体を考えてください:

var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);

2番目のcallcccurrent-continuationは次のとおりです。

function cc(y_squared) {
    add(x_squared, y_squared, cont);
}

同様に、最初のcallcccurrent-continuationは次のとおりです。

function cc(x_squared) {
    var y_squared = callcc(square.bind(null, y));
    add(x_squared, y_squared, cont);
}

最初のcallcccurrent-continuationには別のcallccが含まれているため、継続渡しスタイルに変換する必要があります。

function cc(x_squared) {
    square(y, function cc(y_squared) {
        add(x_squared, y_squared, cont);
    });
}

したがって、本質的にcallccは、関数本体全体を論理的に元の状態に変換します(そして、それらの匿名関数にccという名前を付けます)。 callccのこの実装を使用するpythagoras関数は、次のようになります。

function pythagoras(x, y, cont) {
    callcc(function(cc) {
        square(x, function (x_squared) {
            square(y, function (y_squared) {
                add(x_squared, y_squared, cont);
            });
        });
    });
}

ここでも、callccをJavaScriptに実装することはできませんが、次のようにJavaScriptで継続渡しスタイルに実装できます。

Function.prototype.async = async;

pythagoras.async(3, 4, console.log);

function pythagoras(x, y, cont) {
    callcc.async(square.bind(null, x), function cc(x_squared) {
        callcc.async(square.bind(null, y), function cc(y_squared) {
            add.async(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply.async(x, x, cont);
}

function multiply(x, y, cont) {
    cont.async(x * y);
}

function add(x, y, cont) {
    cont.async(x + y);
}

function async() {
    setTimeout.bind(null, this, 0).apply(null, arguments);
}

function callcc(f, cc) {
    f.async(cc);
}

関数callccを使用して、try-catchブロック、コルーチン、ジェネレーター、 fibers などの複雑な制御フロー構造を実装できます。

159
Aadit M Shah

すばらしい文章にもかかわらず、用語を少し混乱させていると思います。たとえば、最後に関数を実行する必要がある場合にテールコールが発生することは正しいですが、継続に関して、テールコールとは、関数が呼び出された継続を変更しないことを意味します。継続に渡される値を更新します(必要な場合)。これが、末尾再帰関数のCPSへの変換が非常に簡単な理由です(パラメーターとして継続を追加し、結果に対して継続を呼び出すだけです)。

継続をコールバックの特殊なケースと呼ぶのも少し奇妙です。それらがどのように簡単にグループ化されるかはわかりますが、コールバックと区別する必要性から継続は生じませんでした。継続は、実際には、計算を完了するために残っている命令、またはthis時点。継続は、埋める必要がある穴と考えることができます。プログラムの現在の継続をキャプチャできる場合、継続をキャプチャしたときのプログラムの状態に正確に戻ることができます。 (これにより、デバッガーの作成が容易になります。)

このコンテキストでは、あなたの質問に対する答えは、コールバックは、提供される契約によって指定された任意の時点で呼び出される一般的なものです[コールバックの]呼び出し元。コールバックは、必要な数の引数を持ち、任意の方法で構造化できます。したがって、continuationは、それに渡される値を解決する1つの引数プロシージャである必要があります。継続は単一の値に適用され、最後に適用される必要があります。継続が終了すると、式の実行が完了し、言語のセマンティクスに応じて、副作用が発生する場合と発生しない場合があります。

26
dcow

簡単な答えは、継続とコールバックの違いは、コールバックが呼び出された(そして終了した)後に、呼び出されたポイントで実行が再開するのに対し、継続を呼び出すと、継続が作成されたポイントで実行が再開するということです。つまり、継続は返されません

機能を考えてみましょう:

function add(x, y, c) {
    alert("before");
    c(x+y);
    alert("after");
}

(Javascriptは実際にファーストクラスの継続をサポートしていませんが、これはあなたがサンプルを提供したものであり、LISP構文に精通していない人々にとってより理解しやすいものです。)

さて、コールバックを渡すと:

add(2, 3, function (sum) {
    alert(sum);
});

その後、「before」、「5」、「after」の3つのアラートが表示されます。

一方、次のように、コールバックと同じことを行う継続を渡すと、次のようになります。

alert(callcc(function(cc) {
    add(2, 3, cc);
}));

その後、「before」と「5」の2つのアラートのみが表示されます。 c()内でadd()を呼び出すと、add()の実行が終了し、callcc()が返されます。 callcc()によって返される値は、cへの引数として渡された値(つまり、合計)でした。

この意味で、継続の呼び出しは関数呼び出しのように見えますが、ある意味ではreturnステートメントや例外のスローに似ています。

実際、call/ccを使用して、returnステートメントをサポートしていない言語にreturnステートメントを追加できます。たとえば、JavaScriptにreturnステートメントがない場合(代わりに、多くのLips言語のように、関数本体の最後の式の値を返すだけ)、call/ccがある場合、次のようにreturnを実装できます。

function find(myArray, target) {
    callcc(function(return) {
        var i;
        for (i = 0; i < myArray.length; i += 1) {
            if(myArray[i] === target) {
                return(i);
            }
        }
        return(undefined); // Not found.
    });
}

return(i)を呼び出すと、匿名関数の実行を終了し、callcc()itargetで見つかったインデックスmyArrayを返す継続を呼び出します。 。

(NB:「戻り」の類推が少し単純化されるいくつかの方法があります。例えば、継続が作成された関数からエスケープする場合-例えばどこかにグローバルに保存されることにより-関数が可能である可能性があります継続を作成しました一度だけ呼び出されたとしても複数回返すことができます。)

Call/ccを同様に使用して、例外処理(スローおよびトライ/キャッチ)、ループ、および他の多くの制御構造を実装できます。

考えられる誤解を解消するには:

  • テールコールの最適化は、ファーストクラスの継続をサポートするために必要な手段ではありません。 C言語でさえ、継続を作成するsetjmp()の形式の(制限された)継続形式と、それを呼び出すlongjmp()を持っていると考えてください!

    • 一方、テールコールの最適化を行わずに継続渡しスタイルでプログラムを単純に作成しようとすると、最終的にスタックをオーバーフローさせる運命にあります。
  • 継続が1つの引数のみを必要とする特別な理由はない。継続に対する引数がcall/ccの戻り値になり、call/ccは通常単一の戻り値を持つと定義されているので、当然、継続には正確に1が必要です。複数の戻り値をサポートする言語(Common LISP、Go、またはSchemeなど)では、複数の値を受け入れる継続が完全に可能です。

13
cpcallen