回调函数是指令式的,Promise 是函数式的:Node 错失的最大机会


我之前都有接触过关于 Promise 的一些文章,但是对它的感觉并不大。因为觉得虽然回调风格确实有问题,但我写的代码还没有复杂到那种程度,所以,要去使用的感觉并不强烈。

但是,后面碰到一个问题真的好像用回调的风格来写的话,会比较糟糕。加上看到了这一篇从另一侧面来看 Promise 对函数式编程的思维方面的转变,觉得很不错。值得一看,所以在有其它大神也翻译过的情况下,自己也译一次,顺便深入学习。

原文链接: Callbacks are imperative, promises are functional: Node’s biggest missed opportunity

Promise的本质就是他们不随着环境的变化而变化。

—— Frank Underwood,‘纸牌屋’

你经常会听到说 JavaScript 是一门 “函数式” 编程语言。通常我们这样描述它的时候是因为函数在它里面是作为 “一等公民” 而存在的。但是其它 “函数式” 编程语言里面的特性,比如:数据不可改变,代数类型系统,使用迭代优于循环,避免副作用都统统忽略了。虽然函数作为 “一等公民” 是非常有用的,并且决定用户能够在需要的时候使用函数式风格来编写代码。但是 JS 是函数式的观点却常常忽略了函数式编程的核心思想:面向值编程。

“函数式编程” 的命名其实会产生误导,以至于人们认为它的意义在于,相对于 “面向对象编程” 来说,它是 “面向函数编程”。但是如果面向对象编程是把所有东西都从对象角度考虑,那函数式编程就是把所有东西都作为值来处理,而不仅仅是把函数考虑为值。很明显,数值当然包含那些数字,字符,列表和其它数据值,但其实它也包含其它面向对象编程的粉丝通常没有考虑过的一些东西:IO 操作和其它副作用,GUI 事件流,空值检查,甚至函数调用的顺序。如果你曾经听说过 “可编程分号” 的话,你应该知道我想说的是什么了.

函数式编程最大的好处是它是声明式的。在命令式编程里面,我们需要写一系列的指令来告诉计算机是怎么去实现我们想要做的事情的。在函数式编程里面,我们只是需要描述值之间的计算关系,计算机就会自己想办法得出需要的计算指令顺序。

如果你使用过 Excel 的话,你其实已经用过函数式编程了:你只需要描述一个图表里面的值,是怎么相互计算出来的。当有新数据插入的时候,Excel 就会自己得出图表里有什么地方的值和效果要更新,而你并不需要再为它写出任何指令,它也可以帮你计算出来。

在阐述了这些基本概念的基础上,我想说明一下我觉得 Node.js 在设计上最大的失误是什么: 这就是在它的设计早期,决定了倾向于使用回调风格的 API 而不是 promise 风格.

所有人都使用回调。如果你发布了一个返回 promise 的模块,根本没有人会关注和使用你那个模块。

如果我写了一个小模块,它需要和 Redis 交互,我所需要做的唯一一件事情就是传递一个回调函数给 Redis。当我们遇到回调无底洞的时候,其实这根本不是什么问题: 因为同样有协程monad 无底洞。因为如果你把任何一个抽象使用地足够频繁的话,都同样会创造一个无底洞。

在 90% 的情况下,你只需要做一件事情,回调如此简单的接口使得你只是需要简单的缩进一下就可以了。如果你遇到了非常复杂的用例,你和其它在 npm 里面的 827 个模块一样,使用 async 就好了.

—— Mikeal Rogers,LXJS 2012

这段话是从 Mikeal Rogers 最近的一次涵盖了好些 Node 设计哲学的演讲里摘取出来的:

在 Node 的初期设计目标里面,我希望可以让更多的非专家级别的程序员可以很容易编写出快速,支持并行的网络程序,虽然我知道这个想法有点违背生产效率。Promises 其实可以使得程序在运行时自动控制数据流动,而不是靠程序员通过显式指令控制,所以能更加容易组织正确清晰和最大化并行操作的程序.

