Go 没有 enum 关键字。你可以用两部分拼出一个枚举:一个命名类型,以及一个由该类型的值组成、用 iota 编号的 const 块。
Sunday 是 0,后面每一行依次加一。正是命名类型 Weekday 让它成为枚举,而不只是一串数字:isWeekend 在签名里就说明了它需要什么,而且还能给这个类型添加方法。输出是 1 5 6,因为还没有任何代码告诉 Go 该如何把 Weekday 显示成文本。下文会讲到。
iota 的工作原理
iota 是编译器在 const 块里提供的计数器。基于它的所有技巧,都可以用两条规则解释:
iota等于当前行在块中的索引,从 0 开始,并在每个新的const块中重置为 0。- 没有
= 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 和配置加载器就会自动使用它。