[译] Promise 是如何工作的?

4,666 阅读41分钟
原文链接: github.com

目录

1. 入门介绍

大部分的JavaScript实现都是单线程的,并且考虑到语言的语义,人们倾向于使用 callbacks (回调函数)来管理并行的过程。在JavaScript中,虽然使用 Continuation-Passing Style(后继传递格式) 并没有什么明显的过错, 但实际上,这样做会非常容易让代码变得难以阅读和更加程序化(比起它本应有的样子)。

关于这一问题,人们已经提出了很多建议,在这当中,使用promise来让这些并行过程同时进行就是其中之一。 在这篇博文中我们将看到什么是promise,它是怎样工作的,为什么你应该/不该使用它们。

备注 这篇文章假定读者至少熟悉高阶函数、闭包和回调(continuation-passing style)。 或许缺少这些知识,你也能从本文收获到一些什么,但是还是建议你先了解清楚这些概念,再回来读这篇文章。

2. 从概念上理解Promise

在一开始,让我们先来回答一个非常重要的问题: “到底什么是promise?”

要回答这个问题,我们先来看一个现实生活中很常见的情景。

插曲: 讨厌排队的姑娘

女生们想要在一个热闹的餐馆里吃晚餐。

Alissa P. Hacker 和她的女性朋友决定到一个非常受欢迎的餐馆吃晚餐。 不幸的是,正如预想的那样,当她们到达的时候所有的餐桌都被占用了。

在一些地方,这意味着她们要不选择放弃,要不选择去别的地方吃,又或者在这排长队,直到有空桌。 但是还好,这个地方给讨厌排队的Alissa提供了完美的解决方法。

“这是一个有魔力的装置,它代表着你未来的餐桌……”

代表着未来餐桌的装置。

“别担心,亲爱的,只要拿着这款装置,它会帮你处理好一切。” 餐厅里的女士手里拿着一个小盒子对她说。

“这是啥……?” Alissa的朋友,Rue Bae问。

“这是一个有魔力的装置,它代表着你在这家餐厅里将来的餐桌,” 女士一边说,一边示意Bae, “其实里面并没有魔力,但是当排到你的时候,它会通知你们,然后你们就可以过来用餐了。” 她低声说道。

2.1. 什么是Promises?

就像那个“有魔力的”装置可以代表着你未来在餐厅里的餐桌,promise的存在,就是为了代表将会在未来发生的某些事情。 在编程语言中,这指的就是值(values)。

放进整个苹果,出来的是苹果片

在同步的世界里,当想到函数时,我们很容易理解计算: 你把输入放进函数里,函数就会给出一些内容作为输出。

这种 输入输出 的模型很容易理解,大部分程序员对此也非常熟悉。 所有JavaScript的句法结构与内建功能,都假设你的函数会跟随这一模型。

可是这一模型有一个大问题: 当我们要给函数提供了输入,为了让我们获得想要的输出,我们需要一直坐等直到函数完成它的工作。 但是理想情况是:我们想要在这段时间内尽量多做点别的事情,而不光是坐着等待。

为了解决这种问题,promise被提了出来,我们会立刻取得某种表示形式来代表这个值,而不需要一直等到最终结果出来。 我们可以继续我们的生活,然后在某个时间点,回来取得我们所需要的值。

Promise是最终结果的表示形式。

放进整个苹果,随后出来一张苹果切片的票据。

插曲: 执行顺序

现在我们希望明白什么是promise,我们可以看看promise是怎么帮助我们更容易写并行程序的。 但在这之前,让我们先后退一步,思考一个更基本的问题: 程序代码的执行顺序。

作为一个JavaScript程序员,你可能已经注意到,你的程序以一种非常特殊的顺序执行,恰好是你在程序源码中所写指令的顺序:

var circleArea = 10 * 10 * Math.PI;
var squareArea = 20 * 20;

如果我们执行这个程序,首先我们的JavaScript虚拟机会运行计算circleArea,一旦计算完成,再执行squareArea的计算。 换句话说,我们的程序会告诉机器,“做这个,再做那个,然后再做那个……”

问题时间! 为什么我们的机器一定要先计算 circleArea 再计算 squareArea? 如果我们颠倒顺序或者同时执行,会产生什么问题呢?

事实证明,按顺序执行每样东西的代价是很高的。如果 circleArea 花费太多时间,我们将会阻塞 squareArea 执行直到前者完成。实际上,对于这一个例子,我们选择什么样的顺序都没问题,结果是一样的。我们程序中可以任意调整这个顺序。

[…] 按顺序执行的代价是非常高的。

我们想要我们的计算机做更多事情,并且要做得更 。 为了做到这样,首先我们完全去掉执行顺序。换言之,我们假设在我们的程序中所有表达式在同一时间执行。

这个方法很适合我们之前的例子。但是当我们做一点细微改变的时候,问题就来了:

var radius = 10;
var circleArea = radius * radius * Math.PI;
var squareArea = 20 * 20;
print(circleArea);

如果我们没有遵循任何顺序,怎么做到组合其他表达式计算的值呢? 好吧,我们办不到,因为没办法保证当我们需要用到值的时候,它已经被计算出来。

来换种方法,在我们程序中,唯一的顺序被定义为表达式的组件之间的相互依赖关系。在本质上,这意味着一旦表达式的组件计算好了,就可以马上执行,即使其它内容还在执行中。

我们的简单例子里的依赖关系图。

不是非要声明我们执行程序时应该用哪种顺序,我们只需要定义好每一个计算是如何相互依赖的。 手里拿着这些数据,电脑可以创建如上的依赖关系图,并自己推断出最高效执行程序的方式。

有趣的事实! 这个图表很好地描述了程序在Haskell编程语言中是怎样求值的,它也非常接近于表达式在更加熟知的系统中(比如Excel)的求值方法。

2.2. Promise和并发

前面一章所描述的执行模型,其执行顺序被简单定义为每个表达式间的依赖关系,这是非常强大且高效的,但我们如何应用到JavaScript中呢?

我们不能直接把这个模型应用到JavaScript,因为这门语言的内在语义是同步顺序的。但我们可以创造一种分离机制,来描述表达式之间的依赖,并且帮助我们解决这些依赖关系,然后根据这些规则执行程序。其中一种实现方法,就是通过在promise之上引入依赖的概念.

