C 中的枚举
简要回顾
最简单的枚举是比宏稍微高级一点的东西。它们可以避免像这样做:
#define COLOR_BLACK 0
#define COLOR_WHITE 1
#define COLOR_BLUE 2
#define COLOR_GREEN 3
#define COLOR_RED 4
你可以这样做:
enum color {
COLOR_BLACK,
COLOR_WHITE,
COLOR_BLUE,
COLOR_GREEN,
COLOR_RED, // Extra ',' here is allowed.
};
在声明一个枚举时,编译器允许你在最后一个常量后面加一个逗号,作为一种便利。
你可以使用color作为一个类型,使用枚举常量作为值:
enum color c = COLOR_BLACK;
枚举的基本思想是使用它们来表达一组相关值。
命名空间和声明
与结构体和联合类似,枚举类型被放在一个单独的“标签”命名空间中,所以你必须继续使用enum前缀。同样地,你也可以使用typedef来将枚举标签“导入”到全局命名空间中:
typedef enum color color;
color c = COLOR_BLUE; // Don't need "enum" now.
然而,与结构体和联合不同,枚举不允许前向声明:
struct node; // OK: forward declaration.
struct node *p; // OK: pointer to node.
enum color; // Error: forward declaration not allowed.
调试器优势
枚举的一个直接优势是调试器能够理解它们,并打印它们的常量名,而不是它们的底层整数值:
(gdb) p c
$1 = COLOR_BLUE
这比如果c只是一个int,你必须查找颜色2对应的是什么要好得多。
名称冲突
如果您不熟悉 C 中的枚举,您可能想知道为什么使用冗长的常量名称。可以更简单一点:
enum color {
BLACK,
WHITE,
BLUE,
GREEN,
RED,
};
枚举常量没有作用域,这意味着它们都被“注入”到全局命名空间中。如果您还有另一个枚举,例如:
enum rb_color { // Red-Black tree node color.
BLACK, // Error: redefinition of 'BLACK'.
RED // Error: redefinition of 'RED'.
};
那么你会得到重新定义错误。因此,最佳实践是使用公共前缀命名同一枚举的所有常量,并希望它们不会与其他地方的其他名称发生冲突。
这个问题在 C++11 中已通过作用域枚举修复,但尚未向后移植到 C(如果有的话)。
基础类型
每个枚举都有一个基础类型,即在机器层面实际用来表示它的类型。它通常是int,但可以是任何足够大的整数类型,能够容纳最大的常量值。 在C23之前,没有办法知道基础类型是什么,也没有办法显式地指定它。(你最多可以通过sizeof知道它的大小。)然而,在C23中,你可以通过在枚举类型的名称后面加上一个冒号和底层类型来显式地指定它,例如:
enum color : unsigned char { // C23 and later only.
// ...
};
隐式转换
枚举常量和变量在表达式中隐式地转换为它们的底层类型的值。另外,底层类型的值也隐式地转换为枚举类型。虽然这些转换有时候很方便,但它们也允许写出没有错误也没有警告的无意义的代码: 幸运的是,隐式转换也有更好的用途——稍后会详细介绍。
color c = COLOR_BLACK + COLOR_WHITE * 2; // ???
Values
枚举常量的值由编译器分配(默认情况下),从 0 开始,每个常量加 1。通常,你并不特别关心这些值实际上是什么。
但是,您可以显式指定所有或仅某些常量的任何值。您甚至可以指定负值(除非您指定了unsigned
基础类型)。如果省略,常量的值将由编译器指定为前一个值加一:
enum color {
COLOR_NONE = -1,
COLOR_BLACK = 0,
COLOR_WHITE = 1,
COLOR_BLUE, // Value is 2 ...
COLOR_GREEN, // ... 3 ...
COLOR_RED, // ... 4 ...
};
然而,你不应该显式地指定值,除非以下情况之一成立:
- 值是“外部强制的”或者有其他含义;
- 你需要“序列化”值(无论是在磁盘上还是“通过网络”);
- 你是在表示位标志。
外部施加值
外部施加值的一个示例是,如果您正在为图形终端编写软件,而硬件使用特定的值来表示特定的颜色:
enum ansi_color {
ANSI_BLACK = 40,
ANSI_WHITE = 47,
ANSI_BLUE = 44,
ANSI_GREEN = 42,
ANSI_RED = 41
};
由于隐式转换为整数,您可以直接使用这些值:
printf( "\33[%dm", ANSI_RED ); // Will print in red.
序列化值
如果您将值写入磁盘(可能是为了在稍后的时间读回它们),您需要确保 3 始终对应于 COLOR_GREEN,即使您添加了更多颜色。如果未明确指定这些值,并且您在除末尾之外的任何位置添加了新颜色,则后续值将默默地移动 1:
enum color {
COLOR_BLACK,
COLOR_WHITE,
COLOR_YELLOW, // New color is now 2.
COLOR_BLUE, // This used to be 2, but is now 3 ...
COLOR_GREEN, // ... and so on.
COLOR_RED
};
当然,您可以制定始终在最后添加新值的策略,但这依赖于程序员遵循该策略。如果您显式指定值,编译器可以帮助您强制执行唯一值,但不是以您可能假设的方式 - 稍后会详细介绍。
或者,您可以将值序列化为字符串:
void write_color( color c, FILE *f ) {
switch ( c ) {
case COLOR_BLACK: fputs( "black", f ); return;
case COLOR_WHITE: fputs( "white", f ); return;
case COLOR_BLUE : fputs( "blue" , f ); return;
case COLOR_GREEN: fputs( "green", f ); return;
case COLOR_RED : fputs( "red" , f ); return;
}
UNEXPECTED_VALUE( c );
}
虽然序列化为文本的成本更高,但如果您将其余数据序列化为 JSON 等文本格式,那么这并不重要。另一个优点是基本值的改变并不重要。
UNEXPECTED_VALUE( c ) 是一个宏,如下所示:
#define UNEXPECTED_VALUE(EXPR) do { \
fprintf( stderr, \
"%s:%d: %lld: unexpected value for " #EXPR "\n", \
__FILE__, __LINE__, (long long)(EXPR) \
); \
abort(); \
} while (0)
如果你想进行防御性编程,你可以使用它(或类似的东西)。
重复值
具有相同基础值的同一枚举的两个常量是完全合法的:
enum color {
// ...
COLOR_GREEN,
COLOR_CHARTREUSE = COLOR_GREEN,
// ...
};
它们是同义词。在这种情况下,这显然是故意的。但是,可能会意外引入同义词,尤其是在具有大量显式提供的值的枚举中。由于同义词是合法的,编译器无法帮助您检测意外的同义词 - 直到您switch
使用它们:
switch ( c ) {
// ...
case COLOR_GREEN:
// ...
break;
case COLOR_CHARTREUSE: // Error: duplicate case value.
// ...
“无”值
如果枚举可以具有“默认”、“确定”、“无”、“未设置”、“未指定”或类似值,则应首先声明它,如下所示:
- 默认情况下,编译器会为其分配值 0,这在调试器中很容易识别。
- 全局或文件范围的
static
枚举变量将自动初始化为 (0)。
例如:
enum eol {
EOL_UNSPECIFIED,
EOL_UNIX,
EOL_WINDOWS
};
检查值
如果您需要检查枚举变量的值是否有一个特定值,可以使用 if :
if ( eol == EOL_UNSPECIFIED )
return;
但是,如果您需要检查多个值,则应始终使用switch
:
switch ( eol ) {
case EOL_UNSPECIFIED: // Default to Unix-style.
case EOL_UNIX:
putchar( '\n' );
break;
case EOL_WINDOWS:
fputs( "\r\n", stdout );
break;
}
为什么要这样做呢?因为如果你在switch语句中漏掉了一个常量的case,编译器会给你一个警告。这非常有用,如果你添加了一个新的枚举常量:编译器可以告诉你你在哪些switch语句中漏掉了case。
但是,你应该避免在switch语句中使用default,因为它会阻止编译器能够警告你当你漏掉了一个常量的case。最好是为每个常量都写一个case,即使这些case什么都不做:
switch ( ast->array.kind ) {
case C_ARRAY_INT_SIZE:
dup_ast->array.size_int = ast->array.size_int;
break;
case C_ARRAY_NAMED_SIZE:
dup_ast->array.size_name = strdup( ast->array.size_name );
break;
case C_ARRAY_EMPTY_SIZE: // Don't use "default" here.
case C_ARRAY_VLA_STAR:
// nothing to do
break;
}
“计数”值
您可能会遇到在末尾添加“count”常量的代码:
enum color {
COLOR_BLACK,
COLOR_WHITE,
COLOR_BLUE,
COLOR_GREEN,
COLOR_RED,
NUM_COLOR // Equal to number of colors (here, 5).
};
这样做的目的是让NUM_COLOR的底层值表示颜色的数量,因为编译器会自动给它赋值为5,这是实际颜色的数量。这样就可以通过使用底层值作为数组的索引(假设第一个常量的值是0)来简化文本的序列化过程:
void write_color( color c, FILE *f ) {
static char const *const COLOR_NAME[] = {
"black",
"white"
"blue",
"green",
"red"
};
if ( c >= NUM_COLOR ) // Defensive check.
UNEXPECTED_VALUE( c );
fputs( COLOR_NAME[ c ], f );
}
这样做的弊端是,它会添加一个“假的颜色”值,你需要在每个switch语句中把这个值作为一个case,以避免编译器警告没有处理的case,即使这个值永远不会匹配。正因为如此,我不推荐在枚举中添加“count”常量。
位标志值
另一种使用枚举的方式是定义一组位标志,其中每个常量都是一个不同的2的幂:
enum c_int_fmt {
CIF_NONE = 0,
CIF_SHORT = 1 << 0,
CIF_INT = 1 << 1,
CIF_LONG = 1 << 2,
CIF_UNSIGNED = 1 << 3,
CIF_CONST = 1 << 4,
CIF_STATIC = 1 << 5,
};
通常的做法是使用N是从 0 到需要多少位的第 N 位,而
1 << N
不是显式指定 2 的幂值,例如 0、1、2、4、8 等,并让编译器为你计算一下。
然后您可以按位或将各个标志组合在一起:
c_int_fmt f = CIF_CONST | CIF_UNSIGNED | CIF_INT;
这会导致一个值 ( 0b0011010
) 不在声明的常量范围内 — 但这是完全合法的。调试器甚至足够聪明,可以注意到这一点并相应地打印:
(gdb) p f
$1 = CIF_INT | CIF_UNSIGNED | CIF_CONST
您还可以测试是否包含特定位:
if ( (f & CIF_STATIC) != CIF_NONE )
puts( "static " );
if ( (f & CIF_CONST) != CIF_NONE )
puts( "const " );
if ( (f & CIF_UNSIGNED) != CIF_NONE )
puts( "unsigned " );
if ( (f & CIF_SHORT) != CIF_NONE )
puts( "short " );
if ( (f & CIF_LONG) != CIF_NONE )
puts( "long " );
if ( (f & CIF_INT) != CIF_NONE )
puts( "int " );
或者测试位组,例如,确实f
有两个特定的位组:
if ( (f & (CIF_SHORT | CIF_LONG)) == CIF_SHORT | CIF_LONG )
goto illegal_format;
当然,需要注意的是,switch
此类枚举上的 a 可能不匹配任何case
。尽管如此,枚举通常用于按位标志。
在这种情况下,隐式转换为int
很方便,因为按位运算符可以正常工作。