Menu

Golang 枚举:用 const 和 iota 实现枚举

Go 没有 enum 关键字。本页介绍地道的替代写法:命名类型加上使用 iota 的 const 块,以及如何添加 String()、校验、解析、位标志和 JSON 支持。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Go 没有 enum 关键字。你可以用两部分拼出一个枚举:一个命名类型,以及一个由该类型的值组成、用 iota 编号的 const 块。

Sunday 是 0,后面每一行依次加一。正是命名类型 Weekday 让它成为枚举,而不只是一串数字:isWeekend 在签名里就说明了它需要什么,而且还能给这个类型添加方法。输出是 1 5 6,因为还没有任何代码告诉 Go 该如何把 Weekday 显示成文本。下文会讲到。

iota 的工作原理

iota 是编译器在 const 块里提供的计数器。基于它的所有技巧,都可以用两条规则解释:

  1. iota 等于当前行在块中的索引,从 0 开始,并在每个新的 const 块中重置为 0。
  2. 没有 = expression 的行会重复上一行的表达式和类型,并用新的 iota 求值。

所以上面的 Monday 是 Monday Weekday = iota 的简写,此时 iota 为 1。由于重复的是整个表达式,它可以是任意常量表达式,而不只是 iota:

iota 数的是行,而不是名字:同一行上的两个常量共用同一个 iota 值,就像 X 和 Y 那样。

从 1 开始,以及为什么你可能不该这么做

一个没人赋值的枚举类型变量保存的是 0,也就是它的零值。如果 0 是像 Sunday 这样的真实值,你就分不清“用户选了星期日”和“这个字段从来没填过”。常见的解决办法有三种:

在生产代码中,选项 1 最常见,protobuf 生成的 Go 枚举也遵循这种做法(..._UNSPECIFIED = 0)。这样零值表达的意思才是诚实的。

跳过值

空白标识符 _ 会消耗一个 iota 值,但不创建名字。可以用它留出空位,比如为了对应协议规定的编号,或者在不重新编号其余值的情况下废弃某个值:

type Opcode byte

const (
	OpContinue Opcode = iota // 0
	OpText                   // 1
	OpBinary                 // 2
	_                        // 3, reserved
	_                        // 4, reserved
	_                        // 5, reserved
	_                        // 6, reserved
	_                        // 7, reserved
	OpClose                  // 8
	OpPing                   // 9
	OpPong                   // 10
)

当编号由外部规范固定时,比如这些 WebSocket 操作码,直接写明编号(OpClose Opcode = 8)往往比数空位更清楚。iota 适用于你并不关心具体数值的情况。

如果 iota 列表的编号会被存储起来,就永远不要调整顺序或在中间插入,无论是存进数据库、文件,还是通过网络发送。在中间加一行,后面所有的值都会跟着偏移。新值要追加到末尾,或者显式指定编号。

添加 String 方法

给类型定义一个 String() string 方法,fmt 在处理 %v、%s 和 Println 时就会使用它:

这个方法里有两个细节很重要:

  • 边界检查。没有它,Weekday(9).String() 会因索引越界而 panic,而且这迟早会发生,因为没有什么能阻止调用方创建 Weekday(9)。
  • Sprintf 里的 int(d)。用 %d 格式化 d 本身没问题,但如果用 %v 格式化它,就会再次调用 String(),不断递归直到栈溢出。

%d 仍然会打印数字,所以两种形式你都能得到:Wednesday is day 3。

用 stringer 生成 String

列表很长时,可以让 stringer 工具替你写这个方法:

//go:generate go run golang.org/x/tools/cmd/stringer@latest -type=Weekday
go generate ./...

它会生成 weekday_string.go,里面有一个精简的 String() 实现,另外还有一个编译期检查:如果常量改了却没有重新生成,构建就会失败。-linecomment 标志会把行尾注释用作名字,适合带空格的名字。

校验值

Go 的枚举不是封闭的。底层类型的任何值都能转换成它,无类型常量还会隐式转换:

var d Weekday = 42     // compiles
d = Weekday(userInput) // compiles

所以要检查来自你代码之外的值(JSON、数据库、命令行 flag、其他包):

块末尾未导出的哨兵值 colorCount 始终位于最后一个真实值的后一位,所以即使你追加新的颜色,IsValid 依然正确。

对枚举使用 switch