这种promises的新机制由两个主要部分构成: 一是可以作为值的表现形式(representations),并把值放入这种表示形式中;二是创建表达式(expressions)和值(values)之间的依赖关系(dependencies),创建一个新的promise,就是为了取得表达式的结果。

创建代表着未来值的表示形式。

创建值和表达式之间的依赖关系

我们的promise代表着我们还没计算出来的值。这个表示形式是不透明的: 我们看不见值,也不能直接和值相互作用。此外,在JavaScript的promise中,我们也不能从表示形式中取出值。一旦你把一些东西放进一个JavaScript promise,你 不能 从promise里面直接取出来。(robotlolita.me/2015/11/15/…)

这本身没什么用,因为我们需要能够以某种方法使用这些值。如果我们不能从表示形式中取出值,我们需要想别的办法去实现。结果解决 “取出问题”的最简单方法,是通过描述我们想怎么让程序去执行,通过明确地提供依赖关系,然后解决这个依赖关系图并执行它。

要做点这点,我们需要一种方法插进表达式中的实际值,然后延迟表达式的执行,直到它确实被需要。幸运的是,JavaScript中的first-class functions(一等函数)可以达到这个目的。

插曲: 表达式的抽象

比如像 a + 1 这种表达式,一旦 a 的值计算出来,可以通过值来代入 a 来抽象化表达式。按这种方式,表达式:

var a = 2;
a + 1;
// { 用 `a` 的当前值替换 }
// => 2 + 1
// { 简化表达 }
// => 3

再变成以下的lambda抽象(robotlolita.me/2015/11/15/…):

var abstraction = function(a) {
  return a + 1;
};

// 然后我们给 `a` 装上值:
abstraction(2);
// => (a => a + 1)(2)
// { 用提供的值替换 `a` }
// => (2 => 2 + 1)
// { 简化表达式 }
// => 2 + 1
// { 简化表达式 }
// => 3

First-class functions是一个很强大的概念(不管是否 lambda 抽象)。因为有了这个,JavaScript可以用一个非常自然的方式去描述这些依赖关系,通过转换使用了promise值的表达式为first-class functions,我们可以在随后插入值。

3. 理解Promise的机制

3.1. Promise的顺序表达

既然我们看过了promise的概念本质,我们开始理解它们在机器中是怎么样工作的。我们将会描述创建promise用到的操作,再把值放进去,然后描述表达式和值之间的依赖。为了方便举例,我们接下来将会用到非常直观的操作,这些操作恰好没有被现存的promise实现使用:

  • createPromise() 构造出一个值的表示形式。这个值必须要在之后及时提供。

  • fulfil(promise, value) 把值放进promise中,也允许表达式依赖值去计算。

  • depend(promise, expression) 定义了表达式和promise的值之间的依赖。返回一个新的promise作为表达式的结果,以便新的表达式可以依赖于那个值。

让我们回到圆形和正方形的例子。目前,我们用简单点的例子开始: 通过使用promises,把同步的squareArea变成一个用并行描述的程序。squareArea之所以简单,因为它只依赖于side值:

// 表达式:
var side = 10;
var squareArea = side * side;
print(squareArea);

// 变成:
var squareAreaAbstraction = function(side) {
  var result = createPromise();
  fulfil(result, side * side);
  return result;
};
var printAbstraction = function(squareArea) {
  var result = createPromise();
  fulfil(result, print(squareArea));
  return result;
}

var sidePromise = createPromise();
var squareAreaPromise = depend(sidePromise, squareAreaAbstraction);
var printPromise = depend(squareAreaPromise, printAbstraction);

fulfil(sidePromise, 10);

这里会引起很多议论,如果我们和同步版本的代码相比较,可是这个新版本并没有和JavaScript的执行顺序相关联,在执行中的唯一约束,是我们所描述的依赖关系。

3.2. 一个最小限度的promise实现

还有一个悬而未决的问题需要回答: 我们如何运行代码,可使得实际顺序跟我们描述的依赖关系一样呢? 如果我们没有跟随JavaScript的执行顺序,别的东西必须提供我们想要的执行顺序。

幸运地,在我们所使用的函数里,这很容易被定义。首先,我们必须决定如何表示值和其依赖关系,最自然的方式是把这个数据添加到createPromise的返回值。

首先,事物的promises必须可以表示那个值,然而并不是在所有时间都必须包含一个值。当我们调用fulfil时,值才会被放入到promise。这个最小限度的表示形式就是:

data Promise of something = {
  value :: something | null
}

Promise of something以空值null初始化,在某个时间点,某个人可能调用这个promise的fulfil函数,从那以后这个promise将包含给定的实现值 (fulfilment value)。由于promise只能fulfill一次,那个值将会在剩余的程序中一直包含着。

考虑到一个promise不能只通过value(因为null也是一个有效值)来判断是否被fulfil,我们还需要跟踪promise处于哪种状态,所以我们不会冒险多于一次去调用fulfil。这需要我们对之前的表示形式做一点小改变:

data Promise of something = {
  value :: something | null,
  state :: "pending" | "fulfilled"
}

我们还需要处理由depend函数创建出的依赖关系。一个依赖关系是一个函数,最终将会被promise中的值所填充,所以它是可以被评估的。一个promise可以有很多依赖其值的函数,因此这样的一个最小限度表示形式可以是:

data Promise of something = {
  value :: something | null,
  state :: "pending" | "fulfilled",
  dependencies :: [something -> Promise of something_else]
}

既然我们已经决定好promise的表示形式,让我们一起开始定义创建新promise的函数:

function createPromise() {
  return {
    // promise初始化为空值,
    value: null,
    // 待定状态的promise,所以它可以在稍后变成fulfilled,
    state: "pending",
    // 它现在还没有依赖关系。
    dependencies: []
  };
}

既然我们决定了我们的简单表示形式,构造一个新对象来表示是相当简单的。让我们来看点更复杂的: 附加依赖到Promise中。

