阅读 3122

Promise 不够中立

原文:https://staltz.com/promises-are-not-neutral-enough.html

原文作者 Staltz 是 cyclejs 和 callbag 的核心开发者。贺师俊对本文进行了全面的反驳,反驳文在此


Promise 产生的问题影响了 JS 的整个生态系统!本文将对其中一些问题进行阐述。

上面这句话可能让你认为我被 Promise 折磨得心情极差,对着电脑骂脏话,于是打算在网上发泄一通。实际上并不是的,我今早刚泡好咖啡,就有人在 Twitter 上问我对 Promise 的看法,我才写下了这篇文章。我当时一遍喝咖啡一遍思考,然后向他回复了几条微博。一些人回复说最好能写成博客,于是就有了这篇文章。

Promise 的主要目的是表示一个终将会得到的值(下文简称最终值)。这个值可能会在下一个 event loop 中得到,也可能会在几分钟后得到。还有很多其他原语可以达到相同的目的,比如回调、C# 中的任务、Scala 中的 Future,RxJS 中的 Observable 等。JS 中的 Promise 只是这些原语中的一个而已。

虽然这些原语都能实现这个目的,但是 JS 的 Promise 是一个太过 opinionated (译注:opinionated 是主观臆断的意思,这里表示不恰当的、强加观点的)的方案,它造成了很多奇怪的问题。这些问题又会引发 JS 语法和生态系统中的其他问题。我认为 Promise 不够中立,其 opinionated 表现在下面四个地方:

  • 立即执行而不是延迟执行
  • 不可中断
  • 无法同步执行
  • then() 其实是 map() 和 flatMap() 的混合体

立即执行,而不是延迟执行

当你创建一个 Promise 实例的时候,任务就已经开始执行了,比如下面代码:

console.log('before');
const promise = new Promise(function fn(resolve, reject) {
  console.log('hello');
  // ...
});
console.log('after');
复制代码

你会在控制台里依次看到 before、hello 和 after。这是因为你传递给 Promise 的函数 fn 是被立即执行的。我把 fn 单独拧出来你可能就看得更清晰一些了:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before');
const promise = new Promise(fn); // fn 是立即执行的!
console.log('after');
复制代码

所以说 Promise 会立即执行它的任务。注意在上面的代码中,我们甚至还没使用这个 Promise 实例,也就是没有使用过 promise.then() 或 promise 的其他 API。仅仅是创建 Promise 实例就会立即执行 Promise 里的任务。

理解这一点很重要,因为

  1. 有的时候你不想 Promise 里的任务立刻开始执行
  2. 有时候你会想要一个可复用的异步任务,但是 Promise 却只会执行一次任务,因此一旦 Promise 实例被创建,你就没法复用它了。

通常解决这个问题的办法就是把 Promise 实例化的过程写在一个函数里:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before');
const promiseGetter = () => new Promise(fn); // fn 没有立即执行
console.log('after');
复制代码

由于函数是可以在后面调用的,所以用一个「返回 Promise 实例的函数」(下文简称为 Promise Getter)就解决了我们的问题。但是另一个问题来了,我们不能简单地用 .then() 把这些 Promise Getter 连起来(译注:原文说得不够清晰,我不太理解作者的意图)。为了解决这个问题,大家的做法一般是给 Promise Getter 写一个类似 .then() 的方法,殊不知这就是在解决 Promise 的复用性问题和链式调用问题。比如下面代码:

// getUserAge 是一个 Promise Getter
function getUserAge() {
  // fetch 也是一个 Promise Getter
  return fetch('https://my.api.lol/user/295712')
    .then(res => res.json())
    .then(user => user.age);
}
复制代码

所以说 Promise Getter 其实更利于组合和复用。这是因为 Promise Getter 可以延迟执行。如果 Promise 一开始就设计成延迟执行的,我们就不用这么麻烦了:

const getUserAge = betterFetch('https://my.api.lol/user/295712')
  .then(res => res.json())
  .then(user => user.age);
复制代码

(译者注:也上面代码执行完了之后,fetch 任务还没开始)

我们可以调用 getUserAge.run(cb) 来让任务执行(译注:很像 Rx.js)。如果你多次调用 getUserAge.run,多个任务就都会执行,最后你会得到多个最终值。不错!这样一来我们既能复用 Promise,又能做到链式调用。(译注:这是针对 Promise Getter 说的,因为 Promise Getter 能复用,却不能链式调用)

延迟执行比立即执行更通用,因为立即执行无法重复调用,而延迟执行却可以多次调用。延迟执行对调用次数没有任何限制。

