Continuations,以及它们为什么会导致回调地狱
使用回调函数编写代码会迫使你使用类似于“传递续延”(CPS)的技术,这是一种非常强大但难以掌握的技术。它代表了对控制的完全反转,从字面上将计算“颠倒过来”。CPS使您的代码结构明确地反映了程序的控制流程(有时是好事,有时是坏事)。实际上,您正在显式地书写匿名函数的堆栈。
在理解本答案之前,您可能会发现这个链接很有用:
http://matt.might.net/articles/by-example-continuation-passing-style/
例如,这就是您正在做的事情:
function thrice(x, ret) {
ret(x*3)
}
function twice(y, ret) {
ret(y*2)
}
function plus(x,y, ret) {
ret(x+y)
}
function threeXPlusTwoY(x,y, ret) {
thrice(x,
function(r1) {
twice(y,
function(r2) {
plus(r1,r2,
ret
)
}
)
}
)
}
threeXPlusTwoY(5,1, alert);
正如你所抱怨的那样,这会导致代码缩进相当深,因为闭包是捕获此堆栈的自然方式。
单子拯救
解决 CPS 缩进的一种方法是像 Haskell 一样“单子化”。我们该怎么做呢?在 JavaScript 中实现单子的一种不错的方法是使用点链接符号,类似于 jQuery。(有趣的是,参见http://importantshock.wordpress.com/2009/01/18/jquery-is-a-monad/。)或者我们可以使用反射。
但首先我们需要一种“写下管道”的方法,然后才能找到一种抽象它的方法。可悲的是,在 JavaScript 中编写通用的单子语法有些困难,因此我将使用列表来表示计算。
var _x = 0;
var steps = [
[0, function(ret){ret(5)},[]],
[1, thrice,[_x]],
[2, twice,[_x]],
[3, plus,[1, 2]]
]
threeXPlusTwoX = generateComputation(steps);
这有点丑陋,但我们可以让这个未缩进的“代码”工作。我们可以在最后一节中考虑如何使其更美观。我们的目的是记录所有“必要信息”的简便方式。我们希望有一种简单的方法来编写每个“行”,以及我们可以编写它们的上下文。
现在,我们实现一个generateComputation
,生成一些嵌套的匿名函数,如果执行它,将按顺序执行上述步骤。这就是这样一个实现的样子:
function generateComputation(steps) {
function computation(ret) {
var stepResults = [];
var nestedFunctions = steps.reduceRight(
function(laterFuture, step) {
var i = step[0];
var stepFunction = step[1];
var stepArgs = step[2];
console.log(i, laterFuture);
return function(returned) {
if (i>0)
stepResults.push(returned);
var evalledStepArgs = stepArgs.map(function(s){return stepResults[s]});
console.log({i:i, returned:returned, stepResults:stepResults, evalledStepArgs:evalledStepArgs, stepFunction:stepFunction});
stepFunction.apply(this, evalledStepArgs.concat(laterFuture));
}
},
ret
);
nestedFunctions();
}
return computation;
}
演示:
threeXPlusTwoX = generateComputation(steps)(alert)
旁注: reduceRight
的语义意味着右侧的步骤将更深地嵌套在函数中(未来更进一步)。对于那些不熟悉的人,[1,2,3].reduce(f(_,_), x) --> f(f(f(0,1), 2), 3)
,而由于设计考虑不周,reduceRight
实际上等同于 [1.2.3].reversed().reduce(...)
以上,generateComputation
创建了一堆嵌套的函数,将它们包装在一起以备用,当使用 ...(alert)
进行评估时,逐个取消包装以输入计算。
旁注:由于在前面的示例中,我们使用闭包和变量名称来实现 CPS。Javascript 不允许足够的反射来做到这一点,而不必诉诸于制作一个字符串并 eval
它(令人讨厌),因此我们暂时放弃函数式风格,选择突变一个对象来跟踪所有参数。因此,以上更接近以下内容:
var x = 5;
function _x(ret) {
ret(x);
}
function thrice(x, ret) {
ret(x*3)
}
function twice(y, ret) {
ret(y*2)
}
function plus(x,y, ret) {
ret(x+y)
}
function threeXPlusTwoY(x,y, ret) {
results = []
_x(
return function(x) {
results[0] = x;
thrice(x,
function(r1) {
results[1] = r1;
twice(y,
function(r2) {
results[2] = r2;
plus(results[1],results[2],
ret
)
}
)
}
)
}
)
}
理想的语法
但是我们仍然希望以合理的方式编写函数。我们如何才能理想地编写代码来利用CPS,同时保持清晰明了?文献中有许多不同的方法(例如,Scala的shift
和reset
运算符只是其中一种方法),但为了保持清晰明了,让我们找到一种为常规CPS提供语法糖的方法。以下是一些可能的方法:
var _x = 0;
var steps = [
[0, function(ret){ret(5)},[]],
[1, thrice,[_x]],
[2, twice,[_x]],
[3, plus,[1, 2]]
]
threeXPlusTwoX = generateComputation(steps);
...变成...
- 如果回调函数是链式的,我们可以轻松地将一个回调函数传递给下一个,而不用担心命名问题。这些函数只有一个参数:回调参数。(如果没有,你可以在最后一行上使用柯里化函数。) 在这里,我们可以使用 jQuery 风格的点链。
// SYNTAX WITH A SIMPLE CHAIN
// ((2*X) + 2)
twiceXPlusTwo = callbackChain()
.then(prompt)
.then(twice)
.then(function(returned){return plus(returned,2)}); //curried
twiceXPlusTwo(alert);
如果回调函数形成依赖树,我们也可以使用 jQuery 风格的点链,但这将背离为 CPS 创建单子句语法的目的,这是为了扁平化嵌套函数。因此,我们不会在这里详细介绍。
如果回调函数形成依赖无环图(例如 2*x+3*x
,其中 x 被使用两次),我们需要一种方式来命名某些回调的中间结果。这时就变得有趣了。我们的目标是尝试模仿 http://en.wikibooks.org/wiki/Haskell/Continuation_passing_style 上使用 do
符号的语法,该符号“展开”和“重写”函数进入和退出 CPS。不幸的是,[1, thrice,[_x]]
语法是我们可以轻松获得的最接近它的方法(甚至还远)。您可以使用其他语言编码并编译为 JavaScript,或者使用 eval(排队令人不安的音乐)。这有点过头了。替代方案必须使用字符串,例如:
thriceXPlusTwiceX = CPS({
leftPart: thrice('x'),
rightPart: twice('x'),
result: plus('leftPart', 'rightPart')
})
你可以只对我描述的
generateComputation
进行少量调整来实现此目标。首先,您需要将其改为使用逻辑名称(例如
'leftPart'
等)而不是数字。然后,您需要将函数实际转换为行为类似于懒对象的对象:
thrice(x).toListForm() == [<real thrice function>, ['x']]
or
thrice(x).toCPS()(5, alert) // alerts 15
or
thrice.toNonCPS()(5) == 15
(你需要使用某种装饰器以自动化方式完成此操作,而不是手动完成。)
附注:所有回调函数都应遵循有关回调参数位置的相同协议。例如,如果您的函数以myFunction(callback, arg0, arg1, ...)
或myFunction(arg0, arg1, ..., callback)
开头,则它们可能不是简单兼容的,但如果它们不兼容,则可以进行javascript反射hack来查看函数的源代码并将其正则匹配出来,从而无需担心。
为什么要费这么多事呢?这样可以混合使用setTimeout
、prompt
和ajax请求,而不会受到“缩进地狱”的困扰。您还可以获得许多其他好处(比如能够编写一个10行的非确定性搜索数独求解器,并实现任意控制流运算符),这里不再赘述。