延续和回调之间有什么区别?


133

我一直在浏览整个网络,以寻找关于连续性的启示,而且令人困惑的是,最简单的解释如何如此彻底地混淆了像我这样的JavaScript程序员。当大多数文章解释Scheme中代码的延续或使用monad时尤其如此。

现在,我终于认为我已经理解了延续的本质,我想知道我确实知道的是事实。如果我认为正确的东西实际上不是真实的,那就是无知而不是启蒙。

所以,这就是我所知道的:

在几乎所有语言中,函数都会将值(和控件)显式返回给其调用方。例如:

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
我的一部分认为这是一个非常好的问题,而我的一部分认为这太长了,很可能会导致回答“是/否”。但是,由于付出的努力和研究,我有了第一感觉。
安德拉斯·佐坦

2
你有什么问题?听起来您很了解这一点。
Michael Aaron Safyan

3
是的,我同意-我认为它可能应该更多地是一篇博客文章,类似于“ JavaScript Continuations-我理解它们是什么”。
安德拉斯·佐坦

9
好吧,这里有一个基本问题:“那么延续和回调之间有什么区别?”,然后是“我相信...”。这个问题的答案可能很有趣?
混乱2012年

3
似乎可以将其更恰当地发布在programmers.stackexchange.com上。
Brian Reischl 2012年

Answers:


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

但是,如果一个函数将另一个函数作为最后一个回调,则第二个函数称为第一个函数的延续。例如:

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(将调用函数的堆栈框架替换为tail调用的堆栈框架)。

奖励:继续传球。考虑以下程序:

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

这种不允许您返回值(因此您必须求助于传递延续)的编程方式称为延续传递方式。

但是,延续传递样式存在两个问题:

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

第二个问题通常使用称为的函数解决,该函数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的此实现的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块,协程,生成器,光纤等。


10
我非常感激,无法形容。我终于一眼就能理解所有与延续有关的概念!单击后,我会重新输入内容,这将变得非常简单,我会在不知不觉中看到很多次使用该模式,就像那样。非常感谢您的精彩而清晰的解释。
ata 2014年

2
蹦床是相当简单但功能强大的东西。请查看Reginald Braithwaite关于它们的帖子
Marco Faustinelli,2015年

1
感谢你的回答。我想知道您是否可以为无法在JavaScript中实现callcc的语句提供更多支持?可能需要解释什么JavaScript才能实现它?
约翰·亨利

1
@JohnHenry-嗯,实际上是Matt Might完成的JavaScript中的call / cc实现(matt.might.net/articles/by-example-continuation-passing-style-转到最后一段),但是请不要不要问我它是如何工作或如何使用它的:-)
Marco Faustinelli 2015年

1
@JohnHenry JS需要一流的延续(将它们视为捕获调用堆栈某些状态的机制)。但是它只有First Class函数和闭包,因此CPS是模仿延续的唯一方法。在Scheme中,conts是隐式的,callcc的工作的一部分是“化”这些隐式cont,以便使用函数可以访问它们。这就是为什么Scheme中的callcc希望将函数作为唯一参数。JS中callcc的CPS版本有所不同,因为cont作为显式func参数传递。因此,Aadit的callcc足以满足许多应用程序的需求。
scriptum

27

尽管撰写了精彩的文章,但我认为您对术语有些困惑。例如,您正确地认为,当调用是函数需要执行的最后一件事时,就会发生尾部调用,但是对于延续,尾部调用意味着该函数不会修改被调用的延续,而只是修改它更新传递给延续的值(如果需要)。这就是为什么将尾递归函数转换为CPS如此简单的原因(您只需将延续作为参数添加,然后对结果调用延续)。

将延续称为回调的特殊情况也有点奇怪。我可以看到如何轻松地将它们组合在一起,但是延续性并不是因为需要与回调区分开来。连续实际上代表了完成计算所需的剩余指令,或从时间点开始的剩余计算。您可以将连续性视为需要填补的空缺。如果我可以捕获程序的当前连续性,那么可以准确地回到捕获连续性时程序的状态。(这肯定会使调试器更容易编写。)

在这种情况下,您的问题的答案是:回调是一种通用的事物,它在[回调的]调用者提供的某些合同指定的任何时间点都会被调用。回调可以具有所需的任意数量的参数,并且可以按所需的任何方式进行构造。因此,continuation必定是一个单参数过程,用于解析传递给它的值。必须对单个值应用延续,并且应用必须在最后进行。当继续执行完成时,表达式就完成了,并且取决于语言的语义,可能会或可能不会产生副作用。


3
感谢您的澄清。没错 延续实际上是对程序控制状态的重新定义:在特定时间点的程序状态快照。可以像正常函数一样调用它的事实是无关紧要的。延续实际上不是功能。另一方面,回调实际上是函数。那是延续和回调之间的真正区别。但是,JS不支持一流的延续。只有一流的功能。因此,用JS用CPS编写的延续是简单的函数。谢谢您的意见。=)
Aadit M Shah 2013年

4
@AaditMShah是的,我在那弄错了。延续不必是一个函数(或我所说的过程)。根据定义,它只是即将出现的事物的抽象表示。但是,即使在Scheme中,延续也像过程一样被调用并作为一个整体传递。嗯,这引起了一个同样有趣的问题,即延续看起来像不是函数/过程。
dcow 2013年

@AaditMShah足够有趣,我一直在继续的讨论在这里:programmers.stackexchange.com/questions/212057/...
dcow

14

简短的答案是,延续与回调之间的区别在于,在调用(并完成)回调后,执行将在被调用时恢复执行,而调用延续会导致执行在创建延续的点恢复执行。换句话说:延续永不返回

考虑以下功能:

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

(即使您实际上没有提供一流的延续性,我也使用Javascript语法,因为这是您在其中提供的示例,对于不熟悉Lisp语法的人来说,它更容易理解。)

现在,如果我们传递一个回调:

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

那么我们将看到三个警报:“之前”,“ 5”和“之后”。

另一方面,如果我们要传递给它一个与回调函数相同的继续函数,如下所示:

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

那么我们只会看到两个警报:“之前”和“ 5”。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()返回itarget中找到的索引myArray

(注意:在某些情况下,“返回”类比有点简化。例如,如果延续从函数中逸出,则该延续是在-通过保存在全局某个位置而创建的,例如-该函数可能即使仅被调用了一次,创建延续的对象也可以返回多次

Call / cc可以类似地用于实现异常处理(抛出和try / catch),循环和许多其他控制结构。

要消除一些可能的误解:

  • 为了支持一流的连续性,不需要任何方式的尾部调用优化。考虑一下,即使C语言也具有(受限制的)延续形式setjmp(),形式为,它创建延续,而和longjmp()则调用一个!

    • 另一方面,如果您天真地尝试以连续传递样式编写程序而没有尾部调用优化,那么您注定最终会溢出堆栈。
  • 没有特别的理由继续需要只接受一个论点。仅仅是延续的参数成为call / cc的返回值,而call / cc通常被定义为具有单个返回值,因此自然地,延续必须正好取一个。在支持多个返回值的语言中(例如Common Lisp,Go或实际上是Scheme),完全有可能接受多个值的延续。


2
抱歉,如果我在JavaScript示例中犯了任何错误。编写此答案大约使我编写的JavaScript总数增加了一倍。
cpcallen '16

我是否正确理解您在此答案中谈论的是无界延续,而被接受的答案是在谈论无界延续?
JozefMikušinec

1
“调用延续会导致在创建延续时恢复执行” –我认为您将“创建” 延续与捕获当前延续混淆
阿列克谢

@Alexey:这是我认可的那种学究方法。但是,大多数语言除了捕获当前的延续以外,没有提供任何方式来创建(已验证的)延续。
cpcallen

1
@jozef:我当然是在谈论无限延续。我认为这也是Aadit的意图,尽管dcow指出,已接受的答案无法将延续与(密切相关的)尾调用区分开,而且我注意到,定界的延续无论如何都等于功能/过程:community.schemewiki.org/ ?composable-continuations-tutorial
cpcallen
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.