原文:JavaScript中的异步编程和CPS
在这篇博文中,我们将基于回调的异步编程风格的称为:延续传递风格(CPS)。 我们将解释CPS的工作原理并提供一些使用的小提示。
1、异步编程与回调函数
如果你曾经用javascript编写异步程序,你肯定注意到与编写同步程序有很大的差别:调用一个异步函数的时候,我们不再等待函数的返回值,取而代之的是将一个回调函数作为参数传递给它。例如请看下面同步程序片段:
function loadAvatarImage(id) {
var profile = loadProfile(id);
return loadImage(profile.avatarUrl);
}
在这里加载profile的操作可能非常耗时,最好是将其异步化。例如给 loadProfile 传递一个额外的回调函数。loadProfile立即返回,然后你就可以去处理其它事情了。等到profile加载完成,用它作参数调用你传递给 loadProfile 的回调函数,然后你就可以在回调函数中执行后续的处理,加载image。这就产生了一种基于回调的异步编程风格:
function loadAvatarImage(id, callback) {
loadProfile(id, function (profile) {
loadImage(profile.avatarUrl, callback);
});
}
这种异步编程风格被称为continuation-passing style(CPS),同步编程被成为直接风格。CPS得名于你总是用一个回调函数作为参数去调用目标函数(译注,回调函数可以看成是控制流,CPS即将控制流显式作为参数传递的编程风格)。回调函数延续了控制流程。正因如此回调函数通常被称之为 continuation,尤其是在函数式程序语言中。CPS的主要问题是其具有传染性,要么完全不用要么全都使用这种风格:loadAvatarImage在内部使用了CPS,但它无法将这个事实隐藏在外部,loadAvatarImage的调用者也必须使用CPS。
2、CPS转化
本节将展示一些用于将普通风格的代码转化成continuation-passing style风格的技术。
2.1. 函数调用序列
通常,函数的调用链很容易会形成一个序列。如先前的示例,十分复杂的函数嵌套,这可以通过函数声明来避免:
function loadAvatarImage(id, callback) {
loadProfile(id, loadProfileAvatarImage); // (*)
function loadProfileAvatarImage(profile) {
loadImage(profile.avatarUrl, callback);
}
}
JavaScript提升函数loadProfileAvatar(将其移动到函数的开头)。 因此,它可以在(*)处调用。我们在 loadAvatarImage内部定义loadProfileAvatarImage,因为loadProfileAvatarImage需要使用参数callback(译注,loadAvatarImage与其可访问的外部变量一起形成闭包)。 只要函数调用之间有共享状态,您就会看到这种嵌套。 另一种方法是使用立即调用的函数表达式:
var loadAvatarImage = function () {
var cb;
function loadAvatarImage(id, callback) {
cb = callback;
loadProfile(id, loadProfileAvatarImage);
}
function loadProfileAvatarImage(profile) {
loadImage(profile.avatarUrl, cb);
}
return loadAvatarImage;
}();
2.2. 数组的遍历
以下代码含有一个简单的循环:
function logArray(arr) {
for(var i=0; i < arr.length; i++) {
console.log(arr[i]);
}
console.log("### Done");
}
通过两个步骤将上面的代码转化成CPS风格.
首先将迭代转化成递归.在函数式程序设计语言中是一种普遍的技术.
function logArray(arr) {
logArrayRec(0, arr);
console.log("### Done");
}
function logArrayRec(index, arr) {
if (index < arr.length) {
console.log(arr[index]);
logArrayRec(index+1, arr);
}
// else: done
}
现在很容易就能把它转化成CPS风格.我们通过引入一个helper函数functionforEachCps来实现.
function logArray(arr) {
forEachCps(arr, function (elem, index, next) { // (*)
console.log(elem);
next();
}, function () {
console.log("### Done");
});
}
function forEachCps(arr, visitor, done) { // (**)
forEachCpsRec(0, arr, visitor, done)
}
function forEachCpsRec(index, arr, visitor, done) {
if (index < arr.length) {
visitor(arr[index], index, function () {
forEachCpsRec(index+1, arr, visitor, done);
});
} else {
done();
}
}
这里有两个有趣的改变:我们向访问者(在(*)位置)传递了一个continuation函数next作为参数.这个函数用于在forEachCpsRec里面触发后续处理.这让我们可以在访问者函数内部执行CPS调用.例如,执行一个异步请求.我们还需要给 forEachCps 提供一个 continuation 参数 done,用于指定循环结束之后该做什么.
2.3. 对数组映射
我们对forEachCps稍做调整就可以获得一个Array.Map函数:
function mapCps(arr, func, done) {
mapCpsRec(0, [], arr, func, done)
}
function mapCpsRec(index, outArr, inArr, func, done) {
if (index < inArr.length) {
func(inArr[index], index, function (result) {
mapCpsRec(index+1, outArr.concat(result),
inArr, func, done);
});
} else {
done(outArr);
}
}
mapCps接受一个数组作为参数,输出一个新的数组,数组中每个元素都用func执行了映射操作.上面的mapCps是一个无副作用版本,每次递归都会创建一个新的outArr数组,下面是一个有副作用(译注,副作用意味着会改变一些外部状态,例如在下面的版本中,每次递归调用都会改变results和index,而在上面的无副作用版本中则没有任何状态被改变)变体版本:
function mapCps(arrayLike, func, done) {
var index = 0;
var results = [];
mapOne();
function mapOne() {
if (index < arrayLike.length) {
func(arrayLike[index], index, function (result) {
results.push(result);
index++;
mapOne();
});
} else {
done(results);
}
}
}
mapCps可以这样使用:
function done(result) {
console.log("RESULT: "+result); // RESULT: ONE,TWO,THREE
}
mapCps(["one", "two", "three"],
function (elem, i, callback) {
callback(elem.toUpperCase());
},
done);
变体:并行映射. 顺序版本的mapCps在某些情形下不是效率最好的.例如,如果每一次的映射操作都涉及一次向服务器的请求,发送一个请求,等待结果,发送另一个请求,等等.更可取的方案应该是发送所有的请求然后再等待结果.这种方案需要注意的是,要确保结果以正确的顺序添加到输出数组中.以下代码实现了并行映射.
function parMapCps(arrayLike, func, done) {
var resultCount = 0;
var resultArray = new Array(arrayLike.length);
for (var i=0; i < arrayLike.length; i++) {
func(arrayLike[i], i, maybeDone.bind(null, i)); // (*)
}
function maybeDone(index, result) {
resultArray[index] = result;
resultCount++;
if (resultCount === arrayLike.length) {
done(resultArray);
}
}
}
在(*),我们必须复制循环变量i的当前值。 如果我们不复制,我们将始终在延续中获得i的当前值。 例如,arrayLike.length,如果在循环结束后调用continuation。 复制也可以通过IIFE或使用Array.prototype.forEach而不是for循环来完成。
2.4. 树的遍历
下面的代码用普通模式递归遍历一棵用嵌套数组表示的树
function visitTree(tree, visitor) {
if (Array.isArray(tree)) {
for(var i=0; i < tree.length; i++) {
visitTree(tree[i], visitor);
}
} else {
visitor(tree);
}
}
可以像这样调用:
> visitTree([[1,2],[3,4], 5], function (x) { console.log(x) })
1
2
3
4
5
如果需要在visitor中执行异步请求,那么必须将visitTree改写成CPS风格:
function visitTree(tree, visitor, done) {
if (Array.isArray(tree)) {
visitNodes(tree, 0, visitor, done);
} else {
visitor(tree, done);
}
}
function visitNodes(nodes, index, visitor, done) {
if (index < nodes.length) {
visitTree(nodes[index], visitor, function () {
visitNodes(nodes, index+1, visitor, done);
});
} else {
done();
}
}
当然我们也可以选择使用forEachCps实现:
function visitTree(tree, visitor, done) {
if (Array.isArray(tree)) {
forEachCps(
tree,
function (subTree, index, next) {
visitTree(subTree, visitor, next);
},
done);
} else {
visitor(tree, done);
}
}
2.5. 陷阱:获得结果之后继续执行
在普通模式下,函数返回一个值将使得函数立即终止:
function abs(n) {
if (n < 0) return -n;
return n; // (*)
}
因此,如果n小于0,注释()的部分将不会被执行.但是,下面的代码中,在注释(*)处CPS风格的处理返回值不会导致函数的终止:
// Wrong!
function abs(n, success) {
if (n < 0) success(-n); // (**)
success(n);
}
因此,如果 n > 0,那么success(-n)和success(n)都会执行.要修复这个问题我们只使用完整的if判断语句.
function abs(n, success) {
if (n < 0) {
success(-n);
} else {
success(n);
}
}
上面代码做了适当调整以适应CPS风格,逻辑控制流经由continuation得以继续执行,而物理控制流没有(这里的物理控制流指代码的实际执行路径,而逻辑控制流是指编程逻辑上的控制流).
3、CPS和控制流
CPS将后续步骤具体化--将它转化成可由你操控的对象。在普通风格下,一个函数无法决定自己调用返回之后做什么(译注:因为控制权必须返回给调用者,只有调用者可以决定之后干什么),而对于CPS风格函数,它拥有完全自主权,即发生了控制反转.让我们更细致的分析两种风格下控制流的差别。
普通风格:调用函数,函数返回后将控制交还给调用者,一个函数无法从已经发生的嵌套调用中跳出(译注,即无法通过非本地跳转从内层调用返回,例如,一个递归求积函数,一旦在递归调用的过程中发现一个乘数是0,应该可以立即停止递归过程返回0,但普通模式的函数调用无法实现这个目标).以下代码含有两个这种类型的函数调用:f调用g,g调用h。
function f() {
console.log(g());
}
function g() {
return h();
}
function h() {
return 123;
}
控制流图:
CPS风格:函数自己决定接下来做什么。它可以按预定的控制流执行也可以选择完全不同的执行次序(译注:按传统模式,函数 a 返回之后是调用函数 b 还是函数 c 是由 a的调用者决定的,但在CPS风格下,这个选择权被交给了函数 a)。以下代码是之前示例代码的CPS版本.
function f() {
g(function (result) {
console.log(result);
});
}
function g(success) {
h(success);
}
function h(success) {
success(123);
}
现在控制流完全变样了。f 调用 g,g 调用 h,h调用g的continuation然后再调用console.log ,控制流图如下:
3.1 Return
作为第一个展示操控控制流威力的例子,请看以下代码。之后我会对这段代码做适当的调整并添加一个helper函数以提供与return等价的能力。
function searchArray(arr, searchFor, success, failure) {
forEachCps(arr, function (elem, index, next) {
if (compare(elem, searchFor)) {
success(elem); // (*)
} else {
next();
}
}, failure);
}
function compare(elem, searchFor) {
return (elem.localeCompare(searchFor) === 0);
}
CPS让我们可以在注释(*)的地方退出循环.而javascript中的Array.prototype.forEach方法则不行,它需要我们等待循环的结束(译注,这里的循环是递归函数实现的).我们也可以将campare改成CPS的形式使得在campare内部跳出循环.
function searchArray(arr, searchFor, success, failure) {
forEachCps(arr, function (elem, index, next) {
compareCps(elem, searchFor, success, next);
}, failure);
}
function compareCps(elem, searchFor, success, failure) {
if (elem.localeCompare(searchFor) === 0) {
success(elem);
} else {
failure();
}
}
这令人惊讶,在普通模式下如果要实现这样的效果我们只能动用异常机制了.
3.2 try-catch
利用CPS你还可以为语言实现异常处理机制.在下面的示例中,我用CPS形式实现了一个函数printDiv.它在内部调用另一个CPS函数div,div可以抛出异常.因此,它必须被包裹在tryIt一种我们自己实现的try-catch机制的内部。
function printDiv(a, b, success, failure) {
tryIt(
function (succ, fail) { // try
div(a, b, function (result) { // might throw
console.log(result);
succ();
}, fail);
},
function (errorMsg, succ, fail) { // catch
handleError(succ, fail); // might throw again
},
success,
failure
);
}
为了使得异常处理得以正常工作,需要为每个函数提供额外两个continuation作为参数;一个用以处理正常终止另一个处理失败情况.注释函数tryIt实现了try-catch语句的功能.它的第一个参数相当于try块,第二个参数相当于catch块.而最后两个参数实际上是用于传递给try和catch的.div函数会在除数为0的时候抛出异常.
function div(dividend, divisor, success, failure) {
if (divisor === 0) {
throwIt("Division by zero", success, failure);
} else {
success(dividend / divisor);
}
}
以下是异常处理的实现.
function tryIt(tryBlock, catchBlock, success, failure) {
tryBlock(
success,
function (errorMsg) {
catchBlock(errorMsg, success, failure);
});
}
function throwIt(errorMsg, success, failure) {
failure(errorMsg);
}
请注意,catch块的延续是静态确定的,当调用失败继续时,它们不会传递给它。 它们与完整的tryIt函数具有相同的延续。
3.3 Generator
Generators与ECMAScript.next特性类似,你可能已经在Firefox浏览器上尝试过[2].generator是一个包装了函数的对象.每当你调用一个generator对象的next方法,内部函数的执行就得以继续.每当在内部函数中执行yield value,函数的执行就被挂起,并从generator的next调用中返回,返回值是value.下面的generator将会产生一个无限数序列0,1,2,...
function* countUp() {
for(let i=0;; i++) {
yield i;
}
}
注意这个包含无限循环的函数外部被generator对象包裹.循环的执行由next调用触发,并且每当调用yield的时候都会挂起.以下是在交互式环境下的输出.
> let g = countUp();
> g.next()
0
> g.next()
1
如果我们通过CPS来实现generators,我们会发现generator函数和generator对象之间的差别越发明显.下面我们编写一个generator函数countUpCps.
function countUpCps() {
var i=0;
function nextStep(yieldIt) {
yieldIt(i++, nextStep);
}
return new Generator(nextStep);
}
countUpCps返回一个generator对象,这个对象的generator函数以CPS风格编写.下面是使用方式:
var g = countUpCps();
g.next(function (result) {
console.log(result);
g.next(function (result) {
console.log(result);
// etc.
});
});
下面是generator对象构造函数的实现.
function Generator(genFunc) {
this._genFunc = genFunc;
}
Generator.prototype.next = function (success) {
this._genFunc(function (result, nextGenFunc) {
this._genFunc = nextGenFunc;
success(result);
});
};
注意我是如何将generator函数的当前continuation保存在generator对象内(译注,nextGenFunc).这样下次调用next的时候就不需要再显式传入.
4. CPS和栈
cps另一个让你感兴趣的方面是,不使用栈,你每次都是调用另一个函数继续执行从没使用过return.这意味着如果你的整个程序完全以CPS风格编写,那么你需要的机制只是从一个函数跳转到另一个函数,以及创建环境(用于保存参数和局部变量).也就是说,CPS风格的函数调用更像goto语句.让我们通过一个示例来展示这点,下面的函数含有一个for循环:
function f(n) {
var i=0;
for(; i < n; i++) {
if (isFinished(i)) {
break;
}
}
console.log("Stopped at "+i);
}
同样的函数用goto实现如下:
function f(n) {
var i=0;
L0: if (i >= n) goto L1;
if (isFinished(i)) goto L1;
i++;
goto L0;
L1: console.log("Stopped at "+i);
}
CPS版本看上去就相当不一样了:
function f(n) {
var i=0;
L0();
function L0() {
if (i >= n) {
L1();
} else if (isFinished(i)) {
L1();
} else {
i++;
L0();
}
}
function L1() {
console.log("Stopped at "+i);
}
}
4.1 尾调用
以下代码使用普通递归函数实现对数组的遍历:
function logArrayRec(index, arr) {
if (index < arr.length) {
console.log(arr[index]);
logArrayRec(index+1, arr); // (*)
}
// else: done
}
对一于些编程语言,递归会导致栈增长.但像上面的示例中,我们注意到在注释(*)的位置,是在对自身做递归调用,并且这个调用是函数中最后一条语句.因此实际上我们无需保留栈空间,因为我们无论如何都不会返回到上层函数去.这种在函数中最后一条语句执行函数调用的情况被称为尾部调用.几乎所有的函数式程序设计语言都对尾部调用作了优化,因此在这样的语言中通过函数递归实现迭代结构具有很高的效率.所有真正的CPS函数调用都应该是尾部调用的,因此都可以被优化.我在前文中提到过这种调用方式与goto语句类似也暗示了这点.
4.2 trampolining
很多函数式程序语言编译的中间代码都具有CPS风格,因为大多数控制结构都可以通过函数递归优雅的表达出来,并且对尾调用优化也非常简单.即便某些语言无法优化尾部调用,还是可以使用一种被称为trampolining的技术来避免栈增长.其主要概念就是在函数中不直接调用最后一个continuation(一个函数),而是终止当前函数,并将continuation返回给trampolining.trampolining只是一段循环代码,不断调用接收到的continuation(类似递归转循环的方法).这样就不存在嵌套函数调用,因此也不会导致栈增长.例如,上面的CPS代码可以被重写如下以支持被trampolining处理.
function f(n) {
var i=0;
return [L0];
function L0() {
if (i >= n) {
return [L1];
} else if (isFinished(i)) {
return [L1];
} else {
i++;
return [L0];
}
}
function L1() {
console.log("Stopped at "+i);
}
}
在CPS风格中所有的函数调用都是尾部调用,我们可以将每条函数调用语句
func(arg1, arg2, arg3)
转化成一条return语句
return {func, {arg1, arg2, arg3}}
由trampolining收集返回值然后执行正确的函数调用
function trampoline(result) {
while(Array.isArray(result)) {
var func = result[0];
var args = (result.length >= 2 ? result[1] : []);
result = func.apply(null, args);
}
}
而我们对函数f的要则需要改下成如下的样子:
trampoline(f(14))
4.3事件队列和trampolining
在浏览器和Node.js中,trampolining配合事件队列一同使用.如果你有一些连续的CPS调用(无需通过事件队列获取异步调用结果的调用),那么你可以将continuation push到事件队列中,这样可以避免栈溢出.因此,以下代码:
continuation(result);
应该改写成这样
setTimeout(function () { continuation(result) }, 0);
Node.js中甚至提供了一个特殊的方法process.nextTick()来完成类似的任务.
process.nextTick(function () { continuation(result) });
5. 结论
JavaScript的异步编程风格非常高效.并且相当容易理解.但它正在变得越来越笨重.因此你需要了解更多的背景知识,正因如此我写了本文介绍continuation-passing style.还有一些技术手段可以使得CPS风格的代码更容接受,但这超过了本文介绍的内容.我将会在另一篇博客(quick teaser:promises)中介绍.