首页 / 文章

C、Java、Rust、Go和TypeScript中的枚举类型揭示了类型设计的哪些特点

比较七种语言对枚举的定义方式,从命名整数到带标签的联合体以及运行时对象,并了解这对TypeScript建模意味着什么。

发布者:REACTAPP.TOP阅读约 17 分钟3847 词

来源: Enums Were Never Just Constants

几乎所有主流语言都有名为枚举的概念,但它们对枚举的定义各不相同。在某种语言中,枚举只是整数的一个简单标签;在另一种语言中,它是一个带有方法的类;在第三种语言中,它是不同结构数据的联合体;而在 TypeScript 中,它则是为数不多的超越传统 JavaScript 的类型级结构之一。通过逐一对比 C、C++、Java、Rust、Go、TypeScript 和 Odin 的实现方式,这些差异会变得清晰可见,同时也能帮助你更准确地判断在某个值只能取有限几种可能时,自己的 TypeScript 代码应使用何种枚举形式。

一个词,多种不同的问题

理论上的描述很简单:只需列出几个名称,每个名称对应一个允许的取值:

Red
Green
Blue

这看起来像是一个通用的概念,但一旦比较不同语言的实现方式,就会发现差异:

  • C语言将枚举主要视为一组带名称的整数常量。
  • C++保留了这种模型,但增加了enum class,从而实现了作用域限制和更严格的类型控制。
  • Java则让每个枚举常量都成为enum class类型的真实对象,包含字段、构造函数、方法,甚至针对每个常量的独立类体。
  • Rust允许一个枚举类型包含携带完全不同数据的变体。
  • Go语言没有enum关键字,而是使用常量以及标识符来表示枚举。
  • TypeScript中的枚举会被编译成运行时存在的JavaScript对象。
  • Odin语言则刻意保持枚举的简洁性,将相关功能交由其他特性处理,比如位集和以枚举索引的数组。

之所以没有统一的定义,是因为“enum”实际上是一组恰好共享同一个关键字的问题的集合。语言设计者必须决定这种结构应具备以下哪些功能:

  • 为固定的一组值命名
  • 创建一种新的类型而非重复使用现有类型
  • 为各个值附加数据
  • 表示一个值的多种可能状态
  • 让编译器证明所有情况都已被处理
  • 紧凑地表示标志位的组合
  • 决定有哪些内容(如果有的话)会保留在运行中的程序中

每种语言都会选择不同的子集,而这些选择能反映出该语言的设计重点。

C语言:整数的可读性名称

传统的C语言枚举类型为整数常量的命名提供了便利。某种状态类型可以这样声明:

enum Status {
    STATUS_OK,
    STATUS_ERROR,
    STATUS_PENDING
};

实际上,这三个名称仅仅对应从零开始的连续整数:

0
1
2

这听起来可能微不足道,但它确实解决了可读性方面的问题。想象一下在配置逻辑中与一个裸数字进行比较的场景:

if (status == 2) {
    ...
}

那行代码中没有任何信息能告诉审阅者2代表什么。而使用命名常量进行同样的比较时,其含义一目了然:

if (status == STATUS_PENDING) {
    ...
}

这就是枚举最古老的用途:用带有明确含义的名称替代那些难以理解的魔法数字。此外,枚举还能与switch语句自然搭配使用,因为每个分支都更像是在描述业务逻辑而非进行算术运算:

switch (status) {
    case STATUS_OK:
        ...
        break;

其余的分支也遵循相同的模式:

    case STATUS_ERROR:
        ...
        break;    case STATUS_PENDING:
        ...
        break;
}

由于这些值都是普通的、可预测的整数,C语言中的枚举类型非常适合用于底层代码:它们能够直接对应到硬件寄存器、协议字段和文件格式,而不会带来任何问题。

但相应的代价是约束力较弱。C语言并不像Rust那样将枚举视为封闭集合。枚举常量表现得如同整数一般,枚举变量通常可以被赋予任意整数而不会引发错误,编译器也没有义务标记缺失的case。虽然许多编译器会对switch语句中未处理的枚举值发出警告,但这只是工具提供的便利,并非语言层面的保证。C23标准确实增加了声明固定底层类型的语法,从而弥补了C++多年前就已解决的缺陷。

C语言的设计理念可概括为:

为整数赋予更有意义的名称,并尽量简化与之相关的机制。

