pointed functor

在继续后面的内容之前,我得向你坦白一件事:关于我们先前创建的容器类型上的 of 方法,我并没有说出它的全部实情。真实情况是,of 方法不是用来避免使用 new 关键字的,而是用来把值放到默认最小化上下文(default minimal context)中的。是的,of 没有真正地取代构造器——它是一个我们称之为 pointed 的重要接口的一部分。

pointed functor 是实现了 of 方法的 functor。

这里的关键是把任意值丢到容器里然后开始到处使用 map 的能力。

  1. IO.of("tetris").map(concat(" master"));
  2. // IO("tetris master")
  3. Maybe.of(1336).map(add(1));
  4. // Maybe(1337)
  5. Task.of([{id: 2}, {id: 3}]).map(_.prop('id'));
  6. // Task([2,3])
  7. Either.of("The past, present and future walk into a bar...").map(
  8. concat("it was tense.")
  9. );
  10. // Right("The past, present and future walk into a bar...it was tense.")

如果你还记得,IOTask 的构造器接受一个函数作为参数,而 MaybeEither 的构造器可以接受任意值。实现这种接口的动机是,我们希望能有一种通用、一致的方式往 functor 里填值,而且中间不会涉及到复杂性,也不会涉及到对构造器的特定要求。“默认最小化上下文”这个术语可能不够精确,但是却很好地传达了这种理念:我们希望容器类型里的任意值都能发生 lift,然后像所有的 functor 那样再 map 出去。

有件很重要的事我必须得在这里纠正,那就是,Left.of 没有任何道理可言,包括它的双关语也是。每个 functor 都要有一种把值放进去的方式,对 Either 来说,它的方式就是 new Right(x)。我们为 Right 定义 of 的原因是,如果一个类型容器可以 map,那它就应该 map。看上面的例子,你应该会对 of 通常的工作模式有一个直观的印象,而 Left 破坏了这种模式。

你可能已经听说过 purepointunitreturn 之类的函数了,它们都是 of 这个史上最神秘函数的不同名称(译者注:此处原文是“international function of mystery”,源自恶搞《007》的电影 Austin Powers: International Man of Mystery,中译名《王牌大贱谍》)。of 将在我们开始使用 monad 的时候显示其重要性,因为后面你会看到,手动把值放回容器是我们自己的责任。

要避免 new 关键字,可以借助一些标准的 JavaScript 技巧或者类库达到目的。所以从这里开始,我们就利用这些技巧或类库,像一个负责任的成年人那样使用 of。我推荐使用 folktaleramdafantasy-land 里的 functor 实例,因为它们同时提供了正确的 of 方法和不依赖 new 的构造器。

混合比喻

http://www.organicchemistry.com/wp-content/uploads/BPOCchapter6-6htm-41.png

你看,除了太空墨西哥卷(如果你听说过这个传言的话)(译者注:此处的传言似乎是说一个叫 Chris Hadfield 的宇航员在国际空间站做墨西哥卷的事,视频链接),monad 还被喻为洋葱。让我以一个常见的场景来说明这点:

  1. // Support
  2. // ===========================
  3. var fs = require('fs');
  4. // readFile :: String -> IO String
  5. var readFile = function(filename) {
  6. return new IO(function() {
  7. return fs.readFileSync(filename, 'utf-8');
  8. });
  9. };
  10. // print :: String -> IO String
  11. var print = function(x) {
  12. return new IO(function() {
  13. console.log(x);
  14. return x;
  15. });
  16. }
  17. // Example
  18. // ===========================
  19. // cat :: IO (IO String)
  20. var cat = compose(map(print), readFile);
  21. cat(".git/config")
  22. // IO(IO("[core]\nrepositoryformatversion = 0\n"))

