10.1 枚举
1. 定义
- 枚举由一组已命名的整数常量值组成,每个不同的枚举构成不同的枚举类型
- 每个枚举都有一个底层类型
2. 声明
枚举类型的声明为:enum (属性说明符序列) (标识符) (: 类型) { 枚举项列表 }
- 枚举项列表中的每个
枚举项之间由逗号分隔 - 每个
枚举项为枚举常量 (= 常量表达式)的形式 - 如果省略
: 类型,则为无固定底层类型的枚举:这个枚举的底层类型由实现定义,这个类型一定能表示每一个枚举项的值; 否则为固定底层类型的枚举,底层类型即为类型
当某个枚举项省略 = 常量表达式 时,它的值等于前一项加一;首项若也省略,则默认从 0 开始。
enum color {
COLOR_RED, // 0
COLOR_GREEN, // 1
COLOR_BLUE = 8, // 8
COLOR_ALPHA // 9
};2
3
4
5
6
3. 作用域与名称
枚举常量名进入其所在作用域,不带 enum 前缀,因此要避免与其他标识符重名。枚举类型名本身需要通过 enum 类型名 使用;如果希望省去 enum 关键字,可以配合 typedef。
typedef enum color color_t;
color_t c = COLOR_GREEN;2
3
4. 转换与比较
枚举对象在表达式中按整数的转换规则参与运算。比较运算和 switch 的 case 标签都可以直接使用枚举常量,这比直接写整数字面量更容易阅读。
enum state {
STATE_INIT,
STATE_READY,
STATE_STOP
};
int run(enum state s) {
switch (s) {
case STATE_INIT:
return 0;
case STATE_READY:
return 1;
case STATE_STOP:
return 2;
}
return -1;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch 默认分支仍然建议保留,因为枚举对象在运行期仍可能接收到未预期值。
5. 关于固定底层类型
固定底层类型的写法(enum E : unsigned char)由 C23 引入;如果目标编译器尚未支持 C23,可以继续使用普通枚举,并在函数接口处检查取值范围。
6. 重复枚举值是合法的
同一个枚举中的不同枚举常量可以具有相同的值,标准并不禁止。这可以用来为旧名称提供兼容的别名,但要注意可读性:多个名字对应同一个值时,switch 中只会出现其中一个,应在该分支注释说明,避免读者误以为遗漏了分支。
enum token_kind {
TOK_EOF = 0,
TOK_END = 0, /* 与 TOK_EOF 同值,作为兼容别名 */
TOK_ID = 1
};2
3
4
5
7. 枚举与位标志
普通枚举适合表示互斥的状态,例如解析器所处的阶段、连接状态或错误类别。可以同时成立的多个选项更适合用位标志表示:每个选项占一个二进制位,用无符号整数保存它们的组合,并为每一位单独定义常量。如果把选项的各种组合逐一列为枚举常量,常量的个数会随选项数成倍增长。
8. 习题
8.1 巩固
阅读下面的程序,写出输出,并回答问题。
#include <stdio.h>
enum color {
COLOR_RED,
COLOR_GREEN = 4,
COLOR_BLUE,
COLOR_ALPHA = COLOR_BLUE + 2,
COLOR_MAGENTA,
COLOR_BLACK = COLOR_RED
};
int main(void) {
enum color c = COLOR_MAGENTA;
printf("%d %d %d\n", (int)c, (int)(c + 1), (int)COLOR_BLACK);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(1) 写出程序的输出。
(2) 列出每个枚举常量的值,并说明省略 = 常量表达式 的项是怎样得到值的。
(3) COLOR_BLACK 与 COLOR_RED 的值相同。如果把 COLOR_MAGENTA 改为 COLOR_ALPHA * 2,输出变成什么?
(4) 把 COLOR_GREEN = 4 改成 COLOR_GREEN = 4.0,编译会发生什么?sizeof(int) 可以吗?COLOR_BLUE + 1 可以吗?据此说明「常量表达式」在这里指什么。
(5) 在同一个文件里再定义 enum state { STATE_IDLE, STATE_RUNNING };,然后写 enum state s = COLOR_BLUE;。两个枚举是同一个类型吗?用 gcc -std=c17 -Wall -Wextra 编译会看到什么提示?既然两者的底层表示都是整数,编译器为什么还要提示?
8.2 编程
日志级别用枚举表示,旧代码把 warn 也写作 warning。下面的程序给出了枚举、两个函数的声明和测试代码,请补全函数,并补上需要的头文件。
typedef enum log_level {
LOG_TRACE = 0,
LOG_DEBUG = 10,
LOG_INFO = 20,
LOG_WARN = 30,
LOG_WARNING = LOG_WARN, /* 旧名,保留兼容 */
LOG_ERROR = 40,
LOG_FATAL = 50
} log_level;
const char *log_level_name(log_level level);
bool log_level_parse(const char *text, log_level *out);
int main(void) {
const char *inputs[] = {"warn", "warning", "WARN", "notice", "error"};
for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
log_level level = LOG_INFO;
bool ok = log_level_parse(inputs[i], &level);
printf("%s -> %s, level = %d\n", inputs[i], ok ? "true" : "false",
(int)level);
}
printf("99 -> %s\n", log_level_name((log_level)99));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
要求:
log_level_name用switch实现。先按直觉为LOG_WARN和LOG_WARNING各写一个case,编译并抄下诊断,再解释为什么不能这样写。log_level_parse用静态表把名字映射到枚举值,按名字线性查找。warn和warning都接受,并且都得到LOG_WARN。- 解析失败时返回
false,且不修改*out。测试代码把初值设为LOG_INFO,失败时应仍打印level = 20。 - 六个规范名
trace、debug、info、warn、error、fatal解析后再取名字,应回到原字符串。 - 开头的
typedef enum log_level { ... } log_level;让标签名和 typedef 名都叫log_level。这样写合法吗?log_level这个名字在普通标识符的命名空间里,和枚举常量LOG_WARN会冲突吗?如果把 typedef 名改成LOG_WARN,会得到什么诊断?
运行后输出应为:
warn -> true, level = 30
warning -> true, level = 30
WARN -> false, level = 20
notice -> false, level = 20
error -> true, level = 40
99 -> unknown2
3
4
5
6
用一个无符号整数保存读、写、执行三种权限的集合,每种权限用枚举常量表示一个二进制位。补全下面的函数,main 不要改动(#include 省略了,按需补上)。
enum {
PERM_READ = 1u << 0,
PERM_WRITE = 1u << 1,
PERM_EXEC = 1u << 2
};
unsigned perm_grant(unsigned set, unsigned flag);
unsigned perm_revoke(unsigned set, unsigned flag);
bool perm_has(unsigned set, unsigned flag);
void perm_print(unsigned set);
int main(void) {
unsigned set = 0;
set = perm_grant(set, PERM_READ);
set = perm_grant(set, PERM_EXEC);
perm_print(set);
set = perm_grant(set, PERM_WRITE);
perm_print(set);
set = perm_revoke(set, PERM_READ);
perm_print(set);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
要求:
perm_print按r、w、x的顺序输出三个字符,没有的权限输出-。- 运行结果应为:
r-x
rwx
-wx2
3
- 集合对象为什么声明为
unsigned,而不是某个枚举类型?如果写成enum perm_flag { PERM_READ = 1, ... }再把PERM_READ | PERM_WRITE存进enum perm_flag对象,类型还有「只能是列出的几个值之一」这层含义吗?编译器能为此给出警告吗? perm_has(set, PERM_READ | PERM_WRITE)表示什么?为什么实现要写成(set & flag) == flag,只写set & flag在什么情况下会给出错误答案?- 如果要新增
PERM_DELETE,需要改哪些地方?结合第 7 节说明:为什么这种写法适合选项会增加的场景,而把每种组合都列成枚举常量不合适。
一个二进制协议的报文首部用 1 字节表示消息类型,取值为 PING、DATA、CLOSE。用 C23 的固定底层类型定义枚举,并补全编码、解码函数(#include 省略了,按需补上)。
enum msg_kind : unsigned char {
MSG_PING = 0,
MSG_DATA = 1,
MSG_CLOSE = 2
};
unsigned char msg_kind_encode(enum msg_kind kind);
bool msg_kind_decode(unsigned char raw, enum msg_kind *out);
int main(void) {
printf("sizeof = %d\n", (int)sizeof(enum msg_kind));
unsigned char raws[] = {0, 2, 3, 255};
for (size_t i = 0; i < sizeof raws / sizeof raws[0]; i++) {
enum msg_kind kind = MSG_PING;
if (msg_kind_decode(raws[i], &kind)) {
printf("%u -> ok, kind = %d\n", (unsigned)raws[i], (int)kind);
} else {
printf("%u -> rejected\n", (unsigned)raws[i]);
}
}
printf("ping encodes to %u\n", (unsigned)msg_kind_encode(MSG_PING));
enum msg_kind bad = MSG_PING;
bool ok = msg_kind_decode(7, &bad);
printf("decode(7) = %s, out = %d\n", ok ? "true" : "false", (int)bad);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
要求:
- 用
gcc -std=c2x -Wall -Wextra编译运行。decode遇到大于MSG_CLOSE的值返回false,并且不修改*out。 - 运行结果应为:
sizeof = 1
0 -> ok, kind = 0
2 -> ok, kind = 2
3 -> rejected
255 -> rejected
ping encodes to 0
decode(7) = false, out = 02
3
4
5
6
7
- 去掉
: unsigned char后再打印一次sizeof,在你使用的编译器上两个值分别是多少?如果这个sizeof的结果要写进协议文档,为什么不能这样定宽度? - 对固定底层类型为
unsigned char的枚举对象写if (kind < 0),编译器会给出什么提示?说明原因。 - 如果目标编译器只支持 C17,请写出等价的接口:用一个
unsigned char传输,在接口处检查取值范围。比较两种写法在类型检查和可读性上的差别。
实现一个任务状态机。状态和事件的枚举已经给出,请补全转移表、task_step 和两个名字函数(#include 省略了,按需补上)。
enum task_state {
TASK_INVALID = 0,
TASK_PENDING,
TASK_RUNNING,
TASK_PAUSED,
TASK_DONE,
TASK_FAILED,
TASK_STATE_COUNT
};
enum task_event {
EV_START,
EV_PAUSE,
EV_RESUME,
EV_FINISH,
EV_FAIL,
EV_RESET,
EV_COUNT
};
enum task_state task_step(enum task_state state, enum task_event event);
const char *task_state_name(enum task_state state);
const char *task_event_name(enum task_event event);
int main(void) {
enum task_state s = TASK_PENDING;
enum task_event script[] = {EV_START, EV_PAUSE, EV_RESUME, EV_FINISH,
EV_RESET, EV_START, EV_FAIL, EV_RESET};
printf("%s", task_state_name(s));
for (size_t i = 0; i < sizeof script / sizeof script[0]; i++) {
s = task_step(s, script[i]);
printf(" -%s-> %s", task_event_name(script[i]), task_state_name(s));
}
printf("\n");
printf("pending+finish = %s\n",
task_state_name(task_step(TASK_PENDING, EV_FINISH)));
printf("pending+bogus = %s\n",
task_state_name(task_step(TASK_PENDING, (enum task_event)99)));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
转移规则:
pending:start转到running,fail转到failed;running:pause转到paused,finish转到done,fail转到failed;paused:resume转到running,fail转到failed;done和failed:reset转到pending;- 其余状态与事件的组合都转到
TASK_INVALID。
要求:
- 用二维数组
transitions[TASK_STATE_COUNT][EV_COUNT]保存转移,用指派符只写有转移的格子,其余格子由零初始化得到TASK_INVALID。 task_step先检查两个下标是否落在[0, TASK_STATE_COUNT)和[0, EV_COUNT)内,越界返回TASK_INVALID。- 运行结果应为:
pending -start-> running -pause-> paused -resume-> running -finish-> done -reset-> pending -start-> running -fail-> failed -reset-> pending
pending+finish = invalid
pending+bogus = invalid2
3
- 为什么状态和事件的枚举值必须从 0 开始连续?为什么把
TASK_STATE_COUNT和EV_COUNT放在最后?如果把TASK_RUNNING改成10,哪些代码会出问题? TASK_STATE_COUNT不是真正的状态,只是计数用的哨兵值。task_state_name的switch要么为它写一个分支,要么加default,否则-Wall下的-Wswitch会提示遗漏。两种做法都试一次,比较哪一个更能暴露以后新增状态时忘记加分支的问题。task_step中为什么把state转换成unsigned再与TASK_STATE_COUNT比较?在底层类型为有符号整数的实现上,直接写state < TASK_STATE_COUNT遇到什么实参时会判断错?为什么无固定底层类型的枚举需要这样写?
8.3 综合与挑战
下面的程序有若干与枚举有关的缺陷。先编译,再逐条定位并修复。
#include <stdio.h>
int default_limit = 4;
enum command {
CMD_NONE = 0,
CMD_RUN = 1,
CMD_START = 1,
CMD_HALT = 2,
CMD_EXIT = 3,
CMD_MAX = default_limit
};
int CMD_HALT = 2;
const char *command_name(enum command cmd) {
switch (cmd) {
case CMD_RUN:
return "run";
case CMD_START:
return "start";
case CMD_HALT:
return "halt";
}
return "unknown";
}
int main(void) {
enum command cmd = 9;
printf("%s\n", command_name(cmd));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
要求:
- 用
gcc -std=c17 -Wall -Wextra编译,抄下全部错误和警告,并指出每条诊断对应哪条语言规则。至少应涉及:枚举项必须是整数常量表达式、枚举常量名与普通标识符共用命名空间、同一个值不能出现在两个case标签中。 - 修复后要求:编译无错误、无
-Wall -Wextra警告;保留CMD_START作为CMD_RUN的别名,command_name(CMD_START)返回"run";command_name((enum command)9)返回"unknown"。 - 解释为什么
CMD_MAX = default_limit不合法,而CMD_EXIT = CMD_HALT + 1合法。 - 如果希望
command_name为CMD_NONE、CMD_EXIT、CMD_MAX也返回名字,switch应该怎么补?补完以后再加default,然后故意删掉其中一个case再编译两次,一次用-Wall,一次再加上-Wswitch-enum。说明两个选项各自检查什么,什么时候该用后者。 - 程序中
CMD_HALT既是枚举常量,又被定义为全局对象。编译器对第二处定义报什么?如果确实需要一个同名的全局对象,应该怎样改名或组织代码?
11014 里,枚举项和名字表是分开写的:加一个级别,要在枚举和表里各改一次,漏改一处两边就对不上。C 的预处理器可以把同一份清单展开成两份代码,这种写法称为 X 宏(X-macro)。
下面的清单把级别名和对应的字符串放在一起,请用这份清单展开出枚举类型和名字表,并验证两者同步:
#define LOG_LEVELS(X) \
X(LOG_TRACE, "trace") \
X(LOG_DEBUG, "debug") \
X(LOG_INFO, "info") \
X(LOG_WARN, "warn") \
X(LOG_ERROR, "error") \
X(LOG_FATAL, "fatal")
/* 在这里用 LOG_LEVELS 展开出枚举 log_level 和名字表 level_names */
int main(void) {
printf("%d %d\n", LOG_LEVEL_COUNT,
(int)(sizeof level_names / sizeof level_names[0]));
for (int i = 0; i < LOG_LEVEL_COUNT; i++) {
printf("%d %s\n", i, level_names[i]);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
要求:
- 用辅助宏
AS_ENUM(id, name)展开成枚举项,令LOG_TRACE从 0 开始依次递增,并在清单之后补一个LOG_LEVEL_COUNT作为元素个数。用#undef撤掉辅助宏,再定义AS_NAME(id, name)展开成一张以枚举值为下标的字符串表level_names。 - 运行结果应为:
6 6
0 trace
1 debug
2 info
3 warn
4 error
5 fatal2
3
4
5
6
7
- 在清单末尾加一行
X(LOG_NOTICE, "notice"),不改其他任何代码,重新编译运行。两个数字和输出怎样变化?说明为什么这种做法能保证枚举与名字表同步,而 11014 的手工表做不到。 - 现在之所以能直接用
LOG_LEVEL_COUNT当数组长度、用[id] = name指派,是因为这份清单的值恰好从 0 开始连续递增。如果把清单改成能给每一项指定值的形式,并让某些值不连续(例如LOG_DEBUG取 10),数组里没有指派到的位置存的是什么?LOG_LEVEL_COUNT还能当元素个数用吗?此时遍历这张表要注意什么? - X 宏用一层间接的可读性换来两个名字的同步。说明这种写法适合什么规模的清单,什么情况下反而该保持 11014 的手写表。