让数据说清楚自己:枚举、结构体、联合体与类型别名 / Enums, Structs, Unions, and Aliases
1. 先看一个“数字都能编译”的问题
假设我们在写一个下载程序。任务有等待、下载中、完成和失败几种状态。
最直接的写法是用整数:
#include <iostream>
void print_status(int status) {
if (status == 0) {
std::cout << "waiting\n";
} else if (status == 1) {
std::cout << "downloading\n";
} else if (status == 2) {
std::cout << "finished\n";
} else if (status == 3) {
std::cout << "failed\n";
}
}
int main() {
print_status(2);
print_status(999);
}它可以编译,也可能可以运行。
问题在于,调用者看到的只是一个 int:
调用者看到 int
|
v
不知道 2 代表什么
|
v
999 也能进入接口我们真正想表达的是“有限的状态集合”。
enum class DownloadStatus {
waiting,
downloading,
finished,
failed
};
void print_status(DownloadStatus status) {
if (status == DownloadStatus::finished) {
std::cout << "finished\n";
}
}调用点现在变成:
print_status(DownloadStatus::finished);这段写法更长,却更容易读,也更难把普通整数误传进来。
本文的主线是:
没有语义的数值
|
v
有名字的有限选项
|
v
相关字段组成一个对象
|
v
多个候选类型共享一片存储
|
v
让类型表达真实概念和边界枚举、结构体、联合体和类型别名不是四个孤立语法。
它们都在回答:
怎样让数据不仅“存得下”,还能够表达它代表什么?
2. 本文基础词小注释
2.1 类型是什么
类型可以先理解成“编译器解释一块数据的规则”。
int count = 42;
float ratio = 0.5f;二者都需要存储空间,但解释方式不同:
二进制位
|
+-- 按 int 解释 -> 整数
|
+-- 按 float 解释 -> 浮点数类型还影响:
- 可以执行哪些操作;
- 需要多少字节;
- 需要怎样对齐;
- 如何初始化和销毁;
- 函数参数是否匹配;
- 编译器能否提前发现错误。
2.2 对象是什么
对象不是变量名。
int count = 42;可以分成:
count
+-- 名字,供源代码引用
对象
+-- 有类型的存储
+-- 有生命周期
+-- 当前保存 42本文讲内存布局时,主要观察对象占用的存储。
讲类型别名和强类型时,主要观察编译器怎样理解对象。
2.3 表示和语义
语义是“这个值代表什么”。
表示是“它怎样编码在存储中”。
enum class Level {
low,
high
};程序员看到的是 low 和 high。
机器最终看到的是某种整数位模式:
Level::low
|
v
底层整数表示
|
v
内存中的二进制位不能因为底层是整数,就把所有整数都当成合法状态。
2.4 布局、对齐和 padding
布局是对象成员在内存中的排列方式。
对齐可以理解为:某些类型更适合从特定地址边界开始。
padding 是编译器为满足对齐而插入的填充字节。
struct Example {
char tag;
int value;
};一种常见布局:
offset 0 tag 1 byte
offset 1..3 padding 3 bytes
offset 4..7 value 4 bytespadding 不是你声明的字段,却会影响 sizeof(Example)。
2.5 ABI
ABI 是 Application Binary Interface 的缩写。
它描述编译后的程序怎样互相配合,例如:
- 结构体怎样布局;
- 函数参数怎样传递;
- 返回值放在哪里;
- 符号如何命名;
- 动态库如何找到函数。
因此:
sizeof(T)
+-- 可以验证当前编译环境
+-- 不自动等于跨平台文件格式3. 枚举:给有限选择起名字
3.1 普通枚举
enum Color {
red,
green,
blue
};
int main() {
Color color = green;
}枚举适合表达“从有限选项中选择一个”。
默认情况下,枚举项通常从 0 开始递增:
red -> 0
green -> 1
blue -> 2这只是默认表示,不要把它当成永久协议。
如果以后在中间插入选项,后面的数值可能变化。
3.2 普通枚举的名字可能冲突
enum Color {
red,
green,
blue
};
enum TrafficLight {
red,
yellow,
green
};两个枚举都把 red 和 green 放进外围作用域。
大型项目中,这很容易产生冲突。
外围作用域
+-- red
+-- green
+-- blue3.3 enum class
enum class Color {
red,
green,
blue
};
enum class TrafficLight {
red,
yellow,
green
};使用时必须写出所属类型:
Color color = Color::green;
TrafficLight light = TrafficLight::green;概念关系变成:
Color
+-- Color::red
+-- Color::green
TrafficLight
+-- TrafficLight::red
+-- TrafficLight::greenenum class 的重要特征:
- 枚举项有自己的作用域;
- 不会轻易隐式转换成整数;
- 不同枚举类型不容易混用;
- 可以明确指定底层整数类型。
3.4 显式转换
enum class Color {
red,
green,
blue
};
Color color = Color::green;
int raw = static_cast<int>(color);static_cast 表示程序员明确要求一次转换。
它表达“我知道这里改变了类型”。
它不代表外部输入已经合法:
int raw = 999;
Color color = static_cast<Color>(raw);读取文件或网络数据时,仍要先检查范围。
bool is_valid(int raw) {
return raw >= 0 && raw <= 2;
}3.5 指定底层类型
#include <cstdint>
enum class MessageType : std::uint8_t {
request = 1,
response = 2
};std::uint8_t 表示无符号八位整数。
观察大小:
#include <iostream>
int main() {
std::cout << sizeof(MessageType) << '\n';
}这里的大小是当前实现的观察结果。
它不能单独解决字节序、版本和协议兼容问题。
3.6 枚举和状态机
enum class ConnectionState {
disconnected,
connecting,
ready,
failed
};状态图:
+---------------+
| disconnected |
+---------------+
|
| connect
v
+---------------+
| connecting |
+---------------+
| |
| ok | error
v v
+-------+ ++--------+
| ready | | failed |
+-------+ ++--------+
|
| close
v
disconnected枚举只表达“有哪些状态”。
它不自动表达“哪些跳转合法”。
跳转规则需要函数维护:
bool can_connect(ConnectionState state) {
return state == ConnectionState::disconnected ||
state == ConnectionState::failed;
}3.7 实验:观察枚举大小
保存为 enum_demo.cpp:
#include <cstdint>
#include <iostream>
enum class MessageType : std::uint8_t {
request = 1,
response = 2
};
int main() {
MessageType type = MessageType::request;
auto raw = static_cast<std::uint8_t>(type);
std::cout << "enum size: " << sizeof(MessageType) << '\n';
std::cout << "raw value: " << static_cast<int>(raw) << '\n';
}最简单的编译命令:
g++ enum_demo.cpp -o enum_demo命令的各部分:
g++
+-- 启动 C++ 编译器驱动
enum_demo.cpp
+-- 输入源文件
-o enum_demo
+-- 把输出程序命名为 enum_demo运行:
./enum_demo可能输出:
enum size: 1
raw value: 1先运行,再解释。
不要把一次运行的地址或大小直接当成所有平台的永久事实。
4. 结构体:把相关字段组成一个对象
4.1 从两个变量到一个类型
double x = 3.0;
double y = 4.0;这是一个点,但变量名没有表达它们属于同一个概念。
struct Point {
double x;
double y;
};
Point point{3.0, 4.0};现在:
Point
+-- x
+-- yPoint 是类型,point 是对象。
4.2 结构体的价值不只是少写变量
struct User {
int id;
bool enabled;
};两个字段属于同一个对象。
它们可以一起传递、一起初始化、一起检查。
User
+-- id
+-- enabled结构体的价值有两层:
第一层:相关数据一起移动
第二层:数据关系进入类型系统4.3 默认成员初始化
struct Config {
int retries = 3;
bool verbose = false;
};
Config config{};
Config custom{5, true};config{} 使用默认成员值。
custom{5, true} 使用显式值。
初始化和赋值不是一回事:
Config first{};
first.retries = 5;第一个语句创建对象。
第二个语句修改已经存在的对象。
4.4 struct 和 class
两者都可以有:
- 数据成员;
- 成员函数;
- 构造函数;
- 私有成员;
- 继承;
- 虚函数。
主要默认差异:
struct
+-- 默认 public
class
+-- 默认 privatestruct PublicPoint {
int x;
};
class PrivatePoint {
int x;
};工程习惯通常是:
struct
+-- 公开的简单值类型
class
+-- 需要维护不变量的对象这是一种习惯,不是语言限制。
4.5 不变量
不变量是对象正常使用期间必须保持的条件。
struct Date {
int year;
int month;
int day;
};Date date{2026, 99, 99};这个对象形式上存在,但语义可能无效。
如果字段公开,调用者可以随时制造非法状态。
需要集中检查时,可以使用类:
class SafeDate {
public:
SafeDate(int year, int month, int day);
int year() const;
int month() const;
int day() const;
private:
int year_;
int month_;
int day_;
};5. 结构体在内存中怎样排列
5.1 sizeof
#include <iostream>
struct Point {
double x;
double y;
};
int main() {
std::cout << sizeof(Point) << '\n';
}sizeof(Point) 表示一个对象占用的字节数。
不能普遍认为:
sizeof(结构体) = 成员 sizeof 之和因为编译器可能插入 padding。
5.2 观察偏移量
#include <cstddef>
#include <iostream>
struct Record {
char tag;
int value;
char active;
};
int main() {
std::cout << "sizeof: " << sizeof(Record) << '\n';
std::cout << "tag: " << offsetof(Record, tag) << '\n';
std::cout << "value: " << offsetof(Record, value) << '\n';
std::cout << "active: " << offsetof(Record, active) << '\n';
}offsetof 查看成员相对对象起始位置的偏移。
这个示例是简单标准布局类型,适合观察。
常见实现可能布局为:
offset 0 tag 1 byte
offset 1..3 padding 3 bytes
offset 4..7 value 4 bytes
offset 8 active 1 byte
offset 9..11 tail pad 3 bytes
sizeof 12 bytes具体结果应以你的编译器运行结果为准。
5.3 对齐为什么存在
对齐是类型对地址边界的要求。
一个四字节对象通常更适合从四的倍数地址开始:
地址 1000 1001 1002 1003 1004
| | | | |
int +-----------------------+
从 1000 开始更自然不同 CPU 处理未对齐访问的方式不同:
- 有些允许但较慢;
- 有些需要拆成多次访问;
- 有些场景不允许。
编译器因此插入 padding。
5.4 alignof
#include <iostream>
struct Record {
char tag;
int value;
};
int main() {
std::cout << alignof(char) << '\n';
std::cout << alignof(int) << '\n';
std::cout << alignof(Record) << '\n';
}alignof(T) 查询类型 T 的对齐要求。
C++ 类型规则
|
v
编译器布局
|
v
CPU 访问约束5.5 尾部 padding
Record records[2];第二个对象也必须从合适的地址开始。
records[0]
+----------------------+
| fields + tail pad |
+----------------------+
records[1]
+----------------------+
| fields + tail pad |
+----------------------+因此 sizeof(Record) 还要保证数组中下一个对象正确对齐。
5.6 成员顺序和布局
struct LessCompact {
char a;
double b;
char c;
int d;
};
struct MoreCompact {
double b;
int d;
char a;
char c;
};可以分别打印大小。
成员顺序可能影响 padding。
但不能只为省几字节就重排公开结构体。
还要考虑:
- 可读性;
- 已发布 ABI;
- 文件兼容;
- 缓存访问;
- 热数据和冷数据。
5.7 实验:打印地址
#include <cstdint>
#include <iostream>
struct Record {
char tag;
int value;
char active;
};
int main() {
Record record{'A', 42, true};
auto base = reinterpret_cast<std::uintptr_t>(&record);
auto tag = reinterpret_cast<std::uintptr_t>(&record.tag);
auto value = reinterpret_cast<std::uintptr_t>(&record.value);
auto active = reinterpret_cast<std::uintptr_t>(&record.active);
std::cout << "value offset: " << value - base << '\n';
std::cout << "active offset: " << active - base << '\n';
}不要关注每次运行的绝对地址。
关注相对偏移是否和 offsetof 一致。
6. 结构体对象不等于文件格式
6.1 序列化
序列化是把对象转换为可保存或传输的字节。
内存对象
|
| serialize
v
文件或网络字节直接写入:
// 不要轻易这样做
write(fd, &record, sizeof(record));可能把这些实现细节写入文件:
- padding;
- 字段宽度;
- 字节序;
- 指针值;
- 编译器布局。
6.2 显式写字段
#include <cstdint>
#include <vector>
void append_u32_be(std::vector<std::uint8_t>& output,
std::uint32_t value) {
output.push_back(static_cast<std::uint8_t>((value >> 24) & 0xff));
output.push_back(static_cast<std::uint8_t>((value >> 16) & 0xff));
output.push_back(static_cast<std::uint8_t>((value >> 8) & 0xff));
output.push_back(static_cast<std::uint8_t>(value & 0xff));
}>> 是右移。
& 0xff 保留低八位。
代码较长,但字节序意图清楚。
6.3 文件格式还需要什么
稳定格式至少要规定:
字段顺序
|
字段宽度
|
字节序
|
编码
|
版本
|
未知字段处理方式结构体内存布局只是实现细节,不自动是协议。
7. 联合体:多个成员共享存储
7.1 最小例子
union Number {
int integer;
double real;
};联合体更接近:
+----------------------+
| integer 或 real |
+----------------------+不是:
+----------+----------+
| integer | real |
+----------+----------+7.2 联合体大小
#include <iostream>
union Number {
int integer;
double real;
};
int main() {
std::cout << sizeof(Number) << '\n';
std::cout << alignof(Number) << '\n';
}大小需要能够容纳最大的成员,并满足对齐要求。
7.3 活跃成员
Number number{};
number.integer = 42;可以把 integer 理解为当前使用的成员。
再写:
number.real = 3.14;同一存储改为按 real 使用。
写 integer
|
v
按 int 解释
写 real
|
v
按 double 解释不能认为两个成员各自拥有独立值。
7.4 union 没有自动 tag
union Payload {
int integer;
double real;
};它没有自动记录当前成员。
需要额外标签:
enum class PayloadKind {
integer,
real
};
struct TaggedPayload {
PayloadKind kind;
Payload payload;
};关系是:
TaggedPayload
+-- kind: 当前类型
+-- payload: 共享存储手写版本必须保证两者始终同步。
7.5 std::variant
#include <variant>
using Payload = std::variant<int, double>;
Payload payload = 42;
payload = 3.14;访问:
#include <iostream>
std::visit([](const auto& value) {
std::cout << value << '\n';
}, payload);std::variant 管理:
- 当前选择;
- 成员对象生命周期;
- 访问接口。
业务代码通常优先使用它。
7.6 非平凡成员
union TextValue {
int number;
std::string text;
};std::string 可能拥有堆内存。
手动切换成员需要处理:
- 构造;
- 析构;
- 复制;
- 移动;
- 异常。
这也是裸 union 容易出错的原因。
7.7 类型双关的边界
union Bits {
float value;
std::uint32_t bits;
};不要把“某个编译器上能读出结果”当成可移植规则。
观察对象表示可以用:
#include <cstring>
float value = 1.0f;
std::uint32_t bits{};
std::memcpy(&bits, &value, sizeof(value));memcpy 表达复制字节表示,而不是同时激活两个不同对象。
8. 类型别名:给已有类型换名字
8.1 using
using UserId = std::uint64_t;UserId user_id = 42;名字更有语义,但仍是 std::uint64_t。
std::uint64_t raw = user_id;通常可以直接通过。
8.2 别名不是强类型
using UserId = std::uint64_t;
using OrderId = std::uint64_t;
void load_user(UserId id);
OrderId order_id = 100;
load_user(order_id); // 通常可以通过编译器看到的是同一个底层类型。
UserId
+-- uint64_t 的别名
OrderId
+-- uint64_t 的别名8.3 强类型包装
struct UserId {
std::uint64_t value{};
};
struct OrderId {
std::uint64_t value{};
};
void load_user(UserId id);现在:
OrderId order_id{100};
// load_user(order_id); // 类型不匹配更明确的类型会增加少量代码。
但它让“用户 ID 不要当订单 ID”成为编译器可检查的规则。
8.4 别名适合的场景
using Callback = void (*)(int);
using Scores = std::vector<double>;别名适合简化复杂类型。
别名模板:
template <typename T>
using Buffer = std::vector<T>;需要隔离业务概念时,使用新 struct 或 class。
8.5 typedef
旧写法:
typedef unsigned long long UserId;现代 C++ 更常见:
using UserId = unsigned long long;二者都是别名,不会创建新类型。
9. 位域:把字段压进若干位
9.1 最小例子
struct Flags {
unsigned ready : 1;
unsigned mode : 3;
};冒号后的数字叫位宽。
ready : 1
+-- 尝试使用一位
mode : 3
+-- 尝试使用三位9.2 位域的边界
位域布局可能受实现影响:
- 位从高位还是低位分配;
- 存储单元多大;
- 跨单元怎样处理;
- 对齐怎样处理。
因此不应直接把位域当成跨平台网络包。
9.3 显式掩码
#include <cstdint>
constexpr std::uint8_t ready_mask = 0b00000001;
constexpr std::uint8_t mode_mask = 0b00001110;
std::uint8_t flags = 0;
flags = static_cast<std::uint8_t>(flags | ready_mask);
bool ready = (flags & ready_mask) != 0;| 是按位或,适合设置位。
& 是按位与,适合提取位。
这种写法较长,却把协议表示写在代码中。
10. 完整例子:一条任务记录
#include <cstdint>
#include <iostream>
#include <string>
#include <variant>
enum class Priority : std::uint8_t {
low = 1,
normal = 2,
high = 3
};
struct UserId {
std::uint64_t value{};
};
using Payload = std::variant<int, std::string>;
struct Task {
UserId owner{};
Priority priority{Priority::normal};
Payload payload{0};
};
void print_task(const Task& task) {
std::cout << "owner: " << task.owner.value << '\n';
std::cout << "priority: "
<< static_cast<int>(task.priority) << '\n';
std::visit([](const auto& value) {
std::cout << "payload: " << value << '\n';
}, task.payload);
}
int main() {
Task task{
UserId{7},
Priority::high,
std::string{"learn object layout"}
};
print_task(task);
}对象关系:
Task
+-- owner: UserId
| +-- value: uint64_t
|
+-- priority: Priority
|
+-- payload: int 或 string保存为 task_types.cpp。
最简单命令:
g++ task_types.cpp -o task_types如果编译器默认标准较旧:
g++ -std=c++17 task_types.cpp -o task_types运行:
./task_types可能输出:
owner: 7
priority: 3
payload: learn object layout这里每个类型都承担了自己的语义:
Priority
+-- 有限选项
UserId
+-- 身份概念
Task
+-- 相关字段组合
variant
+-- 多种 payload 之一11. 常见误解
11.1 结构体大小就是成员大小相加
不一定。
对齐和 padding 会影响大小。
用 sizeof、alignof 和 offsetof 验证。
11.2 结构体内存可以直接写进文件
不一定。
布局、字节序、宽度和 padding 可能不同。
11.3 enum class 只能有列出的值
不一定。
整数显式转换仍可能产生没有对应枚举项的值。
外部输入要验证。
11.4 union 的成员同时保存
不是。
成员共享存储,不能按结构体那样理解。
11.5 using 创建强类型
不是。
using UserId = int 只是换名字。
11.6 位域是稳定协议
不是。
位域布局具有实现相关部分。
11.7 struct 只能放数据
不是。
struct 也可以有构造函数、成员函数和私有成员。
11.8 结构化绑定总是复制
不一定:
auto [x, y] = point;
auto& [rx, ry] = point;前者通常是独立绑定,后者绑定到原对象。
12. 调试和验证:不要靠猜
12.1 static_assert
static_assert(sizeof(std::uint8_t) == 1);static_assert 在编译期检查条件。
如果确实依赖当前布局,可以写:
static_assert(sizeof(MessageHeader) == 8);它只锁定当前构建假设。
12.2 观察对象布局
验证顺序:
先提出模型
|
v
用 sizeof / offsetof 观察
|
v
用调试器查看字段地址
|
v
确认语言保证还是 ABI 行为不要从一次实验直接推出所有平台结论。
12.3 三个边界问题
这是 C++ 标准保证的吗?
对象要不要跨机器保存?
这是性能优化,还是公开 ABI?如果跨机器保存,就应设计显式序列化。
如果公开 ABI,就要谨慎改变字段顺序和类型。
13. 练习
练习一:改造状态函数
把:
void set_state(int state);改为:
enum class State {
idle,
busy,
stopped
};
void set_state(State state);回答:
- 调用点是否更清楚?
- 外部整数在哪里校验?
- 非法状态如何处理?
练习二:观察 padding
定义成员顺序不同的三个结构体。
打印:
sizeof(T)
alignof(T)
offsetof(T, member)先猜,再运行,再解释差异。
练习三:实现强类型 ID
定义:
struct UserId {
std::uint64_t value;
};
struct OrderId {
std::uint64_t value;
};尝试把 OrderId 传给需要 UserId 的函数。
练习四:比较 union 和 variant
写一个手动 tag + union 版本。
再写一个 std::variant 版本。
列出手动版本必须维护的生命周期和一致性规则。
练习五:稳定字节格式
定义一个消息头:
version: 1 byte
kind: 1 byte
length: 4 bytes不要直接写结构体内存。
逐字段写入,并明确字节序。
14. 本篇总结
枚举
+-- 给有限选择起名字
结构体
+-- 把相关字段组织为一个对象
联合体
+-- 让候选成员共享一片存储
类型别名
+-- 给已有类型换一个更有语义的名字底层链路:
语义
|
v
C++ 类型
|
v
对象表示
|
v
内存布局、对齐和 padding
|
v
编译器、ABI 和 CPU 约束| 工具 | 表达什么 | 是否创建新类型 | 跨平台布局是否自动稳定 |
|---|---|---|---|
enum class | 有限状态 | 是 | 否 |
struct | 相关字段 | 是 | 否 |
union | 共享存储 | 是 | 否 |
using | 已有类型的新名字 | 否 | 不适用 |
强类型 struct | 业务概念隔离 | 是 | 否 |
15. 下一篇连接
本文讨论一个源文件内部怎样表达数据。
下一篇转向多个源文件之间的组织:
多个 .cpp
|
v
头文件和 #include
|
v
预处理后的翻译单元
|
v
命名空间、名字查找和链接下一篇:预处理、头文件与命名空间