Best Enum

在 Rust 吸引人的诸多优点中,Enum 肯定十分值得一提。对于从 C/C++ 转过来的开发者来说,看到这种自带 namespace 的枚举,不仅可以在一个枚举值里存储其他类型的值,甚至每个枚举值都可以有不同类型的值,实在令人耳目一新。

enum Message {

    Join { id: u32, name: String },

    Move(Point),

    Quit,

}

struct Point {

    x: i32,

    y: i32,

}

作为 Rust 的一等公民,也是完全融入到语言的类型系统里,最最基础的 OptionResult 类型都基于 Enum 实现:

enum Option<T> {

    None,

    Some(T),

}

诸如模式匹配、穷尽性检查等特性都能用上,写出的代码的抽象层度高、可读性和简洁性都很不错。

代数数据类型(Algebraic data type,缩写:ADT)

包括 和类型积类型两种。

  • 积类型(Product Type):表示由多种类型组合而成,总可能数等于包含的每种类型的每种可能数的乘积。
  • 和类型(Sum Type):表示可以是多种类型中的一种,总可能数等于包含的每种类型的可能数之和。

如果一个语言的设计里提到了 ADT,那么通常它都会包含能覆盖这两种类型的实现。

能看出 Rust 的 Enum 是和类型的,Tuple/Struct 是积类型的。

Product Type | And | +

比如一个 Point 结构体,它由两个 i32 组成,我们可以说这个 Point 包含一个 i32 和(And) 另一个 i32

struct Point {

    x: i32,

    y: i32,

}

所有 Point 的集合会包含所有可能的 (x, y) 对。

Sum Type | Or | *

比如 Option<T> 可以是 None 或者(Or) Some(T),总可能数为 1 + N(其中 N 为 T 的可能数)。

因此通常会说 Enum 是 Sum Type 在 Rust 中的实现方式,也可以称 Enum 为 Tagged Union

  • tag (Discriminant) 表明这是哪一类枚举成员,编译器会最小化其大小,甚至一些情况下可以做到 0 大小。(niche optimization, e.g. Option<&T>
  • union (Payload) 保存枚举值的实例, 根据 tag 的不同而解释不同

Tagged Union 有很多类似含义的术语 variant/discriminated union/disjoint union/sum type/coproduct 等。

这在函数式编程语言里很常见,Rust 也是从 Haskell 等语言中借鉴了这个思想。

Exponential Type | Function | ^

我们其实可以用代数的方式来表示这些类型,这里的 × 表示积,+ 表示和,所有类型都可以类比成一个多项式:

type Point = i32 * i32;

type OptionT = 1 + T;

实际上还有 Exponential Type(指数类型),\(A^B\) 表示从 \(B\)\(A\) 的函数类型。其可能的数为 \(A^{B}\),即 \(A\) 的可能数的 \(B\) 次方。那么自然我们可以把一个从 \(B\)\(A\) 的函数认为是一个 Exponential Type。

fn func(b: B) -> A;



// 如果我们认为 func 是一个类型,那么它的类型可以表示为如下:

// 其包含 B 的所有可能值的个数个成员,每个成员对应一个 A 的可能值的结果

struct func {

    b1: A,

    b2: A,

    // ...

    bn: A,

}

自然这个类型 func 的可能数就是 \(A^{B}\)

More in Enum

FixPoint

如果尝试写过 Rust 里的链表,一定会对这个递归结构很熟悉:

enum List<T> {

    Nil(),

    Cons(T, Box<List<T>>),

}

仅作类型分析,我们稍作简化,去掉因为递归无限大小引入的 Box,同时假定类型 Ti32,那么这个链表的类型可以写成:

enum IntList {

    Nil(),

    Cons(i32, IntList), // missing Box, won't compile

}

IntList 类型就可以代数地表示为:

type IntList = 1 + (i32 * IntList)

IntList 在等式左右两边都出现了,所以这里其实是一个递归定义,实际上 IntList 刚好是

type IntListF<X> = 1 + (i32 * X)

的不动点,即 IntList = IntListF(IntList)

递归表达集合

上面的递归定义,也可以让一个 Enum 表达出某个集合的所有可能值。比如所有的自然数的集合就可以用下面这个枚举来表达:

enum Nat {

    Zero,

    Succ(Box<Nat>),

}

Nat 的可能值就可以是所有的自然数:0, 1, 2, 3,

如果在 Succ 里增加一些内容,就会演变成链表。所以可以把链表看成是自然数的一个泛化。

lists are natural numbers which carry content.

Enum in API

其实写这篇 blog 的最最出发点是实践里多次遇到了一个问题,然后在查找相关资料时发现了一些有趣的思想记录在上面。

遇到的问题就是,当我们以这种 和类型 的思想来设计了一个 API 的 schema 后,如何能让它更好用。

举个例子你有一个 API POST /action 需要在 reqbody 里填入 Action,它可以是 Move, Attack, Heal 等等,且不同的 Action 会有不同的参数。那么很自然可以用类似这样的 Enum 来表达:

enum Action {

    Move(Direction),

    Attack(Target),

    Heal(Amount),

}

// ... Direction, Target, Amount 等类型定义

尽管 OpenAPI 3.0 支持 oneOf 语法,可以写出这样的 schema:

Action:

  oneOf:

    - $ref: '#/components/schemas/Move'

    - $ref: '#/components/schemas/Attack'

    - $ref: '#/components/schemas/Heal'

但是实际情况下其它检查工具的支持不完善、语言代码生成工具不支持等问题,会导致不得不使用退化成 object 的方式来表达:

Action:

  type: object

  properties:

    move:

      $ref: '#/components/schemas/Move'

    attack:

      $ref: '#/components/schemas/Attack'

    heal:

      $ref: '#/components/schemas/Heal'

这种方式虽然可以表达出 Action 的所有可能值,但是却失去了 Enum 的语义。所以成员都只能定义成可选的,但业务又要求有且仅有一个成员存在。于是就会出现许多小问题:

  • 在接收到请求的时候不得不额外的做唯一性检查,这本该是生成工具自动完成的内容,不应该混杂在业务逻辑里
  • 不同语言的 SDK 代码、前端调用时都需要理解这个约束。因为它们不一定有 sum type 的概念

Not a perfect world.

References && Further Reading

What Are Sum, Product, and Pi Types?