CodeGym /课程 /Go SELF /iota — 枚举(enum)和标志集合

iota — 枚举(enum)和标志集合

Go SELF
第 8 级, 课程 3
可用

1. iota 的基础:为什么以及它如何计数

如果你刚开始编程,事情看起来似乎很简单:状态“新任务”是 0,“进行中”是 1,“已完成”是 2。可是一周过去后,你加上“暂停”,然后有人又重排这些值,接着还引入了“标志”(复选项),代码就会变成“魔法数字”的博物馆。iota 就像一把整齐的标尺:它帮助你用统一的方式给值编号,让代码按含义来读,而不是像谜语一样。

先看两个版本的代码。第一个是“随手写出来”的:

package main

import "fmt"

func main() {
	const (
		StatusNew        = 0
		StatusInProgress = 1
		StatusDone       = 2
	)

	fmt.Println(StatusDone) // 2
}

它能工作——直到有人手滑出错。第二个版本是“我们约定好的”:

package main

import "fmt"

func main() {
	const (
		StatusNew = iota			// 0
		StatusInProgress			// 1
		StatusDone						// 2
	)

	fmt.Println(StatusDone) // 2
}

下面会更详细地解释它是怎么工作的。但核心意思是一样的,只不过现在“编号”不再散落在代码里:你能清楚地看到这是一个序列。

还有一点:程序员有时会争论,魔法数字和魔法字符串哪个更可怕。实际答案通常是:更可怕的是那些看起来还特别合理的魔法数字。

iota 如何工作:const 块中的行计数器

要把 iota 当成一个简单的自动计数器来理解:它只有一条规则,而且不会比你更“聪明”。iota 只存在于 const ( ... ) 中,在块的第一行从 0 开始,并在后续每一行增加 1。

不是“在每个声明的常量上递增”,也不是“在每个逗号上递增”,而是按行(块内部声明的逻辑行)递增。这一点很重要,因为你有时会跳过某些值,而那时就需要知道究竟跳过了什么。

下面这个小表格展示了编译器如何“看待”这样的块:

const 块中的行 写法 值
1
A = iota
0
2
B = iota
1
3
C = iota
2

我们用代码来验证一下(顺便练习打印值):

package main

import "fmt"

func main() {
	const (
		A = iota
		B = iota
		C = iota
	)

	fmt.Println(A, B, C) // 0 1 2
}

现在来看一个重要技巧,没有它的话,iota 会比它本应有的样子无聊一些。在 const 块中,你可以不写右侧表达式,这样 Go 就会重复上一行的表达式,只是把新的 iota 值代入进去。也就是说,编译器像是在说:“好吧,你没写公式,那我就把同一个公式再算一遍。”

package main

import "fmt"

func main() {
	const (
		A = iota
		B
		C
	)

	fmt.Println(A, B, C) // 0 1 2
}

还有一个细节:iota 在每个新的 const 块中都会重置为 0。这很方便:你可以拥有彼此独立的编号(状态、级别、命令码),而不必担心它们会“接着彼此往下数”。

2. Enum 和 flags:两个不同的含义

枚举:从一组中选一个值

在 Go 中,枚举通常这样做:创建一个有语义的数值类型(type Status int)以及一组这种类型的常量。这样就有了一个不错的效果:你保留了一个数字(它易于存储和比较),但代码会把这个数字读成“一个明确的名字”。

而且编译器不会让你不小心把“状态”和“比如年龄”混在一起,因为它们是不同的类型。

我们为教学用的小应用创建一个任务状态的小枚举:

package main

import "fmt"

type TaskStatus int

const (
	StatusNew TaskStatus = iota
	StatusInProgress
	StatusDone
)

func main() {
	var s TaskStatus = StatusInProgress
	fmt.Println(s) // 1
}

这里有两点很重要。第一,TaskStatus 是一个 新类型,它不等于 int。第二,我们把常量显式标注成 TaskStatus,这让检查更易读:s == StatusDone 看起来就像一句正常的话。

接着我们通过 switch 加上人类可读的文本:

package main

import "fmt"

type TaskStatus int

const (
	StatusNew TaskStatus = iota
	StatusInProgress
	StatusDone
)

