4.1 代数数据类型与模式匹配:非法状态不可表示


4.1 代数数据类型与模式匹配:非法状态不可表示

本节摘要:代数数据类型(ADT)是函数式类型系统的积木厂:和类型表达"几选一",积类型表达"几合一",名字里的"代数"名副其实——类型之间真的可以做加法与乘法,且运算规则与数学一致。本节讲清两种类型的构造与消耗(模式匹配),并以一次真实建模演示函数式的招牌能力:把非法状态设计成编译不过的代码。

从一个"代数"名不副实的疑惑开始

第一次听说"代数数据类型",多数人的反应是:这跟中学代数有什么关系?答案关系很大,而且值得先给结论:和类型(sum type)是类型的加法,积类型(product type)是类型的乘法,取值个数严格按加法与乘法计算。设一个类型可能的取值个数为它的"大小":Bool 的大小是 2(True 或 False),Unit(空元组)的大小是 1。那么:

和类型 Either Int Bool 大小 = 大小Int + 大小Bool —— 要么一个Int,要么一个Bool 积类型 (Int, Bool) 大小 = 大小Int × 大小Bool —— 一个Int配一个Bool,组合枚举

"要么 A 要么 B"是加法,"既要 A 又要 B"是乘法——这正是类型版的代数。这不是文字游戏:第 4.2 节会看到函子定律对代数运算成立,第 7 章的属性测试会利用"类型大小"自动生成用例。代数视角从今天开始就留在你的工具箱里。

一、和类型:把"几种可能"变成一等公民

和类型解决的问题日常到不能再日常:一个值"要么是 A 形态,要么是 B 形态"。命令式语言的传统应对是两件套——可空指针加错误码,两者的共同缺陷是编译器不知道它们可能"缺",于是缺检查的责任落在每个调用者的自觉上。和类型把"可能缺"直接写进类型:

-- Haskell:Maybe 是最著名的和类型,标准库定义等价于 -- data Maybe a = Nothing | Just a -- 读作:一个 Maybe Int 的值,要么是 Nothing,要么是 Just 某个Int safeHead :: [a] -> Maybe a safeHead [] = Nothing -- 空表没有头,如实申报 safeHead (x:_) = Just x -- 调用方拿到 Maybe a,编译器强迫先"拆"再"用": greet :: [String] -> String greet names = case safeHead names of Just name -> "你好," ++ name Nothing -> "你好,陌生人" -- 若删掉 Nothing 分支,编译直接报错: -- warning: pattern match(es) are non-exhaustive

对比 Python 的 Nonenames[0] 在空表上抛异常,而类型签名 list[str] -> str 对此只字不提。信息在类型里的可见性,决定了检查是编译期的还是运行时的——这是和类型的第一价值。第二价值在"多形态业务对象"上更明显,本节末尾的真实建模会展示。

二、积类型与模式匹配:构造与消耗的对称

积类型把几个值"捆成一个",对应大多数语言的结构体与元组,无需赘述;真正值得注意的是函数式的消耗方式——模式匹配(pattern matching)。它不是 switch 的换皮,本质区别有二:匹配的对象是数据的形状(构造器与结构),不只是取值;编译器执行穷尽性检查,漏一个形状就是编译错误。

-- 一个积类型与和类型的组合体 data UserInfo = UserInfo { name :: String, age :: Int } -- 积类型:命名积 -- 和类型:三种形态的身份 data Identity = Guest -- 形态一:访客 | Registered UserInfo -- 形态二:注册用户(携带积) | Banned String -- 形态三:封禁(携带原因) -- 消耗:模式匹配穷尽三种形态 welcome :: Identity -> String welcome Guest = "欢迎试用" welcome (Registered (UserInfo n _)) = "欢迎回来," ++ n welcome (Banned reason) = "账号不可用:" ++ reason

welcome 的三个分支一一对应三种构造器,漏写任何一个都编译不过。给 Identity 加第四种形态时,编译器会列出所有需要补分支的位置——类型系统的这项服务在重构时的价值难以估量:改了数据形态,所有消耗点被一次性点名,而不是等测试或生产环境慢慊暴露。

JavaScript/TypeScript 世界可以用"可辨识联合"逼近同样的能力,Python 3.10 的结构化模式匹配(match-case)配合 dataclass 也已到位:

from dataclasses import dataclass @dataclass(frozen=True) class Guest: ... @dataclass(frozen=True) class Registered: name: str age: int @dataclass(frozen=True) class Banned: reason: str Identity = Guest | Registered | Banned # 和类型:类型联合 def welcome(identity: Identity) -> str: match identity: case Guest(): return "欢迎试用" case Registered(name=name): return f"欢迎回来,{name}" case Banned(reason=reason): return f"账号不可用:{reason}" # Python 的 match 不强制穷尽,需静态检查工具辅助——诚实地说,弱一档

图:一次支付结果的代数建模——非法状态被排除

图:一次支付结果的代数建模——非法状态被排除

三、实战建模:支付结果

把上图的设计落成代码。需求:支付接口返回三种终态——成功、已退款(带原因)、未支付。布尔标志建模用 paid: bool, refunded: bool, reason: str 三个字段,可表示的状态有八种组合,其中五种毫无意义(未支付却带退款原因?);和类型建模只允许三种值存在:

data PaymentResult = Paid { amount :: Double, txnId :: String } | Refunded { originalTxn :: String, why :: String } | NotPaid -- 消耗:编译器保证三分支穷尽 describe :: PaymentResult -> String describe Paid{amount = a} = "已支付,金额 " ++ show a describe Refunded{why = w} = "已退款:" ++ w describe NotPaid = "未支付" -- 想构造非法状态?语法上不存在那条路: -- bad = Refunded { originalTxn = 无, why = 无, paid = True } -- 无处可写 paid

这就是"非法状态不可表示"(make illegal states unrepresentable)的完整含义:不是写运行时检查去捕获非法状态,而是把类型的构造空间收窄到只剩合法状态。变式练习:把"购物车(可能为空)、订单(必有至少一件商品)、空订单"三个概念用和类型重新建模,数一数布尔标志方案与和类型方案各自的可表示状态数——这道练习做完,你对第 7 章属性测试为何钟爱代数类型会有提前的体感。

本节要点回顾

  • 代数名副其实:和类型是加法、积类型是乘法,取值个数严格按代数规则计算。
  • Maybe 是最小和类型:把"可能缺"写进类型,检查从运行时提前到编译期。
  • 模式匹配的两个本质:匹配数据形状(构造器)而非取值;穷尽性检查让漏分支变成编译错误。
  • 非法状态不可表示:收窄构造空间,让布尔标志方案的五种陷阱组合根本造不出来。
  • 改形态即点名:数据类型加形态时,编译器列出全部待补分支,重构安全网由编译器充当。

类型造好了,接下来解决"容器里的值怎么变换":函子与应用函子——第 3 章 map 思想在类型层面的完整形态。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U