这里我们得到的是一个 IO,只不过它陷进了另一个 IO。要想使用它,我们必须这样调用: map(map(f));要想观察它的作用,必须这样: unsafePerformIO().unsafePerformIO()

  1. // cat :: String -> IO (IO String)
  2. var cat = compose(map(print), readFile);
  3. // catFirstChar :: String -> IO (IO String)
  4. var catFirstChar = compose(map(map(head)), cat);
  5. catFirstChar(".git/config")
  6. // IO(IO("["))

尽管在应用中把这两个作用打包在一起没什么不好的,但总感觉像是在穿着两套防护服工作,结果就形成一个稀奇古怪的 API。再来看另一种情况:

  1. // safeProp :: Key -> {Key: a} -> Maybe a
  2. var safeProp = curry(function(x, obj) {
  3. return new Maybe(obj[x]);
  4. });
  5. // safeHead :: [a] -> Maybe a
  6. var safeHead = safeProp(0);
  7. // firstAddressStreet :: User -> Maybe (Maybe (Maybe Street) )
  8. var firstAddressStreet = compose(
  9. map(map(safeProp('street'))), map(safeHead), safeProp('addresses')
  10. );
  11. firstAddressStreet(
  12. {addresses: [{street: {name: 'Mulburry', number: 8402}, postcode: "WC2N" }]}
  13. );
  14. // Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

这里的 functor 同样是嵌套的,函数中三个可能的失败都用了 Maybe 做预防也很干净整洁,但是要让最后的调用者调用三次 map 才能取到值未免也太无礼了点——我们和它才刚刚见面而已。这种嵌套 functor 的模式会时不时地出现,而且是 monad 的主要使用场景。

我说过 monad 像洋葱,那是因为当我们用 map 剥开嵌套的 functor 以获取它里面的值的时候,就像剥洋葱一样让人忍不住想哭。不过,我们可以擦干眼泪,做个深呼吸,然后使用一个叫作 join 的方法。

  1. var mmo = Maybe.of(Maybe.of("nunchucks"));
  2. // Maybe(Maybe("nunchucks"))
  3. mmo.join();
  4. // Maybe("nunchucks")
  5. var ioio = IO.of(IO.of("pizza"));
  6. // IO(IO("pizza"))
  7. ioio.join()
  8. // IO("pizza")
  9. var ttt = Task.of(Task.of(Task.of("sewers")));
  10. // Task(Task(Task("sewers")));
  11. ttt.join()
  12. // Task(Task("sewers"))

如果有两层相同类型的嵌套,那么就可以用 join 把它们压扁到一块去。这种结合的能力,functor 之间的联姻,就是 monad 之所以成为 monad 的原因。来看看它更精确的完整定义:

monad 是可以变扁(flatten)的 pointed functor。

一个 functor,只要它定义个了一个 join 方法和一个 of 方法,并遵守一些定律,那么它就是一个 monad。join 的实现并不太复杂,我们来为 Maybe 定义一个:

  1. Maybe.prototype.join = function() {
  2. return this.isNothing() ? Maybe.of(null) : this.__value;
  3. }

看,就像子宫里双胞胎中的一个吃掉另一个那么简单。如果有一个 Maybe(Maybe(x)),那么 .__value 将会移除多余的一层,然后我们就能安心地从那开始进行 map。要不然,我们就将会只有一个 Maybe,因为从一开始就没有任何东西被 map 调用。

既然已经有了 join 方法,我们把 monad 魔法作用到 firstAddressStreet 例子上,看看它的实际作用:

  1. // join :: Monad m => m (m a) -> m a
  2. var join = function(mma){ return mma.join(); }
  3. // firstAddressStreet :: User -> Maybe Street
  4. var firstAddressStreet = compose(
  5. join, map(safeProp('street')), join, map(safeHead), safeProp('addresses')
  6. );
  7. firstAddressStreet(
  8. {addresses: [{street: {name: 'Mulburry', number: 8402}, postcode: "WC2N" }]}
  9. );
  10. // Maybe({name: 'Mulburry', number: 8402})

只要遇到嵌套的 Maybe,就加一个 join,防止它们从手中溜走。我们对 IO 也这么做试试看,感受下这种感觉。

  1. IO.prototype.join = function() {
  2. return this.unsafePerformIO();
  3. }

同样是简单地移除了一层容器。注意,我们还没有提及纯粹性的问题,仅仅是移除过度紧缩的包裹中的一层而已。

  1. // log :: a -> IO a
  2. var log = function(x) {
  3. return new IO(function() { console.log(x); return x; });
  4. }
  5. // setStyle :: Selector -> CSSProps -> IO DOM
  6. var setStyle = curry(function(sel, props) {
  7. return new IO(function() { return jQuery(sel).css(props); });
  8. });
  9. // getItem :: String -> IO String
  10. var getItem = function(key) {
  11. return new IO(function() { return localStorage.getItem(key); });
  12. };
  13. // applyPreferences :: String -> IO DOM
  14. var applyPreferences = compose(
  15. join, map(setStyle('#main')), join, map(log), map(JSON.parse), getItem
  16. );
  17. applyPreferences('preferences').unsafePerformIO();
  18. // Object {backgroundColor: "green"}
  19. // <div style="background-color: 'green'"/>

getItem 返回了一个 IO String,所以可以直接用 map 来解析它。logsetStyle 返回的都是 IO,所以必须要使用 join 来保证这里边的嵌套处于控制之中。

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 年的集中营的故事。此书亦有同名改编电影,中译名《穿梭集中营》),我们就像一颗弹珠一样在变幻莫测的迷宫中穿梭。无法想象如果这是一个典型的应用,而且一直在改变变量会怎样——我们肯定会像陷入沥青坑那样无所适从。

理论

我们要看的第一条定律是结合律,但可能不是你熟悉的那个结合律。

  1. // 结合律
  2. compose(join, map(join)) == compose(join, join)

这些定律表明了 monad 的嵌套本质,所以结合律关心的是如何让内层或外层的容器类型 join,然后取得同样的结果。用一张图来表示可能效果会更好:

monad associativity law

从左上角往下,先用 join 合并 M(M(M a)) 最外层的两个 M,然后往右,再调用一次 join,就得到了我们想要的 M a。或者,从左上角往右,先打开最外层的 M,用 map(join) 合并内层的两个 M,然后再向下调用一次 join,也能得到 M a。不管是先合并内层还是先合并外层的 M,最后都会得到相同的 M a,所以这就是结合律。值得注意的一点是 map(join) != join。两种方式的中间步骤可能会有不同的值,但最后一个 join 调用后最终结果是一样的。

第二个定律与结合律类似:

  1. // 同一律 (M a)
  2. compose(join, of) == compose(join, map(of)) == id

这表明,对任意的 monad Mofjoin 相当于 id。也可以使用 map(of) 由内而外实现相同效果。我们把这个定律叫做“三角同一律”(triangle identity),因为把它图形化之后就像一个三角形:

monad identity law

如果从左上角开始往右,可以看到 of 的确把 M a 丢到另一个 M 容器里去了。然后再往下 join,就得到了 M a,跟一开始就调用 id 的结果一样。从右上角往左,可以看到如果我们通过 map 进到了 M 里面,然后对普通值 a 调用 of,最后得到的还是 M (M a);再调用一次 join 将会把我们带回原点,即 M a

我要说明一点,尽管这里我写的是 of,实际上对任意的 monad 而言,都必须要使用明确的 M.of

我已经见过这些定律了,同一律和结合律,以前就在哪儿见过…等一下,让我想想…是的!它们是范畴遵循的定律!不过这意味着我们需要一个组合函数来给出一个完整定义。见证吧:

  1. var mcompose = function(f, g) {
  2. return compose(chain(f), chain(g));
  3. }
  4. // 左同一律
  5. mcompose(M, f) == f
  6. // 右同一律
  7. mcompose(f, M) == f
  8. // 结合律
  9. mcompose(mcompose(f, g), h) == mcompose(f, mcompose(g, h))

毕竟它们是范畴学里的定律。monad 来自于一个叫 “Kleisli 范畴”的范畴,这个范畴里边所有的对象都是 monad,所有的态射都是联结函数(chained funtions)。我不是要在没有提供太多解释的情况下,拿范畴学里各式各样的概念来取笑你。我的目的是涉及足够多的表面知识,向你说明这中间的相关性,让你在关注日常实用特性之余,激发起对这些定律的兴趣。

总结

monad 让我们深入到嵌套的运算当中,使我们能够在完全避免回调金字塔(pyramid of doom)情况下,为变量赋值,运行有序的作用,执行异步任务等等。当一个值被困在几层相同类型的容器中时,monad 能够拯救它。借助 “pointed” 这个可靠的帮手,monad 能够借给我们从盒子中取出的值,而且知道我们会在结束使用后还给它。

是的,monad 非常强大,但我们还需要一些额外的容器函数。比如,假设我们想同时运行一个列表里的 api 调用,然后再搜集返回的结果,怎么办?是可以使用 monad 实现这个任务,但必须要等每一个 api 完成后才能调用下一个。合并多个合法性验证呢?我们想要的肯定是持续验证以搜集错误列表,但是 monad 会在第一个 Left 登场的时候停掉整个演出。

下一章,我们将看到 applicative functor 如何融入这个容器世界,以及为何在很多情况下它比 monad 更好用。

第 10 章: Applicative Functor

练习

  1. // 练习 1
  2. // ==========
  3. // 给定一个 user,使用 safeProp 和 map/join 或 chain 安全地获取 sreet 的 name
  4. var safeProp = _.curry(function (x, o) { return Maybe.of(o[x]); });
  5. var user = {
  6. id: 2,
  7. name: "albert",
  8. address: {
  9. street: {
  10. number: 22,
  11. name: 'Walnut St'
  12. }
  13. }
  14. };
  15. var ex1 = undefined;
  16. // 练习 2
  17. // ==========
  18. // 使用 getFile 获取文件名并删除目录,所以返回值仅仅是文件,然后以纯的方式打印文件
  19. var getFile = function() {
  20. return new IO(function(){ return __filename; });
  21. }
  22. var pureLog = function(x) {
  23. return new IO(function(){
  24. console.log(x);
  25. return 'logged ' + x;
  26. });
  27. }
  28. var ex2 = undefined;
  29. // 练习 3
  30. // ==========
  31. // 使用 getPost() 然后以 post 的 id 调用 getComments()
  32. var getPost = function(i) {
  33. return new Task(function (rej, res) {
  34. setTimeout(function () {
  35. res({ id: i, title: 'Love them tasks' });
  36. }, 300);
  37. });
  38. }
  39. var getComments = function(i) {
  40. return new Task(function (rej, res) {
  41. setTimeout(function () {
  42. res([
  43. {post_id: i, body: "This book should be illegal"},
  44. {post_id: i, body: "Monads are like smelly shallots"}
  45. ]);
  46. }, 300);
  47. });
  48. }
  49. var ex3 = undefined;
  50. // 练习 4
  51. // ==========
  52. // 用 validateEmail、addToMailingList 和 emailBlast 实现 ex4 的类型签名
  53. // addToMailingList :: Email -> IO([Email])
  54. var addToMailingList = (function(list){
  55. return function(email) {
  56. return new IO(function(){
  57. list.push(email);
  58. return list;
  59. });
  60. }
  61. })([]);
  62. function emailBlast(list) {
  63. return new IO(function(){
  64. return 'emailed: ' + list.join(',');
  65. });
  66. }
  67. var validateEmail = function(x){
  68. return x.match(/\S+@\S+\.\S+/) ? (new Right(x)) : (new Left('invalid email'));
  69. }
  70. // ex4 :: Email -> Either String (IO String)
  71. var ex4 = undefined;