解决这个问题的其中一个方法,是把所有创造出的依赖放入promise的 dependencies 属性中,然后把promise交给解释器按需计算。用这种实现,解释器开启之前将没有依赖关系会被执行。我们不会这样去实现promise,因为这对于人们通常所写的JavaScript程序并不适合(robotlolita.me/2015/11/15/…)。

另一种解决方案,来源于这个事实:我们只有当promise处于pending状态时,才真正需要跟踪一个promise的依赖关系,因为一旦promise被调用fulfil,我们就可以立刻执行函数了!

function depend(promise, expression) {
  // 当我们可以计算表达式的时候,我们需要返回一个包含表达式的值的promise
  var result = createPromise();

  // 假若我们还不能执行表达式,把它放进依赖列表,作为未来的值
  if (Promise.state === "pending") {
    Promise.dependencies.push(function(value) {
      // 我们关心的是表达式最后的值,所以我们可以把值放进我们的promise结果中
      depend(expression(value), function(newValue) {
        fulfil(result, newValue);
        // 我们返回一个空的promise,因为`depend`函数需要一个promise
        return createPromise();
      })
    });

  // 否则只需要执行表达式,我们就可以得到准备好插入的值
  } else {
    depend(expression(promise.value), function(newValue) {
      fulfil(result, newValue);
      // 我们返回一个空的promise,因为`depend`函数需要一个promise
      return createPromise();
    })
  }

  return result;
}

depend函数等待的值准备好的时候,depend函数负责执行我们的依赖关系计算,但如果我们太早附加依赖,那样函数会在promise对象的一个数组中结束,这样我们的工作并没有完成。对于第二部分的执行,需要在得到值的时候,运行依赖关系。幸运地,我们可以使用fulfil函数。

通过调用fulfil函数把我们的值放进promise当中,我们可以实现正处于pending状态的promise。这是一个好时机,来调用promise值可以用之前所创建的任何的依赖关系,并负责另外一半的执行工作。

function fulfil(promise, value) {
  if (promise.state !== "pending") {
    throw new Error("Trying to fulfil an already fulfilled promise!");
  } else {
    promise.state = "fulfilled";
    promise.value = value;
    // 依赖关系可以添加其他的依赖到这个promise当中,
    // 因此我们需要清理依赖列表,
    // 把列表复制出来以避免我们的的迭代受影响。
    var dependencies = promise.dependencies;
    promise.dependencies = [];
    dependencies.forEach(function(expression) {
      expression(value);
    });
  }
}

4. Promise和错误处理

插曲: 当计算失败的时候

并非所有计算都总能产生一个有效值。某些函数,比如a / ba[0],称作部分函数,因此只能被定义为ab的可能取值的子集。 如果我们写的代码包含了部分函数,并碰上了一种函数不能处理的情况,我们就不能继续执行程序了。换句话说,我们的整个程序会崩溃。

一个更好的在程序中包含部分函数的方法是通过让它变得完整。也就是说,定义函数之前没被定义的部分。总之,我们要考虑让函数处理“成功”的情况,和不能处理的“失败”情况。仅这一点,就已经足以让我们写出整个程序,甚至当面临计算不能产生出一个有效值的时候,也可以继续执行:

部分函数的分支

一个合理但不一定实用的处理方法,是在每一个可能的失败值上建立分支来处理。比如,我们组合了三个可能失败的计算,意味着我们至少要定义6个不同的分支!

在每个部分函数都建分支

有趣的事实! 对一些编程语言,比如 OCaml,更喜欢这种风格的错误处理,因为这样可以很清楚每个步骤。通常来说函数式编程语言偏爱这种明确性,但在某些编程语言,比如 Haskell,使用一个称作Monad的接口(robotlolita.me/2015/11/15/…)来让错误处理(比起其它处理方式)变得更为实用。

更理想的方法是,我们只需要写y / (x / (a / b)),然后对整个组合式只处理一次错误,而不是处理每一个子表达式的错误。编程语言对此有不同的处理方法,比如 C 和 Go,让你可以完全忽略错误,或者至少尽可能延迟碰它。比如Erlang,会让程序崩溃,但也会提供工具让你的程序恢复运行。但最通用的方法,是给可能发生错误的代码块定义一个“错误处理程序”。JavaScript允许通过try/catch声明,实现后一种方法,比如:

一种错误处理的可行方法

4.1. 用Promise处理错误

至今,我们的promise构想中,还没允许失败。因此,所有在promises中的计算必须产生一个有效的结果。如果我们要在promise中运行像 a / b 这样的计算,如果 b 取 0,比如 2 / 0,那样的话计算不能产生有效的结果。

我们的新promise的可能状态

我们可以很容易修改promise,来考虑失败的表达方式。当前我们的promise以pending状态开始,然后它只能被满足。假如我们增加一个新的状态rejected,然后我们就可以在promise当中模仿部分函数了。成功的计算以pending开始,最终以fulfilled状态结束。失败的计算也以pending开始,但状态最后会变为rejected

既然现在我们有可能失败,依赖于promise的值的计算也必须要意识这一点。目前我们的depend失败只需在promise变成fulfilled或者rejected的时候各自运行不同的表达式。

带着这个,我们的promise表示形式变成了:

data Promise of (value, error) = {
  value :: value | error | null,
  state :: "pending" | "fulfilled" | "rejected",
  dependencies :: [{
    fulfilled :: value -> Promise of new_value,
    rejected  :: error -> Promise of new_error
  }]
}

Promise可能包含一个合适的值,或者一个错误,又或者是 null 直到它解决(可能是fulfilled或者rejected)。要这样处理的话,我们的依赖关系也需要知道对于合适值和错误值分别怎样处理,因此稍微改变一下dependencies数组。

除了在表示形式中的改变,我们还要改一下 depend 函数,现在读起来就像这样:

// 注意我们现在需要两个表达式了,而不是一个。
function depend(promise, onSuccess, onFailure) {
  var result = createPromise();

  if (promise.state === "pending") {
    // 依赖关系现在拿到一个对象,包含了promise在成功与失败情况下分别该怎么做。
    // 函数和前面的大致相同。
    promise.dependencies.push({
      fulfilled: function(value) {
        depend(onSuccess(value),
               function(newValue) {
                 fulfil(result, newValue);
                 return createPromise()
               },
               // 我们在应用表达式的时候也必须关心错误
               function(newError) {
                 reject(result, newError);
                 return createPromise();
               });
      },

      // 失败的分支和成功的分支做的事情是一样的,只不过是使用onFailure表达式。
      rejected: function(error) {
        depend(onFailure(error),
               function(newValue) {
                 fulfil(result, newValue);
                 return createPromise();
               },
               function(newError) {
                 reject(result, newError);
                 return createPromise();
               });
        }
      });
    }
  } else {
    // 如果promise已经成功实现,我们运行onSuccess
    if (promise.state === "fulfilled") {
      depend(onSuccess(promise.value),
             function(newValue) {
               fulfil(result, newValue);
               return createPromise();
             },
             function(newError) {
               reject(result, newError);
               return createPromise();
             });
    } else if (promise.state === "rejected") {
      depend(onFailure(promise.value),
             function(newValue) {
               fulfil(result, newValue);
               return createPromise();
             },
             function(newError) {
               reject(result, newError);
               return createPromise();
             });
    }
  }

  return result;
}

最终,我们需要一个把错误放进promise的方法。为此我们需要一个 reject 函数:

function reject(promise, error) {
  if (promise.state !== "pending") {
    throw new Error("Trying to reject a non-pending promise!");
  } else {
    promise.state = "rejected";
    promise.value = error;
    var dependencies = promise.dependencies;
    promise.dependencies = [];
    dependencies.forEach(function(pattern) {
      pattern.rejected(error);
    });
  }
}

由于dependencies改变了,我们还要轻微改变下 fulfil 函数。

function fulfil(promise, value) {
  if (promise.state !== "pending") {
    throw new Error("Trying to fulfil a non-pending promise!");
  } else {
    promise.state = "fulfilled";
    promise.value = value;
    var dependencies = promise.dependencies;
    promise.dependencies = [];
    dependencies.forEach(function(pattern) {
      pattern.fulfilled(value);
    });
  }
}

有了这些新内容,我们已经准备好把可能失败的计算放进promise中:

// 可能失败的计算
var div = function(a, b) {
  var result = createPromise();

  if (b === 0) {
    reject(result, new Error("Division By 0"));
  } else {
    fulfil(result, a / b);
  }

  return result;
}

var printFailure = function(error) {
  console.error(error);
};

var a = 1,b = 2,c = 0,d = 3;
var xPromise = div(a, b);
var yPromise = depend(xPromise,
                      function(x) {
                        return div(x, c)
                      },
                      printFailure);
var zPromise = depend(yPromise,
                      function(y) {
                        return div(y, d)
                      },
                      printFailure);

4.2. Promises的错误传播

上一段代码永远不会执行 zPromise,因为 c 的值是0,并导致了 div(x,c) 计算失败。这正是我们希望的,但是现在我们需要的是:在promise中定义的每一个计算都传递错误。理想情况下,我们喜欢只在必要情况之下定义错误分支,就像我们用 try/catch 处理同步的计算一样。

对我们的promise来说,支持这一功能并不重要。只需要在我们不能抽象的时候,始终定义我们的成功与失败分支,并且这通常是在控制流中的条件。比如在JavaScript中,不可能在 if 声明或者 for 声明上面抽象,因为他们是二等控制流机制了,并且你也不能修改、传递,或者保存在变量当中。我们的promise是一等的对象,有具体的失败与成功的表示形式,以便我们去审查并作出反应什么时候需要它,而不仅仅在它们被创建的时间点上。

promise可能的链式生命周期

为了可以得到类似于 try/catch 这样的结构,首先,我们必须在成功和失败的表示形式上做到这两点:

  • 从错误中恢复: 如果计算失败了,我必须可以把值变成某种有意义的成功。比如说,当从 Map 或者 Array 中尝试取值时,设置默认值。如果map中不存在 "foo" 这个键,map.get("foo").recover(1) + 2 会返回3。

  • 任何时候可能失败: 如果我计算成功了,我必须可以把那个值变成失败;如果我失败了,我必须可以保持这个失败。前面的模型允许了计算短路(short-circuiting),后面这个则允许了错误传播。有了这两个,即使 (a / b) / (c / d) 的任何的子表达式失败了,你也可以完全去捕获它。

很幸运,depend 函数已经帮我们完成了大部分工作了。因为 depend 要求它的表达式返回整个 promise,使得其不仅可以传播值,也可以传播状态。这很重要,因为如果我们只定义了一个 successful 分支,然后promise失败了,我们就不仅要传播值,也要传播失败的状态。

带着这些适如其分的机制:支持简单的失败传播,错误处理,和失败时短路,还需要添加两个操作:chain 在promise的成功值上创建一个依赖关系,在失败时进行短路计算;recover 在promise的失败值上创建依赖关系,并允许从错误中恢复。

function chain(promise, expression) {
  return depend(promise, expression,
                function(error) {
                  // 只需要创建一个等价的promise,我们便可以传播错误状态和相应值。
                  var result = createPromise();
                  reject(result, error);
                  return result;
                })
}

function recover(promise, expression) {
  return depend(promise,
                function(value) {
                  // 只需要创建一个等价的promise,我们便可以传播成功值。
                  var result = createPromise();
                  fulfil(result, value);
                  return result;
                },
                expression)
}

我们可以用这两个函数来简化我们之前的除法例子:

var a = 1,b = 2,c = 0,d = 3;
var xPromise = div(a, b);
var yPromise = chain(xPromise, function(x) {
                                 return div(x, c)
                               });
var zPromise = chain(yPromise, function(y) {
                                 return div(y, d);
                               });
var resultPromise = recover(zPromise, printFailure);

5. 组合promise

5.1. 组合确定性的promise

对promise进行顺序操作时,要求我们创建一个依赖关系链,而并行组合promise只要求promise不存在相互间依赖。

在我们的圆形例子中,我们自然地进行了并行计算。radius 表达式和 Math.PI 表达式之间没有互相依赖,因此它们可以分开计算,但是 circleArea 依赖它们俩的值。依据这个,代码可以写成:

var radius = 10;
var circleArea = radius * radius * Math.PI;
print(circleArea);

