C语言的枚举类型(enum)用于表达对象所属的类别。比如,人分男女,大学的学生则又分为专科生、本科生、硕士研究生和博士研究生。C语言中的联合(union)类型则为我们提供了操纵和解读“数据”的独特方式,它允许对同一块内存以不同的方式进行解读和操纵。

在C++程序中,上述两项特性使用得不多,故本章内容以在线方式提供。至于C++中的枚举类(enum class),我们在第13章讨论。

版权声明

本文可以在互联网上自由转载,但必须:注明出处(作者:海洋饼干叔叔)并包含指向本页面的链接。

本文不可以以纸质出版为目的进行改编、摘抄。

10. 枚举与联合

性灵出万象,风骨超常伦。 — 高适


10.1 枚举型

对事物进行分类是人类的技能之一。人分男女,大学的学生则又分为专科生、本科生、硕士研究生和博士研究生。与现实世界相对应,在程序当中,我们也常常需要表达对象所属的类别。而枚举类型,则是完成该任务的工具之一。

下述C语言代码定义了一个名为ColorType的枚举(enumeration)类型。

1
2
3
4
5
6
//Project - ColorType

enum ColorType {
red,orange,yellow=100,green,blue,violet
};//注意末尾的分号不能少
// 0 1 100 101 102 103

对于编译器而言,枚举类型的实质就是整数。上述定义中,编译器会从0开始,给各个枚举项确定对应的整数值。同时,程序也可以给某些枚举项指定值,如本例中的yellow被指定为100。那些在yellow之后的枚举项,则依次被确定100+1,100+2,100+3。上述程序中的第5行注释列出了所有枚举项所对应的整数值。

上述定义之后,在C语言里便可以把enum ColorType当成一个新的数据类型来使用。在C++里,可以直接把ColorType当成数据类型使用,不必加上enum关键字。

1
enum ColorType color = blue;

上述代码定义了一个名为color的对象/变量,其类型为enum ColorType,初始值为blue,即整数102。

1
2
3
color = green;
color = 999; //编译器警告:999不存枚举范围内
color = 1; //等价于orange

任何ColorType的枚举项都可以赋值给color对象,也可以直接把整数赋值给color对象。当把一个不在ColorType枚举项范围内的整数,比如999赋值给color时,编译器会给出警告。

1
color = (enum ColorType)103;  //等价于violet

有的编译器可能不喜欢你直接把整数赋值给枚举类型的对象,从而给出类型不一致的警告。严格地说,上述赋值语句的左边是enum ColorType类型,而103则是整数字面量。可以通过类型转换消除这种警告。

1
printf("violt = %d\n",violet);

所有的枚举项,均可视为整数类型的常量,上述代码的输出值应为103。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Project - ColorType
const char* getColor(enum ColorType c){
switch (c){
case red:
return "red";
case orange:
return "orange";
case yellow:
return "yellow";
case green:
return "green";
case blue:
return "blue";
case violet:
return "violet";
default:
return "error";
}
}

也可以把枚举类型的对象作为switch分支语句的“整数表达式”。上述getColor()函数将enum ColorType对象转换成对应的英文字符串。

1
printf("color = %s\n",getColor(green));

借助于getColor()函数,上行代码将枚举项green转换成字符串”green”并输出。

在作者的开发环境及编译器下,sizeof(color)及sizeof(enum ColorType)均为4,这说明编译器内部将该枚举类型按4字节整数处理。枚举型对应整数,但到底对应1个字节(unsigned char, char),2个字节(unsigned short, short),还是4个字节的整数(unsigned int, int)则取决于编译器的决定。但可以放心,编译器最终确定的整数类型一定可以容纳所有枚举项对应的整数值。

读者可能会问:既然枚举类型的实质就是整数,那直接用整数0、1、2、3 … 来表示红、橙、黄、绿不就可以了?要枚举型为何用?答案是枚举型可以改善程序的可读性,对程序的阅读者而言,red比0更容易理解和记忆。

练习巩固 👣
10-1(枚举月份)定义枚举型Month,其中的枚举项以英文月份名January、February等表示;实现如下的getDays()函数,并编写代码加以验证。
int getDays(int year, enum Month m);

说明:函数返回指定年月的天数,getDays(2022,April)应返回30,请注意区分闰年。

10.2 typedef语句

在编写单片机的C语言程序时,电气工程师特别喜欢清楚地了解每个整数类型的字节数,以及符号特性,即是有符号还是无符号整数。这个目的可以通过typedef语句实现。typedef源自英文type definition。

1
2
3
4
5
6
7
8
//Project - TypeDef
typedef unsigned char UINT8; //无符号8位整数 unsigned int of 8 bits
typedef unsigned short UINT16; //无符号16位整数
typedef unsigned int UINT32; //无符号32位整数

typedef char INT8; //有符号8位整数
typedef short INT16; //有符号16位整数
typedef int INT32; //有符号32位整数

相对于unsigned char,unsigned short这种类型名称,UINT8,UINT16具有更好的解释性。在上述typedef之后,UINT8、INT32等类型直接与unsigned char,int通用。

1
2
3
UINT8 b = 0x77;             //b的类型实为unsigned char
UINT16 s = 0xf900; //s的类型实为unsigned short
INT32 c = 0x1f2f3f4f; //c的类型实为int

如果用sizeof操作符求上述b、s、c对象的字节数,分别应为1、2和4。

在第7章中,我们曾经借助typedef定义了一个名为biggerFunc的函数指针类型:

1
2
typedef bool (*biggerFunc)(const string&, const string&);
biggerFunc f;

定义完成后,biggerFunc可以当成数据类型来使用,上述代码第2行中的变量f即是类型为biggerFunc的函数指针。

借助于typedef,我们也可以简化枚举类型的使用语法。下述代码将一个枚举类型定义为一个名为GenderType的数据类型:

1
2
3
4
//Project - GenderType
typedef enum {
male, female //依次取值0,1
} GenderType;

在C语言里,前述枚举类型ColorType必须结合enum关键字来使用,而使用typedef定义的GenderType可以直接使用。

1
2
enum ColorType color = red;     //必须结合enum关键字使用
GenderType gender = female; //直接当成数据类型使用

10.3 枚举类

传统C/C++语言中的枚举类型有个很大的缺点。在下述GenderType枚举类型引入后,male、female作为一个枚举项,被视为整数常量。

1
2
3
enum GenderType {
male, female
};

male、female这两个名字处于全局名字空间。male、female是很普通的命名,它们的存在“污染”了名字空间。

【C++ 11】引入了enum class(枚举类),示例如下:

1
2
3
4
//Project - EnumClass
enum class GenderType:unsigned char{
male,female
}; //注意末尾分号不能少

上述代码定义了一个名为GenderType的枚举类(enum class),其有两个枚举项,分别为male和female。基于类似的规则,male和female分别与整数0和1对应。上述定义中,GenderType冒号之后的unsigned char则显式地指定了该枚举类按unsigned char进行存储。如果省略该冒号及其之后的存储类型指示,枚举类的存储格式将由编译器自行确定。

要点🎯 域解析符
“::”称为域解析符。Rocket::Engine可以理解为火箭(Rocket)里的发动机(Engine),以区别于Car::Engine(轿车里的发动机),和Engine(发动机)。

相较于传统的枚举类型,enum class带来诸多益处。首先,其枚举项不再“污染”名字空间,必须通过域解析符::来使用,见下述代码:

1
2
GenderType gender = GenderType::male; //::为域解析符
//gender = male; //错误: male不在全局名字空间内

作为一个整体,GenderType::male具有更好的自解释性且不容易导致意外重名。

此外,不同于传统枚举型,enum class不允许其枚举项与整数之间的隐式类型转换,但显式类型转换是允许的。这种更严格的类型要求可以减少因为疏忽而导致的软件缺陷。

1
2
3
//gender = 1;       //错误:不允许进行整数与enum class之间的隐式类型转换
gender = GenderType(0); //显式类型转换 整数 -> 枚举项
int i = int(GenderType::female); //显式类型转换 枚举项 -> 整数

如果对上述gender对象应用sizeof操作符,返回的字节数应为1,因为GenderType的定义过程中指定了背后的存储类型:unsigned char。

练习巩固 👣
10-2(春花夏蝉秋实冬雪)请定义枚举类Season,并设计一个名为season的函数,使得下述代码可以得到期望的执行结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(){
cout << "spring - " << season(Season::spring) << endl;
cout << "summer - " << season(Season::summer) << endl;
cout << "autumn - " << season(Season::autumn) << endl;
cout << "winter - " << season(Season::winter) << endl;
return 0;
}

期望的执行结果:

spring – flower
summer - cicada
autumn - fruit
winter – snow

10.4 联合

C语言中的联合(union)类型为我们提供了操纵和解读“数据”的独特方式,它允许对同一块内存以不同的方式进行解读和操纵。

1
2
3
4
union UINT {
unsigned int intValue; //占4个字节
unsigned char bytes[4]; //占4个字节
}; //注意末尾分号不能少

上述代码定义了一个名为UINT的联合类型。该类型提供了两个成员,分别是unsigned int类型的intValue,以及元素类型为unsigned char的长度为4的字符数组bytes。这两个成员的内存空间是共享的,即一个union UNIT类型的对象只占4个字节的空间。当以成员intValue进行操作时,这4个字节的内存被当成一个unsigned int进行操纵和解读;当以成员bytes进行操作时,这4个字节的内存被当成一个4字节的字符数组进行操纵和解读。

我们通过下述C语言程序来解释联合类型的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Project - UnionExample
#include <stdio.h>

union UINT {
unsigned int intValue; //占4个字节
unsigned char bytes[4]; //占4个字节
}; //注意末尾分号不能少

int main() {
union UINT v = {.intValue=0x11223344};

printf("&v = %p, &v.intValue = %p, v.bytes = %p\n",
&v, &v.intValue, v.bytes);

printf("v.bytes[0..3] = 0x%x 0x%x 0x%x 0x%x\n",
v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3]);

v.bytes[0] = 0x55; v.bytes[1] = 0x66; v.bytes[2] = 0x77; v.bytes[3] = 0x88;
printf("v.intValue = 0x%x\n",v.intValue);

printf("sizeof(v) = %lld",sizeof(v));
return 0;
}

上述程序的执行结果为:

1
2
3
4
&v = 000000000061FE1C, &v.intValue = 000000000061FE1C, v.bytes = 000000000061FE1C
v.bytes[0..3] = 0x44 0x33 0x22 0x11
v.intValue = 0x88776655
sizeof(v) = 4

说明:在读者的计算机上,执行结果中的地址很可能与本书不同。

要点🎯
“.”被称为成员操作符,a.b意为对象a的b成员。

🚩第10行:C语言中,union UINT作为一个整体,代表名为UNIT的联合类型。在C++语言中,使用UINT类型时,前面的union关键字可以省略。此处的v是一个对象,其类型为union UINT。因为联合对象的多个成员是内存共享的,所以v的初始值必须以{ }包裹起来,.intValue指明了v初始化的实际动作是把0x11223344赋值给v的intValue成员。作者在这里故意使用了十六进制,因为十六进制每位占4个比特,每两位占1个字节。v定义并初始化以后,其内存结构可以用图10-1表示。

unionstructure

图10-1 联合对象v的内存结构

如图10-1所示,v占据了地址为0x0061FE1C、0x0061FE1D、0x0061FE1E和0x0061FE1F的连续4个字节的存储空间。v的所谓数据成员,无非这4字节内存的不同视图(view)而已。从v.intValue的角度看,这是一个地址为0x0061FE1C的32位无符号整数;从v.bytes的角度看,这是一个从地址0x0061FE1C开始的4个元素的字符数组。

读者可能注意到,图10-1 中,4个字节从低地址往高地址方向读,依次是0x44、0x33、0x22和0x11,其顺序与作为无符号整数的v.intValue的值正好相反。这是因为,Intel的x86系列CPU执行Little Endian的字节顺序(小端序),高位字节(0x11)存高地址(0x0061FE1F)

🚩第12 ~ 13行:依次打印v的地址,v.intValue的地址,v.bytes的地址(数组名即为地址)。从执行结果的第1行可见,3个地址值相同。这证实,联合对象v的不同成员间是共享内存的。此处的v.intValue应用了”.”操作符,读者可以形象地将其理解为“v对象intValue”。

🚩第15 ~ 16行:依次打印v.bytes的4个元素。这相当于从v.bytes的角度去解释v.intValue的数据。执行结果的第2行证实,v.intValue最高位字节的0x11存在了v.bytes[3]里。如刚才所述,这是小端序导致的。

🚩第18 ~ 19行:对v.bytes成员进行赋值,然后再以v.intValue成员解释数据。执行结果的第3行证实,对v.bytes的修改即是对v.intValue的修改。

🚩第21行:打印sizeof(v),执行结果证实,联合对象v占4个字节的空间。

请阅读下述C语言程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Project - MoreUnion
#include <stdio.h>

union UMore {
double dValue; //全部8个字节
char cValue; //8个字节中的前1个字节
int iValue; //8个字节中的前4个字节
};

int main() {
printf("sizeof(union UMore) = %d\n", sizeof(union UMore));

union UMore v = {33.22}; //未指定初始化成员时默认赋值给第0个成员dValue
printf("v.dValue = %f\n",v.dValue);

printf("&v.dValue = %p, &v.cValue = %p, &v.iValue = %p\n",
&v.dValue, &v.cValue, &v.iValue);

union UMore* p = &v;
printf("p->iValue = %d", p->iValue);

return 0;
}

上述代码的执行结果为:

1
2
3
4
sizeof(union UMore) = 8
v.dValue = 33.220000
&v.dValue = 000000000061FE10, &v.cValue = 000000000061FE10, &v.iValue = 000000000061FE10
p->iValue = -171798692

说明:在读者的计算机上,执行结果中的地址很可能与本书不同。

要点🎯
“->”称为指向操作符,如果p是一个指针,p->a表示p所指向的对象的a成员。p->a与(*p).a等价。

上述程序中,联合类型UMore的三个成员分别占据8个、1个及4个字节的空间。从执行结果看,UMore类型的联合对象v占据8个字节的空间,正好是各成员空间尺寸的最大值。同时,还应注意到v的三个成员的地址相同。这意味着,当我们操作v.cValue时,仅会影响v的第0个字节,其余7个字节不受影响。

🚩第13 ~ 14行:当联合对象初始化时,如果没有指明被初始化的成员,则会默认初始化第1个成员。执行结果的第2行证实,v.dValue被初始化为33.22。

🚩第16 ~ 17行:打印v的dValue、cValue及iValue成员的地址。执行结果证实,三者的地址相同。

🚩第19 ~ 20行:定义了一个指向v的指针p。p->iValue表示指针p所指向的联合对象的iValue成员。从执行结果可见,将8个字节double数据的前4个字节当成int来解读,结果是“莫名其妙”的。

练习巩固 👣
10-3(判定大小端序)结合typedef定义一个联合类型Endian,使得下述代码能正确判断CPU的大小端序。请解释该程序的工作原理。提示:联合体包括无符号整数成员i和无符号字符成员b。
1
2
3
4
5
6
7
8
int main(){
Endian e = {.i=99};
if (e.b==99)
printf("little endian."); //小端序:高位字节在高地址
else
printf("big endian."); //大端序:高位字节在低地址
return 0;
}