ADT 与模式匹配:让非法状态无法表示
如果整本书只允许你带走一件工具,我会劝你带这件。代数数据类型(ADT)回答的是软件里最根本的问题之一:「这个值,到底有多少种可能?」当类型的形状恰好等于业务的形状,一整类 bug 会从「靠测试拦截」降级为「根本写不出来」。这一章从两种乘法与加法讲起,终点是那句被 Kotlin/Swift/Rust/TS 集体引用的箴言——make illegal states unrepresentable。
积类型:你早就会了
定义一个「值的组合」,用 data:
data Person = Person String Int -- ↑类型名 ↑构造子 ↑两个字段:名字、年龄 alice :: Person alice = Person "Alice" 30
类型名和构造子(constructor)经常同名,位置不同不冲突:签名里出现的是类型,表达式里出现的是构造子。构造子本身是个函数——:t Person 给你 String -> Int -> Person,所以它也能柯里化、也能 map。字段多了用记录语法(record syntax),顺便白送访问器:
data Person = Person { name :: String, age :: Int }
λ> age alice
30
这类「A 和 B 都要有」的类型叫积类型(product type)。为什么叫积?数一数:(Bool, Bool) 有 2×2 = 4 种可能的值。字段的可能性相乘——data class、record、struct、tuple,全世界的语言都有积类型,没什么新鲜的。新鲜的是下一个。
和类型:被主流语言遗忘四十年的另一半
data Shape = Circle Double -- 半径 | Rect Double Double -- 宽、高 area :: Shape -> Double area (Circle r) = 3.14159 * r * r area (Rect w h) = w * h
竖线读作「或」:一个 Shape 要么是 Circle(带一个 Double),要么是 Rect(带两个)。可能性相加(是「圆的所有可能」加「矩形的所有可能」),所以叫和类型(sum type)。「和 + 积」凑齐了加法和乘法,「代数」数据类型由此得名——而且这个代数是真的能算的:
把类型 A 的可能值个数记作 |A|。则 |(A, B)| = |A| × |B|,|A 竖线 B| = |A| + |B|。设计类型时,让「类型能表示的状态数」恰好等于「业务合法的状态数」——多出来的每一种,都是一颗等着测试或用户去踩的雷。这个数数的习惯,是本章可以立刻带走的第一件纪念品。
用第 1 章埋的伏笔开刀。网络请求页面的状态,布尔堆写法 vs ADT 写法,状态数差多少?自己按一按:
三个布尔字段能编码 8 种状态,业务只有 4 种合法——剩下 4 种(「既成功又失败」之类)靠代码评审和运气防守。每加一个布尔,非法状态翻倍。ADT 写法里,非法状态是 0:不是「被检查掉了」,是类型里压根没有那个格子。这就是「让非法状态无法表示」的字面意思。
模式匹配:解构与分派一步完成
ADT 是「造」,模式匹配(pattern matching)是「拆」。上面 area 的两个子句已经在用了:每个子句的参数位置写一个模式,运行时从上到下找第一个形状吻合的,同时把字段绑定到名字。表达式内部用 case:
describe :: Shape -> String
describe s = case s of
Circle r | r > 100 -> "大圆"
| otherwise -> "圆"
Rect w h | w == h -> "正方形"
Rect _ _ -> "矩形"
几个高频武器:_ 通配(「有这一格但我不关心」);嵌套模式(Just (Rect w _) 一步拆两层);@ 别名(whole@(Circle r):既要整体又要零件);守卫和第 2 章一样可以挂在模式后面。
而全场最有价值的功能,是穷尽检查(exhaustiveness check)。开着 -Wincomplete-patterns(工程里必开),漏一个分支编译器立刻点名:
λ> -- 假设 Shape 新增了 Triangle,而 area 没改:
Shape.hs:12:1: warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In an equation for ‘area’: Patterns not matched: Triangle _ _
体会一下这个工作流:业务变化 → 改类型 → 编译器列出所有需要跟着改的地方。重构从「全文搜索 + 祈祷」变成「顺着报错清单走」。这是静态类型在业务代码里最实在的一笔回报。
Kotlin:sealed interface Shape + 两个 data class + when(s),when 作为表达式使用时同样有穷尽检查。Java 21:sealed interface Shape permits Circle, Rect + record + switch 模式匹配(JEP 441),连守卫都抄了(case Circle c when c.r() > 100)。TypeScript:判别联合 type Shape = {kind:'circle', r:number} | {kind:'rect', w:number, h:number},穷尽检查靠 never 收尾。三家语法各异,蓝图完全一致——都是 1970 年代 ML 定型、Haskell 发扬的这一套。差别在「默认值」:上游的 data 天生封闭、天生可匹配;下游要记得写 sealed、记得配 when,少写一个关键字,保护就漏了。
Maybe 与 Either:明星也只是普通 ADT
第 8 章的两位主角,现在可以剧透它们的全部定义了:
data Maybe a = Nothing | Just a data Either a b = Left a | Right b
就这么多。没有魔法,没有关键字,没有编译器特判——「可能不存在的值」和「二选一的值」用和类型直接表达,是任何人都写得出来的两行。这是上游给你的重要一课:下游语言里那些「内置的、特殊的」东西(null 安全、Result),在类型系统足够好的语言里只是库代码。语言的进化方向,就是把魔法降级为库。
顺带认识 newtype:给已有类型穿一件零开销的新马甲,专治「三个参数都是 String,传反了编译器也不知道」:
newtype UserId = UserId String newtype OrderId = OrderId String cancel :: UserId -> OrderId -> IO () -- cancel (OrderId "o1") (UserId "u9") ← 现在这行直接编译不过
真实代码:把订单流程写进类型
ADT 建模的完整体是「状态 + 状态携带的数据」一起编码。看一个够真实的例子——电商订单,每个状态携带只有该状态才有的数据:
data Order
= Cart { items :: [Item] } -- 购物车:只有商品
| Placed { items :: [Item], orderNo :: OrderNo } -- 已下单:多了单号
| Paid { items :: [Item], orderNo :: OrderNo
, txId :: TxId } -- 已支付:多了流水号
| Shipped { orderNo :: OrderNo, trackNo :: TrackNo } -- 已发货:多了运单号
| Cancelled { orderNo :: OrderNo, reason :: String }
refund :: Order -> Either String Refund
refund (Paid _ no tx) = Right (issueRefund no tx)
refund _ = Left "只有已支付订单可以退款"
对比常见写法——一个大而全的 Order 类,塞满可空字段(txId 可空、trackNo 可空、reason 可空),配一个 status 枚举,注释里写「txId 仅在 PAID 后有值」。那句注释就是漏洞:类型没拦着任何人在 CART 状态读 txId。而上面的 ADT 版本里,「购物车没有流水号」不是约定,是事实——那个格子根本不存在。refund 想拿 txId,就必须先匹配出 Paid,想跳过检查都没有语法可用。
还有一层保护给「造」的环节:智能构造子(smart constructor)。把裸构造子藏在模块里,只导出带校验的工厂函数:
module Email (Email, mkEmail) where -- 注意:导出 Email 类型,不导出构造子 newtype Email = Email String mkEmail :: String -> Maybe Email mkEmail s = if '@' `elem` s then Just (Email s) else Nothing
模块外的世界不可能造出一个没有 @ 的 Email——不是「不应该」,是「不可能」:构造子没导出,想绕都没路。于是所有接收 Email 的下游函数,永远不必再校验一遍。校验从「每层都做、每层都不放心」收缩为「入口做一次、类型作担保」——这个模式叫 parse, don't validate,值得写进你任何语言的代码评审清单。
标准库里的 head、tail、fromJust 遇到「不该来的输入」会直接崩溃(第 2 章你试过 head [])。这类只对部分输入有定义的函数叫部分函数(partial function),是 Haskell 早期的历史遗留,现代风格视为坏味道:要么用模式匹配把空列表的情况显式写出来,要么用返回 Maybe 的安全版本(listToMaybe)。GHC 9.8 起连标准库都开始给 head/tail 挂废弃警告了。一句话:会崩的可能性,应该长在类型上,而不是文档里。
ADT 可以引用自己。你每天在用的列表,定义不过是:data List a = Nil | Cons a (List a)——「空,或者一个头加一条尾巴」。Haskell 只是给了它语法糖:[] 是 Nil,x : xs 是 Cons。二叉树同理:data Tree a = Leaf | Node (Tree a) a (Tree a)。递归的数据配递归的函数——这正是下一章 fold 的入口。
1. 数状态:Maybe Bool 有几种值?Either Bool (Maybe Bool) 呢?(答案分别是 3 和 5,数不出来就回去看源头碑。)
2. 找出你项目里一个「String 表示多种含义」的字段(比如 status: "active"/"banned"/"pending"),用 ADT 重新设计它,数一数消灭了多少非法状态。
3. 给 data Traffic = Red | Yellow | Green 写 next :: Traffic -> Traffic,故意漏一个分支,想象穷尽检查的报错长什么样。
4. 用智能构造子模式设计一个 NonEmptyList a:类型上保证「至少一个元素」,让 safeHead :: NonEmptyList a -> a 不需要 Maybe。写出模块导出清单。
5. 思考题:为什么 newtype 是零开销而 data 单构造子不完全是?(提示:第 7 章的惰性会给出答案的另一半。)