Maybe 与 Either:告别 null 与异常
峡谷从最熟悉的水域进入。null 的发明者 Tony Hoare 在 2009 年公开道歉,称它为「十亿美元的错误」;异常则是另一种隐形债——签名上看不见,处理靠自觉。Haskell 对这两者的替代方案你在第 4 章已经见过全部源码:两行 data 声明。这一章讲怎么用它们干活,以及一个更重要的问题——当「可能失败」的操作要串成链,代码会发生什么。这个问题的完整答案要到第 10 章,但铁路的形状,本章就能看见。
Maybe:把「没有」变成一种值
data Maybe a = Nothing | Just a。它和 null 的区别不在表达力,在位置:null 潜伏在每个引用类型里(Java 的 String 其实是「String 或 null」,但类型上看不出来);Maybe 只出现在声明了它的地方。于是整个世界被劈成两半:
userCity :: User -> City -- 这个函数【保证】给你城市 lookupUser :: UserId -> Maybe User -- 这个函数【明说】可能查无此人
第一个签名是承诺:调用方不需要判空,判了反而是坏代码——防御性判空的泛滥,本质是类型系统没帮你区分这两种函数。第二个签名是警告:不处理 Nothing,模式匹配都写不全(穷尽检查追杀你)。「可能没有」从口口相传的部落知识,变成编译器盖章的合同条款。取值的三板斧:
λ> lookup "tokyo" cities -- 标准库 lookup 天生返回 Maybe
Just "东京"
λ> fromMaybe "未知" (lookup "mars" cities) -- 三板斧①:给默认值
"未知"
λ> maybe 0 length (Just "hello") -- ②:有则转换,无则兜底
5
λ> case lookup "tokyo" cities of -- ③:老老实实模式匹配
Just c -> c
Nothing -> "查无此城"
"东京"
金字塔问题:峡谷的入口
单个 Maybe 很美好。串起来试试——查用户、取地址、取邮编,每步都可能落空:
postcode :: UserId -> Maybe Postcode
postcode uid =
case lookupUser uid of
Nothing -> Nothing
Just user -> case userAddress user of
Nothing -> Nothing
Just addr -> case addrPostcode addr of
Nothing -> Nothing
Just pc -> Just pc
三层嵌套,六行样板,信息量却只有一句话:「三步依次做,谁落空整体落空」。你在 Kotlin 里的解法是 ?. 链:user?.address?.postcode。Haskell 的解法要到第 10 章揭晓(预告:这段代码会缩成三行 do),但请把这个「金字塔之痛」记在身上——整个峡谷卷就是为了消灭它而存在的。现在先记住结构上的观察:每层 case 都在重复同一个模式「Nothing 就传递 Nothing,Just 就拆开继续」。重复的模式,按第 5 章的教导,就该被抽出来——抽出来的那个东西,名字以 M 开头。
Either:失败也要有理由
Maybe 的 Nothing 是一张白纸:失败了,但没说为什么。生产代码里你通常需要原因——data Either a b = Left a | Right b,惯例是 Left 装错误,Right 装正确结果(记法:right 双关「正确」):
data ConfigError
= MissingField String
| BadPort String
deriving Show
parsePort :: String -> Either ConfigError Int
parsePort s = case readMaybe s of
Nothing -> Left (BadPort s)
Just p | p > 0 && p < 65536 -> Right p
| otherwise -> Left (BadPort s)
注意错误类型:不是 String,是 ADT。错误用字符串,下游只能打印;错误用和类型,下游可以模式匹配——「缺字段就用默认配置,端口非法才终止启动」这种分级处理,只有结构化的错误才写得出来。这是第 4 章「让非法状态无法表示」在错误通道上的续集:错误也是业务状态,配得上一个类型。
铁路指向:失败通道的几何学
把「每步可能失败的管道」画出来,是两条并行的轨道:上轨 Right(继续),下轨 Left(短路直达终点)。这个心智图叫铁路指向编程(railway-oriented programming),下面的调度台跑的是一条真实的三站校验管道——试试每个预置输入,看道岔在哪一站扳向下轨:
盯住短路行为:第一站失败后,后面两站的代码根本没运行——不是每站都判一遍「上一步是否失败」,而是失败值自动绕过所有后续站点。这正是嵌套 case 金字塔想表达的语义,铁路图把它画直了。注意每一站的类型:进站是裸值,出站是 Either——「吃 a 吐 Either e b」的函数是铁路的标准道岔件,把一节节道岔焊成整条线路的那个「焊接」动作本身是可以抽象的——又是那个 M 开头的东西,第 10 章见。
Kotlin:可空类型 User? + ?. 是编译器特判的 Maybe(但不能嵌套:T?? 塌缩,「查到了 null」和「没查到」分不清;Maybe (Maybe a) 分得清);Result<T>/runCatching 是 Either 的半成品(错误位固定为 Throwable,不能是你的 ADT);Arrow 库补齐全套,第 18 章实操。Java:Optional 是官方 Maybe,但有个著名脚枪 Optional.get()——不判空直接取,等于把 null 又请回来了,评审必拦。TS:T | null + 可选链是 Maybe;Either 没有官方版,社区用判别联合手搓或用 neverthrow,第 19 章实操。Go:(val, err) 二返回值是「靠纪律维持的 Either」——编译器不拦你忘记检查 err,所以每年都有 CVE 来自漏检查的 err。
批量场景的工具箱:分拣流水线
单值的 Maybe/Either 三板斧之外,真实工程更常见的是一批可能失败的东西:解析一万行日志,总有几十行是坏的。用异常,第一行坏日志就把整批炸停;用「错误是值」的思路,坏数据只是另一种数据,标准库给全了分拣工具:
λ> import Data.Maybe (mapMaybe, catMaybes) λ> mapMaybe readMaybeInt ["12", "x", "7", "-"] [12,7] -- 解析 + 静默丢弃失败,一步到位 λ> import Data.Either (partitionEithers, rights, lefts) λ> partitionEithers [Right 1, Left "行2坏了", Right 3, Left "行4坏了"] (["行2坏了","行4坏了"],[1,3]) -- 好坏分家,谁也不丢
mapMaybe 是「能解析多少算多少」的宽容策略;partitionEithers 是「结果照常出,坏账单独报」的审计策略。选哪个是业务决策——重点是两种策略都只是一行库函数,因为失败没有脱离值的世界。对照一下异常世界里同样需求的写法:try/catch 包在循环体里,坏数据 add 进一个手工维护的 errors 列表……你一定写过那段代码。
还有第三种策略「一个都不能坏,坏一个全作废」:把 [Either e a] 收成 Either e [a]。这个「把一列可能失败,变成可能失败的一列」的翻转动作,标准库叫 sequence——它其实是下一章 traverse 的特例,先在这里见个面:
λ> sequence [Just 1, Just 2, Just 3] Just [1,2,3] λ> sequence [Just 1, Nothing, Just 3] Nothing -- 一票否决
和异常打个明账
Either 相对异常的得失,值得像大人一样算清楚。得:失败写进签名,调用方想装看不见都不行;错误是普通值,能存列表、能聚合(「收集所有校验错误再一起报」——异常做不到,它第一个就飞了);控制流可见,没有隐形的 goto。失:样板代码(第 10 章的 do 治它);跨很多层传递时,每层签名都得带着错误类型(有人视为噪音,有人视为诚实——本书站诚实这边);真正的灾难性错误(内存耗尽)还是得用异常,Haskell 也保留了运行时异常给这类场景。
工程口径:预期内的失败(解析、校验、查找、网络超时)用 Either/Maybe;预期外的灾难(bug、资源耗尽)才配异常。这条线画清楚,系统里 95% 的 try/catch 会消失,换成类型可见的数据流。顺便,这也是 Rust 把 Result 立为国策、Go 把 error 摆上返回值的共同理由——2010 年代新语言在错误处理上的集体转向,方向盘都指向上游这两行 data。
其一,fromJust 成瘾:「我确定这里有值」——那就用模式匹配把「确定」写成代码;fromJust 是 head 的表兄弟,部分函数,崩起来没有上下文。其二,Maybe 层层透传:五层调用全返回 Maybe,最底层的 Nothing 到顶层已经不知道是谁产生的——该升级成 Either 带上下文了。其三,Either 当异常用:一个函数返回 Either String a 然后调用方立刻 error 抛掉——那你要的其实就是异常,别披着 Either 的皮。类型是用来诚实的,不是用来表演的。
历史遗留的部分函数(head/tail/fromJust)在现代实践里都有 Maybe 版替身:listToMaybe(安全 head)、readMaybe(安全 read)、Map 的 lookup 天生安全。GHC 9.8 起 head/tail 自带警告。一个可以直接抄走的团队规约:库函数可能失败的,返回类型必须写 Maybe/Either;敢返回裸值,就要为全域输入负责。这条规约在 Kotlin/TS 里同样成立,只是没有编译器帮你执行。
1. 用 maybe 一行改写:case lookup k m of { Just v -> show v; Nothing -> "-" }。
2. 设计错误 ADT:给「用户注册」建模失败原因(用户名占用/密码太短/邮箱非法),写出 register :: Form -> Either RegisterError User 的类型和错误类型定义。哪种失败你希望调用方能单独处理?
3. 在铁路台输入 150:第二站失败。如果想让「越界」不失败而是钳制到 [0,130],管道该改哪一站?这算不算「把错误藏起来」——什么时候钳制合理,什么时候应该 Left?
4. 分拣策略选择:「导入通讯录 CSV」「支付网关回调批处理」「编译器报错」三个场景,分别该用 mapMaybe、partitionEithers 还是 sequence 式一票否决?说出理由。
5. 铁路题:三条校验各自独立(用户名/密码/邮箱),你想一次性报出全部错误而不是遇错即停。短路式的 Either 链做不到这件事——想想需要什么形状的组合?(第 9 章的 Applicative 正是答案,带着问题去。)