延续和回调有什么区别?

我一直在浏览整个网页,寻找关于延续的启发,令人难以置信的是,最简单的解释怎么会让像我这样的 JavaScript 程序员如此困惑。当大多数文章解释 Scheme 中代码的延续或使用 monads 时,这一点尤其正确。

现在我终于明白了延续的本质,我想知道我所知道的是不是真的。如果我认为的是真的,实际上不是真的,那就是无知,而不是启蒙。

我知道的是:

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

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

因此,我们不再从函数返回值,而是继续使用另一个函数。因此,这个函数被称为第一个函数的延续。

那么,延续和回调之间的区别是什么呢?

22512 次浏览

我相信延续是回调的一种特殊情况。一个函数可以回调任意数量的函数,任意次数。例如:

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 (用调用函数的堆栈帧替换为尾调用的堆栈帧)。

奖励 : 继续传递样式。请考虑以下程序:

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

第二个 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);
});
});
});
}

同样,你不能在 JavaScript 中实现 callcc,但是你可以在 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 块、协同程序、生成器、 纤维等。

尽管你的文章写得很好,但我觉得你的术语有点混乱。例如,当调用是函数需要执行的最后一个操作时,就会发生尾部调用,这是正确的,但是对于延续,尾部调用意味着函数不会修改被调用的延续,只是更新传递给延续的值(如果需要的话)。这就是为什么将尾递归函数转换为 CPS 非常容易(只需将延续作为参数添加并在结果上调用延续)。

将延续称为回调的特殊情况也有点奇怪。我可以看到它们是如何轻松地组合在一起的,但是延续并不是由于需要区分回调而产生的。延续实际上表示 完成计算的剩余指令,或者从 这个时间点计算的其余部分。你可以把延续看作是一个需要填充的空洞。如果我可以捕获一个程序当前的延续,那么我就可以回到捕获延续时程序的确切状态。(这肯定使调试器更容易编写。)

在这种情况下,您的问题的答案是,复试是一个通用的东西,它在调用者[回调的]提供的某个契约指定的任何时间点被调用。回调可以有任意多的参数,并以任意方式进行结构化。因此,继续必须是一个参数过程,用于解析传递给它的值。必须将延续应用于单个值,并且应用程序必须在结束时发生。当延续完成执行表达式时,表达式就完成了,并且根据语言的语义,可能已经生成了副作用,也可能没有生成副作用。

简而言之,延续和回调的区别在于,在回调被调用(并且已经完成)之后,执行会在被调用的时候恢复,而调用延续会导致执行在创建延续的时候恢复。换句话说: 延续永远不会回来

考虑一下函数:

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

(我使用 Javascript 语法,尽管 Javascript 实际上并不支持一流的延续,因为这就是你给出的例子,对于不熟悉 Lisp 语法的人来说,它会更容易理解。)

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

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

然后我们会看到三个警报: “之前”,“5”和“之后”。

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

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

那么我们只会看到两个警报: “之前”和“5”。在 add()内部调用 c()将结束 add()的执行并导致 callcc()返回; callcc()返回的值是作为参数传递给 c的值(即和)。

在这个意义上,即使调用一个延续看起来像一个函数调用,它在某些方面更类似于一个 return 语句或抛出一个异常。

事实上,call/cc 可以用来向不支持 return 语句的语言添加 return 语句。例如,如果 JavaScript 没有 return 语句(相反,像许多 Lisp 语言一样,只是返回函数体中最后一个表达式的值) ,但是有 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()返回在 myArray中找到 target的索引 i

(注意: 在某些方面,“返回”的类比有点过于简单化了。例如,如果一个连续函数从创建它的函数中逃逸出来——通过保存在全局的某个地方,比如说——那么创建连续 即使只调用了一次,也可以返回多次的函数就有可能逃逸出来。)

调用/cc 同样可以用于实现异常处理(throw 和 try/catch)、循环和许多其他控制结构。

为了澄清一些可能的误解:

  • 尾部呼叫优化并不是为了支持头等舱延续而采取的任何手段。考虑到甚至 C 语言也有一种(受限制的)延续形式,即 setjmp(),它创建一个延续,而 longjmp(),它调用一个延续!

    • 另一方面,如果你天真地尝试在没有尾部调用优化的情况下以延续传递的方式编写程序,那么你最终注定会溢出堆栈。
  • 没有什么特别的原因需要一个延续只需要一个参数。只是连续的参数变成了 call/cc 的返回值,call/cc 通常定义为只有一个返回值,所以连续的参数自然只能有一个返回值。在支持多个返回值的语言中(比如 Common Lisp、 Go 或者 Scheme) ,完全可以有接受多个值的延续。