ADT 与模式匹配:FP 下多态的一种方式

把分支写进类型里

1,631 words 13 min read
目录 9 节
  1. ADT:把可能性写进类型
  2. 模式匹配:把类型里的分支拆出来
  3. 和 OOP 的差异
  4. Java:从开放继承到封闭匹配
  5. Scala:sealed、enum 和 union type
  6. Rust:enum 是语言骨架的一部分
  7. 多态到底在哪里
  8. 不是所有分支都值得变成 ADT
  9. 结尾:分支应该在哪里

聊多态的时候,我们很容易先想到 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();

运行时到底调用 Circlearea,还是 Rectanglearea,由对象自己的实际类型决定。这是很经典的子类型多态。

但函数式编程里也有另一种很常见的路子:先用 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 作为一个外部函数,通过模式匹配处理这些形态。

这不是谁比谁高级的问题,而是扩展方向不同。

如果你经常新增 TrianglePolygon 这些新形态,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 traitcase 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 带一个 f64Rectangle 带两个命名字段,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,而是 OptionResult

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 更啰嗦,有时候又清楚得多。关键不在于范式,而在于你想把分支放在哪里。

如果一个概念本来就只有几种合法形态,先问一句:它是不是应该成为一个类型?