薛定谔的 Maybe
说实话 Container
挺无聊的,而且通常我们称它为 Identity
,与 id
函数的作用相同(这里也是有数学上的联系的,我们会在适当时候加以说明)。除此之外,还有另外一种 functor,那就是实现了 map
函数的类似容器的数据类型,这种 functor 在调用 map
的时候能够提供非常有用的行为。现在让我们来定义一个这样的 functor。
var Maybe = function(x) {
this.__value = x;
}
Maybe.of = function(x) {
return new Maybe(x);
}
Maybe.prototype.isNothing = function() {
return (this.__value === null || this.__value === undefined);
}
Maybe.prototype.map = function(f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
Maybe
看起来跟 Container
非常类似,但是有一点不同:Maybe
会先检查自己的值是否为空,然后才调用传进来的函数。这样我们在使用 map
的时候就能避免恼人的空值了(注意这个实现出于教学目的做了简化)。
Maybe.of("Malkovich Malkovich").map(match(/a/ig));
//=> Maybe(['a', 'a'])
Maybe.of(null).map(match(/a/ig));
//=> Maybe(null)
Maybe.of({name: "Boris"}).map(_.prop("age")).map(add(10));
//=> Maybe(null)
Maybe.of({name: "Dinah", age: 14}).map(_.prop("age")).map(add(10));
//=> Maybe(24)
注意看,当传给 map
的值是 null
时,代码并没有爆出错误。这是因为每一次 Maybe
要调用函数的时候,都会先检查它自己的值是否为空。
这种点记法(dot notation syntax)已经足够函数式了,但是正如在第 1 部分指出的那样,我们更想保持一种 pointfree 的风格。碰巧的是,map
完全有能力以 curry 函数的方式来“代理”任何 functor:
// map :: Functor f => (a -> b) -> f a -> f b
var map = curry(function(f, any_functor_at_all) {
return any_functor_at_all.map(f);
});
这样我们就可以像平常一样使用组合,同时也能正常使用 map
了,非常振奋人心。ramda 的 map
也是这样。后面的章节中,我们将在点记法更有教育意义的时候使用点记法,在方便使用 pointfree 模式的时候就用 pointfree。你注意到了么?我在类型标签中偷偷引入了一个额外的标记:Functor f =>
。这个标记告诉我们 f
必须是一个 functor。没什么复杂的,但我觉得有必要提一下。
用例
实际当中,Maybe
最常用在那些可能会无法成功返回结果的函数中。
// safeHead :: [a] -> Maybe(a)
var safeHead = function(xs) {
return Maybe.of(xs[0]);
};
var streetName = compose(map(_.prop('street')), safeHead, _.prop('addresses'));
streetName({addresses: []});
// Maybe(null)
streetName({addresses: [{street: "Shady Ln.", number: 4201}]});
// Maybe("Shady Ln.")
safeHead
与一般的 _.head
类似,但是增加了类型安全保证。引入 Maybe
会发生一件非常有意思的事情,那就是我们被迫要与狡猾的 null
打交道了。safeHead
函数能够诚实地预告它可能的失败——失败真没什么可耻的——然后返回一个 Maybe
来通知我们相关信息。实际上不仅仅是通知,因为毕竟我们想要的值深藏在 Maybe
对象中,而且只能通过 map
来操作它。本质上,这是一种由 safeHead
强制执行的空值检查。有了这种检查,我们才能在夜里安然入睡,因为我们知道最不受人待见的 null
不会突然出现。类似这样的 API 能够把一个像纸糊起来的、脆弱的应用升级为实实在在的、健壮的应用,这样的 API 保证了更加安全的软件。
有时候函数可以明确返回一个 Maybe(null)
来表明失败,例如:
// withdraw :: Number -> Account -> Maybe(Account)
var withdraw = curry(function(amount, account) {
return account.balance >= amount ?
Maybe.of({balance: account.balance - amount}) :
Maybe.of(null);
});
// finishTransaction :: Account -> String
var finishTransaction = compose(remainingBalance, updateLedger); // <- 假定这两个函数已经在别处定义好了
// getTwenty :: Account -> Maybe(String)
var getTwenty = compose(map(finishTransaction), withdraw(20));
getTwenty({ balance: 200.00});
// Maybe("Your balance is $180.00")
getTwenty({ balance: 10.00});
// Maybe(null)
要是钱不够,withdraw
就会对我们嗤之以鼻然后返回一个 Maybe(null)
。withdraw
也显示出了它的多变性,使得我们后续的操作只能用 map
来进行。这个例子与前面例子不同的地方在于,这里的 null
是有意的。我们不用 Maybe(String)
,而是用 Maybe(null)
来发送失败的信号,这样程序在收到信号后就能立刻停止执行。这一点很重要:如果 withdraw
失败了,map
就会切断后续代码的执行,因为它根本就不会运行传递给它的函数,即 finishTransaction
。这正是预期的效果:如果取款失败,我们并不想更新或者显示账户余额。
释放容器里的值
人们经常忽略的一个事实是:任何事物都有个最终尽头。那些会产生作用的函数,不管它们是发送 JSON 数据,还是在屏幕上打印东西,还是更改文件系统,还是别的什么,都要有一个结束。但是我们无法通过 return
把输出传递到外部世界,必须要运行这样或那样的函数才能传递出去。关于这一点,可以借用禅宗公案的口吻来叙述:“如果一个程序运行之后没有可观察到的作用,那它到底运行了没有?”。或者,运行之后达到自身的目的了没有?有可能它只是浪费了几个 CPU 周期然后就去睡觉了…
应用程序所做的工作就是获取、更改和保存数据直到不再需要它们,对数据做这些操作的函数有可能被 map
调用,这样的话数据就可以不用离开它温暖舒适的容器。讽刺的是,有一种常见的错误就是试图以各种方法删除 Maybe
里的值,好像这个不确定的值是魔鬼,删除它就能让它突然显形,然后一切罪恶都会得到宽恕似的(译者注:此处原文应该是源自圣经)。要知道,我们的值没有完成它的使命,很有可能是其他代码分支造成的。我们的代码,就像薛定谔的猫一样,在某个特定的时间点有两种状态,而且应该保持这种状况不变直到最后一个函数为止。这样,哪怕代码有很多逻辑性的分支,也能保证一种线性的工作流。
不过,对容器里的值来说,还是有个逃生口可以出去。也就是说,如果我们想返回一个自定义的值然后还能继续执行后面的代码的话,是可以做到的;要达到这一目的,可以借助一个帮助函数 maybe
:
// maybe :: b -> (a -> b) -> Maybe a -> b
var maybe = curry(function(x, f, m) {
return m.isNothing() ? x : f(m.__value);
});
// getTwenty :: Account -> String
var getTwenty = compose(
maybe("You're broke!", finishTransaction), withdraw(20)
);
getTwenty({ balance: 200.00});
// "Your balance is $180.00"
getTwenty({ balance: 10.00});
// "You're broke!"
这样就可以要么返回一个静态值(与 finishTransaction
返回值的类型一致),要么继续愉快地在没有 Maybe
的情况下完成交易。maybe
使我们得以避免普通 map
那种命令式的 if/else
语句:if(x !== null) { return f(x) }
。
引入 Maybe
可能会在初期造成一些不适。Swift 和 Scala 用户知道我在说什么,因为这两门语言的核心库里就有 Maybe
的概念,只不过伪装成 Option(al)
罢了。被迫在任何情况下都进行空值检查(甚至有些时候我们可以确定某个值不会为空),的确让大部分人头疼不已。然而随着时间推移,空值检查会成为第二本能,说不定你还会感激它提供的安全性呢。不管怎么说,空值检查大多数时候都能防止在代码逻辑上偷工减料,让我们脱离危险。
编写不安全的软件就像用蜡笔小心翼翼地画彩蛋,画完之后把它们扔到大街上一样(译者注:意思是彩蛋非常易于寻找。来源于复活节习俗,人们会藏起一些彩蛋让孩子寻找),或者像用三只小猪警告过的材料盖个养老院一样(译者注:来源于“三只小猪”童话故事)。Maybe
能够非常有效地帮助我们增加函数的安全性。
有一点我必须要提及,否则就太不负责任了,那就是 Maybe
的“真正”实现会把它分为两种类型:一种是非空值,另一种是空值。这种实现允许我们遵守 map
的 parametricity 特性,因此 null
和 undefined
能够依然被 map
调用,functor 里的值所需的那种普遍性条件也能得到满足。所以你会经常看到 Some(x) / None
或者 Just(x) / Nothing
这样的容器类型在做空值检查,而不是 Maybe
。