枚举通常由 switch 来处理。Go 不会检查 switch 是否覆盖了所有值,所以要加一个 default 来报告意外情况:

func (c Color) Hex() string {
	switch c {
	case Red:
		return "#ff0000"
	case Green:
		return "#00ff00"
	case Blue:
		return "#0000ff"
	default:
		return "#000000"
	}
}

第三方的 exhaustive linter(已包含在 golangci-lint 中)会报告对枚举类型做 switch 却漏掉某个 case 的地方,这能为你提供其他语言里穷尽性枚举检查的大部分功能。

位标志枚举

当多个值可以组合时(比如权限),用 1 << iota 让每个值占一个二进制位:

| 组合标志,& 测试标志,&^(Go 的 AND NOT 运算符)清除标志。这里底层类型应该选无符号整数:uint8 能放 8 个标志,uint64 能放 64 个。

字符串枚举

如果这个值本来就要以文本形式存储或发送,基于字符串的类型可以省掉一层转换:

type Env string

const (
	EnvDev     Env = "dev"
	EnvStaging Env = "staging"
	EnvProd    Env = "prod"
)

不需要 String() 方法,值在打印和序列化时就清晰可读,数据库的列里存的是 "prod",而不是一个取决于声明顺序的数字。代价是:比较变成了字符串比较,无法做位标志,而且校验仍然要靠你自己,因为 Env("banana") 同样能编译通过。

枚举与 JSON

整数枚举默认序列化为数字。如果想读写名字,就实现 encoding.TextMarshaler 和 encoding.TextUnmarshaler。encoding/json 会把它们用于值和 map 的键:

MarshalText 使用值接收者,所以对 Level 和 *Level 都有效;UnmarshalText 需要指针接收者,因为它要修改值。同样这两个方法还能让该类型配合 flag 包的 TextVar 以及大多数配置库使用。

常见陷阱

  • 字面量的隐式转换。 接收 Weekday 的函数也会接受无类型常量 42。只有其他类型的类型化值才会被拒绝。
  • 第一行忘了写类型。 在 const ( Red = iota; Green; Blue ) 中,三个都是无类型整数常量,而不是 Color 值,所以 Color 上的方法对它们不起作用。写成 Red Color = iota,让重复的表达式带上类型。
  • 调整已存储枚举的顺序。 在 iota 块中间插入一个值,会悄悄改变已经保存在别处的编号。
  • String 中的递归。 在 String() 里,永远不要用 %v 或 %s 格式化接收者。先把它转换成底层类型。

常见问题

Go 有枚举吗?

语言本身没有这个特性,也没有 enum 关键字。地道的替代写法是一个命名类型加上一组类型化常量,通常用 iota 编号:

type Color int

const (
	Red Color = iota
	Green
	Blue
)

这个类型让函数签名更易读,也让你可以给它挂上 String() 这样的方法。但它拦不住别人写 Color(42),所以来自外部的值要做校验。

Go 中的 iota 是什么?

iota 是一个预声明标识符,它的值等于当前行(常量声明)在 const 块中的索引,从 0 开始。每个新的 const 块里它都会重置为 0。某一行省略表达式时,Go 会用下一个 iota 重复上一行的表达式,所以 Red = iota; Green; Blue 会得到 0、1、2。

怎样让 iota 从 1 开始?

在第一行写 First Kind = iota + 1,或者用空白标识符跳过 0:先写 _ = iota,再写 First。不过很多 Go 程序员会保留 0,并把它命名为 Unknown 或 Invalid,这样一个未初始化的变量(零值为 0)一看就知道不是真正的选项。

在 Go 中怎样把枚举打印成字符串?

给这个类型定义一个 String() string 方法。fmt 在处理 %v、%s 和 Println 时会调用它,所以 fmt.Println(Green) 打印的是 Green 而不是 1。你可以用 switch 或数组手写这个方法,也可以用 go run golang.org/x/tools/cmd/stringer@latest -type=Color 自动生成。

在 Go 中怎样把字符串转换成枚举?

写一个解析函数来查找这个字符串,通常借助 map[string]Color 或 switch,遇到未知输入时返回错误:func ParseColor(s string) (Color, error)。用同样的逻辑实现 UnmarshalText,JSON、命令行 flag 和配置加载器就会自动使用它。

Coddy 编程语言插图

和 Coddy 一起学编程

开始