在系统编程中,这种极简风格往往正是人们所追求的。

C++:同样的理念,更严格的约束

C++继承了C语言风格的枚举类型,后来又引入了一种更为严格的形式——作用域枚举:

enum class Status {
    Ok,
    Error,
    Pending
};

这一变化并非仅仅是语法上的简化。作用域枚举的枚举值必须加上类型名称作为限定:

Status::Ok

而不能像在普通作用域中的标识符那样直接使用:

Ok

同样重要的是,作用域枚举不能隐式转换为整数。因此你不会误将Status类型与无关的Color类型进行比较,也不会在没有显式转换的情况下将其传递给期望接收int类型的函数,从而避免了诸多潜在的混淆问题。

C++还允许你明确指定存储类型:

enum class Status : uint8_t {
    Ok,
    Error,
    Pending
};

每当布局成为协议的一部分时,比如在网络数据包或与C代码共享的结构中,这一点就非常重要。该语言会将未限定作用域的枚举与使用enum class或enum struct声明的限定作用域枚举区分开来,而且这两种形式都允许使用固定的底层类型。

值得注意的一点是,C++并没有重新定义枚举的概念,而是对其进行了三方面的控制:

  • 名称的作用域划分方式
  • 类型与整数的分离程度
  • 值在内存中的表示方式

从根本上说,它仍然是一组带名称的值;程序员只需对其边界进行更明确的界定即可。

Java:作为对象模型一部分的枚举

Java则进一步远离了整数的概念。其声明方式看起来很熟悉:

enum Status {
    OK,
    ERROR,
    PENDING
}

不过 OK、ERROR 和 PENDING 并非数值别名。根据《Java语言规范》,枚举是一种特殊的、受限制的类形式,其所有实例都必须通过名称提前声明。每个常量都是一个对象,在类初始化时创建一次,由于它是对象,因此可以拥有状态和行为。

携带数据的常量

构造函数参数可以为每个常量赋值。此处,每颗行星都记录了自己的质量:

enum Planet {
    EARTH(5.97),
    MARS(0.642);

字段以及用于初始化该字段的构造函数共同完成了声明:

    final double mass;    Planet(double mass) {
        this.mass = mass;
    }
}

现在每个常量都自带数据,需要获取行星质量时可以直接读取字段,而无需查询可能不同步的独立查找表。

携带行为的常量

枚举也可以声明方法,每个常量都可以提供自己的实现。以下操作类型会为每个常量重写抽象的 apply 方法:

enum Operation {
    ADD {
        @Override
        int apply(int a, int b) {
            return a + b;
        }
    },

第二个常量以及抽象声明的形式如下:

    MULTIPLY {
        @Override
        int apply(int a, int b) {
            return a * b;
        }
    };    abstract int apply(int a, int b);
}

此时该枚举显然已经具备类的特性,而这正是设计意图。Java将枚举整合到其面向对象模型中,因此常量可以拥有字段、构造函数参数、共享方法以及针对该常量的特定重写。具有独立类体的常量会被编译为枚举类型的匿名子类,在较新版本的Java中,规范将这类枚举类定义为隐式密封的,因为其子类集合由声明固定。

因此,Java的立场大致如下:

如果某个值来自固定的集合,为何非要限制其为整数呢?它完全可以是一个具有自身行为的对象啊。

正因如此,Java枚举非常适合用于状态机、解析器、带有优先级的运算符表,以及那种每种选项都知晓如何执行自身任务的策略式调度机制。

当数据结构取决于不同变体时

当所有常量都包含相同类型的预定义信息时,Java枚举才能发挥优势。而如果每种选项都需要不同的结构,就会出现不同的问题。

以聊天协议中的消息为例:

TextMessage
ImageMessage
VideoMessage

文本消息包含字符串,图片消息包含图像字节及元数据,而视频消息则存储其他类型的数据。此处不同的并非字段值,而是数据本身的结构。那种所有常量都共享同一组字段的Java风格枚举并不适用,而这正是Rust枚举所解决的问题。

Rust:作为带标签联合体的枚举

在Rust中,同一个枚举的不同变体可以各自拥有独立的载荷:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

Quit不携带任何数据,Move拥有命名字段,Write封装了一个String,而ChangeColor则存储三个字节。创建值时只需选择对应的变体并提供其数据即可:

let message = Message::Move { x: 10, y: 20 };

同一类型的不同变体看起来如下:

let message = Message::Write(String::from("Hello"));

这两个表达式都会生成一个Message对象,但其中包含的信息结构却截然不同。类型理论将这种结构称为和类型,而系统程序员则常称之为带标签的联合体:标签用于标识当前有效的变体,而有效载荷则存储该变体的数据。该类型描述了一组封闭的备选方案:

Message
 ├── Quit
 ├── Move { x, y }
 ├── Write(String)
 └── ChangeColor(r, g, b)

穷尽匹配

编译器知晓所有的变体,而match机制则能利用这一知识。第一个分支用于处理Quit情况:

match message {
    Message::Quit => {
        println!("Quit");
    }

其余分支则分别解构各个变体的有效载荷,并将其字段绑定到本地变量名中:

    Message::Move { x, y } => {
        println!("Move to {}, {}", x, y);
    }    Message::Write(text) => {
        println!("{}", text);
    }    Message::ChangeColor(r, g, b) => {
        println!("RGB: {}, {}, {}", r, g, b);
    }
}

如果删除了这些分支中的一个,程序将无法编译;Rust会拒绝非穷尽的match表达式。正因如此,Rust的文档才会将枚举与模式匹配放在一起介绍。日后添加新的变体时,代码库中所有不完整的match表达式都会变成编译错误,从而指出需要更新的位置。

这正是它与C语言枚举的本质区别。C语言枚举的回答是:

这个数值对应哪个命名常量?

Rust枚举的回答则是:

当前哪个变体处于激活状态,它携带了什么数据?

第二个问题涵盖了更广泛的设计场景。

使无效状态无法被表示

实际效果最容易通过结果来体现。那些可能成功也可能失败的操作可以被建模为具有两种变体的类型:

Result
 ├── Success(value)
 └── Error(error)

可能存在也可能不存在的值会得到相同的处理方式:

Option
 ├── Some(value)
 └── None

Rust的标准库将这两种类型分别以Result和Option的形式提供,它们实际上都是普通的枚举类型。这与许多代码库中常用的松散字段方式形成对比:

value = null
hasValue = false
error = ...

使用独立字段时,无法阻止hasValue为true而value为null的情况,也无法避免同时设置错误信息和实际值。每个使用者都必须记住这些不成文的规则。而使用聚合类型则根本无法构造出这样的组合,因为创建值的唯一方式就是通过已声明的变体之一。

这正是枚举设计最核心的用途之一:

枚举能够将关于数据结构的规则直接编码在类型系统中。

一旦这些规则被纳入类型定义,编译器便能在程序运行前发现缺失的情况。TypeScript 开发者可以通过区分联合类型实现类似功能,其中像 kind 这样的共享字面量字段起到标记作用,而限定操作与 never 检查则确保覆盖所有可能情况。

Go:完全没有枚举关键字

继 Rust 之后,Go 的设计方式似乎让人感觉倒退了。它没有 enum 关键字,其惯用方式是由 iota 驱动的常量块:

const (
    StatusOK = iota
    StatusError
    StatusPending
)

在常量声明中,iota 表示当前常量指定的索引,从零开始,且可以出现在表达式中。如果某行省略了表达式,Go 会重复上一行的表达式,如下所示:

const (
    A = iota
    B
    C
)

输出结果为:

A = 0
B = 1
C = 2

由于隐式重复会复制整个表达式,iota 不仅可用于计数,还可以用于生成2的幂次:

const (
    Read  = 1 << iota
    Write
    Execute
)

生成的数值为:

Read    = 1
Write   = 2
Execute = 4

不同的位组合使得这些常量可以自然地用作标志:Read | Write 可以组合权限,而按位与操作则可用于检测某一种权限是否存在。此外还可以定义命名类型,这样这些常量就不再是单纯的整数了:

type Permission uint8

然后使用该类型来声明常量:

const (
    Read Permission = 1 << iota
    Write
    Execute
)

只有 Read 明确表示为 Permission,但由于该表达式会携带类型信息,Write 和 Execute 也被视为 Permission 类型的值。Go 语言规范区分了有类型常量与无类型常量,而命名类型能够在一定程度上防止无关值之间的混用。不过它仍无法实现那些拥有专用枚举结构的语言所具备的枚举标识功能。

