第34章 枚举与联合体
第34章 枚举与联合体
想象一下,如果你是一位服装设计师,但你的衣柜里只能放一种衣服——今天想穿T恤?对不起,昨天那件西装还在占着位置,你得先把它扔掉。这是不是很荒谬?但是在C++的远古时代(咳咳,我是说C时代),程序员们就经常遇到这种尴尬的情况:一个变量只能存一种类型的值,哪怕你明明知道它有时候是数字,有时候是文字,有时候是其他什么东西。
别担心!C++的枚举(Enum)和联合体(Union)就是为了解决这种"一件衣服占据整个衣柜"的浪费问题而诞生的。它们就像是给程序员配备了一个神奇的衣柜——enum让你能给一堆相关的常量起名字,而union则允许同一个内存位置扮演不同的数据类型角色。
本章我们将深入探讨这两种"变形金刚"级别的数据类型,看看它们如何让我们的代码既优雅又高效。
34.1 枚举类型
枚举(Enumeration),听起来像是一种"数数"的行为——没错,它本质上就是给一堆相关的常量赋予人类可读的名字。想象一下,如果你写代码时看到status = 3,你可能会问:“3是什么鬼?“但如果看到status = Active,哇,瞬间就明白了!
无作用域枚举(C风格枚举)
无作用域枚举(Unscoped Enumeration)是C++从C语言继承来的"老古董”,也是最基础、最直接的枚举类型。它的特点是枚举成员直接暴露在枚举类型所在的作用域里,就像把你的衣服直接扔在房间地板上——想拿就能拿到,但也容易造成混乱。
在C++中,enum关键字用于声明无作用域枚举,紧跟在后面的是枚举类型的名称,然后是一对花括号,里面包含了所有的枚举成员。每个枚举成员都对应一个整数值,默认从0开始递增,除非你显式指定。
1
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
| #include <iostream>
// 定义一个Color枚举类型,包含三个颜色成员
// Red默认是0,Green是1,Blue是2
enum Color {
Red, // 0 - 红色,就像番茄炒蛋里的番茄
Green, // 1 - 绿色,程序员的护眼色(并不是)
Blue // 2 - 蓝色,忧郁的蓝色代码...
};
// 再定义一个Status枚举,这次我们给成员指定具体的值
// 使用等号可以自定义值,没指定的会从上一个值继续递增
enum Status {
Pending = 1, // 待处理,任务堆积如山的状态
Active = 2, // 进行中,疯狂加班的状态
Completed = 4 // 已完成,终于可以摸鱼的状态
};
int main() {
// 声明一个Color类型的变量c,初始化为Red
Color c = Red;
// 枚举类型可以隐式转换为int,这有时候很方便,有时候很危险
int val = c;
std::cout << "Color is now: " << c << std::endl; // 输出: 0
// 哦不,我们打印出来的是数字0,而不是"Red"!
// 这就是无作用域枚举的小缺点——名字空间是扁平的
// 声明一个Status类型的变量s,初始化为Pending
Status s = Pending;
int statusVal = s;
std::cout << "Status value: " << statusVal << std::endl; // 输出: 1
// 完美,这次我们指定了值,所以打印出来的是1而不是默认的0
return 0;
}
|
小明的踩坑日记:小明兴冲冲地定义了两个枚举,enum Color { Red, Green, Blue }; 和 enum Mood { Red, Happy, Blue };。结果,他在同一个作用域里写了 Color c = Red; —— 编译直接报错!因为 Red 这个名字被两个枚举共享了,在全局作用域里产生了冲突。小明因此被产品经理催着改了三天代码,把所有枚举成员都加上了前缀(如 Color_Red、Mood_Red),代码瞬间变丑了三倍。这就是无作用域枚举的"名字空间污染"问题。
无作用域枚举的优缺点:
- ✅ 优点:语法简洁,与C语言兼容,可以隐式转换为整数方便做位运算
- ❌ 缺点:名字空间是扁平的,容易产生命名冲突;可以隐式转换为整数也意味着类型安全级别较低
强类型枚举enum class(C++11)
强类型枚举(Strongly-typed Enumeration)是C++11引入的"升级版"枚举,它使用enum class(或等价的enum struct)语法声明。最大的改进就是枚举成员被严格封闭在枚举类型的作用域内,再也不会跟外面的"邻居"打架了!而且不会隐式转换为整数,类型安全性大大提高。
如果说无作用域枚举是"敞开门的宿舍”(随便进出),那么强类型枚举就是"需要门禁卡的公寓"——安全多了!
1
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
| #include <iostream>
// 定义强类型枚举ErrorCode,底层类型是int
// enum class 的特点是:成员不在全局作用域,而是属于这个枚举类型
enum class ErrorCode : int {
Success = 0, // 成功,0是个好数字
FileNotFound = 1, // 文件找不到,你的代码找不到它的"另一半"
PermissionDenied = 2 // 权限被拒绝,你不是这座城堡的国王
};
// 再定义一个强类型枚举,底层类型是unsigned char(只占1字节)
// 指定底层类型可以控制枚举占用的空间
enum class Priority : unsigned char {
Low = 1, // 低优先级,可以慢慢来
Medium = 2, // 中优先级,做完再说
High = 3 // 高优先级,火箭般紧急!
};
int main() {
// 声明强类型枚举变量,必须使用枚举类型名::成员名的形式
// 这就像是写信时必须写"北京市朝阳区XX小区"而不是只写"XX小区"
ErrorCode e = ErrorCode::Success;
// 想要获取枚举的整数值?必须显式使用static_cast转换!
// 这是一个重要的安全特性,防止你"不小心"做傻事
int val = static_cast<int>(e);
std::cout << "ErrorCode value: " << val << std::endl; // 输出: 0
// 试试Priority枚举
Priority p = Priority::High;
// 注意:即使底层类型是unsigned char,转换语法也是一样的
int pVal = static_cast<int>(p);
std::cout << "Priority value: " << pVal << std::endl; // 输出: 3
// 强类型枚举不会隐式转换为int,下面的代码是错误的:
// int x = Priority::High; // 编译错误!想转换?乖乖用static_cast
int x = static_cast<int>(Priority::High); // 必须显式转换
return 0;
}
|
小红的真香定律:小红一开始觉得强类型枚举的"::“语法好麻烦,每次写ErrorCode::Success都要敲那么多字。但后来她发现,用无作用域枚举写了一个巨大的项目后,光是因为命名冲突而改bug就花了一周时间。从此以后,小红逢人就推荐强类型枚举:“真香!”
强类型枚举的特点总结:
- ✅ 成员位于枚举类型的作用域内,不会污染外部名字空间
- ✅ 不会隐式转换为整数,类型安全
- ✅ 可以指定底层类型(char、int、unsigned等)
- ❌ 语法稍微繁琐一点(但这点代价完全值得!)
底层类型指定
在定义枚举时,你可以在枚举名后面加一个冒号和类型名,这就是底层类型指定(Underlying Type Specification)。它告诉编译器:“我用这个枚举来存什么类型的整数”。
为什么要指定底层类型?想象一下,如果你要写一个程序来控制核电站的反应堆堆芯温度传感器,每个传感器有四种状态:Normal(正常)、Warning(警告)、Critical(危险)、Meltdown(融毁)。如果你用默认的int类型,每个状态值占用4个字节。但实际上你只需要0-3这四个值,用1个字节就够了。
在嵌入式开发、游戏开发等对内存极度敏感的场景,底层类型指定可以帮你省下宝贵的内存!
1
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
| #include <iostream>
#include <cstdint> // 包含uint8_t等固定宽度整数类型的定义
// 定义一个强类型枚举,底层类型是uint8_t(8位无符号整数,范围0-255)
// 占用的内存只有1个字节!超省空间!
enum class SmallEnum : uint8_t {
A = 0, // 成员A的值是0
B = 1 // 成员B的值是1
};
int main() {
// 打印SmallEnum类型的大小,验证它确实只占1字节
std::cout << "sizeof(SmallEnum) = " << sizeof(SmallEnum) << std::endl; // 输出: 1
// 如果没有指定底层类型,枚举通常会占用4字节(int的大小)
// 使用枚举值
SmallEnum flag = SmallEnum::A;
int flagVal = static_cast<int>(flag);
std::cout << "Flag value: " << flagVal << std::endl; // 输出: 0
// 底层类型的好处:
// 1. 省内存 - 对于几百万个枚举值,节省的内存很可观
// 2. 可预测的大小 - 网络协议、文件格式往往要求固定宽度
// 3. 性能 - 在某些架构上,对齐要求可能影响访问速度
return 0;
}
|
嵌入式工程师老王的肺腑之言:老王在开发一个只有256KB内存的物联网设备固件时,发现程序占用的内存总是不够用。后来他发现项目中定义了上百个枚举,但每个枚举默认都是4字节——光这一项就浪费了几百KB的内存!后来老王把所有能省的枚举都加上了: uint8_t,成功把程序塞进了设备里。他说:“底层类型指定这玩意儿,简直是嵌入式开发者的续命丹!”
底层类型指定的使用建议:
- 当你需要枚举值在网络协议或文件格式中占用特定大小时,指定底层类型
- 当你创建大量枚举对象且内存紧张时(如嵌入式、游戏开发),使用较小的底层类型
- 常用选项:
uint8_t(1字节)、uint16_t(2字节)、uint32_t(4字节)
34.2 联合体union
联合体(Union)是一种特殊的数据类型,它的精髓在于"一宫多用”——同一个内存位置可以存储不同类型的值,但同一时刻只能扮演一种角色。
你可以把union想象成一个"变形工坊":这个工坊的地板上有100平米的空间,你可以放一台洗衣机,但一旦放了洗衣机,这里就只能当洗衣机用,不能同时又当冰箱用——哪怕洗衣机只占了10平米的空间,剩下的90平米也必须空着,不能塞进去别的东西。
union就是这种"霸道"的家伙:一个union变量可以存储int或double或char,但每次只能存一种。当你给它赋一个double值后,之前存的int值就"消失"了——不是真的消失,而是被覆盖了,因为它们共享同一块内存。
匿名联合体
匿名联合体(Anonymous Union)是union家族中的"隐士"——它没有名字,直接在结构体或类内部声明,它的成员就像是直接属于外层结构体的普通成员一样使用。
1
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
| #include <iostream>
// 定义一个Widget结构体,用于存储不同类型的部件数据
struct Widget {
int type; // 类型标识符,1表示intValue有效,2表示doubleValue有效
// 匿名联合体 - 它没有名字,直接在这里声明
// 联合体内的成员(intValue和doubleValue)会直接成为Widget的一部分
union {
int intValue; // 整数类型的值
double doubleValue; // 浮点数类型的值
};
// 注意:intValue和doubleValue共享同一块内存!
// 就像同一个房间,既可以是卧室,也可以是办公室,但不能同时是两者
};
int main() {
Widget w; // 创建一个Widget对象
// 设置type为1,表示这个widget存的是一个整数
w.type = 1;
// 给intValue赋值42
w.intValue = 42;
// 打印结果
std::cout << "intValue = " << w.intValue << std::endl; // 输出: 42
// 如果我们现在给doubleValue赋值,会发生什么?
// 答案:intValue的值会被覆盖,因为它们共享内存!
w.doubleValue = 3.14;
std::cout << "After setting doubleValue, intValue looks like: " << w.intValue << std::endl;
// 打印出来的可能是一个乱七八糟的数,因为intValue的内存被double值覆盖了
// 这就是使用union时的"陷阱"——你必须自己记住当前存的是什么类型!
// 如果你忘了type是1而以为还是intValue,读取doubleValue就会得到垃圾数据
return 0;
}
|
小李的惨痛教训:小李用union写了一个"万能变量"来存储配置值,他觉得这样特别省内存。结果线上出了个bug,用户投诉说自己的分数显示成"1.34523e+19"这样的乱码。调查了半天发现:代码先给union存了一个整数用户分数score.intValue = 95;,然后另一个模块以为这是个double,直接读了score.doubleValue——两个int位被当成double的尾数部分解释,当然就变成乱码了!小李后来加了type标识,并在每次访问前检查类型,这才平息了这场"union引发的血案"。
匿名联合体的特点:
- ✅ 成员直接属于外层结构体,访问时不需要中间步骤(如
w.intValue而不是w.data.intValue) - ✅ 语法更简洁
- ❌ 联合体成员对外层结构体"可见",可能会被意外访问,导致未定义行为
- ❌ 同样需要使用者自己维护"当前有效的是哪个成员"的状态
有作用域联合体(C++11)
有作用域联合体(Scoped Union)是C++11对匿名联合体的"改进版"。它本质上就是一个有名字的联合体,作为结构体或类的成员存在,访问时需要通过联合体名字作为中间人。
C++11还允许给union定义构造函数和析构函数,这对于管理资源(比如动态内存、文件句柄)非常重要——当union切换存储类型时,之前的值需要被正确地销毁。
1
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
41
| #include <iostream>
// 定义一个Data结构体,内部包含一个有作用域的联合体
struct Data {
// 有作用域联合体:联合体有自己的名字"Storage"
// 访问成员需要通过 Storage::成员名 的形式
union Storage {
int i; // 整数成员
double d; // 浮点数成员
};
Storage s; // Data里声明一个Storage联合体对象
// 构造函数,使用初始化列表给s.i一个默认值0
// 这个构造函数会在创建Data对象时自动调用
Data() : s{} {}
// 析构函数(这里为空,但如果有指针成员可能需要)
~Data() {}
};
int main() {
// 创建Data对象,构造函数会初始化s.i为0
Data data;
// 通过联合体名::成员名访问——这就是"有作用域"的意思!
data.s.d = 3.14; // 现在联合体存的是double
std::cout << "d = " << data.s.d << std::endl; // 输出: 3.14
// 打印i看看——它现在是什么?
// 没错,就是一堆乱码,因为double的二进制表示被int解释
std::cout << "i (after setting d) = " << data.s.i << std::endl;
// 不要慌,这是union的正常行为!怪就怪你自己没记住当前类型
// 重新设置i
data.s.i = 100;
std::cout << "i (after resetting) = " << data.s.i << std::endl; // 输出: 100
// 现在i正常了,但之前存的3.14已经"香消玉殒"了
return 0;
}
|
有作用域vs匿名:小张纠结了好久到底用哪种联合体。他的老师告诉他:“如果你确认联合体的生命周期完全由外层结构体管理,且不会有歧义,用匿名的更简洁;如果你想明确表达’这是一组互斥的数据’,用有作用域的更清晰。“小张后来做游戏开发,他把技能效果存成union,匿名的那种植入了技能结构体内部,有作用域的用在事件系统里。他说:“合适的才是最好的!”
34.3 std::variant作为类型安全联合体(C++17)
终于!我们来到了联合体的"究极进化形态”——std::variant!
std::variant是C++17引入的标准库类型,它是一个"类型安全的联合体”。如果说union是个"粗心大意"的数据结构(不记得自己当前存的是什么类型),那么std::variant就是配备了"记忆芯片"的智能保险箱——它永远知道自己存的是什么类型,并且会在你试图访问错误类型时给你一个明确的错误,而不是默默地返回乱码。
std::variant定义在<variant>头文件中,它可以存储多种类型中的一种,就像union一样。但关键区别在于:variant会记录当前有效值的类型,并且提供类型检查的访问方式。
1
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
41
42
43
44
45
46
| #include <iostream>
#include <variant> // std::variant的头文件
int main() {
// 定义一个variant,它可以存int、double或std::string三种类型之一
// variant<int, double, std::string> 就像一个"三选一"的保险箱
std::variant<int, double, std::string> v;
// 存一个整数进去
v = 42;
// 用std::get<T>访问,如果类型不对会抛异常
std::cout << "int: " << std::get<int>(v) << std::endl; // 输出: 42
// 换成存一个double
v = 3.14;
std::cout << "double: " << std::get<double>(v) << std::endl; // 输出: 3.14
// 换成存一个字符串
v = "hello";
// 字符串常量"hello"是const char*类型,
// 但variant会帮我们转换成std::string(因为我们声明的是std::string)
std::cout << "string: " << std::get<std::string>(v) << std::endl; // 输出: hello
// 安全检查:用std::get_if<T>可以安全地检查当前是否是某种类型
// 它返回一个指针,如果是那种类型就指向值,否则返回nullptr
if (auto* val = std::get_if<int>(&v)) {
// 只有当v当前存的是int时,才会进入这个分支
std::cout << "Current type is int: " << *val << std::endl;
} else {
std::cout << "Current type is NOT int" << std::endl;
}
// 现在v存的是string,所以上面会输出"Current type is NOT int"
// 尝试用错误的类型访问?std::bad_variant_access异常在等着你!
try {
int x = std::get<int>(v); // v当前是string,不是int!
std::cout << x << std::endl; // 这行不会执行
} catch (const std::bad_variant_access& e) {
std::cout << "Oops! Tried to get int but variant holds string: "
<< e.what() << std::endl;
// 输出: Oops! Tried to get int but variant holds string: ...
}
return 0;
}
|
小明的完美逆袭:小明之前被普通union坑了三周,这次他学聪明了。接手新项目时,他一看要求"C++17以上",立刻眼睛发光:“太好了!可以用std::variant了!“他用variant重构了之前容易出bug的配置解析代码,加班了两天,结果后来的维护工作几乎为零——variant在编译时就杜绝了类型错误的可能性,产品经理都觉得他代码质量突飞猛进。小明得意地说:“不是我变聪明了,是C++17变聪明了!”
std::variant的"超能力”:
- ✅ 类型安全:始终知道当前存储的类型,访问错误类型会抛异常或返回nullptr
- ✅ 无额外开销:不占用额外内存(除了可能用于异常处理的少量开销)
- ✅ 支持递归:可以嵌套,比如
std::variant<int, std::vector<std::variant<int, double>>> - ✅ 可替代性:可以用
std::visit对不同类型执行不同的操作,类似模式匹配 - ❌ 语法稍复杂:需要头文件
<variant>,不支持C++17之前的项目 - ❌ 不能存储void:variant至少需要一种类型
- ❌ 不能直接存储数组:但可以存储
std::array
让我们再看看一个更有趣的例子——用std::visit来"访问"variant:
1
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
| #include <iostream>
#include <variant>
#include <string>
// 定义一个打印器仿函数
struct Printer {
// 重载operator(),对不同类型执行不同的打印操作
void operator()(int i) const {
std::cout << "Integer: " << i << std::endl;
}
void operator()(double d) const {
std::cout << "Double: " << d << std::endl;
}
void operator()(const std::string& s) const {
std::cout << "String: " << s << std::endl;
}
};
int main() {
std::variant<int, double, std::string> v = 42;
// std::visit就像一个"万能钥匙",能根据v当前的实际类型调用对应的operator()
std::visit(Printer{}, v); // 输出: Integer: 42
v = 3.14;
std::visit(Printer{}, v); // 输出: Double: 3.14
v = "Hello, variant!";
std::visit(Printer{}, v); // 输出: String: Hello, variant!
// 更简洁的写法:使用lambda代替仿函数
std::visit([](const auto& val) {
std::cout << "Value: " << val << std::endl;
}, v); // 输出: Value: Hello, variant!
return 0;
}
|
这个std::visit的技巧在处理"多种类型但要统一操作"的场景时特别有用,比如写一个表达式求值器、状态机、或者JSON解析器。
本章小结
本章我们深入探索了C++中两种重要的"数据容器”——枚举(Enum)和联合体(Union),它们就像是程序员工具箱里的多功能瑞士军刀,各有各的用途。
枚举类型就像是给一堆常量起了个好听的名字:
- 无作用域枚举(C风格
enum)简单直接,但名字空间是扁平的,容易产生命名冲突 - 强类型枚举(
enum class)是C++11带来的升级版,成员作用域严格封闭,不会隐式转换为整数,类型安全性更高 - 底层类型指定允许你控制枚举占用的内存大小,省内存的利器
联合体则是"一宫多用"的内存共享高手:
- 匿名联合体没有独立的名字,成员直接属于外层结构体,访问简洁但也容易误用
- 有作用域联合体有独立的名字,访问需要通过联合体名中转,语义更清晰
- 联合体的核心特点是同一时刻只能存储一种类型的值,切换类型会覆盖旧值
std::variant(C++17)代表了联合体的"究极进化形态":
- 它是类型安全的,不会让你读取到错误类型的数据
- 提供
std::get<T>和std::get_if<T>两种访问方式 - 支持
std::visit进行类型分发的操作 - 是现代C++处理"异构数据"的首选方案
老程序员的忠告:能用强类型枚举就别用无作用域枚举,能用std::variant就别用裸union。这些"升级版"工具在编译时就给你更多的保护,省得你在debug时流下没有技术含量的眼泪。
记住,枚举让你的代码更易读,联合体让你的内存更高效,而std::variant让两者兼得!好好掌握它们,你就是办公室里的"类型大师"!