要写出正确的并行程序基本上需要你实现尽可能多的并行工作的同时,保证操作指令还是以正确的顺序执行。虽然 JavaScript 是单线程的,但我们依然有可能因为在异步操作的情况下触发了竞争机制: 任何涉及 IO 的操作都会在它等待回调的时候把 CPU 时间腾到其它操作上面。多个并发操作就有可能同时访问同一段内存数据,或者产生一系列重叠的操作数据库或者 DOM 的指令。所以,我希望在这篇文章里可以告诉大家,promies 能够像 Excel 一样,提供一种只需要描述值之间的关系模型,你的工具就能够自动寻求最佳解决方案给你。而不是需要你自己控制程序流.

我希望可以清除掉一个误区就是 promises 的使用就是为了让语法结构看起来比基于回调的异步操作更清晰。其实它们可以帮助你用一个完全不同的方式来建模。它们的作用比简化语法来得更深层次。事实上,它们完全从语意角度改变你解决问题的方式。

首先,我想先重温一下几年前写的一篇文章。它是关于 promises 是怎么在异步编程上作为一个 monad 的角色而存在的。这里的核心思想就是 monad 其实是帮助你组织函数的工具,比如说,当一个函数的返回值要做为下一个函数的输入的时候,建立数据管道。数据关系的结构化是实现的关键。

在这里的,我还是需要用到 Haskell 的类型注解来帮助说明一下。在 Haskell 里,注解 foo :: bar 表示 “foo 是 bar 的类型“。注解 foo :: Bar -> Qux 表示 “foo 是一个接受输入值为 Bar 类型和返回值为 Qux 类型的函数“。如果输入输出的类别并不重要的话,我们会用单一小写字母,foo :: a -> b。如果函数 foo 可以接受多个参数的化,我们会添加多个箭头,比如:“ foo :: a -> b -> c ” 表示 foo 接收两个分别为类型 a 和 b 的参数并返回类型 c 的值.

我们来看一个 Node 函数吧,比如,fs.readFile()。这个函数接收一个 String 类型的路径参数,还有一个回调函数,并且没有任何返回值。回调函数会接收一个可能为空的 Error 类型和一个包含了文件内容的 Buffer 类型的参数,并且也没有返回值。那我们就可以把 readFile 的类型用注解表示为:

readFile :: String -> Callback -> ()

() 在 Haskell 注解中表示空值类型。这里的 callback 是另一个函数,它的注解可以表示为:

Callback :: Error -> Buffer -> ()

把它们放在一起的话,我们可以说 readFile 接收两个参数,一个 String 类型,一个是接收 Buffer 参数的函数:

readFile :: String -> (Error -> Buffer -> ()) -> ()

现在,我们来想象一下假如 Node 使用 promises 会是怎么样的。这样的情况下,readFile 可以简单的接收一个 String 类型参数然后返回一个 Buffer 的 promise:

readFile :: String -> Promise Buffer

一般来说,我们可以认为回调风格的函数接收一些参数和一个函数,这个函数将会被最终调用并传递返回值作为它的输入;promises 风格的函数就是接收一些参数,和返回一个带结果的 promise:

callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b

那些回调风格返回的空值其实就是为什么使用回调风格来编程会很困难的根本原因: 回调风格不返回任何值,所以难以组合。一个没有返回值的函数执行的效果其实是利用它的副作用 – 一个没有返回值和利用副作用的函数其实就是一个黑洞。所以,使用回调风格来编程无法避免会是指令式的,它实际上是通过把一系列严重依赖于副作用的操作安排好执行顺序,而不是通过函数的调用来把输入输出值对应好。它是通过人手组织程序执行流程而不是靠理顺值的关系来解决问题的。这正是编写正确的并行程序困难的原因.

相反,基于 promise 的函数总是让你把函数返回值作为一个不依赖于时间的值来考虑的。当你调用一个回调风格的函数时,在你的函数调用和它的回调函数被调用之间,在程序里面我们没办法找到一个最终结果的表现形式.

fs.readFile('file1.txt',
  // some time passes...
  function(error,buffer) {
    // the result now pops into existence
  }
);

从基于回调和事件的函数里面取得结果基本上意味着 “你必须在恰当的时间和地点”。如果你在事件被触发之后才绑定你的事件监听器,或者你没有在恰当的地方回调你的函数,那么恭喜你,你将无法得到你要的结果了。这些事情使得人们在 Node 里写 HTTP 服务器相当困难。如果你的控制流不对,你的程序就无法按期望运行.

相反,Promises 并不关心执行的顺序。你可以在 promise 兑现前或后注册监听器,