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


133

私は継続についての啓蒙を求めてウェブ全体をブラウジングしてきましたが、最も単純な説明が私のような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);
}

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

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


4
これは本当に良い質問だと思う人もいれば、長すぎると思う人もいます。おそらく「はい/いいえ」という答えになるだけです。しかし、努力と研究が含まれていたので、私は私の第一印象で行きます。
Andras Zoltan

2
あなたの質問は何ですか?あなたはこれをかなりよく理解しているように聞こえます。
Michael Aaron Safyan

3
はい、同意します。おそらく、「JavaScript Continuations-私が理解していること」の説明に沿ったブログ投稿であったと思います。
Andras Zoltan

9
さて、本質的な質問があります。「だから継続とコールバックの違いは何ですか?」の後に「信じます...」が続きます。その質問への答えは興味深いかもしれませんか?
混乱

3
これは、programmers.stackexchange.comにより適切に投稿されるようです。
Brian Reischl、

回答:


164

継続はコールバックの特別なケースだと思います。関数は、任意の数の関数を任意の回数コールバックできます。例えば:

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-continuation(と略しますcc)。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);

current-continuation第二のは、callcc次のとおりです。

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

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

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

以来current-continuation最初のcallcc別含まれcallcc、それが継続渡しスタイルに変換する必要があります。

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

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

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ブロック、コルーチン、ジェネレーター、ファイバーなどの複雑な制御フロー構造を実装するために使用できます。


10
言葉では言い表せないほど感謝しています。私はついに直感レベルで継続関連のすべての概念を一挙に理解しました!クリックすると新しいので、シンプルになり、無意識のうちに何度もパターンを使用したことがわかりました。素晴らしくて明確な説明をありがとう。
ata 2014

2
トランポリンはかなりシンプルですが、強力です。それらについてのReginald Braithwaiteの投稿をチェックしてください。
Marco Faustinelli、2015年

1
答えてくれてありがとう。callccをJavaScriptに実装できないステートメントをさらにサポートできると思いませんか?おそらくそれを実装するためにどのJavaScriptが必要になるかについての説明でしょうか?
John Henry

1
@JohnHenry-まあ、実際にはJavaScriptにはMatt Might(matt.might.net/articles/by-example-continuation-passing-style-最後の段落に移動)によって実行されるcall / cc実装がありますが、しないでくださいそれがどのように機能するか、それをどのように使用するかを私に聞いてください:-)
マルコ・ファウスティネッリ

1
@JohnHenry JSには、ファーストクラスの継続が必要になります(これらは、コールスタックの特定の状態をキャプチャするメカニズムとして考えてください)。ただし、ファーストクラスの関数とクロージャしか持たないため、CPSは継続を模倣する唯一の方法です。Schemeではcontsは暗黙的で、callccの仕事の一部はこれらの暗黙的contsを「修正」することです。これにより、消費する関数がそれらにアクセスできるようになります。これが、Schemeのcallccが関数を唯一の引数として期待する理由です。contが明示的なfunc引数として渡されるため、JSのcallccのCPSバージョンは異なります。したがって、Aaditのcallccは、多くのアプリケーションで十分です。
聖典

27

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

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

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


3
ご説明ありがとうございます。あなたは正しいです。継続とは、実際にはプログラムの制御状態を具体化したものであり、ある時点でのプログラムの状態のスナップショットです。通常の関数のように呼び出すことができるという事実は無関係です。継続は実際には機能ではありません。一方、コールバックは実際には関数です。それが継続とコールバックの本当の違いです。それにもかかわらず、JSはファーストクラスの継続をサポートしていません。ファーストクラスの機能のみ。したがって、JSのCPSで記述された継続は単なる関数です。ご意見ありがとうございます。=)
Aadit M Shah

4
@AaditMShahはい、私はそこに間違って話した。継続は関数(または私がそれを呼んだ手順)である必要はありません。定義上、これは単に、これから来るものの抽象的な表現です。ただし、Schemeでも継続はプロシージャのように呼び出され、1つとして渡されます。うーん..これは、継続がどのように見えるかという関数/手順ではないという同様に興味深い質問を提起します。
dcow 2013

@AaditMShah私はここで議論を続けてきたことは興味深い十分:programmers.stackexchange.com/questions/212057/...を
dcow

14

簡単な答えは、継続とコールバックの違いは、コールバックが呼び出された(および終了した)後、それが呼び出されたポイントから実行が再開される一方で、継続を呼び出すと、継続が作成されたポイントから実行が再開されるということです。言い換えれば、継続は決して戻りません

関数を考えてみましょう:

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

(JavaScriptはファーストクラスの継続を実際にはサポートしていませんが、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ステートメントを、それらをサポートしない言語に追加できます。たとえば、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()インデックスを返すためにiその時targetに発見されたがmyArray

(注:「リターン」のアナロジーが少し単純化しているいくつかの方法があります。たとえば、継続がそれが作成された関数からエスケープする場合-どこかでグローバルに保存されるなどして-関数が継続作成されたため、一度だけ呼び出された場合でも、複数回戻ることができます。)

Call / ccは同様に、例外処理(throwおよびtry / catch)、ループ、および他の多くの制御構造を実装するために使用できます。

考えられるいくつかの誤解を解消するには:

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

    • 一方で、単純に末尾呼び出しの最適化なしで継続渡しスタイルでプログラムを記述しようとすると、最終的にスタックがオーバーフローする運命にあります。
  • 継続が1つの引数のみを取る必要がある特定の理由はありません。継続への引数がcall / ccの戻り値になるだけであり、call / ccは通常、単一の戻り値を持つと定義されているため、当然、継続は正確に1つを取る必要があります。複数の戻り値をサポートする言語(Common Lisp、Go、または実際にSchemeなど)では、複数の値を受け入れる継続を持つことが完全に可能です。


2
JavaScriptの例でエラーが発生した場合はお詫びします。この回答を書くことで、私が記述したJavaScriptの総量がおよそ2倍になりました。
cpcallen

この回答では、区切りなしの継続について話していることを正しく理解していますか。承認された回答は区切り付きの継続について話しているのですか?
JozefMikušinec

1
「継続を呼び出すと、継続が作成された時点で実行が再開されます」- 継続の「作成」と現在の継続のキャプチャを混同していると思います。
アレクセイ

@Alexey:これは私が承認する一種のペダントリーです。しかし、ほとんどの言語は、現在の継続をキャプチャする以外に、(具体化された)継続を作成する方法を提供していません。
cpcallen

1
@jozef:私は確かに無制限の継続について話している。私はdcowノートとして受け入れ答えは末尾再帰(密接に関連して)からの継続を区別するために失敗してもそれは、同様Aaditの意図だったと思うし、私は限定継続はとにかくのfuction /手順と同等であることに注意してください。community.schemewiki.org/を?
composable
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.