func main() {
	var s TaskStatus = StatusDone

	switch s {
	case StatusNew:
		fmt.Println("status: new") // status: new
	case StatusInProgress:
		fmt.Println("status: in progress")
	case StatusDone:
		fmt.Println("status: done") // status: done
	default:
		fmt.Println("status: unknown")
	}
}

注意 default:即使你“确信不可能出现那个值”,生活也总喜欢证明你是错的。有时这是用户输入错误,有时是旧数据,有时只是你忘了在某处更新代码。default 就是一个安全垫:不算最柔软,但总比 runtime 里脸着地要好。

标志集合:多个彼此独立的特征

Enum 回答的是“当前选中了哪一个选项?”:任务状态不可能同时是 new 和 done。但有时你需要保存 一组彼此独立的特征。

例如,一个任务可能有这些选项:“紧急”、“需要通知”、“已归档”、“只读”——这些特征可以以任意组合出现。

为此通常会使用位标志:一个数字,其中每一位都是一个单独的“勾选项”。今天我们的目标是理解 iota 如何帮助我们 优雅地创建 这些标志。

最常见的写法是:1 << iota。它的意思是“取数字 1,把那个 1 位向左移动 iota 个位置”。结果你会得到 2 的幂:1、2、4、8、16……——这正是标志所需要的。

package main

import "fmt"

type TaskFlag uint

const (
	FlagUrgent TaskFlag = 1 << iota
	FlagNotify
	FlagArchived
)

func main() {
	fmt.Println(FlagUrgent, FlagNotify, FlagArchived) // 1 2 4
}

为什么用 uint 而不是 int?因为从逻辑上讲,标志是“位掩码”,而不是“可能为负数的数字”。当你开始输出二进制表示并检查各个位时,uint 更顺手:更少意外,也更安心。

Enum 和 flags:如何不混淆它们

它们看起来很像:这里是常量,那里也是常量;这里有 iota,那里也有 iota。但含义本质上不同。

在 enum 中,值是连续的:0、1、2、3……并通过 == 进行比较。在 flags 中,值必须是 2 的幂:1、2、4、8……这样它们才能在同一个数字里组合而不会“冲突”。

看这张表——这是快速判断你是在“enum 模式”还是“flags 模式”的好方法:

模式 值看起来像什么 变量表示什么
Enum (
StatusNew = iota
)
0、1、2、3… 恰好一种状态
Flags (
FlagX = 1 << iota
)
1、2、4、8… 一组特征

初学者最常见的错误,就是把“标志”做成 enum:

// 对 flags 来说很糟糕:1, 2, 3 —— 这不是 2 的幂
const (
	FlagUrgent = iota + 1 // 1
	FlagNotify            // 2
	FlagArchived          // 3 (问题从这里开始)
)

为什么这是个问题?因为 3 的二进制表示是 11,也就是“两个标志同时开启”。这意味着你的某个“标志”实际上表示了两个东西。就像你做了一个“开灯”按钮,却意外地还把微波炉也打开了。

3. 小示例:从输入中读取状态和标志

现在我们把所有内容拼起来,做一个非常小的“应用片段”:程序从 stdin 读取两个数字。第一个是任务状态码(0/1/2),第二个是标志掩码码值(例如,如果标志是 1 和 2,那么 3 表示“紧急 + 通知”)。

我们刻意把它保持得很简单:没有函数、结构体和集合,这样重点就能放在 iota 和常量上。

package main

import "fmt"

type TaskStatus int
type TaskFlag uint

const (
	StatusNew TaskStatus = iota
	StatusInProgress
	StatusDone
)

const (
	FlagUrgent TaskFlag = 1 << iota
	FlagNotify
	FlagArchived
)

func main() {
	var statusCode int
	var flagsCode uint

	fmt.Scan(&statusCode, &flagsCode)

	status := TaskStatus(statusCode)
	flags := TaskFlag(flagsCode)

	switch status {
	case StatusNew:
		fmt.Println("status: new")
	case StatusInProgress:
		fmt.Println("status: in progress")
	case StatusDone:
		fmt.Println("status: done")
	default:
		fmt.Println("status: unknown")
	}

	// For now, just remember the flag-checking pattern:
	// "if the needed bit is set, the & result will not be 0"
	if flags&FlagUrgent != 0 {
		fmt.Println("flag: urgent")
	}
	if flags&FlagNotify != 0 {
		fmt.Println("flag: notify")
	}
	if flags&FlagArchived != 0 {
		fmt.Println("flag: archived")
	}
}

