chain 函数

(译者注:此处标题原文是“My chain hits my chest”,是英国歌手 M.I.A 单曲 Bad Girls 的一句歌词。据说这首歌有体现女权主义。)

chain

你可能已经从上面的例子中注意到这种模式了:我们总是在紧跟着 map 的后面调用 join。让我们把这个行为抽象到一个叫做 chain 的函数里。

  1. // chain :: Monad m => (a -> m b) -> m a -> m b
  2. var chain = curry(function(f, m){
  3. return m.map(f).join(); // 或者 compose(join, map(f))(m)
  4. });

这里仅仅是把 map/join 套餐打包到一个单独的函数中。如果你之前了解过 monad,那你可能已经看出来 chain 叫做 >>=(读作 bind)或者 flatMap;都是同一个概念的不同名称罢了。我个人认为 flatMap 是最准确的名称,但本书还是坚持使用 chain,因为它是 JS 里接受程度最高的一个。我们用 chain 重构下上面两个例子:

  1. // map/join
  2. var firstAddressStreet = compose(
  3. join, map(safeProp('street')), join, map(safeHead), safeProp('addresses')
  4. );
  5. // chain
  6. var firstAddressStreet = compose(
  7. chain(safeProp('street')), chain(safeHead), safeProp('addresses')
  8. );
  9. // map/join
  10. var applyPreferences = compose(
  11. join, map(setStyle('#main')), join, map(log), map(JSON.parse), getItem
  12. );
  13. // chain
  14. var applyPreferences = compose(
  15. chain(setStyle('#main')), chain(log), map(JSON.parse), getItem
  16. );

我把所有的 map/join 都替换为了 chain,这样代码就显得整洁了些。整洁固然是好事,但 chain 的能力却不止于此——它更多的是龙卷风而不是吸尘器。因为 chain 可以轻松地嵌套多个作用,因此我们就能以一种纯函数式的方式来表示 序列(sequence) 和 变量赋值(variable assignment)。

  1. // getJSON :: Url -> Params -> Task JSON
  2. // querySelector :: Selector -> IO DOM
  3. getJSON('/authenticate', {username: 'stale', password: 'crackers'})
  4. .chain(function(user) {
  5. return getJSON('/friends', {user_id: user.id});
  6. });
  7. // Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);
  8. querySelector("input.username").chain(function(uname) {
  9. return querySelector("input.email").chain(function(email) {
  10. return IO.of(
  11. "Welcome " + uname.value + " " + "prepare for spam at " + email.value
  12. );
  13. });
  14. });
  15. // IO("Welcome Olivia prepare for spam at olivia@tremorcontrol.net");
  16. Maybe.of(3).chain(function(three) {
  17. return Maybe.of(2).map(add(three));
  18. });
  19. // Maybe(5);
  20. Maybe.of(null).chain(safeProp('address')).chain(safeProp('street'));
  21. // Maybe(null);

本来我们可以用 compose 写上面的例子,但这将需要几个帮助函数,而且这种风格怎么说都要通过闭包进行明确的变量赋值。相反,我们使用了插入式的 chain。顺便说一下,chain 可以自动从任意类型的 mapjoin 衍生出来,就像这样:t.prototype.chain = function(f) { return this.map(f).join(); }。如果手动定义 chain 能让你觉得性能会好点的话(实际上并不会),我们也可以手动定义它,尽管还必须要费力保证函数功能的正确性——也就是说,它必须与紧接着后面有 joinmap 相等。如果 chain 是简单地通过结束调用 of 后把值放回容器这种方式定义的,那么就会造成一个有趣的后果,即可以从 chain 那里衍生出一个 map。同样地,我们还可以用 chain(id) 定义 join。听起来好像是在跟魔术师玩德州扑克,魔术师想要什么牌就有什么牌;但是就像大部分的数学理论一样,所有这些原则性的结构都是相互关联的。fantasyland 仓库中提到了许多上述衍生概念,这个仓库也是 JavaScript 官方的代数数据结构(algebraic data types)标准。