所以我认为立即执行比延迟执行更 opinionated(译注:opinionated 是贬义词)。C# 中的 Task 跟 Promise 很像,只不过 C# 的 Task 是延迟执行的,而且 Task 有一个 .start() 方法,Promise 却没有。

我打个比方吧,Promise 既是菜谱又是做出来的菜,你吃菜的时候必须把菜谱也吃掉,这不科学。

不可中断

一旦你创建了一个 Promise 实例,Promise 里的任务就会马上执行,更悲催的是,你无法阻止的执行。所以你现在还想创建一个 Promise 实例吗?这是一条不归路。

我认为 Promise 的「不可中断」跟它的「立即执行」特性密切相关。这里用一个不错的例子来说明:

var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
复制代码

假设我们可以使用 promiseB.cancel() 来中断任务,请问 promiseA 的任务应该被中断吗?也许你认为可以中断,那就再看看下面这个例子:

var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
var promiseC = promiseA.then(/* ... */);
复制代码

这个时候如果我们可以用 promiseB.cancel() 来中断任务,promiseA 的任务就不应该被中断,因为 promiseC 依赖了 promiseA。

正是由于「立即执行」,Promise 任务中断的向上传播机制才变得复杂起来。一个可能的解决办法是引用计数,不过这种方案有很多边界情况甚至 bug。

如果 Promise 是延迟执行的,并提供 .run 方法,那么事情就变得简单了:

var execution = promise.run();

// 一段时间后
execution.cancel();
复制代码

promise.run() 返回的 execution 就是任务的回溯链,链上的每一个任务都分别创建了自己的 execution。 如果我们调用 executionC.cancel(),那么 executionA.cancel() 就会被自动调用,而 executionB 有它自己的一个 executionA,跟 executionC 的 executionA 互不相干。所以可能同时有多个 A 任务在执行,这并不会造成什么问题。

如果你想避免多个 A 任务都在执行,你可以给 A 任务添加一个共享方法,也就是说我们可以「选择性地使用」引用计数,而不是「强制使用」引用计数。注意「选择性地使用」和「强制使用」的区别,如果一个行为是「选择性地使用」的,那么它就是中立的;如果一个行为是「强制使用」的,那么它就是 opinionated 的。

回到那个奇怪的菜谱的例子,假设你在一个餐厅点了一盘菜,但是一分钟后你又不想吃这盘菜了,Promise 的做法就是:不管你想不想吃,都会强行把菜塞进你的喉咙里。因为 Promise 认为你点了菜就必须吃(不可中断)。

无法同步执行

Promise 的设计策略中,允许最早的 resolve 时机是进入下一个 event loop 阶段之前(译注:请参考 process.nextTick),以方便解决同时创建多个 Promise 实例时产生的竞态问题。

console.log('before');
Promise.resolve(42).then(x => console.log(x));
console.log('after');
复制代码

上面代码会依次打印出 'before' 'after' 和 42。不管你如何构造这个 Promise 实例,你都没有办法使 then 里的函数在 'after' 之前打印 42。

最后的结果就是,你可以把同步代码写成 Promise,但是却没有办法把 Promise 改成同步代码。这是一个人为的限制,你看回调就没有这个限制,我们可以把同步代码写成回调,也可以把回调改成同步代码。以 forEach 为例:

console.log('before');
[42].forEach(x => console.log(x));
console.log('after');
复制代码

这个代码会一次打印出 'before' 42 和 'after'。

由于我们不可能把 Promise 重新改写成同步代码,所以一旦我们在代码里使用了 Promise,就使得它周围的代码都变成了基于 Promise 的代码(译注:不是很理解这为什么就叫做基于 Promise 的代码),即使这样做没意义。

我能理解异步代码让周围的代码也异步,但是 Promise 却强制让同步代码周围的代码也变成异步的。这就是 Promise 的又一个 opinionated 之处。一个中立的方案不应该强制数据的传递方式是同步或是异步。

我认为 Promise 是一种「有损抽象」,类似于「有损压缩」,当你把东西放在 Promise 里,然后把东西从 Promise 里拿出来,这东西就跟以前不一样了。

想象你在一个连锁快餐店里点了一个汉堡,服务员立即拿出一个做好的汉堡递给你,但是把手伸过去接却发现这个服务器死死地抓住这个汉堡不给你,他只是看着你,然后开始倒数 3 秒钟,然后他才松手。你拿到你的汉堡走出快餐店,想逃离这个诡异的地方。莫名其妙啊,他们就是想让你在拿餐之前等一会,还说是以防万一。

then() 其实是 map() 和 flatMap() 的混合体

当传递一个回调给 then 的时候,你的回调函数可以返回一个常规的值,也可以返回一个 Promise 实例。有趣的是,两种写法的效果一模一样。

