函数式编程Continuation总结

原文: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;
    }

控制流图:

image

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 ,控制流图如下:


image.png

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)中介绍.

你可能感兴趣的:(函数式编程Continuation总结)