“纯”错误处理

pick a hand... need a reference

说出来可能会让你震惊,throw/catch 并不十分“纯”。当一个错误抛出的时候,我们没有收到返回值,反而是得到了一个警告!抛错的函数吐出一大堆的 0 和 1 作为盾和矛来攻击我们,简直就像是在反击输入值的入侵而进行的一场电子大作战。有了 Either 这个新朋友,我们就能以一种比向输入值宣战好得多的方式来处理错误,那就是返回一条非常礼貌的消息作为回应。我们来看一下:

  1. var Left = function(x) {
  2. this.__value = x;
  3. }
  4. Left.of = function(x) {
  5. return new Left(x);
  6. }
  7. Left.prototype.map = function(f) {
  8. return this;
  9. }
  10. var Right = function(x) {
  11. this.__value = x;
  12. }
  13. Right.of = function(x) {
  14. return new Right(x);
  15. }
  16. Right.prototype.map = function(f) {
  17. return Right.of(f(this.__value));
  18. }

LeftRight 是我们称之为 Either 的抽象类型的两个子类。我略去了创建 Either 父类的繁文缛节,因为我们不会用到它的,但你了解一下也没坏处。注意看,这里除了有两个类型,没别的新鲜东西。来看看它们是怎么运行的:

  1. Right.of("rain").map(function(str){ return "b"+str; });
  2. // Right("brain")
  3. Left.of("rain").map(function(str){ return "b"+str; });
  4. // Left("rain")
  5. Right.of({host: 'localhost', port: 80}).map(_.prop('host'));
  6. // Right('localhost')
  7. Left.of("rolls eyes...").map(_.prop("host"));
  8. // Left('rolls eyes...')

Left 就像是青春期少年那样无视我们要 map 它的请求。Right 的作用就像是一个 Container(也就是 Identity)。这里强大的地方在于,Left 有能力在它内部嵌入一个错误消息。

假设有一个可能会失败的函数,就拿根据生日计算年龄来说好了。的确,我们可以用 Maybe(null) 来表示失败并把程序引向另一个分支,但是这并没有告诉我们太多信息。很有可能我们想知道失败的原因是什么。用 Either 写一个这样的程序看看:

  1. var moment = require('moment');
  2. // getAge :: Date -> User -> Either(String, Number)
  3. var getAge = curry(function(now, user) {
  4. var birthdate = moment(user.birthdate, 'YYYY-MM-DD');
  5. if(!birthdate.isValid()) return Left.of("Birth date could not be parsed");
  6. return Right.of(now.diff(birthdate, 'years'));
  7. });
  8. getAge(moment(), {birthdate: '2005-12-12'});
  9. // Right(9)
  10. getAge(moment(), {birthdate: 'balloons!'});
  11. // Left("Birth date could not be parsed")

这么一来,就像 Maybe(null),当返回一个 Left 的时候就直接让程序短路。跟 Maybe(null) 不同的是,现在我们对程序为何脱离原先轨道至少有了一点头绪。有一件事要注意,这里返回的是 Either(String, Number),意味着我们这个 Either 左边的值是 String,右边(译者注:也就是正确的值)的值是 Number。这个类型签名不是很正式,因为我们并没有定义一个真正的 Either 父类;但我们还是从这个类型那里了解到不少东西。它告诉我们,我们得到的要么是一条错误消息,要么就是正确的年龄值。

  1. // fortune :: Number -> String
  2. var fortune = compose(concat("If you survive, you will be "), add(1));
  3. // zoltar :: User -> Either(String, _)
  4. var zoltar = compose(map(console.log), map(fortune), getAge(moment()));
  5. zoltar({birthdate: '2005-12-12'});
  6. // "If you survive, you will be 10"
  7. // Right(undefined)
  8. zoltar({birthdate: 'balloons!'});
  9. // Left("Birth date could not be parsed")

如果 birthdate 合法,这个程序就会把它神秘的命运打印在屏幕上让我们见证;如果不合法,我们就会收到一个有着清清楚楚的错误消息的 Left,尽管这个消息是稳稳当当地待在它的容器里的。这种行为就像,虽然我们在抛错,但是是以一种平静温和的方式抛错,而不是像一个小孩子那样,有什么不对劲就闹脾气大喊大叫。

在这个例子中,我们根据 birthdate 的合法性来控制代码的逻辑分支,同时又让代码进行从右到左的直线运动,而不用爬过各种条件语句的大括号。通常,我们不会把 console.log 放到 zoltar 函数里,而是在调用 zoltar 的时候才 map 它,不过本例中,让你看看 Right 分支如何与 Left 不同也是很有帮助的。我们在 Right 分支的类型签名中使用 _ 表示一个应该忽略的值(在有些浏览器中,你必须要 console.log.bind(console) 才能把 console.log 当作一等公民使用)。

我想借此机会指出一件你可能没注意到的事:这个例子中,尽管 fortune 使用了 Either,它对每一个 functor 到底要干什么却是毫不知情的。前面例子中的 finishTransaction 也是一样。通俗点来讲,一个函数在调用的时候,如果被 map 包裹了,那么它就会从一个非 functor 函数转换为一个 functor 函数。我们把这个过程叫做 lift。一般情况下,普通函数更适合操作普通的数据类型而不是容器类型,在必要的时候再通过 lift 变为合适的容器去操作容器类型。这样做的好处是能得到更简单、重用性更高的函数,它们能够随需求而变,兼容任意 functor。

Either 并不仅仅只对合法性检查这种一般性的错误作用非凡,对一些更严重的、能够中断程序执行的错误比如文件丢失或者 socket 连接断开等,Either 同样效果显著。你可以试试把前面例子中的 Maybe 替换为 Either,看怎么得到更好的反馈。

此刻我忍不住在想,我仅仅是把 Either 当作一个错误消息的容器介绍给你!这样的介绍有失偏颇,它的能耐远不止于此。比如,它表示了逻辑或(也就是 ||)。再比如,它体现了范畴学里 coproduct 的概念,当然本书不会涉及这方面的知识,但值得你去深入了解,因为这个概念有很多特性值得利用。还比如,它是标准的 sum type(或者叫不交并集,disjoint union of sets),因为它含有的所有可能的值的总数就是它包含的那两种类型的总数(我知道这么说你听不懂,没关系,这里有一篇非常棒的文章讲述这个问题)。Either 能做的事情多着呢,但是作为一个 functor,我们就用它处理错误。

就像 Maybe 可以有个 maybe 一样,Either 也可以有一个 either。两者的用法类似,但 either 接受两个函数(而不是一个)和一个静态值为参数。这两个函数的返回值类型一致:

  1. // either :: (a -> c) -> (b -> c) -> Either a b -> c
  2. var either = curry(function(f, g, e) {
  3. switch(e.constructor) {
  4. case Left: return f(e.__value);
  5. case Right: return g(e.__value);
  6. }
  7. });
  8. // zoltar :: User -> _
  9. var zoltar = compose(console.log, either(id, fortune), getAge(moment()));
  10. zoltar({birthdate: '2005-12-12'});
  11. // "If you survive, you will be 10"
  12. // undefined
  13. zoltar({birthdate: 'balloons!'});
  14. // "Birth date could not be parsed"
  15. // undefined

终于用了一回那个神秘的 id 函数!其实它就是简单地复制了 Left 里的错误消息,然后把这个值传给 console.log 而已。通过强制在 getAge 内部进行错误处理,我们的算命程序更加健壮了。结果就是,要么告诉用户一个残酷的事实并像算命师那样跟他击掌,要么就继续运行程序。好了,现在我们已经准备好去学习一个完全不同类型的 functor 了。