Promise.resolve(42).then(x => x / 10);
// 效果跟下面这句话一致
Promise.resolve(42).then(x => Promise.resolve(x / 10));
复制代码

为了防止 Promise 套 Promise 的情况,then 内部遇到返回值是常规的值就转换成 Promise 实例(译注:这就是 map,参见 hax 对 map 的解释 Promise<T>.then(T => U): Promise<U>),遇到 Promise 实例就直接使用(译注:这就是 flatMap,Promise<T>.then(T => Promise<U>): Promise<U>)。

从某种程度上说,这么做对你是有帮助的,因为如果你对其中的细节不是很了解它会自动帮你搞定。假设 Promise 其实是可以提供 map、flatten 和 flatMap 方法的,我们却只能使用 then 方法来搞定所有需求。你看到 Promise 的限制了吗?我被限制只能使用 then,一个会做一些自动转换的简化版 API,我想做更多控制都是不可能的。

很久之前,Promise 刚被引入 JS 社区的时候,一些人有想过为 Promise 添加 map 和 flatMap 方法,详情你可以在这篇讨论里看到。不过参与语法制定的人以 category theory 和函数式编程等理由反驳了这些人。

我不想在这篇文章里对函数式编程讨论太多,我只说一点:如果不遵循数学的话,就基本不可能创造出一个中立的编程原语。数学并不是一门与实际编程不相关的学科,数学里的概念都是有实际意义的,所以如果你不想你创造出来的东西出现自相矛盾的情况的话,也许你应该多了解一些数学。

这篇讨论的主要焦点就是为什么不能让 Promise 有 map、flatMap 和 concat 这些方法。很多其他的原语都有这些方法,比如数组,另外如果你用过 ImmutableJS 你会发现它也有这些方法。map、flatMap 和 concat 真的很好用。

想象一下,我们写代码的时候只管调用 map、flatMap 和 concat 即可,不用管它到底是什么原语,是不是很爽。只要输入源有这些方法即可。这样一来测试就会很方便,因为我可以直接把数组作为 mock 数据(译注:而不需要去构造一些 HTTP 请求)。如果代码中使用了 ImmutableJS 或生产环境中的异步 API,那么测试环境中只要用数组来模拟就够了。函数式编程中说的「泛型」「type class 编程」和 monad 等都有类似的意思,说的是我们可以给不同的原语以一批相同的方法名。如果一个原语的方法名是 concat 另一个原语的方法名是 concatenate,但是实质上它们做的是几乎相同的事情,就很令人讨厌了。

所以为什么不把 Promise 理解成跟数组差不多的概念,有 concat、map 等方法。Promise 基本上可以被 map,所以就给 Promise 添加 map 方法吧;Promise 基本上可以被 chain,所以就给 Promise 添加上 flatMap 方法吧。

不幸的是现实不是这样的,Promise 把 map 和 flatMap 挤到 then 里面,并加了一些自动转换逻辑。这么做只是因为 map 和 flapMap 看起来很类似,他们认为写成两个方法有点多此一举。

总结

好吧,Promise 也能工作,你可以用 Promise 搞定你的业务而且一切都运行良好。没必要惊慌。Promise 只是看起来有点怪异了,而且真不幸它还很 opinionated。他们强加给 Promise 一些在某些时候毫无意义的规则。这么做问题不大,因为我们可以很容易的绕过这些规则。

Promise 很难复用,没关系我们可以用额外的函数搞定; Promise 不能被中断,没关系我们可以让那些本该中断的任务继续执行,不就是浪费了一些资源而已嘛。

真烦人,我们总是要给 Promise 做一些修修补补; 真烦人,现在新出的 API 都是基于 Promise 的,我们甚至给 Promise 发明了一个语法糖:async/await。

所以接下来几年我们都要忍受 Promise 的这些怪异之处。如果我们一开始就把延迟执行考虑到 Promise 里,也许 Promise 就是另外一番光景了。

如果 Promise 的设计初期就是从数学角度思考会是什么样子?这里我给出两个例子:fun-taskavenir,这两个库都是延迟执行的,所以有很多共同点,不同点主要体现在命名和方法可访问性上。这两个库都比 Promise 更不 opinionated,因为它们:

  1. 延迟执行
  2. 允许同步
  3. 允许中断

Promise 是被发明的,不是被发现的。最好的原语都是被发现的,因为这些原语是中立的,所以我们无法反驳它们。例如,圆就是这样一个无法被反驳的数学概念,所以说是人类发现了圆,而不是发明了圆。由于圆是中立的,没有被强加任何主观的限制,所以你没有办法反驳一个圆。而且,圆,无处不在。

关注下面的标签,发现更多相似文章
评论

查看更多 >