好了,我们来看上面的例子。第一个例子中,可以看到两个 Task 通过 chain 连接形成了一个异步操作的序列——它先获取 user,然后用 user.id 查找 userfriendschain 避免了 Task(Task([Friend])) 这种情况。

第二个例子是用 querySelector 查找几个 input 然后创建一条欢迎信息。注意看我们是如何在最内层的函数里访问 unameemail 的——这是函数式变量赋值的绝佳表现。因为 IO 大方地把它的值借给了我们,我们也要负起以同样方式把值放回去的责任——不能辜负它的信任(还有整个程序的信任)。IO.of 非常适合做这件事,同时它也解释了为何 pointed 这一特性是 monad 接口得以存在的重要前提。不过,map 也能返回正确的类型:

  1. querySelector("input.username").chain(function(uname) {
  2. return querySelector("input.email").map(function(email) {
  3. return "Welcome " + uname.value + " prepare for spam at " + email.value;
  4. });
  5. });
  6. // IO("Welcome Olivia prepare for spam at olivia@tremorcontrol.net");

最后两个例子用了 Maybe。因为 chain 其实是在底层调用了 map,所以如果遇到 null,代码就会立刻停止运行。

如果觉得这些例子不太容易理解,你也不必担心。多跑跑代码,多琢磨琢磨,把代码拆开来研究研究,再把它们拼起来看看。总之记住,返回的如果是“普通”值就用 map,如果是 functor 就用 chain

这里我得提醒一下,上述方式对两个不同类型的嵌套容器是不适用的。functor 组合,以及后面会讲到的 monad transformer 可以帮助我们应对这种情况。

炫耀

这种容器编程风格有时也能造成困惑,我们不得不努力理解一个值到底嵌套了几层容器,或者需要用 map 还是 chain(很快我们就会认识更多的容器类型)。使用一些技巧,比如重写 inspect 方法之类,能够大幅提高 debug 的效率。后面我们也会学习如何创建一个“栈”,使之能够处理任何丢给它的作用(effects)。不过,有时候也需要权衡一下是否值得这样做。

我很乐意挥起 monad 之剑,向你展示这种编程风格的力量。就以读一个文件,然后就把它直接上传为例吧:

  1. // readFile :: Filename -> Either String (Future Error String)
  2. // httpPost :: String -> Future Error JSON
  3. // upload :: String -> Either String (Future Error JSON)
  4. var upload = compose(map(chain(httpPost('/uploads'))), readFile);

这里,代码不止一次在不同的分支执行。从类型签名可以看出,我们预防了三个错误——readFile 使用 Either 来验证输入(或许还有确保文件名存在);readFile 在读取文件的时候可能会出错,错误通过 readFileFuture 表示;文件上传可能会因为各种各样的原因出错,错误通过 httpPostFuture 表示。我们就这么随意地使用 chain 实现了两个嵌套的、有序的异步执行动作。

所有这些操作都是在一个从左到右的线性流中完成的,是完完全全纯的、声明式的代码,是可以等式推导(equational reasoning)并拥有可靠特性(reliable properties)的代码。我们没有被迫使用不必要甚至令人困惑的变量名,我们的 upload 函数符合通用接口而不是特定的一次性接口。这些都是在一行代码中完成的啊!

让我们来跟标准的命令式的实现对比一下:

  1. // upload :: String -> (String -> a) -> Void
  2. var upload = function(filename, callback) {
  3. if(!filename) {
  4. throw "You need a filename!";
  5. } else {
  6. readFile(filename, function(err, contents) {
  7. if(err) throw err;
  8. httpPost(contents, function(err, json) {
  9. if(err) throw err;
  10. callback(json);
  11. });
  12. });
  13. }
  14. }

看看,这简直就是魔鬼的算术(译者注:此处原文是“the devil’s arithmetic”,为美国 1988 年出版的历史小说,讲述一个犹太小女孩穿越到 1942 年的集中营的故事。此书亦有同名改编电影,中译名《穿梭集中营》),我们就像一颗弹珠一样在变幻莫测的迷宫中穿梭。无法想象如果这是一个典型的应用,而且一直在改变变量会怎样——我们肯定会像陷入沥青坑那样无所适从。