这正是 Go 语言的特点:它不直接添加新功能,而是提供灵活的常量系统,将更高层次的抽象留给程序员处理。相应的代价是该语言从未正式建立

these constants

与……的关联

this closed set of variants

与 Rust 或 Java 的方式类似。底层类型的任何值都可以转换为 Permission,而且编译器不会检查针对这些常量的 switch 语句是否覆盖了所有情况。那些需要这种保证的团队通常会额外添加代码检查工具或代码生成器。

TypeScript:运行时存在的枚举

TypeScript 本质上是 JavaScript 上的一层静态类型机制。像这样的类型别名:

type User = {
    name: string;
};

在生成的 JavaScript 代码中不会留下任何痕迹,因为它会在编译过程中被删除。而枚举则打破了这一规则:

enum Direction {
    Up,
    Down,
    Left,
    Right
}

普通的 TypeScript 枚举会被编译成真正的 JavaScript 对象,TypeScript 手册也明确说明了这种运行时行为。对于数字枚举,生成的对象同时包含正向和反向映射关系,因此可以由名称转换为数值,也可以由数值转换回名称:

Direction.Up    -> 0
Direction[0]    -> "Up"

这令那些认为 TypeScript 的所有功能仅作用于编译时的开发者感到意外。它还会带来实际影响:枚举类型会将代码包含在打包文件中,而且使用 Object.keys 遍历数字枚举时,会同时返回数值键的字符串形式及其名称。

字符串枚举会为每个成员指定明确的字符串值:

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT"
}

它们没有反向映射关系,而且其值在日志、网络数据包和调试器输出中更易于阅读,因为 “UP” 这样的字符串能直接表达含义,而数字 0 则无法做到。TypeScript 还支持计算成员和 const enum,后者会在使用位置被内联处理并从最终输出中移除。需要注意的是,const enum 与那些一次只编译一个文件的工具兼容性较差,这也是许多项目避免使用它的原因。

使用 object-plus-as-const 的替代方案

TypeScript文档还展示了一种更简洁的模式,它由一个普通对象和as const断言构成:

const Direction = {
    Up: 0,
    Down: 1,
    Left: 2,
    Right: 3,
} as const;

这是任何工具都能理解的普通JavaScript代码,而as const能为每个属性赋予TypeScript的字面类型。可以通过(typeof Direction)[keyof typeof Direction]来推导出对应的联合类型。随着运行时具备了直接移除类型的能力,这种模式变得更加重要:那种方法仅能删除注解,而需要实际代码生成的枚举则不适用该方法。请查阅你所使用的运行时和编译器的最新文档以了解具体规则;文章详细介绍了Node.js对TypeScript的原生支持能力。

关键在于,TypeScript 的枚举设计受其运行环境的影响。Java 可以从零开始将枚举定义为第一类的运行时特性。而 TypeScript 必须与本身没有枚举功能的 JavaScript 共存,因此它面临的问题变为:

当底层运行环境是 JavaScript 时,类型级特性应该演变成什么形式?

这个问题没有简单的答案,这也是 TypeScript 最终采用了多种相互重叠的解决方案的原因。

Odin:简单的枚举加上独立的工具

Odin选择了另一种路径。它的枚举类型仍遵循经典的命名值集合理念,而非将枚举扩展为对象层次结构或代数数据类型,而是为相关问题提供独立的解决方案:不同的类型、以枚举为索引的枚举数组、联合类型,以及元素类型可为枚举的位集。

一个普通的枚举用于描述方位:

Direction :: enum {
    North,
    East,
    South,
    West,
}

基于该枚举的位集则可以表示任意方向的组合:

Direction_Set :: bit_set[Direction]

其设计原则是分工协作:

  • 固定的值集合即为枚举
  • 这些值的集合则为位集
  • 与每个值相关的数据存储在以枚举为索引的数组中
  • 具有不同数据结构的替代方案则为联合类型

由于每种工具只负责一项任务,因此可以单独分析每一种,且无需承担其他所有工具的功能压力。这与 Go 语言形成对比——在 Go 中,标志位是通过位移操作手动生成的;而 Rust 则让枚举本身同时涵盖简单列表和带标签的联合体。

没有哪种语言能完美实现枚举

