ADT 与模式匹配:FP 下多态的一种方式
把分支写进类型里
目录
聊多态的时候,我们很容易先想到 OOP。
定义一个接口,几个类实现它:
interface Shape {
double area();
}
class Circle implements Shape {
private double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
class Rectangle implements Shape {
private double width;
private double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
调用的时候只看接口:
Shape shape = new Circle(10);
shape.area();
运行时到底调用 Circle 的 area,还是 Rectangle 的 area,由对象自己的实际类型决定。这是很经典的子类型多态。
但函数式编程里也有另一种很常见的路子:先用 ADT 把一个值可能有哪些形态写进类型里,再用模式匹配根据这些形态选择行为。
FP 里的多态还有参数多态和 type class。
ADT:把可能性写进类型
ADT (Algebraic Data Type),即代数数据类型。
它关心的事情其实很朴素:一个值到底由哪些部分组成,又有哪些可能形态。
FP 里常说两种基本结构:
product type: 积类型(几个东西同时存在)
sum type: 和类型(几种可能性选其一)
product type 很像“并且”。
Haskell 里可以写:
data User = User String Int
一个 User 里同时有一个 String 和一个 Int。
sum type 则像“或者”。
data Shape
= Circle Double
| Rectangle Double Double
一个 Shape 要么是 Circle Double,要么是 Rectangle Double Double。
这就是 ADT 最实用的地方:它不是把可能性藏在某个字段、字符串、数字编码里,而是直接告诉类型系统:这个概念只有这些合法形态。
更经典一点的例子是 Maybe:
data Maybe a
= Nothing
| Just a
一个 Maybe a 要么没有值,要么有一个 a。很多语言里的 null 是一个运行时陷阱,而 Maybe a 是把“可能没有”这件事提前写进返回类型。
还有 Either:
data Either e a
= Left e
| Right a
它可以表示两种结果之一。日常写法里经常把 Left 当错误,把 Right 当成功值。
这就很像 Rust 的 Result<T, E>、Scala 里的 Either[E, A]。
模式匹配:把类型里的分支拆出来
有了 ADT,还需要一种方式消费它。这个方式就是 pattern matching。
模式匹配不是更漂亮的 switch。更准确地说,它是在按照数据构造时的形状,把值重新拆开。
还是用 Haskell 的 Shape:
data Shape
= Circle Double
| Rectangle Double Double
area :: Shape -> Double
area shape =
case shape of
Circle r -> pi * r * r
Rectangle w h -> w * h
这里没有 shape.area()。
area 是一个普通函数,它接收一个 Shape。然后它根据 Shape 的具体构造子选择不同逻辑。
也可以写成更 Haskell 的风格:
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
这个地方就可以看到一种和 OOP 很不一样的多态感:
OOP: 同一个方法调用,交给对象自己分派。
FP: 同一个函数入口,通过模式匹配分派到不同分支。
同样是“对不同形态的数据做不同事情”,分派发生的位置不一样。
OOP 把分派藏在对象方法里。
ADT + pattern matching 把分派展开在函数里。
和 OOP 的差异
如果用 OOP 写 Shape,大概会是这样:
interface Shape {
double area();
}
final class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
final class Rectangle implements Shape {
private final double width;
private final double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
调用方很舒服:
double n = shape.area();
它不关心 shape 是圆还是矩形。对象内部自己知道该怎么做。
FP 这边的味道不一样:
data Shape
= Circle Double
| Rectangle Double Double
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
Shape 的所有形态集中写在类型定义里。area 作为一个外部函数,通过模式匹配处理这些形态。
这不是谁比谁高级的问题,而是扩展方向不同。
如果你经常新增 Triangle、Polygon 这些新形态,OOP 的继承模型往往很自然:新增一个类,实现接口就行。
如果你经常新增操作,比如:
area
perimeter
prettyPrint
toSvg
ADT + pattern matching 会很舒服。数据形态是封闭的,新函数可以一个个加。
这就是常说的 expression problem :新增数据类型和新增操作,很难同时都保持完全开放、完全无痛。
不过日常写代码并不一定要把这个问题当作学术难题。很多时候只要问一句:这个概念的形态是不是有限且稳定?
如果是,ADT 往往很适合。
Java:从开放继承到封闭匹配
顺便提一句,现代 Java 也开始支持这个方向的特性。
Java 17 之后有 sealed class / interface,可以把继承层级封闭;Java 21 之后,pattern matching for switch 也正式可用了。
现在可以写出这样的代码:
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
这当然还不是 Rust 或 Scala 那种原生 ADT 体验,但方向很有意思:sealed 让类型形态变得封闭,switch pattern matching 让调用方可以按具体形态拆开处理。
也就是说,即使是 Java 这种典型 OOP 语言,也在补一种“封闭类型层级 + 结构化分支处理”的能力。
Scala:sealed、enum 和 union type
Scala 里可以很自然地写 ADT。
经典写法是 sealed trait 加 case class / case object:
sealed trait Shape
final case class Circle(radius: Double) extends Shape
final case class Rectangle(width: Double, height: Double) extends Shape
case object Unknown extends Shape
sealed 很重要。它告诉编译器:Shape 的直接子类型都在当前文件里,外面不能再随便扩展。
这就让编译器有机会检查模式匹配是不是穷尽。
def area(shape: Shape): Double =
shape match
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
case Unknown => 0.0
case class 和 pattern matching 配合得很顺。定义数据的时候,它天然带着“可以被拆开”的意味。
如果漏掉一个分支,比如不处理 Unknown,编译器就可能提醒你:
def area(shape: Shape): Double =
shape match
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
这就是 ADT + pattern matching 的一个关键价值:新增一种形态以后,消费这个类型的地方会被迫重新面对它。
Scala 3 里还可以直接用 enum:
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Unknown
然后匹配:
def area(shape: Shape): Double =
shape match
case Shape.Circle(r) => math.Pi * r * r
case Shape.Rectangle(w, h) => w * h
case Shape.Unknown => 0.0
这比 sealed trait 那套更收拢。你可以把它看成 Scala 3 对 ADT 的正面支持。
Scala 还有一个很有意思的能力:union type。
type Id = UserId | OrderId
这表达的是:一个 Id 可以是 UserId,也可以是 OrderId。
它确实很像类型层面的“或”。但它和 ADT 不是完全一回事。
ADT 更像是在定义一个新的领域概念:
enum Payment:
case Cash(amount: BigDecimal)
case Card(number: String)
case Coupon(code: String)
这里 Payment 自己就是一个概念,它的合法形态由这些 case 构成。
union type 更像是在已有类型之间做组合:
type SearchKey = UserId | Email
这通常适合表达“这个 API 接受这些已有类型之一”。
所以需要把它们分开来看:
ADT: 这个概念本身有哪些形态?
Union type: 这个位置可以接受哪些类型?
它们都在表达“或”,但建模的重心不一样。
Rust:enum 是语言骨架的一部分
Rust 的 enum 不是 C 那种“给整数起名字”的枚举,而是正统的 sum type:每个 variant 都可以携带不同形状的数据。
enum Shape {
Circle(f64),
Rectangle { width: f64, height: f64 },
Unknown,
}
这就是异构枚举。
Circle 带一个 f64,Rectangle 带两个命名字段,Unknown 什么都不带。它们都是 Shape,但内部结构完全可以不同。
匹配起来也很直接:
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle { width, height } => width * height,
Shape::Unknown => 0.0,
}
}
Rust 的 match 结合 enum 时,你必须穷举每一种情况。
如果不写 Unknown 分支,编译器会告诉你匹配不完整。
当然你也可以用 _ 接住剩下的情况:
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle { width, height } => width * height,
_ => 0.0,
}
}
但 _ 这东西要稍微克制一点。它方便,也可能把未来新增的分支悄悄吞掉。
在 Rust 里,最日常的 ADT 其实不是你自己写的 Shape,而是 Option 和 Result。
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Option<T> 把“可能没有”变成类型。
Result<T, E> 把“可能失败”变成类型。
所以 Rust 代码里会经常看到这种模式:
fn parse_age(input: &str) -> Result<u32, String> {
match input.parse::<u32>() {
Ok(age) => Ok(age),
Err(_) => Err(format!("invalid age: {input}")),
}
}
失败不是异常通道里的暗门,而是函数签名的一部分。
这也是很 Rusty 的地方:它不是把 ADT 当成一个高级特性放在角落里,而是把 enum + match 做成了日常编程的骨架。
再看一个递归 ADT:
enum Expr {
Lit(i32),
Add(Box<Expr>, Box<Expr>),
Neg(Box<Expr>),
}
解释器可以这样写:
fn eval(expr: Expr) -> i32 {
match expr {
Expr::Lit(n) => n,
Expr::Add(left, right) => eval(*left) + eval(*right),
Expr::Neg(inner) => -eval(*inner),
}
}
这里的多态感也很明显:eval 接受同一种入口类型 Expr,但根据不同 variant 执行不同逻辑。
只是这个分派不是通过虚方法做的,而是通过 match 显式写出来的。
多态到底在哪里
如果把多态理解成“同一个操作可以作用在不同形态的数据上”,那么 ADT + pattern matching 确实是一种多态方式。
它和 OOP 的差别在于分派方向。OOP 的写法像这样:shape.area(),调用点是统一的。具体行为跟着对象走。
ADT + pattern matching 的写法像这样:area(shape),函数入口是统一的。具体行为在函数内部对形态做匹配。
换句话说:
OOP: 数据带着行为走。
FP: 数据先声明形态,行为通过函数解释这些形态。
这也是为什么二者的痛点不一样。
在 OOP 里,新增一种 Shape 很自然:class Triangle implements Shape,已有调用 shape.area() 的地方不用动。
但如果要新增一个操作,比如 toSvg(),就要去每个类里补方法。
在 ADT 里,新增一个函数很自然:
def perimeter(shape: Shape): Double = ...
或者:
fn perimeter(shape: Shape) -> f64 { ... }
但如果新增一个 Triangle variant,所有匹配 Shape 的地方都可能需要重新处理。
如果你的领域模型是“形态有限,操作会增长”,ADT 会很舒服。
如果你的领域模型是“操作稳定,类型会增长”,OOP 子类型多态会更自然。
很多争论其实没有必要上升到范式优劣。它本质上是:变化主要发生在哪个方向?
不是所有分支都值得变成 ADT
ADT 很好,但也不是看到 if 就要立刻改成 enum。
比如只是一个局部条件:
if amount > 0 then "positive" else "non-positive"
这没必要硬套。
ADT 更适合表达稳定的业务形态:
订单状态
支付方式
表达式语法树
接口返回结果
解析成功或失败
这些东西一旦用字符串、数字、布尔值散落开,很容易变成隐形协议。
比如:
status = 1 表示待支付
status = 2 表示已支付
status = 3 表示已取消
这当然能跑,但类型系统不知道这些数字背后的世界。
如果写成 ADT,味道就变了:
enum OrderStatus:
case PendingPayment
case Paid
case Cancelled
或者 Rust:
enum OrderStatus {
PendingPayment,
Paid,
Cancelled,
}
这不是为了显得函数式,而是为了让非法状态少一点藏身之处。
结尾:分支应该在哪里
很多代码里的分支,其实都是没有被命名的 sum type。
null 表示没有值。
-1 表示找不到。
status = 3 表示已取消。
type = "card" 表示银行卡支付。
这些东西当然都能工作,但它们把业务可能性藏在值里,藏在注释里,藏在约定里。
ADT 做的事,是把这些可能性拉到类型层面:这个值要么是 A,要么是 B,要么是 C。
模式匹配做的事,是让你在消费这个值时正面面对这些可能性:
A 怎么处理?
B 怎么处理?
C 怎么处理?
从这个角度看,ADT + pattern matching 确实是 FP 下多态的一种方式。
它不靠对象重写方法来选择行为,而是靠类型列出形态,再靠函数匹配形态。
这条路有时候比 OOP 更啰嗦,有时候又清楚得多。关键不在于范式,而在于你想把分支放在哪里。
如果一个概念本来就只有几种合法形态,先问一句:它是不是应该成为一个类型?