如果用promise来表达,代码如下:

var circleAreaAbstraction = function(radius, pi) {
  var result = createPromise();
  fulfil(result, radius * radius * pi);
  return result;
};

var printAbstraction = function(circleArea) {
  var result = createPromise();
  fulfil(result, print(circleArea));
  return result;
};

var radiusPromise = createPromise();
var piPromise = createPromise();

var circleAreaPromise = ???;
var printPromise = chain(circleAreaPromise, printAbstraction);

fulfil(radiusPromise, 10);
fulfil(piPromise, Math.PI);

这里有个小问题: circleAreaAbstraction 是依赖于 两个 值的表达式,但是 depend 只能够定义表达式和单个值的依赖!

有些变通的方法可以解决这个限制,让我们从简单的开始。如果 depend 对一个表达式能提供单个值,那就必须能够在一个闭包中获取值,然后从promise中每次提取一个值。虽然这样确实创建出一种隐含的执行顺序,但这应该没有过分影响并发性。

function wait2(promiseA, promiseB, expression) {
  // 我们先从 promiseA 提取值
  return chain(promiseA, function(a) {
    // 然后从 promiseB 提取值
    return chain(promiseB, function(b) {
      // 既然我们已经取得两个值了,我们就可以执行依赖多于一个值的表达式:
      var result = createPromise();
      fulfil(result, expression(a, b));
      return result;
    })
  })
}

有了这个,我们定义如下的 circleAreaPromise

var circleAreaPromise = chain(wait2(radiusPromise, piPromise),
                              circleAreaAbstraction);

对于依赖三个值的表达式我们可以定义 wait3 ,依赖四个值的表达式我们可以定义 wait4等。但是,wait* 创建出一种隐含顺序(promise以某种特定顺序执行),这样还要求我们提前知道我们需要依赖多少个值。所以,举个例子,如果我们想等待一整个promise数组的话,这种方法就不好使了。(尽管可以通过组合 wait2Array.prototype.reduce来这么做)

另一种解决方案是接收一个promise数组作为参数,逐一执行,然后归还一个promise到原promise包含的值数组。这种方法有点复杂,因为我们要实现一个简单的有限状态机,但是这样没有隐含顺序(除了JavaScript自己的执行语义)。

function waitAll(promises, expression) {
  // 用于存放promise值的数组,一旦有值会马上放进该数组。
  var values = new Array(promises.length);
  // 记录有多少个promise还在等待着
  var pending = values.length;
  // promise结果
  var result = createPromise();
  // 记录promise是否已经被解决
  var resolved = false;

  // 我们开始执行每个promise,并跟踪原始索引值,以此来获取应该把值放进结果数组的哪个位置。
  promises.forEach(function(promise, index) {
    // 对于每个promise,我们会等到promise解决,然后把值存入 `values` 数组
    depend(promise, function(value) {
      if (!resolved) {
        values[index] = value;
        pending = pending - 1;

        // 如果我们完成了等待所有的promise,我们可以把values数组放进结果的promise中。
        if (pending === 0) {
          resolved = true;
          fulfil(result, values);
        }
      }
      // 我们不关心这个promise的其它方面,并返回空promise,因为`depends`需要它。
      return createPromise();
    }, function(error) {
      if (!resolved) {
        resolved = true;
        reject(result, error);
      }
      return createPromise();
    })
  });

  // 最后,我们返回一个promise,作为最终的值数组。
  return result;
}

如果我们要把 waitAll 用到 circleAreaAbstraction,应该会像下面这样:

var circleAreaPromise = chain(waitAll([radiusPromise, piPromise]),
                              function(xs) {
                                return circleAreaAbstraction(xs[0],xs);
                              })

5.2. 组合非确定性的promise

我们已经知道怎样合并promise了,但是到现在我们只能确定性地合并它们。举个例子,比如我们想选择两个计算中最快一个的时候,这就帮不到我们了。或许我们正在两台服务器上面搜索某些东西,而且并不关心哪一台会应答我们,我们只选择最快那一个。

为了支持这样,我们先介绍一些非决定论的知识。特别是,我们需要一个操作是,给定两个promise,拿走更快那个的值与状态。这个主意背后的操作很简单:并行运行两个promise,等待第一个解决,然后把它传到promise结果中。但实现起来并不那么简单,因为我们需要保持着状态。

function race(left, right) {
  // 创建promise结果
  var result = createPromise();

  // 并行等待两个promise,doFulfil 和 doReject 会传播第一个解决的promise的值/状态。
  // 这通过检查 `result` 的当前状态并确认是等待中来完成。
  depend(left, doFulfil,doReject);
  depend(right, doFulfil,doReject);

  // 返回promise结果
  return result;

  function doFulfil(value) {
    if (result.state === "pending") {
      fulfil(result, value);
    }
  }

  function doReject(value) {
    if (result.state === "pending") {
      reject(result, value);
    }
  }
}

通过这种非确定的选择,我们就可以开始组合操作了。就拿上面的例子来说:

function searchA() {
  var result = createPromise();
  setTimeout(function() {
    fulfil(result, 10);
  }, 300);
  return result;
}

function searchB() {
  var result = createPromise();
  setTimeout(function() {
    fulfil(result, 30);
  }, 200);
  return result;
}

var valuePromise = race(searchA(), searchB());
// => valuePromise最终的值是30

在两个promise中作出选择已经成为了可能,因为 race(a, b) 基本就变成了 ab,依赖于哪个解决得更快。因此,如果我们进行 race(c,race(a, b)),并且 b 先解决,然后就变得和 race(c, b) 一样了。当然了,输入 race(a, race(b,race(c, ...))) 并非最佳,因此我们可以写一个简单的组合器来完成这件事:

function raceAll(promises) {
  return promises.reduce(race, createPromise());
}

然后我们可以这样使用:

raceAll([searchA(), searchB(), waitAll([searchA(), searchB()])]);

另一种在两个promise中作出非确定性选择的方法,是等待第一个成功满足的promise。举个例子,如果你正试图从一个镜像源列表里面找出一个可用的下载链接,你可不想因为第一个链接不能下载而失败了,你想要的是从第一个能下载的镜像进行下载,如果全都不能下才算失败。我们可以写一个attempt操作来这么做:

function attempt(left, right) {
  // 创建promise结果
  var result = createPromise();

  // doFulfil会传第一个成功解决的值与状态。
  // 反之,doReject会合计错误,直到所有的promise失败
  //
  // 我们需要跟踪发生的错误
  var errors = {}

  // 现在我们可以等待两个promise,就像在`race`中那样。
  // 不同的是,在这里`doReject`需要知道拒绝哪一个promise,并保持跟踪错误。
  depend(left, doFulfil,doReject('left'));
  depend(right, doFulfil,doReject('right'));

  // 最后,把promise结果作为返回值。
  return result;

  function doFulfil(value) {
    if (result.state === "pending") {
      fulfil(result, state);
    }
  }

  function doReject(field) {
    return function(value) {
      if (result.state === "pending") {
        // 如果我们还在等待中,我们可以安全地一直收集错误。
        // 我们确保得到的错误能进入对象中正确收集这些错误的地方
        errors[field] = value;

        // 如果我们设法收集了所有的错误,我们可以拒绝promise结果。
        // 我们在所有错误都发生时,以正确顺序拒绝它。
        if ('left' in errors && 'right' in errors) {
          reject(result, [errors.left, errors.right]);
        }
      }
    }
  }  
}

race 用法一样,attempt(searchA(), searchB()) 会返回第一个成功解决的promise,而不仅是第一个解决的promise。可是,和 race 不一样,attempt 不会自然构成,因为它会聚集错误。因此,如果我们想尝试几个promise时,我们需要解释下:

function attemptAll(promises) {
  // 由于我们聚集了所有的promise,我们需要从被拒绝的一个promise开始,
  // 否则,如果存在错误,我们的尝试将一直不能完成。
  var initial = createPromise();
  reject(initial, []);

  // 最后,我们用 `attempt` 来把promise组合起来,注意每一步都要平铺错误数组:
  return promises.reduce(function(result, promise) {
    return recover(attempt(result, promise), function(errors) {
      return errors[0].concat([errors]);
    });
  }, createPromise());
}

attemptAll([searchA(), searchB(), searchC(), searchD()]);

6. 对Promise的一种实际理解

ECMAScript 2015 定义了JavaScript中promise的概念,但直到现在,我们使用的还是一个非常简单却非常规的promise实现。其原因是ECMAScript的promise标准过于复杂,要彻底解释这个概念更加艰难。但是,既然你现在知道promise是什么了,和其中的每个方面是怎样实现的,要迁移到理解标准promise也就很简单了。

6.1. 介绍ECMAScript Promise

新版本ECMAScript语言中,定义了一种JavaScript中的promise标准 standard for promises。这个标准和最小限度promise实现有所不同,我们将从几个方面进行介绍,这使得它更复杂,但也更加实际和易于使用。下面的表格列出了每一个实现的不同之处。

我们的 Promises ES2015 Promises
p = createPromise() p = new Promise(…)
fulfil(p, x) p = new Promise((fulfil, reject) => fulfil(x))
p = Promise.resolve(x)
reject(p, x) p = new Promise((fulfil, reject) => reject(x))
p = Promise.reject(x)
depend(p, f, g) p.then(f, g)
chain(p, f) p.then(f)
recover(p, g) p.catch(g)
waitAll(ps) Promise.all(ps)
raceAll(ps) Promise.race(ps)
attemptAll(ps) (None)

在标准promise中,主要的方法是 new Promise(...) 引入一个promise对象,然后用 .then(...) 变换。通过以上对比,所描述的操作,它们的工作方式也有些不一样的地方。

new Promise(f) 构造一个新的promise对象,它通过计算,最终带着某个特定值将状态变为成功或失败。成功或失败的行为,按照预期传递到函数 ff 是带有两个参数的函数对象。第一个参数用在处理执行成功的场景,第二个参数则用在处理执行失败的场景,因此:

var p = createPromise();
fulfil(p, 10);

// 变为:
var p = new Promise((fulfil, reject) => fulfil(10));

// ---
// 并且:
var q = createPromise();
reject(q, 20);

// 变为:
var p = new Promise((fulfil, reject) => reject(20));

Promise.then(f, g) 是一个操作,它在一个有空洞的表达式和一个值之间创建依赖关系,类似于 depend 操作。fg 都是可选参数,如果它们都没被提供,promise会把值在那个状态中传播。

不像我们的 depend.then 是一个复杂的操作,它试图让promise的使用变得更简单。传给 .then 的函数参数可以是一个promise,也可以是一个常规的值,在这种情况下, .then 操作会自动帮你把值放入到promise当中。因此:

depend(promise, function(value) {
  var q = createPromise();
  fulfil(q, value + 1);
  return q;
})

// ---
// 变为:
Promise.then(value => value + 1);

对比我们之前的构想,这样使得promise的代码变得简洁和更方便阅读。

var squareAreaAbstraction = function(side) {
  var result = createPromise();
  fulfil(result, side * side);
  return result;
};
var printAbstraction = function(squareArea) {
  var result = createPromise();
  fulfil(result, print(squareArea));
  return result;
}

var sidePromise = createPromise();
var squareAreaPromise = depend(sidePromise, squareAreaAbstraction);
var printPromise = depend(squareAreaPromise, printAbstraction);

fulfil(sidePromise, 10);

// ---
// 变为:
var sideP = Promise.resolve(10);
var squareAreaP = sideP.then(side => side * side);
squareAreaP.then(area => print(area));

// 这更加类似于同步的版本:
var side = 10;
var squareArea = side * side;
print(squareArea);

类似于我们的 waitAll 操作,并行依赖多个值可以通过 Promise.all 操作来处理:

var radius = 10;
var pi = Math.PI;
var circleArea = radius * radius * pi;
print(circleArea);

// ---
// 变为:
var radiusP = Promise.resolve(10);
var piP = Promise.resolve(Math.PI);
var circleAreaP = Promise.all([radiusP, piP])
                         .then(([radius, pi]) => radius * radius * pi);
circleAreaP.then(circleArea => print(circleArea));

失败和成功的传播通过 .then 操作自身来处理,另外还提供了.catch 操作,作为一种简洁的、无需定义成功分支的 .then 调用。

