WHY “PROMISES ARE NOT NEUTRAL ENOUGH” IS NOT NEUTRAL ENOUGH

这篇文章的标题有点绕口,不过大家都懂的,这是一个吐槽手法。

本文就是要吐槽 Staltz 最近写的这篇文章《Promises are not neutral enough》。


Staltz 作为 Cycle.js 的作者,也算是社区名人之一。最近他搞了一个大新闻叫 Callbag(Why we need Callbags),一看名字就是给 callback 招魂的。这篇我不打算吐槽 callbag(想看吐槽 callbag 的可移步:callbag和rxjs有什么区别?),就单吐槽一下 Staltz 对于 promise 的偏见。

Staltz 说 promise 是“opinionated primitive that introduce a lot of weirdness”,并列了四点 opinion:

  • Eager, not lazy
  • No cancellation
  • Never synchronous
  • then() is a mix of map() and flatMap()

我一点点来说。


第一点,promise 是 eager 立即求值而不是 lazy 延迟求值。

其实这个事情是有点扯的。因为所有语言、库里的 promise 抽象(有些叫 future 或 deferred,语义上有些差别,但是在此问题上不重要,所以这里不展开说)都是如此。也就是说如果还需要用户主动调用 x.run() 来开始计算,那就不是 promise 了。那叫 task(或 fiber,或类似的 thunk)。

(当然不排除世界上有些傻逼库硬是要做一个 lazy future 之类的东西。其实你既然要提供不同的抽象,安安心心的叫 task 就好了,不要把概念搞乱行不行。)


到底 task 好还是 promise 好?这本身其实有点关公战秦琼。因为两者其实是不同的抽象。task 的抽象侧重于“执行(任务)”,而 promise 的抽象侧重于“(最终的)值”。这不同的抽象选择导致不一样的语义和 API,是一件非常自然的事情。若侧重于“执行”,那自然应该允许用户选择何时执行,也没有必要限制执行一定是同步的还是异步的,甚至无所谓是否在单独线程里跑 —— 直接抵达了 thread 的领域。而若侧重于“值”,那用户为什么要 care 这个值的运算过程?

其实如果你需要控制执行(sometimes you don’t want the promise to start right away),或重用异步任务(you may want to have a reusable asynchronous task),直接写一个返回 promise 的函数,或者一个 async 函数就好了啊!函数就是用来表达执行的啊!!!如此简单而自然!

Staltz 当然知道这一点,但他强词夺理说函数就不能用 then 来 chain 了。我擦,人家 promise 就是一个异步值的原语,then 方法只是为了在没有 async/await 的时代,提供你一个利用异步值的基础设施。(否则你压根没法用啊!)然而你为什么要让它去管函数链式调用?你如果要处理一般的函数链式调用,自己 compose 函数啊,或者等着 pipeline operator 啊!(在别的地方你倒知道吹 pipeline operator,怎么说起 promise 来就忘了??)

说什么“Eager is less general than lazy”,完全是胡说八道。你在一个 lazy 的语言比如 haskell 里这么说也就算了,你在一个明明全然是 eager 的语言里说“eager is less general”,颠倒黑白没有这么流利的吧?


第二点,没有 cancellation。确实 promise 没有内置这能力(cancelable promise 提案因为各种原因被撤销了)。但是现在有 cancelation 提案(tc39/proposal-cancellation)啊,而且最新版浏览器已经支持了一个非常类似的方案(DOM Standard)!(当然dom规范里的 AbortController/AbortSignal 如何跟语言规范里的机制协调可能是个棘手问题,有待处理,不过大方向是没有问题的。)

