Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hax opened this issue Mar 29, 2018 · 21 comments
Open

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

hax opened this issue Mar 29, 2018 · 21 comments

Comments

@hax
Copy link
Owner

hax commented Mar 29, 2018

【知乎专栏上的文章链接:https://zhuanlan.zhihu.com/p/35082528 ,此处乃备胎。】


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

本文就是要吐槽 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: aborting-ongoing-activities)!(当然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 多年前的经典文章:http://blog.izs.me/post/59142742143/designing-apis-for-asynchrony

所以 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> 有毛个意义。

我们谈理论。


在 monad 鼻祖的 haskell 那里,定义 monad 只需要 2 个操作:returnbind

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

bind 就是 flatMap


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


当然我们也可以用另一个方式定义 monad,使用 3 个操作:returnfmapjoin

fmap 就是 mapjoin 则是 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>。实际上在 JavaScript 中是不可能产生 Promise<Promise> 嵌套类型的。显而易见,这一限制是出于实际编程的考虑。但是 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>

才是 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 吧。


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


【讨论建议移步知乎文章评论区:https://zhuanlan.zhihu.com/p/35082528

@dou4cc
Copy link

dou4cc commented Mar 30, 2018

有空你评价一下:Clarence-pan/shortcut-promise#1 (comment)

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

你怎么看如下这个?
https://4r.gitlab.io/#https://github.com/tc39/proposal-async-iteration/issues/93

(用了redirector来避免产生引用)

@hax
Copy link
Owner Author

hax commented Apr 4, 2018

@dou4cc
关于shortcut-promise不知道你具体想问什么?

async iteration 93 问题主要是 async iter 中 yield 的行为。从个人偏好来说,zenparsing 的方案(即只在 for await of 中 unwrap,如果你要手写 next,得自己处理)我觉得也是可以接受的,但现在的方案(yield 等价于 yield await)也没有什么大问题,至少对绝大多数程序员和绝大多数use case是比较好的。

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

@hax

关于shortcut-promise不知道你具体想问什么?

你点进去,我想让你评价我在issue里的另一种实现。


关于AsyncGenerator我的看法是不unwrap。

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

AsyncFunction中returnreturn await等价是因为Promise。对于AsyncIterator的同步版本Iterator,Generator能做到和手写Iterator一样,AsyncGenerator却不能,这是关键的不一致。

@hax
Copy link
Owner Author

hax commented Apr 4, 2018

要看你怎么定义“不一致”。假若我们把 iterator 和 async 看成两个正交的特性,那么 yield 等价于 yield await 确实比较奇怪。但是我想 domenic 的 slide (https://docs.google.com/presentation/d/1U6PivKbFO0YgoFlrYB82MtXf1ofCp1xSVOODOvranBM )已经给出了比较充分的理由。第22页上讲了他反对不unwrap的理由。尽管我也说了,我认为不unwrap是可以接受的,但是以我的看法,这两种行为至多是各有千秋,很难说哪个一定更好。既然tc39已经开会决定了,那么在没有其他更强有力的理由的情况下,是很难改变决定的。

另外你说“对于AsyncIterator的同步版本Iterator,Generator能做到和手写Iterator一样,AsyncGenerator却不能”,是指什么?

@hax
Copy link
Owner Author

hax commented Apr 4, 2018

你点进去,我想让你评价我在issue里的另一种实现。

你的另一种实现的差异是什么?就是 Promise.resolve(1).then(f) 中的 f 会同步执行么?

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

不一致指:AsyncGenerator对应AsyncIterator,手写AsyncIterator能next出promise的value,甚至被for await支持,却不能用友好的AsyncGenerator做到,这和AsyncFunction不同,AsyncFunction不能返回两层promise是因为手写promise也搞不出两层。

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

是的,Iterator和async/sync是正交的,因为async的传染性,sync的东西会越来越少,所以AsyncGenerator得承担Generator的全部功能,再升华。考虑AsyncGenerator实现的不定长队列,如何用AsyncGenerator实现并发的map,类似Array.prototype.map?

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

我十分反对domenic对双层promise的理解,Promise.resolve({value:Promise.resolve(),done:true})就不是双层promise,他把那个done当什么?考虑到搞出AsyncGenerator的动机就是想在generator里await,unwrap什么的就别搞,没有困扰、歧义。

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

你的另一种实现的差异是什么?就是 Promise.resolve(1).then(f) 中的 f 会同步执行么?

那个repo不是我的,我只是让你看那个issue,里面有我的实现,不仅不强制异步,而且不支持throw,flatten时直接脱光,把result暴露出来。

@dou4cc
Copy link

dou4cc commented Apr 4, 2018

这样就没有传染性了,不过不支持throw。

@hax
Copy link
Owner Author

hax commented Apr 4, 2018

不一致指:AsyncGenerator对应AsyncIterator,手写AsyncIterator能next出promise的value,甚至被for await支持,却不能用友好的AsyncGenerator做到,这和AsyncFunction不同,AsyncFunction不能返回两层promise是因为手写promise也搞不出两层。

这个事情 domenic 的解释是这(手动弄一个Promise<{value:Promise}>)属于打破 protocol 的情况,所以不能算“不一致”。就好像说 iterator 要求 next() 得到 {done: true} 之后不能再返回 {done: false: value: ...} 了,但你手写当然可以写出这样的。

固然,要更严谨一点,你可以要求它在遇到这种情况时扔 TypeError,否则总会有人故意写这样打破 protocol 的 async iter。虽然我不觉得这是很大的问题,但是确实你可以去开个issue,😜

我十分反对domenic对双层promise的理解,Promise.resolve({value:Promise.resolve(),done:true})就不是双层promise,他把那个done当什么?考虑到搞出AsyncGenerator的动机就是想在generator里await,unwrap什么的就别搞,没有困扰、歧义。

从正交角度看确实是这样的。所以从理论简单性上,我也略微倾向于zenparsing 的选择。

然而从历史的角度看,js 语言特性的设计目标从来不是追求理论上的完美,反而要考虑对相对一般开发者的友好。诚然,随着语言的复杂,很难说总能保证这个目标,但至少这确实是目标之一。

如果我们能抛开对理论完美的痴迷,则最关键的问题是,做出这一妥协之后,对主要 use cases 的影响是什么。从目前来看,我觉得是 ok 的。除非你能举出一些实际的use case必须依赖yield promise。

另外,即使你一定要考虑一致性,从 async 的一致性来说,在 async func 或 async generator 里所有suspend 的点(await x, yield x, return x)都自动 wrap x(yield等价于yield await x,相当于yield await wrap(x)),这从一致性上来说是 ok 的。(sync generator 里可以 yield promise 是因为它是 sync 的,所以无所谓是不是 promise。)——这个不是说就一定比其他解释更“一致”,而是说“一致性”其实是可以有不同的理解的。

因为async的传染性,sync的东西会越来越少,所以AsyncGenerator得承担Generator的全部功能,再升华。考虑AsyncGenerator实现的不定长队列,如何用AsyncGenerator实现并发的map,类似Array.prototype.map?

我不认为 async 传染性是问题。就像我在顶贴文章里写的,如果你发现都变成 async 了,说明模块抽象没做好。这就跟 pure fp 里的副作用一样,如果 IO 到处传染,说明你的代码写的有问题。

说到 map,如果数组的每项之间并无依赖(通常如此),直接用 map 就好了。你可以 map 一个 async func 得到一堆 promise,必要的时候用 Promise.all(),但 map 本身不需要是异步的。

@hax
Copy link
Owner Author

hax commented Apr 4, 2018

那个repo不是我的,我只是让你看那个issue,里面有我的实现,不仅不强制异步,而且不支持throw,flatten时直接脱光,把result暴露出来。
这样就没有传染性了,不过不支持throw。

我对具体的实现其实兴趣不大。如果要重新发明轮子,首要的是语义上(而不是实现上)有什么不同,目的是什么。

总是异步这个事情,如我所说,不是 promise 的发明,node callback 也是如此(原因我就不再解释了)。一致的错误处理这两者也是一致的(只是node callback限于异步callback的限制,不能throw自动传播,必须自己手动传递,所以比较蛋疼)。

如果要重新发明轮子,采用不一样的语义(不总是异步,不支持 throw),得首先说明为什么你要采用和 promise/node callback 相背离的语义,然后,先不说你的理由是否站得住脚,即使有一定道理,也要考虑实践上这样做对即有代码的后果,如果好处抵不上成本,那也很难得到支持。

@dou4cc
Copy link

dou4cc commented Apr 5, 2018

我不认为 async 传染性是问题。就像我在顶贴文章里写的,如果你发现都变成 async 了,说明模块抽象没做好。这就跟 pure fp 里的副作用一样,如果 IO 到处传染,说明你的代码写的有问题。

搞泛函编程是避不开传染的。刚刚看了会儿pipeline,现在有点头痛,你如果要我举泛函编程的例子,吱一声。

@dou4cc
Copy link

dou4cc commented Apr 5, 2018

说到 map,如果数组的每项之间并无依赖(通常如此),直接用 map 就好了。你可以 map 一个 async func 得到一堆 promise,必要的时候用 Promise.all(),但 map 本身不需要是异步的。

拜托仔细看我的回复,又没要你及时回复,如果没仔细读一遍请不要回。

我说了需要map的是个AsyncGenerator实现的队列。既然map,队列各元素确实独立,但这些元素是迭代生成的,因为上文提到的传染性,这些迭代得是异步的。好了,你可以继续了。

@dou4cc
Copy link

dou4cc commented Apr 5, 2018

这倒是不搞unwrap了
default

@dou4cc
Copy link

dou4cc commented Apr 5, 2018

我真不是人身攻击,google根本搞不出什么好东西,让google的人参与js标准的制定是个灾难。对于一个generator,从外面next/throw到里面和从里面yield/throw到外面是对称的,那个什么domenic搞得现在只是从里到外unwrap,从外到里却不unwrap,不仅不一致、不正交,连对称性都搞没了。

@dou4cc
Copy link

dou4cc commented Apr 5, 2018

过去我曾用generator实现await,现在我打算反过来,用await实现generator,本来generator没箭头写法就让我很不爽,反正两者可以互相实现,互相作为对方的基础,想想用await实现(单边)generator还更灵活一些。

@dou4cc
Copy link

dou4cc commented Apr 6, 2018

default
yield的reject居然能被catch?

@hax
Copy link
Owner Author

hax commented Apr 8, 2018

搞泛函编程是避不开传染的。

所以是准备先黑一波haskell么?

我说了需要map的是个AsyncGenerator实现的队列。既然map,队列各元素确实独立,但这些元素是迭代生成的,因为上文提到的传染性,这些迭代得是异步的。

Array.prototype.map 当然只适用于数组。序列本来就不是用数组处理的。比如同步generator返回一堆promise自己弄。无论是sync generator还是async generator都可以自己写一个用于它的map函数或者用observable呗(按照当前observable草案在异步generator上会有[Symbol.observable]方法帮你转成observable)。如果是最简单的case,在异步函数里用 for await of。

所以我不知道问题在哪里?你总不能指望拿Array.prototype.map包打天下吧。

不仅不一致、不正交,连对称性都搞没了

我之前已经强调过,你不能光追求理论上的性质,而缺乏实际use cases的支持。

说实话,虽然 iterator 协议允许你通过 next(value) 形成双向通讯,但是(除了当初模拟async/await)我没见过真的 use cases。手动调用 next 本来就非常罕见,何况在 function.sent 进入标准之前,这协议还是不完善的。你如果要喷对称性,不如喷这个。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants