☰
Scala ADT代数数据类型:用类型消灭非法状态
2026/10/13 2:14:54 网站建设 项目流程

把业务规则里的选择和状态直接翻译成类型,让那些"根本不该出现"的组合在编译期就无路可走——Scala ADT代数数据类型之所以被称作构建类型安全领域模型的基石,靠的就是这一点。我第一次系统接触ADT时,总觉得"代数"两个字吓人,好像不先啃下范畴论就不好意思继续。后来真正用它建模了订单、审批流、支付回调这类系统之后才明白:代数数据类型并没有多高深,它本质上是两种结构的组合,一种是把字段"乘"在一起的产品类型,另一种是把多个分支"加"在一起的求和类型。你只需抓住sealed trait和case class这个小组合,领域建模的质感就会立刻不一样。

这篇文章不打算从抽象定义讲起,而是直接回答三个问题:ADT的"代数"到底体现在哪、它凭什么能让非法状态无处藏身、以及在实际项目里怎么把它用好。内容会覆盖原理、完整代码示例和我在真实系统里踩过的坑,适合正在用Scala做后端或领域建模的开发者参考。

1. 先搞清楚"代数"在哪里:和类型与积类型的基本盘

很多人把"代数数据类型"当成一个高大上的术语,其实它描述的是一种非常朴素的可数关系:一个类型可能拥有的值的数量,可以像普通数字一样做乘法和加法。理解这一点之后,sealed trait和case class这对组合就不再是死记硬背的语法,而是有明确数学直觉的建模工具。

1.1 积类型:值域是字段域的笛卡尔积

先看最常见的case class:

case class Point(x: Double, y: Double)

Point能表达的值的集合,是Double取值集合和Double取值集合的笛卡尔积。也就是说,x有多少种可能,y有多少种可能,Point就有"两者相乘"那么多种可能。这正是"积类型"名字的来源——多个字段组合在一起,值的数量是相乘的关系。

这个直觉有什么用?它提醒我们:每增加一个字段,类型能表达的状态空间就膨胀一次。假如领域模型里有一个"用户"概念,你给它塞了十个可空字段,它就能组合出海量的状态,而且其中绝大多数在业务上毫无意义。积类型本身没有错,错的是把不必要的积越叠越大。

1.2 和类型:值域是分支数量的加法

再看sealed trait的经典结构:

sealed trait Shape case class Circle(radius: Double) extends Shape case class Rectangle(width: Double, height: Double) extends Shape

Shape这个类型能容纳的值,要么来自Circle的所有可能值,要么来自Rectangle的所有可能值,总数是两者相加。所以它叫和类型,也叫求和类型。ADT这个总称,就是指由积类型和和类型组合嵌套出来的数据结构。

听起来简单,但这里藏着一个很容易被忽略的点:如果只说sealed trait本身,它只是一个空壳接口;真正的领域信息分布在各个子类型里。和类型的威力不在于"继承",而在于它让类型系统明确地表达"当前值一定是这些分支中的一个"。这种确定性是之后所有安全性讨论的前提。

1.3 Unit与Nothing:1和0的角色

既然能乘能加,自然可以聊聊Unit和Nothing这两个特殊类型。Unit只有一个值,通常写作(),它在一组积类型里扮演数字1的角色:A * Unit的值的数量仍然等于A。所以在case class里塞一个Unit字段不会增加任何信息量,这是设计上的噪音。

Nothing没有任何值,它在一组和类型里扮演数字0:A + Nothing的值的数量仍然等于A。Scala里Nothing是所有类型的子类型,这个看似抽象的性质在泛型ADT里很有用,比如Option的定义就依赖"None是Option[Nothing]的实例"来让协变成立。

理清这层关系之后,你看标准库的很多设计都会豁然开朗:为什么List的Nil是case object?为什么Option分成Some和None?因为它们都是典型的和类型。底层的"代数直觉"会帮你在设计自己的ADT时做出更干净的选择。

2. 用类型消灭非法状态:sealed trait和case class为什么能扛起领域模型

领域模型最怕的不是逻辑复杂,而是"对象处于一个不该出现的状态"。传统做法是用散装字段加一堆if判断来约束,但这些判断散落在各处,漏掉一条就会留下隐患。ADT的思路完全不同:如果某个状态组合在类型层面就不存在,你根本不需要为它写防御逻辑。

2.1 典型反例:散装字段造成的状态爆炸

我见过很多项目是这样建模用户状态的:

case class User( name: String, status: String, // "active", "suspended", "banned" suspendReason: Option[String], bannedAt: Option[Instant] )

一眼看过去,问题就摆在面前。status等于"active"时,suspendReason是Some还是None?按业务规则应该是None,但这个模型允许Some存在。status等于"suspended"时,suspendReason理论上不能为空,但普通字符串加Option完全拦不住。更别说哪天产品加一个新状态"inactive",你得回去把所有if else翻一遍。

这种模型的本质问题在于:约束规则存在于人的脑子里,而不是类型里。每个调用点都要记得"先看status再决定哪些字段有效",一旦有人忘记,非法状态就从那个缺口溜进系统。

2.2 正确姿势:把状态的选择直接建模成和类型

用ADT来表达同样的业务,会变成这样:

sealed trait UserStatus case class Active(since: LocalDate) extends UserStatus case class Suspended(reason: String, until: LocalDate) extends UserStatus case object Banned extends UserStatus case class User(name: String, status: UserStatus)

这里每个状态都自带它需要的字段:Active带activation日期,Suspended带封禁原因和截止时间,Banned不需要额外字段所以直接用了case object。整个模型里根本不存在"active状态却有suspendReason"这种组合——因为Active分支里压根没有那个字段。

这带来的最大改变是,所有处理用户状态的代码都收敛到模式匹配上。你不用在每个方法里写散装判断,只需要写一个match表达式,把三种情况处理干净。

2.3 sealed的限制为什么是设计上的礼物

很多从Java转过来的朋友第一次看到sealed trait时会有疑问:子类型只能放在同一个文件里,这也太限制了吧?其实这正是安全感来源。因为编译器知道一个sealed trait的所有直接子类型,它才能帮你做穷尽检查;如果子类型可以散落在任何地方,编译器永远无法确定你有没有漏掉分支。

我自己的经验是:把同一类领域概念的所有分支放在一个文件里,反而让代码更好维护。订单状态、支付事件、审批动作,这些概念的演变通常是整体性的,放在一起能一眼看到全貌。当产品经理提出新增一个状态时,你只需要打开那一个文件,就能看到所有需要同步修改的位置。

3. 模式匹配与穷尽性检查:让编译器替你审计每一个分支

ADT和模式匹配是天生一对。有了和类型之后,写代码的人必须面对每一个可能的子类型分支,而编译器会盯着你:漏了哪个,警告就甩到脸上。这个"被编译器监督"的过程,就是类型安全落到实处的瞬间。

3.1 sealed是编译期穷尽检查的大前提

还是用UserStatus为例:

def describe(status: UserStatus): String = status match { case Active(since) => s"活跃用户,注册于$since" case Banned => "已封禁" }

如果我只写了两个分支,漏掉了Suspended,在Scala里编译时会得到类似这样的警告:

match may not be exhaustive. It would fail on the following input: Suspended(reason, until)

这个警告不是可选项,它是在明确告诉你:有一个真实存在的业务状态没有被处理,一旦运行时遇到,就会抛出MatchError。编译期的穷尽检查,本质上是用类型做了一次静态审计。

3.2 编译器对遗漏分支的两种反应

很多人会问:既然编译器已经警告了,为什么运行时还是会炸?因为警告和错误是两码事。默认配置下,Scala编译器只会给出警告,代码照样能跑。如果你的团队没人理会警告,或者CI没有把警告升级为错误,那"穷尽检查"就形同虚设。

我强烈建议在构建配置里把漏分支警告变成编译失败。Scala 2可以使用-Xfatal-warnings,Scala 3可以使用-Werror -Xfatal-warnings这类组合。这样"忘记处理新分支"从"可能会炸"变为"根本没法提交代码"。这种强制约束一开始会让团队觉得烦,但只要坚持两周,所有人都能体会到好处:再也不用靠记忆去维护那些散落的分支逻辑。

3.3 @unchecked和通配符:有效但必须慎用的逃生门

有些情况确实不方便穷尽匹配,比如你只想处理少数几个分支,其他分支统一忽略。这时可以写:

def isActive(user: User): Boolean = user.status match { case Active(_) => true case _ => false }

通配符_把剩下所有分支都吸收了,编译器不再发出穷尽警告。还有一种做法是在match前加@unchecked注解,明确告诉编译器"请你闭嘴,我知道自己在干什么"。

但我必须说清楚:这两个逃生门是有代价的。通配符的隐患在于,以后新增一个Unverified状态时,这段代码依然能编译通过,但isActive会静默地把它当成false。这可能是你要的结果,也可能不是。最怕的是某天新分支被通配符吞掉,业务逻辑悄悄出错,而没有任何编译期信号。所以我的习惯是:只有在对"其他所有分支都应当做同一处理"有十足把握时才用通配符,并且每次都写注释说明为什么。

4. 实战建模:一个订单状态机从需求到类型的完整落地

理论说再多,不如跑一个完整的例子。我用电商订单状态机来演示ADT从需求到实现的完整过程。选择订单是因为它状态多、字段随状态变化、转换规则明确,特别适合展示和类型的价值。

4.1 需求:一个用户可感知的订单生命周期

假设业务要求如下:订单创建后处于待支付状态;用户支付成功后变为已支付;商家发货后变为已发货,此时需要记录运单号;用户确认收货后变为已送达;在待支付状态下用户可取消;已支付状态下可以退款,这里简化为取消。每个状态都绑定了不同的时间和原因信息。

如果用传统散装字段建模,大概会写出五六个nullable字段,然后靠一长串if else来维持不变量。用ADT建模时,我第一步不是画表,而是写类型骨架。

4.2 建模:从业务规范到sealed trait加case class

sealed trait OrderState case object PendingPayment extends OrderState case class Paid( paidAt: Instant ) extends OrderState case class Shipped( paidAt: Instant, shippedAt: Instant, trackingNo: String ) extends OrderState case class Delivered( paidAt: Instant, shippedAt: Instant, deliveredAt: Instant ) extends OrderState case class Cancelled( reason: String, cancelledAt: Instant ) extends OrderState case class Order( orderId: Long, items: List[OrderLine], state: OrderState )

注意几个设计的取舍。Paid状态只带paidAt;Shipped在Paid基础上增加shippedAt和trackingNo;Delivered继续累积信息。这种"状态越深字段越多"的建模非常直观:你从当前类型就能看出这个订单走到了哪一步。Cancelled完全独立,它的字段和支付无关。

另一个值得提的点是:有些人会嫌Paid、Shipped、Delivered里的paidAt重复出现,想提取到父类型里。Scala 2里trait不能带构造参数,所以可以用sealed abstract class来定义父类型,但这会引入额外的复杂度。我更推荐先用最直白的写法,让每个分支字段完整自足,等真的发现重复带来的维护成本时再重构。

4.3 状态流转:把事件也建模为ADT

只有状态没有流转,模型还缺一半。业务动作也就是事件,非常适合用另一个ADT表达:

sealed trait OrderEvent case object PaymentReceived extends OrderEvent case class ShipmentDispatched(trackingNo: String) extends OrderEvent case object DeliveryConfirmed extends OrderEvent case class CancellationRequested(reason: String) extends OrderEvent

然后写一个纯函数,把当前状态和收到的事件做匹配:

def applyEvent(order: Order, event: OrderEvent): Order = { (order.state, event) match { case (PendingPayment, PaymentReceived) => order.copy(state = Paid(Instant.now())) case (Paid(_), ShipmentDispatched(trackingNo)) => order.copy(state = Shipped( paidAt = Instant.now(), // 实际项目中应在事件里携带具体时间 shippedAt = Instant.now(), trackingNo = trackingNo )) case (Shipped(_, _, _), DeliveryConfirmed) => order.copy(state = Delivered( paidAt = Instant.now(), shippedAt = Instant.now(), deliveredAt = Instant.now() )) case (PendingPayment, CancellationRequested(reason)) => order.copy(state = Cancelled(reason, Instant.now())) case (state, event) => throw new IllegalStateException(s"无法从状态$state处理事件$event") } }

最后一行的兜底分支很关键。它表示"类型上虽然这两种ADT都可以组合,但业务上存在非法转换"。当有人试图对已支付订单再次发起支付,或者对已送达订单发货时,会在运行时明确报错。而所有合法的转换路径,都被前面的具体分支一一列出,可读性极强。

4.4 序列化与Schema演化的落地建议

ADT在内存里很漂亮,落到JSON或数据库里就需要一点额外设计。我的实践经验是,每个分支必须有一个类型标记字段,否则反序列化时没法区分。比如JSON里可以这样约定:

{ "type": "Shipped", "paidAt": "2024-05-01T10:00:00Z", "shippedAt": "2024-05-02T10:00:00Z", "trackingNo": "SF1234567890" }

数据库里也可以用类似的type列加payload列,或者用单表继承的正规化设计。值得警惕的是schema演化:当你在OrderState里新增一个分支时,旧数据里没有这个type,反序列化可能直接失败。所以生产环境里建议给序列化库配置好未知type时的fallback策略,或者在一开始就设计一个"未知状态"分支来兜底,避免把存储层的兼容性问题爆发到应用层。

5. 递归ADT与标准库里的Option、Either、List:看穿它们的共同骨架

你每天在用Option和List,但可能没意识到它们都是ADT。一旦看穿这一点,很多标准库API的设计逻辑会变得异常清晰,你也能从中偷师,写出自己的递归结构。

5.1 递归结构与"无穷大"类型的表达

ADT允许分支里引用自身,这就是递归ADT。最经典的例子是二叉树:

sealed trait Tree case object Empty extends Tree case class Node(value: Int, left: Tree, right: Tree) extends Tree

Node的left和right又是Tree,于是这个类型能表达任意深度的树。递归ADT之所以重要,是因为真实世界的数据结构——文件系统、JSON、评论回复树——天生就是递归的。你不可能用有限个平铺字段建模它们,但你完全可以用有限个类型定义来表达无限嵌套的结构。

这里有一个编译器相关的细节:递归ADT的正常使用不会让类型系统陷入无限循环,因为值是有限的,只是类型定义里可以引用自己。真正需要小心的是"无限递归类型"和"无限值"的区别,比如定义一个case class Loop(next: Loop)确实能构造无限链,但这种结构在序列化和相等性比较上都有坑,实际建模时要人工限制深度。

5.2 Option、Either、List都是同一个套路

标准库里的Option,本质上就是这样一个和类型:

sealed trait Option[+A] case object None extends Option[Nothing] case class Some[A](value: A) extends Option[A]

None代表"没有值",Some代表"有一个值"。它用类型消灭了空指针问题:一个Option[A]的值,要么是None,要么是Some(a),不存在第三种可能。处理的时候必须两个分支都写出来,编译器替你把"没考虑为空"的错误提前拦截。

Either的定义同样直白:

sealed trait Either[+A, +B] case class Left[A](value: A) extends Either[A, Nothing] case class Right[B](value: B) extends Either[Nothing, B]

Left通常表示错误,Right表示成功。从此"可能失败"的结果不再需要用异常打断控制流,而是变成一个值参与组合。

List在Scala标准库里也是ADT,虽然实际实现用了更多优化,但概念上就是:

sealed trait List[+A] case object Nil extends List[Nothing] case class Cons[+A](head: A, tail: List[A]) extends List[A]

Nil表示空列表,Cons表示一个元素加上剩余列表。你看到的List(1, 2, 3),本质上就是Cons(1, Cons(2, Cons(3, Nil)))的语法糖。理解这个结构后,递归处理List的直觉就建立起来了。

5.3 对日常建模的启发:复杂JSON和树形结构

我做过的某后台系统里有一个动态表单引擎,表单项可以嵌套分组。最初的模型是一个Map加若干标记位,后端解析JSON时每层都要手写if判断,迭代几个月后已经没人敢改。后来重构为递归ADT:

sealed trait FormNode case class Field(name: String) extends FormNode case class Group(title: String, children: List[FormNode]) extends FormNode

同一套代码既能处理单字段又能处理嵌套分组,新增一种节点类型时编译器会提醒你所有递归遍历的地方需要更新。这种重构带来的不只是类型安全,还有修改信心的恢复。

6. 这些年用ADT建模踩过的坑和我的设计习惯

ADT很容易让人爱上,但设计不当也会带来麻烦。我把自己踩过的坑和一些形成定式的习惯整理出来,希望能帮你少走弯路。

6.1 别把case class当万能对象

case class用起来太顺手了,于是有人不管什么概念都先堆一个case class出来。后果是类型数量爆炸,每个类只有两三个字段,业务逻辑全散在外面的隐式转换里。ADT的精髓是用和类型表达"选择",用积类型表达"组合",而不是把所有数据一股脑塞进product type。如果一个case class里的字段经常只有一部分被使用,那它很可能应该被拆成和类型下的多个分支。

6.2 父类型的选择:trait、abstract class还是Scala 3的enum

我发现很多人对"父类型应该用trait还是abstract class"有困惑。简单说:如果只需要一组分支作为标记,用trait;如果父类型需要携带构造参数或公共字段,用sealed abstract class。比如订单状态里如果想要一个公共的展示名称,可以写成:

sealed abstract class OrderState( val displayName: String ) case object PendingPayment extends OrderState("待支付") case class Paid(paidAt: Instant) extends OrderState("已支付")

如果你在Scala 3上,还可以考虑enum直接定义:

enum OrderState: case PendingPayment case Paid(paidAt: Instant) case Shipped(shippedAt: Instant, trackingNo: String)

enum语法更紧凑,但本质上还是ADT。选型时优先考虑团队熟悉度以及和既有序列化库的匹配情况。

6.3 序列化和版本演化的坑

前面提到过type标记,这里再补充两个实际教训。第一个是,不要在ADT分支里使用未经密封的字段类型。比如某个case class里放了一个普通的trait字段,反序列化时你会面临"不知道具体是哪个实现类"的问题。解决原则是:ADT嵌套ADT,所有核心节点都要密封。

第二个教训是旧数据兼容。我经历过一次给sealed trait新增case class的升级,线上有一批旧JSON没有type字段的type标记,反序列化直接失败,不得不上线紧急补丁。现在我的习惯是,序列化的type字段一定要有默认值,并且预留一个Unknown分支来兜底,保证老数据至少能被加载出来。

6.4 通配符与重构纪律

不管是_通配符还是@unchecked,都要当成"有借有还"的技术债来管理。每次写完通配符匹配,我都会顺手加一个注释,记录"这里默认忽略所有未来新增分支",并尽量在分支注释里写清楚业务含义。等到sealed trait真的新增了子类,编译器不会提醒你,但重构时搜索@unchecked和裸通配符,可以快速找到所有需要复核的位置。

另一个我坚持的纪律是:一个sealed trait下的所有分支必须放在同一个文件里,不允许为了省事拆到多个文件。这不是风格洁癖,而是为了穷尽检查有效。如果sealed trait和它的子类不在同一编译单元,Scala编译器就无法保证列出所有分支。

最后分享一个让我受益最多的习惯:每次在sealed trait下新增一个case class时,先不急着写业务逻辑,直接编译一遍项目,让编译器的穷尽检查把每个遗漏的match都揪出来。这是ADT建模中最爽的时刻——你刚改了类型定义,编译器就把所有需要同步修改的位置列了出来,比任何代码搜索都准确。真正用过一次之后,你就再也不想退回那种"改一个状态,满项目找if else"的日子了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询