联合与std::variant
需要预先了解:
1. 联合(Union)
联合(union) 是一种特殊的数据类型,它允许在同一块内存空间中存储不同类型的数据,但同一时刻只能有一个成员是有效的。定义语法与 struct 类似,只是把关键字换为 union:
union Data
{
int i;
double d;
char c;
};
使用时像结构体一样访问成员,但所有成员共享同一块内存:
#include <iostream>
int main()
{
Data u;
u.i = 42; // 写入 int
std::cout << u.i << std::endl; // 输出: 42
u.d = 3.14; // 写入 double——覆盖了原来的 int
std::cout << u.d << std::endl; // 输出: 3.14
std::cout << u.i << std::endl; // 输出: 垃圾值(被 double 的字节覆盖了)
return 0;
}
当 u.d = 3.14 执行后,之前写入的 u.i 就被覆盖了。再读 u.i 拿到的是 3.14 的 IEEE 754 二进制表示被重新解释为 int 的垃圾值。
1.1 内存布局
union 的内存布局只有一条核心规则:所有成员从同一地址(offset 0)开始,union 的大小等于其最大成员的大小(再按对齐要求补齐)。
union Data (大小为 8 字节,即 sizeof(double))
┌───────────────────────────────────────────────────────────────────────┐
│ │
offset: 0 1 2 3 4 5 6 7 │
├────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┤
│◄─ int i (4字节,从 offset 0 开始) ─►│ │
├───────────────────────────────────────────────────────────────────────┤
│◄────────────────── double d (8 字节,从 offset 0 开始) ────────────────►│
├────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┤
│ char c │ │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
验证代码:
#include <iostream>
union Data
{
int i;
double d;
char c;
};
int main()
{
Data u;
std::cout << "sizeof(Data) = " << sizeof(Data) << std::endl; // 8(通常)
std::cout << "sizeof(u.i) = " << sizeof(u.i) << std::endl; // 4
std::cout << "sizeof(u.d) = " << sizeof(u.d) << std::endl; // 8
std::cout << "sizeof(u.c) = " << sizeof(u.c) << std::endl; // 1
// 所有成员的地址相同
std::cout << "&u = " << &u << std::endl;
std::cout << "&u.i = " << (void*)&u.i << std::endl;
std::cout << "&u.d = " << (void*)&u.d << std::endl;
std::cout << "&u.c = " << (void*)&u.c << std::endl;
return 0;
}
编译并运行后,四个地址完全相同——它们指向同一块内存的同一个起点。
1.2 union 的对齐规则
union 的对齐要求等于所有成员中对齐要求最严格的那一个的值,大小也需要是该对齐值的整数倍。
#include <iostream>
union Example
{
char c; // 对齐 1,大小 1
int i; // 对齐 4,大小 4
double d; // 对齐 8,大小 8
};
int main()
{
std::cout << "sizeof(Example) = " << sizeof(Example) << std::endl; // 8
std::cout << "alignof(Example) = " << alignof(Example) << std::endl; // 8
return 0;
}
double 对齐要求为 8,所以 alignof(Example) = 8;sizeof(Example) 取最大成员大小 8,正好是对齐值 8 的整数倍,所以不需要额外填充。
再看一个填充的例子:
#include <iostream>
union Padding
{
char arr[5]; // 大小 5,对齐 1
int i; // 大小 4,对齐 4
};
int main()
{
std::cout << "sizeof(Padding) = " << sizeof(Padding) << std::endl; // 8
std::cout << "alignof(Padding) = " << alignof(Padding) << std::endl; // 4
return 0;
}
最大成员 arr 占 5 字节,但 int 要求对齐 4,所以 union 大小必须对齐到 4 的边界——结果 5 → 8(补齐 3 个字节的尾部填充)。
1.3 匿名 union
匿名 union 不声明类型名,其成员直接作为外层作用域的变量来访问:
#include <iostream>
struct Message
{
int type;
// 匿名 union——成员直接视为 Message 的成员
union
{
int int_val;
float float_val;
};
};
int main()
{
Message msg;
msg.type = 1;
msg.int_val = 42; // 直接访问,不需要 msg.u.int_val
std::cout << msg.int_val << std::endl; // 输出: 42
return 0;
}
匿名 union 常用于 struct/class 内部,减少一层命名嵌套。
1.4 union 的应用场景
1. 带标签的联合(Tagged Union)
由于 union 不记录当前活跃成员是谁,通常搭配一个 标签(tag,通常用 enum) 来跟踪:
#include <iostream>
#include <string>
#include <cstring>
enum class DataType { INT, FLOAT, STRING };
union DataValue
{
int i;
float f;
char s[32]; // 固定大小的字符数组
};
struct TaggedData
{
DataType type;
DataValue value;
};
void print(const TaggedData& data)
{
switch (data.type)
{
case DataType::INT:
std::cout << "int: " << data.value.i << std::endl;
break;
case DataType::FLOAT:
std::cout << "float: " << data.value.f << std::endl;
break;
case DataType::STRING:
std::cout << "string: " << data.value.s << std::endl;
break;
}
}
int main()
{
TaggedData d1;
d1.type = DataType::INT;
d1.value.i = 100;
print(d1); // 输出: int: 100
TaggedData d2;
d2.type = DataType::STRING;
std::strcpy(d2.value.s, "Hello");
print(d2); // 输出: string: Hello
return 0;
}
这是 C 语言中实现"多类型变量"的经典模式,也是 std::variant 的设计前身。
2. 类型双关(Type Punning)
利用 union 用不同的类型去解读同一块内存,可以在不借助指针强转的情况下实现类型双关:
#include <iostream>
#include <cstdint>
union FloatBits
{
float f;
uint32_t bits;
};
int main()
{
FloatBits fb;
fb.f = 3.14159f;
// 用整数视角逐个检查 float 的 IEEE 754 二进制表示
std::cout << std::hex << "0x" << fb.bits << std::endl;
return 0;
}
注意:在 C++ 中,读取 union 中非当前活跃成员的行为是未定义行为(Undefined Behavior,UB)——尽管在 C 语言中这是合法的,而且几乎所有 C++ 编译器也"实际允许"这样做。如果你需要可靠地重新解释字节,应使用
std::memcpy或 C++20 的std::bit_cast。
// C++20:安全的类型双关
float f = 3.14159f;
uint32_t bits = std::bit_cast<uint32_t>(f);
1.5 union 的缺点
1. 完全没有类型安全
编译器不知道——也不会帮你检查——当前哪个成员是活跃的。读错成员就是 UB,所有责任都在程序员身上:
union U { int i; double d; };
U u;
u.d = 3.14;
int x = u.i; // 未定义行为!当前活跃的是 d,却读了 i
2. 不能包含"非平凡"类型的成员
在 C++ 中,如果一个类型有自定义构造函数、析构函数、拷贝/移动操作(即非平凡特殊成员函数),它不能直接作为 union 的成员。因为编译器不知道该在什么时候调用谁的构造和析构。
#include <string>
union Bad
{
int i;
std::string s; // 编译报错!std::string 有非平凡的构造/析构/拷贝
};
将得到类似下面的报错:
main.cpp:12:9: error: use of deleted function 'Bad::Bad()'
12 | Bad b;
| ^
main.cpp:4:7: note: 'Bad::Bad()' is implicitly deleted because the default definition would be ill-formed:
4 | union Bad
| ^~~
C++11 起允许 union 包含非平凡类型,但你必须手动管理生命周期——通过 placement new 构造、显式调用析构函数销毁:
#include <iostream>
#include <string>
#include <new>
union ManualString
{
int i;
std::string s;
ManualString() : i(0) {} // 默认激活 int
~ManualString() {} // 析构函数留空——手动管理!
};
int main()
{
ManualString ms;
// 激活 string 成员——使用 placement new
new (&ms.s) std::string("Hello, Union!");
std::cout << ms.s << std::endl; // 输出: Hello, Union!
// 用完必须手动析构!
ms.s.~basic_string();
return 0;
}
一旦忘记手动析构,std::string 内部在堆上分配的字符串缓冲区就会泄漏。这种写法容易出错,极少被推荐。
3. 缺乏工程安全性
- 无默认的构造/析构:编译器删除默认特殊成员函数,必须完全手写。
- 无活跃成员查询:运行时完全无法获知哪个成员是活跃的。
- 异常不安全:如果在构造新成员时抛出异常,union 的状态被破坏,不清楚谁在活跃。
- 维护困难:每新增一种成员类型,所有 switch/if-else 分支都需要同步修改,容易遗漏。
简单总结:union 是 C 语言遗产,在 C++ 中仍有用——主要用于内存受限场景和底层字节操作。但在业务逻辑中使用 union,几乎等同于放弃了 C++ 的类型系统对你的保护。
2. std::variant
std::variant 是 C++17 引入的类型安全的联合体,定义在头文件 <variant>。它解决了 union 的所有核心缺陷:自动管理生命周期、编译期类型检查、运行时查询活跃成员。
#include <variant>
#include <string>
#include <iostream>
int main()
{
// 定义一个 variant,可以存储 int 或 double 或 std::string
std::variant<int, double, std::string> v;
v = 42; // 存入 int
v = 3.14; // 替换为 double
v = std::string("Hello, Variant!"); // 替换为 std::string
return 0;
}
std::variant<int, double, std::string> 表示"这个变量可以是 int、double 或 std::string 中的任意一种"。每次赋值都会:
- 销毁旧的值(调用对应类型的析构函数)
- 在新位置构造新的值(调用对应类型的构造函数)
2.1 std::variant 的内存布局
std::variant 内部需要存放最坏情况下最大的那个类型的存储空间,再加上一个**判别器(discriminator)**来记录当前活跃的是第几个类型。
#include <variant>
#include <iostream>
#include <string>
int main()
{
using VarType = std::variant<int, double, std::string>;
std::cout << "sizeof(int) = " << sizeof(int) << std::endl; // 4
std::cout << "sizeof(double) = " << sizeof(double) << std::endl; // 8
std::cout << "sizeof(std::string) = " << sizeof(std::string) << std::endl; // 32
std::cout << "sizeof(VarType) = " << sizeof(VarType) << std::endl;
return 0;
}
内存开销总结:
std::variant<T...>的大小 ≈max(sizeof(T)...)+ 判别器大小。判断器通常占 1~8 字节,取决于类型的个数和平台的对齐规则。这意味std::variant的内存开销接近于手动 tagged union,但多了类型安全和自动生命周期管理。
2.2 访问 std::variant 的值
std::get<类型/索引>:按类型或索引取值
#include <variant>
#include <iostream>
int main()
{
std::variant<int, double, std::string> v;
v = 3.14;
// 按类型访问
double d = std::get<double>(v);
std::cout << d << std::endl; // 输出: 3.14
// 按索引访问——0=int, 1=double, 2=std::string
double d2 = std::get<1>(v);
std::cout << d2 << std::endl; // 输出: 3.14
return 0;
}
如果取错了类型,std::get 会抛出 std::bad_variant_access 异常:
#include <variant>
#include <iostream>
int main()
{
std::variant<int, double> v;
v = 42; // 当前活跃的是 int
try
{
double d = std::get<double>(v); // 错误:活跃的是 int
}
catch (const std::bad_variant_access& e)
{
std::cout << "异常: " << e.what() << std::endl;
}
return 0;
}
编译并运行后输出:
异常: bad variant access
std::get_if:安全地"尝试取值"
std::get_if<T>(&v) 返回指针而不是引用:如果当前活跃类型是 T,返回指向该值的指针;否则返回 nullptr。
#include <variant>
#include <iostream>
int main()
{
std::variant<int, double, std::string> v;
v = 42;
// 尝试取 int——成功
if (int* p = std::get_if<int>(&v))
std::cout << "int: " << *p << std::endl; // 输出: int: 42
else
std::cout << "不是 int" << std::endl;
// 尝试取 double——失败
if (double* p = std::get_if<double>(&v))
std::cout << "double: " << *p << std::endl;
else
std::cout << "不是 double" << std::endl; // 输出: 不是 double
return 0;
}
std::get_if 不抛异常,特别适合在分支逻辑中快速判断。
std::visit:对所有可能的类型执行操作
std::visit 是最强大的访问方式——它接受一个**可调用对象(访问器)**和一个 variant,编译器会自动生成所有类型的分支跳转:
#include <variant>
#include <iostream>
#include <string>
int main()
{
std::variant<int, double, std::string> v;
// 定义 visitor——重载了 operator() 的结构体
struct Visitor
{
void operator()(int i) const { std::cout << "int: " << 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; }
};
v = 42;
std::visit(Visitor{}, v); // 输出: int: 42
v = 3.14;
std::visit(Visitor{}, v); // 输出: double: 3.14
v = std::string("你好");
std::visit(Visitor{}, v); // 输出: string: 你好
return 0;
}
也可以用 C++17 的泛型 lambda 简化书写:
auto printer = [](const auto& val)
{
std::cout << "值是: " << val << std::endl;
};
std::visit(printer, v);
2.3 std::visit 的底层实现与性能
std::visit 不是简单的 if-else 链,编译器通常将其展开为跳转表(jump table):
// 编译器在内部大致生成等价于以下伪代码的汇编:
//
// switch (v.index()) {
// case 0: return visitor(get<0>(v));
// case 1: return visitor(get<1>(v));
// case 2: return visitor(get<2>(v));
// }
- 时间复杂度为 O(1)——一次索引读取 + 一次跳转,与 variant 中类型的数量无关(在合理范围内)。
- 如果 visitor 的每个
operator()返回相同类型,std::visit本身也是表达式,可直接用于赋值和return。 - 编译器还能对 visit 做内联优化,将整个调用展开为直接对活跃成员的操作。
// visit 的返回值
auto length = std::visit([](const auto& s) -> size_t
{
return std::size(s); // 适用于有 .size() 的类型
}, v);
2.4 std::variant 的安全性
编译期类型检查
std::variant 的类型列表在编译期就完全确定。如果你试图 get<NotListed>(),会在编译时报错:
std::variant<int, double> v;
v = 3.14f; // float 可隐式转为 int 或 double(此处优先匹配 double)
auto x = std::get<float>(v); // 编译报错:float 不在类型列表中
运行时类型检查
std::get在类型不匹配时抛出std::bad_variant_access(运行时检查)。std::get_if在类型不匹配时返回nullptr,不抛异常。v.index()返回当前活跃类型的索引(编译期常量),可以用于 switch。
std::variant<int, double, std::string> v;
v = 3.14;
std::cout << "当前活跃类型索引: " << v.index() << std::endl; // 输出: 1
“valueless_by_exception”——当构造失败时
如果在对 variant 赋值时:
- 先销毁了旧值
- 然后在构造新值时抛出了异常
那么 variant 会进入一个特殊的 valueless_by_exception 状态:它不持有任何合法的值。
#include <variant>
#include <iostream>
#include <string>
struct MayThrow
{
MayThrow() {}
MayThrow(const MayThrow&) { throw std::runtime_error("拷贝失败"); }
};
int main()
{
std::variant<int, MayThrow> v;
v = 42; // 当前持有 int
MayThrow mt;
try
{
v = mt; // 尝试替换为 MayThrow
}
catch (...)
{
// 构造 MayThrow 时抛异常,v 可能变成 valueless
}
if (v.valueless_by_exception())
std::cout << "variant 处于无效状态!" << std::endl;
return 0;
}
这是 variant 最危险的边缘情况。好在只有当类型列表中的类型可能抛出时才出现,且通常在异常后应当立即重建一个已知良好的值:
if (v.valueless_by_exception())
v = 0; // 用 int 重新初始化,恢复有效状态
2.5 与 union 的对比
union |
std::variant |
|
|---|---|---|
| 类型安全 | 完全无,读错即 UB | 编译期+运行时双重检查 |
| 非平凡类型 | C++11 前不支持;之后需手动管理生命周期 | 完全支持 |
| 活跃成员查询 | 无,必须手动标记 | v.index() / std::holds_alternative |
| 内存开销 | sizeof(最大成员) + 对齐 | sizeof(最大成员) + 判别器(1~8 字节) |
| 访问方式 | 直接 .member |
get / get_if / visit(异常安全) |
| 新增类型成本 | 改 enum + 改所有 switch 分支 | 改类型列表 + 编译器强制补全 visitor |
| 运行时性能 | 直接内存访问,零开销 | get/visit 几乎零开销(跳转表 O(1)) |
| 适用场景 | 底层字节操作、C 接口兼容 | 业务逻辑、状态机、多态替代 |


