延续和回调有什么区别?

我一直在浏览整个网络,寻找关于延续的启示,令人难以置信的是,最简单的解释如何会如此彻底地迷惑像我这样的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);
}

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

那么,延续和回调有什么区别呢?


答案 1

我认为延续是回调的特例。一个函数可以回调任意数量的函数,任意次数。例如:

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

第二个问题通常使用称为 的函数来解决,该函数通常缩写为 。不幸的是,不能在JavaScript中完全实现,但是我们可以为其大多数用例编写一个替换函数:call-with-current-continuationcallcccallcc

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

该函数采用函数并将其应用于 (缩写为 )。是一个延续函数,它在调用 后包装函数体的其余部分。callccfcurrent-continuationcccurrent-continuationcallcc

考虑函数的主体:pythagoras

var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);

第二个是:current-continuationcallcc

function cc(y_squared) {
    add(x_squared, y_squared, cont);
}

同样,第一个是:current-continuationcallcc

function cc(x_squared) {
    var y_squared = callcc(square.bind(null, y));
    add(x_squared, y_squared, cont);
}

由于第一个包含另一个,因此必须将其转换为连续传递样式:current-continuationcallcccallcc

function cc(x_squared) {
    square(y, function cc(y_squared) {
        add(x_squared, y_squared, cont);
    });
}

因此,从本质上讲,从逻辑上讲,将整个函数体转换回我们开始的内容(并为这些匿名函数命名)。使用 callcc 的这种实现的毕达哥拉斯函数变为:callcccc

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中实现,但你可以在JavaScript中实现它作为延续传递样式,如下所示:callcc

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


答案 2

尽管写得很棒,但我认为你有点混淆了你的术语。例如,当调用是函数需要执行的最后一件事时,尾部调用发生是正确的,但是对于延续,尾部调用意味着函数不修改调用它的延续,只是它更新传递给延续的值(如果需要)。这就是为什么将尾递归函数转换为 CPS 如此简单(您只需将延续添加为参数,然后在结果上调用延续)。

将延续称为回调的特殊情况也有点奇怪。我可以看到它们如何很容易地组合在一起,但是延续并不是因为需要与回调区分开来。延续实际上表示完成计算的剩余指令,或从时间点开始的剩余计算。您可以将延续视为需要填补的漏洞。如果我能捕获程序的当前延续,那么我就可以回到捕获延续时程序的确切位置。(这肯定会使调试器更易于编写。

在这种情况下,您的问题的答案是,回调是一种通用事物,在调用方(回调的)提供的某个合约指定的任何时间点被调用。回调可以具有任意数量的参数,并以所需的任何方式进行结构化。因此,延续必然是解析传递给它的值的一个参数过程。必须将延续应用于单个值,并且必须在最后进行应用。当一个延续完成执行表达式时,表达式是完整的,并且,根据语言的语义,可能会或可能不会产生副作用。