var div = function(a, b) {
  var result = createPromise();

  if (b === 0) {
    reject(result, new Error("Division By 0"));
  } else {
    fulfil(result, a / b);
  }

  return result;
}

var a = 1,b = 2,c = 0,d = 3;
var xPromise = div(a, b);
var yPromise = chain(xPromise, function(x) {
                                 return div(x, c)
                               });
var zPromise = chain(yPromise, function(y) {
                                 return div(y, d);
                               });
var resultPromise = recover(zPromise, printFailure);

// ---
// 变为:
var div = function(a, b) {
  return new Promise((fulfil, reject) => {
    if (b === 0)  reject(new Error("Division by 0"));
    else          fulfil(a / b);
  })
}

var a = 1,b = 2,c = 0,d = 3;
var xP = div(a, b);
var yP = xP.then(x => div(x,c));
var zP = yP.then(y => div(y,d));
var resultP = zP.catch(printFailure);

6.2. 深入探究 .then

.then 方法和我们之前的 depend 函数相比,有几个不同之处。.then 是一个用来定义最终值和某些计算的依赖关系的方法,它也尝试让大部分情况下promise的使用变得更加容易。这使得 .then 成为了一个复杂的方法(robotlolita.me/2015/11/15/…),但我们可以通过联系我们之前的机制,去理解这个新方法。

.then 自动适应常规值

我们的 depend 函数只适用于接受promise作为参数。它期待于计算依赖关系返回一个promise,目的是为了它自身的promise返回值。.then 却没有这个要求。如果依赖关系返回的是一个像 42 这样的常规值,.then会把值转换成一个包含该值的promise。本质上说,.then 会按需把常规值转换为promise。

把简化类型和我们的 depend 函数相比较:

depend : (Promise of α, (α -> Promise of β)) -> Promise of β

把简化类型和 .then 方法相比较:

Promise.then : (this: Promise of α, (α -> β)) -> Promise of β
Promise.then : (this: Promise of α, (α -> Promise of β)) -> Promise of β

depend 函数里,我们唯一能做的,就是返回一个包含某些内容的promise(并且在promise结果中包含同样的东西),.then 函数出于方便,也接受返回一个常规值,而不需要把值包装在promise当中。

.then 不允许嵌套 promise

为了方便通常的使用情况,ECMAScript 2015 promises的另一种方法是禁止嵌套promise。通过同化带有 .then 方法的任何东西,会使得你在不期待同化的情景之下出问题(robotlolita.me/2015/11/15/…),但另一方面也使大家摆脱了思考匹配返回值类型的痛苦。

受这一功能影响,不可能在非依赖类型系统中给 .then 方法一个明智的类型,但大概这意味着如下的例子:

Promise.resolve(1).then(x => Promise.resolve(Promise.resolve(x + 1)))

等价于:

Promise.resolve(1).then(x => Promise.resolve(x + 1))

这里执行 Promise.resolve ,而不是 Promise.reject

.then 使异常具体化

如果一个异常同步地发生在 .then 方法计算依赖关系的过程中,那么异常会被捕捉到,并具体化为一个被拒绝的Promise。本质上,这意味着所有的在 .then 中的附加在promise的值之上的计算,都好像被包裹在 try/catch 代码块之中,如此:

Promise.resolve(1).then(x => null());

等价于:

Promise.resolve(1).then(x => {
  try {
    return null();
  } catch (error) {
    return Promise.reject(error);
  }
});

Promise的原生实现会追踪这些,并汇报没被处理的内容。由于没有详述promise中的一个“捕获的错误”是由什么构成,所以不同的开发工具汇报的内容有所不同。例如,Chrome开发者工具会输出所有被拒绝的实例到控制台,这可能会给你造成困扰。

.then 异步调用依赖关系

我们之前的promise实现是同步调用依赖关系计算的,标准ECMAScript promise做这个事情是异步的。如果不是用合理手段(.then方法)的话,我们将很难依赖一个promise的值。

因此,下面的代码将不会起作用:

var value;
Promise.resolve(1).then(x => value = x);
console.log(value);
// => undefined
// (`value = x` 到这里才发生,在所有其它代码运行以后)

这保证了依赖关系运算总是执行在一个空栈上,尽管这种保证在 ECMAScript 2015 中并不是那么重要,因为其要求所有的实现都支持适当的尾部调用(robotlolita.me/2015/11/15/…)。

7. 什么时候不适合用promise?

虽然promise作为原生并发可以很好地工作,但promise既不像Continuation-Passing Style那样普遍,也不是所有用例的最佳解决方案。Promise是值的占位符,最终会被计算出来,因此它只能在上下文当中有意义,因为你可以使用那些值自身。

Promises只在的上下文中起作用

试着在想要的结果之外使用promise,包括在一些非常复杂的代码库,理解,并且扩展。以下是一些应该完全避免使用promise的例子:

  • 通知计算某个特定值的结果。 Promise被用在和值本身一样的上下文中,所以就像我们不能知道计算某个特定的字符串的进度一样,给定字符串本身,我们不能用promise来做这个。因为这个,如果你有兴趣知道一个文件的下载进度,你会想要一个分离的东西,比如说事件。

  • 一段时间内需要产生多个值。 Promises只能代表单个最终值。对于一段时间内要产生多个值的情况 (等价于异步迭代器),你可能需要像流(Streams),Observables,或者 CSP Channels 这样的东西。

  • 表示动作。 这也意味着不能按顺序执行promise,因为一旦得到一个promise,就马上开始计算它的值了。对于动作可以使用 CPSContinuation monad,或者像 C♯ 那样的 Task (co)monad

8. 结论

Promise 允许我们组合同步与异步过程,对于处理最后返回的值是一种很棒的方式。虽然 ECMAScript 2015 里面的 promise 标准还有它自身的一系列问题,比如自动地具体化错误应该使进程崩溃,但它有一个非常好用的工具来处理上述问题。无论你是否使用他们,理解 promise 是什么和它的工作原理是很重要的,因为在所有的 ECMAScript 工程当中,它们的使用正变得越来越普遍。

引用

ECMAScript® 2015 Language Specification