Staltz 说“I believe lack of cancellation is related to eagerness.”不好意思,全错。你后面提到的 cancel 在向上游传播时的问题,本质上在于向上传播本身就是概念混乱的产物,跟立即执行没有半毛钱关系。建议好好再学习一下 cancelation token 提案的 architechture 部分(tc39/proposal-cancellation#architecture)。


比较神奇的是

Try to pay attention to the words “opt-in”, “restriction”, “always”. When a behavior is opt-in, it is neutral. When a behavior is always forced, it is opinionated.

这段完全是稻草人攻击。实际上 cancellation 无论是当前提案还是 dom 规范里的设施,都是独立于 promise 的,所以必然是 opt-in 的。

其实前面的 eager 问题也是。显然返回 promise 的 function 就提供了所谓 lazy,且 promise 和 function 是独立特性,所以我们可以说你所谓的 lazy 是 opt-in 的。但是你反过来说这是 restriction??这双重标准是怎么玩的??


第三点,总是异步。这一点其实没有好多说的。node callback convention 也包含了这一点(只不过 callback 形式很难强制约束这一点,这是 callback 的缺陷之一)。对此有疑问的人建议再好好读 Isaac Z. Schlueter 多年前的经典文章:blog.izs.me/post/591427

所以 forEach 的例子正说明问题。forEach 明确的告诉你这里是同步的。promise 则明确的告诉你这里是异步的。这是为什么 promise 必须总是异步,且你应该在所有处理异步的地方都使用 promise。这样就不会出现你看到一个 callback 但是搞不清它是同步还是异步了。

为什么同步异步之分在 JS 里那么重要?因为 JS 不是 pure 函数式语言!JS 代码会依赖副作用,而副作用取决于代码的执行时序。JS 有 run-to-completion 语义,所以只要明确是同步还是异步,其执行时序是非常容易推断的。

下面忍不住要逐段打脸。

The impossibility of going back to synchronous once you convert to Promise means that using Promises in a code base will force code around it to be Promise-based even when it doesn’t make sense.

Promise 本来就是异步原语。异步当然不能被转换为同步啊!除非你用阻塞。而在 JS 里提供阻塞等于提供一把注定会打死你自己的枪。

promise 也并没有把所有代码都变成基于 promise 的,传给 then 的回调完全可以是纯同步的代码啊!

I can understand why async code forces surrounding code to become async too, but Promises make this effect worse by forcing sync code to become async. That’s yet another opinion inserted into Promises.

说来说去就是说异步的传染性。你要是依赖一个异步值,你的函数当然就得是异步的啊。但是你已经 await 到一个值之后所做的计算可以抽成一个纯同步的函数啊。自己模块化做不好,怪语言设施…… 再说你不是 observable 和 pipeline operator 玩得很溜嘛,又没说不许用。

A neutral stance would be to have the primitive make no claims whether the data will be delivered synchronously or asynchronously.

同样的话也可以用来批评 haskell,你们搞什么 pure,搞什么 lazy,完全不“中立”!

Promises are what I call a “lossy abstraction”, similar to lossy compression, where you can put stuff in that container, but when you take it out of the container, it’s not quite the same as it was before.

对“抽象”的理解简直一团屎。按照这说法,高级语言都是“lossy abstraction”,汇编才是无损纯真的代码!

说了半天其实 Staltz 就是有意忽略一点,Promise 对 JS 来说就是异步原语,由此施加额外约束是应有之义。你所谓“中立”的结果无非是给程序员留坑。


最后一点,Staltz 吐槽 then() 不是正宗原味 monad。

这算整篇文章比较有技术含量的部分了。然而……


首先,map 和 flatMap 的签名是:

M<T>.map(T => U): M<U>

M<T>.flatMap(T => M<U>): M<U>

而 then 的签名是:

Promise<T>.then(T => U): Promise<U>

Promise<T>.then(T => Promise<U>): Promise<U>

易见,then 实际上是自动 overload 版的 map/flatMap。


Staltz 吐槽点就是,干嘛不直接暴露 map/flatMap 呢?这样就可以跟其他 monad 小伙伴一起玩耍啦!

我先不说你是不是真的有场景要统一操作异种 monad,我先把你提到的“马上就要到来的”Array.prototype.flatMap 拿出来看一下。

Array<T>.flatMap(T => Array<U>): Array<U>

理想上其签名应该是这样的,然而,JS 不是静态类型语言啊!谁确保传进来的回调是 T => Array<U> 呢?如果返回值不是 Array,那就等于传进来了 T => U 啊。

于是你突然发现,Array.prototype.flatMap 明明跟 Promise.prototype.then 是一样的,自动 overload 了!

所以,在动态类型语言里,只要你不打算做运行时检查类型扔 TypeError 这种事情,flatMap 对回调的结果进行自动 wrap(从而 overload 了 map)是必然的选择。

所以 then 就是 flatMap。唯一的问题是为什么 promise 不像 array 一样提供单独的 map?


为什么要提供?


我先不说提供单独的 map 方法让你可以得到 Promise<Promise<U>> 有毛个意义。

我们谈理论。


在 monad 鼻祖的 haskell 那里,定义 monad 只需要 2 个操作:return 和 bind。

return 就是 wrap/unit,即从 T => M<T>。

而 bind 就是 flatMap。


所以 Promise 从 Haskell 本源意义上说千真万确就是一个 monad。


当然我们也可以用另一个方式定义 monad,使用 3 个操作:return、fmap 和 join。

fmap 就是 map,join 则是 flatten,即将 M<M<T>> 打平为 M<T>。


所以本来你就有两种方式定义 monad,一种用 flatMap,一种用 map + flatten。实际上很容易理解,有了 map 和 flatten 你就可以实现出 flatMap。但是,反过来说,有 flatMap 我们也可以实现出 map 和 flatten。

function map(f) { return this.flatMap(x => wrap(f(x))) }

function flatten() { return this.flatMap(x => x) }


所以 promise 本身不提供 map 和 flatten 方法并没有任何问题。当然你可以吐槽 JS 没有内置的 mixin 语法或 extensive methods(其实都有提案),使得统一接口比较麻烦,但无论如何吐槽不到 promise 。


当然,promise 有特殊之处,比如 wrap 操作理论上不能直接用 Promise.resolve,因为 Promise.resolve(promise) 并不返回 Promise<Promise<T>>。实际上在 JavaScript 中是不可能产生 Promise<Promise<T>> 嵌套类型的。显而易见,这一限制是出于实际编程的考虑。但是 Staltz 直接否定了这一点。

So it’s better to recognize that Promises can practically be concatenated, so they should have the concat method.

问题是你不能简单的吹说“practically”,你得拿出真实 use cases 啊!嘴炮谁不会?你倒是真拿一个把 Promise 给 concat 起来的例子啊!妈蛋!!bullshit!!


结论部分。

上面我已经把 Staltz 的各点批驳完毕。

关键点在于,promise 的出发点是提供异步原语。有意无意的忽略这一点,所有论证就都乱来了。Promise 的设计总体上没有任何问题,Staltz 希望的:

  • 所谓 lazy
  • 直接在 promise 接口上提供 cancel()
  • resolve 时而同步时而异步
  • 提供无意义的 Promise<Promise<T>>

才是 weird、unfortunately opinionated 的。

Promises were invented, not discovered. The best primitives are discovered, because they are often neutral and we can’t argue against them. For instance, the circle is such a simple mathematical concept, that’s why people discovered it instead of inventing it. You often can’t “disagree” with a circle because there’s no opinion embedded in it, and it occurs often in nature and systems.

说不清道理,就上比喻,文章里那无聊的 food 比喻我就不吐槽了,这里又拿圆形来比喻。一股浓郁的民科风。


实际上,编程设施全都是发明出来的。从最基本的二进制补码整数类型、IEEE754浮点数、Unicode字符,到复杂的数据结构如红黑树、bloom filter乃至神经网络,无一不是发明出来的。各种语言的语法语义也都是发明出来的符号系统。包括monad。我们发明它们用来表达运算逻辑。(其实真正搞数学的人,会告诉你数学里也是如此,符号公理系统都是发明出来的。)

Promise 是发明出来的,node callback conversion 或者 Staltz 自己搞的 callbag 显然也都是发明出来的。

或者我们换个正常点的词,这些东西是为了一定目的被设计出来的。

如果有人说我发现了某某,多数是谦辞,表示不是我牛逼,只是运气好而已。


真正可以被发现的,只有客观存在。

编程里有什么东西是真的发现出来的?估计只有 bug 吧。



最后,有人可能会问,你写这吐槽,(欺负)老外看不懂啊。是啊,谁让他不懂中文。同志们要有点自信啊。

编辑于 2018-03-29

文章被以下专栏收录