这里有一个重要想法:iota 帮我们 生成了正确的常量,而对掩码的操作(运算符 &、|、清除标志)则是“故事的第二部分”,我们会在后面更系统地讲位运算。

不过,标志检查的基本模板现在就可以记住:flags&SomeFlag != 0。

4. iota 的技巧:跳过和不从零开始

有时你希望 0 表示“未知”或“未设置”,而真实值从 1 开始。这是很常见的想法,尤其是当数据来自外部时:0 常被用作“默认空值”。

对 iota 来说,这可以优雅地实现:要么你显式地设置第一个元素,要么通过 _ 跳过第一行。

下面是一个显式设置第一个值的版本:

package main

import "fmt"

type Level int

const (
	LevelUnknown Level = 0
	LevelLow     Level = iota // iota here will be 1, because this is the second line
	LevelHigh                 // 2
)

func main() {
	fmt.Println(LevelUnknown, LevelLow, LevelHigh) // 0 1 2
}

再看一个通过 _ “跳过值”的版本:

package main

import "fmt"

const (
	_ = iota // skipped 0
	One      // 1
	Two      // 2
)

func main() {
	fmt.Println(One, Two) // 1 2
}

为什么 _ 很有用?因为它向读者发出了一个明确的信号:“对,这里是故意跳过的,这是设计的一部分。” 这比“为什么编号从 1 开始,0 去哪儿了?”这种谜一样的情况要好得多。

5. 使用 iota 时的典型错误

错误 1:以为 iota 能在 const-块外工作。
iota 不是变量,也不是函数。它是编译器的特殊标识符,只存在于 const ( ... ) 中。尝试在 var 中或在 main 中的普通表达式里使用 iota,都会以编译错误告终。如果你需要运行时计数器,那就是普通变量和循环的问题了。

错误 2:以为 iota 会在不同的 const-块之间继续计数。
每个 const 块都是一个独立的小宇宙,其中 iota 从 0 开始。这很方便,但如果你以为它会在整个文件里“连续编号”,那么你会在不同的常量组中得到意外相同的值。当你确实需要连续编号时,通常最好重新思考设计:大多数时候,各组应该是彼此独立的。

错误 3:把行顺序调换后,意外改变了数值。
iota 会按顺序编号。这在顺序确实重要时非常好,但如果顺序只是随便排的,那就很危险了。如果你在“中间”插入一个值,那么后面的所有值都会跟着变化。对 enum 来说,这有时可以接受;但对于外部协议(例如这些数字已经被存到某处)来说就很危险。在这种情况下,最好显式固定数值,并谨慎使用 iota。

错误 4:用 iota 做标志,但忘了写 1 << iota。
对于标志来说,光有“0、1、2、3”是不够的。你需要 2 的幂,否则组合会开始重叠。如果你看到“标志”被声明成 FlagX = iota,这几乎总是一个危险信号:正确写法应该是 FlagX = 1 << iota。

错误 5:把 enum 和 flags 混在同一个变量里,并用同样的方式检查它们。
enum 用 == 检查,因为它表示的是“从集合中选一个值”。flags 用掩码检查,因为它表示的是“一组特征”。当你开始想“那为什么不把状态也做成 flags 呢?”的时候,请停下来问自己一句:“任务能同时是 done 和 in progress 吗?”如果不能,那它就是 enum。如果可以,那也许这已经是另一个领域的问题了,你需要把概念分开。

1
任务
Go SELF, 第 8 级, 课程 3
已锁定
城市交通信号灯
城市交通信号灯
1
任务
Go SELF, 第 8 级, 课程 3
已锁定
两个计数器
两个计数器
1
任务
Go SELF, 第 8 级, 课程 3
已锁定
包裹状态
包裹状态
1
任务
Go SELF, 第 8 级, 课程 3
已锁定
通知标志
通知标志
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION