“纯”错误处理

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

var Left = function(x) {
  this.__value = x;
}

Left.of = function(x) {
  return new Left(x);
}

Left.prototype.map = function(f) {
  return this;
}

var Right = function(x) {
  this.__value = x;
}

Right.of = function(x) {
  return new Right(x);
}

Right.prototype.map = function(f) {
  return Right.of(f(this.__value));
}

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

Right.of("rain").map(function(str){ return "b"+str; });
// Right("brain")

Left.of("rain").map(function(str){ return "b"+str; });
// Left("rain")

Right.of({host: 'localhost', port: 80}).map(_.prop('host'));
// Right('localhost')

Left.of("rolls eyes...").map(_.prop("host"));
// Left('rolls eyes...')

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

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

var moment = require('moment');

//  getAge :: Date -> User -> Either(String, Number)
var getAge = curry(function(now, user) {
  var birthdate = moment(user.birthdate, 'YYYY-MM-DD');
  if(!birthdate.isValid()) return Left.of("Birth date could not be parsed");
  return Right.of(now.diff(birthdate, 'years'));
});

getAge(moment(), {birthdate: '2005-12-12'});
// Right(9)

getAge(moment(), {birthdate: 'balloons!'});
// Left("Birth date could not be parsed")

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

//  fortune :: Number -> String
var fortune  = compose(concat("If you survive, you will be "), add(1));

//  zoltar :: User -> Either(String, _)
var zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 10"
// Right(undefined)

zoltar({birthdate: 'balloons!'});
// 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 接受两个函数(而不是一个)和一个静态值为参数。这两个函数的返回值类型一致:

//  either :: (a -> c) -> (b -> c) -> Either a b -> c
var either = curry(function(f, g, e) {
  switch(e.constructor) {
    case Left: return f(e.__value);
    case Right: return g(e.__value);
  }
});

//  zoltar :: User -> _
var zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 10"
// undefined

zoltar({birthdate: 'balloons!'});
// "Birth date could not be parsed"
// undefined

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