虽然很想对这些设计进行排名,但它们其实是在回答不同的问题。以下是简短的对比:

  • C 语言:命名整型常量,注重简洁性、可读性以及与低级语言的兼容性。
  • C++ 语言:在相同基础之上增加了可选的作用域、更严格的类型检查以及明确的底层类型。
  • Java 语言:枚举类包含固定数量的实例,这些实例能够拥有字段、构造函数、方法以及针对每个常量的特定行为。
  • Rust:一组封闭的变体,每个变体都能存储自己的数据,编译器会强制进行全面的模式匹配。
  • Go:没有专门的构造方式;常量与用于生成顺序值或计算得到的值。
  • TypeScript:枚举在运行时表现为JavaScript对象,这与大多数TypeScript类型特性不同,同时还存在可删除的替代方案。
  • Odin:简单的枚举,辅以位集、枚举数组和联合类型来处理相关任务。
  • 这些都不是对前一种语言的升级版本,每种语言都体现了设计者希望该特性承担的功能。

    枚举设计能揭示关于一种语言的哪些信息

    枚举简单到只需一个下午就能学会,却能体现其所对应语言的设计理念。这些理念可以看作是每种语言似乎都在探讨的问题:

    • C语言:如何用简单的原始类型高效地表示这些内容?
    • C++语言:如何在保持接近硬件控制的同时加入更安全的抽象层?
    • Java语言:如果一个封闭集合中的每个元素都是带有自己方法的完整对象会怎样?
    • Rust语言:能否用一种类型来明确所有合法状态及其对应的数据内容?
    • Go语言:真的需要专门的特性吗?
    • TypeScript语言:当它必须作为普通JavaScript运行时,其编译时特性应如何变化?
    • Odin语言:核心结构能否保持简单,而由独立的特性来处理相关需求?

    由于各种语言的优化目标不同,枚举的概念也从未形成统一的标准。

    枚举是建模决策,而非语法

    人们很容易将枚举视为一种语法元素:

    enum {
        A,
        B,
        C
    }
    

    语法是最无趣的部分。真正的问题在于你希望类型系统为你列出的值提供哪些保障:

    A, B, C
    

    要解决这个问题,就需要回答几个更小的问题:

    • 这些仅仅是数字的名称吗?
    • 每个值是否都需要关联数据?
    • 每种取值是否都需要独立的行为逻辑?
    • 编译器是否应该知道这些是唯一有效的状态,并强制进行全面处理?
    • 你是否需要紧凑的位标志?
    • 这些值是否必须在运行时存在,以便进行迭代或验证?
    • 它们应该如何通过网络传输或存储到磁盘中?

    不同的答案会带来不同的设计,其影响远不止语法层面:包括内存布局、类型安全、编译器能分析的内容、运行时开销、模式匹配、API结构、序列化格式,以及最终团队对数据的处理方式。

    有时合适的枚举并非真正的枚举

    这对日常的 TypeScript 开发有着直接的影响。一种语言提供枚举是因为存在特定问题,但这并不意味着每组固定的值都必须被定义为枚举。具体取决于情况:

    • 使用字符串字面量的联合类型通常更清晰,且运行时没有额外开销
    • 使用as const的普通对象无需特殊编译即可提供运行时值和类型
    • 当每个选项都涉及大量行为逻辑时,专门的类更为合适
    • 位集或标志整数则是处理组合情况的理想工具
  • 当不同变体携带不同数据时,实际上需要的是区分式联合类型。
  • 有时,三个命名恰当的常量就足以构成解决该问题所需的所有结构。
  • 良好的类型设计应从数据及其不变量出发,而非依赖语言所提供的任意关键字。

    关键要点

    • “枚举”整合了多个独立要素:命名、不同类型标识、附加数据、变体形态、穷尽性检查、标志位以及运行时存在性。
    • C和C++以整数及其表示方式为核心;Java以对象为核心;Rust则以带编译器校验的求和类型为核心。
    • Go和Odin展示了两种避免使用通用枚举的相反方法:要么通过常量自行构建,要么将功能分配到不同的专用组件中。
    • TypeScript 枚举是真正的运行时对象,这会影响代码包大小、迭代方式以及与类型剥离工具的兼容性;字面量联合类型和 as const 对象往往更为简单。
    • 在考虑使用枚举之前,先确定自己需要哪些保障。所选择的构造方式应基于这些需求,而非相反。