Allen Wirfs-Brock — 定义了 JavaScript 中的 promise 标准。

Alice Through The Looking Glass

Andreas Rossberg,Didier Le Botlan,Guido Tack,Thorsten Brunklaus,and Gert Smolka — 提出了 Alice 语言,通过 future 和 promise 支持了并发。

Haskell 98 Language and Libraries

Simon Peyton Jones — 非正式地描述了 Haskell 编程语言的语义。

Communicating Sequential Processes

C. A. R. Hoare — 描述了进程的并发组合,比如确定性和非确定性的选择。

Monads For Functional Programming

Philip Wadler — 描述了在这当中的其他内容,monads 是如何被用在函数式语言错误处理的。尽管在 ECMAScript 2015 中,promise 没有实现 monad 的接口,但是 Promise 的顺序和错误处理非常接近于 monad 的构想。

附加资源

Source Code For This Blog Post

包含了这篇博文里所有的(有注释的)源代码(包含一个遵循了 ECMAScript 2015 规范的 promise 最小化实现)。

Promises/A+ Considered Harmful

Quildreen Motta — 在复杂程度、错误处理、性能方面,讨论了Promises/A+ 和 ECMAScript 2015 Promises 标准中的一些问题。

Professor Frisby’s Mostly Adequate Guide to Functional Programming

Brian Lonsdorf — 一本关于 JavaScript 函数式编程的引导性的图书。

Callbacks Are Imperative,Promises Are Functional: Node’s Biggest Missed Opportunity

James Coglan — 通过描述一个程序的执行顺序,对比了 Continuation-Passing Style 和 Promise。

Simple Made Easy

Rich Hickey — Rich在演讲中讨论了在设计的背景下的“简单”和“容易”,虽然和 promise 没有直接相关,但是和编程有很大的关系。

Proper Tail Calls in Harmony

Dave Herman — 讨论了在 ECMAScript 中合理使用尾部调用的好处。

Your Mouse is a Database

Erik Meijer — 讨论了基于事件和异步计算的Rx的协调和编制,使用了观察者的概念。

Stream Handbook

James Halliday (substack) — 涵盖了编写 Node.js 流(Streams)程序的一些基础知识。

By Example: Continuation-Passing Style in JavaScript

Matt Might — 描述了 continuation-passing style 如何被应用在 JavaScript 非阻塞计算中。

The Continuation Monad

Gabriel Gonzalez — 基于 Haskell 编程语言环境,讨论了诸如 monads 这样的概念延续。

Pause ‘n’ Play: Asynchronous C♯ Explained

Claudio Russo — 解释了使用 Task comonad 的异步计算在 C♯ 中如何工作,以及那个解决方案是怎样和其它模型建立联系的。

资源库

es6-promise

对于没有实现 ECMAScript 2015 的平台,这是一个用来实现 ES2015 promise 的 polyfill。

Bluebird

一个高效的 Promises/A+ 实现。

脚注

  1. 在 JavaScript 中,你不能在 Promises/A,Promises/A+ 和其它 promise 的常见实现中,直接取出 promise 的值。

    在一些 JavaScript 环境中,比如 Rhino 和 Nashorn(译者注:都是用Java实现的JavaScript引擎),也许可以实现支持提取值的 promise。Java的 Futures 就是一个例子。

    要从 promise 取出还没计算出来的值,要求阻塞线程,直到值被计算出来。对于大多数JS环境,这并不通用,因为它们都是单线程的。

  2. “lambda抽象”是一种在表达式中使用抽象变量的匿名函数。JavaScript 的匿名函数等价于LC的Lambda抽象,然而 JavaScript 也允许给函数命名。

  3. Haskell编程语言的工作方式,就是“计算定义”和“执行计算”的分离。一个 Haskell 程序只不过是大量计算结果为 IO 数据结构的表达式。这个结果多少类似于我们在这里定义的 Promise 结构,因为它只定义了程序中不同计算之间的依赖关系。

    在Haskell中,你的程序必须返回 IO 类型的值,这个值会随后传递到一个单独的解释器。解释器只知道如何允许 IO 计算,并遵守其定义的依赖关系。对于JS,也可以定义某些类似的内容。如果我们那样做的话,所有我们的JS程序都仅仅是一个导致 promise 的表达式,并且那个 promise 会传递到一个单独的组件,这个组件知道如何执行 promise 和它的依赖关系。

    看看 Pure Promises 示例目录,可作为这种 promise 形式的一个实现。

  4. Monad 是一个接口,可以(并且通常是)用作顺序语义,通过以下操作,可被描述为一个结构体:

    class Monad m where
      -- 把值放进monad中
      of    :: ∀a. a -> Monad a
    
      -- 在 monad 中变换值
      -- (转换必须保持类型不变)
      chain :: ∀a, b. m a -> (a -> m b) -> m b
    

    在这个构想中,monad 的 chain 操作符 print(1).chain(_ => print(2)) 和JS的 “分号操作符” 多少有点类似(例如: print(1); print(2))。

  5. 这里使用了Rich Hickey的概念:“复杂”和“简单”。 .then 就被定义为一种简单的方法。它迎合了一般的使用案例,作为简化概念的代价,那就是 .then 做了太多的事情,而且这些事情有相当多的重叠。

    另一方面,一个简单的API,会把这些单独概念分离到不同的函数中,使得你可以用 .then 把这些功能都实现。

  6. .then 方法接收一切值和状态,让它们看起来像一个 promise 。在以前,这些是通过一个接口去检查,这意味着通过检查一个对象是否提供了 .then 方法,可以包含所有的对象,它们都不符合 promise 的 .then 方法。

    如果 promise 标准不受限于向后兼容性,使用现存的 promise 实现,可以进行更可靠的测试,通过使用接口符号(Symbols for interfaces),或者品牌的某些类似形式实现。

  7. 适当的尾部调用保证了尾部位置的所有调用将在恒定的堆栈中发生。本质上,这保证了你的程序完全由尾部调用构成,栈将不会增加,因此,栈溢出错误在这样的代码中将不可能出现。附带地,它也允许语言实现,来让这样的代码变得更快,因为它不需要处理常见的函数调用开销。