C++ 的 union 从来不是一个能随便装任何类型的容器。它在底层只是让所有成员共享同一块起始地址的存储区域,而这块存储上到底运行着哪个对象的构造函数、析构函数、拷贝逻辑,编译器并不会替你判断。因此问题“联合体可以包含类吗”的答案要分两层:语法上 C++11 以后可以包含很多类类型,但语义上,如果这个类不是平凡类型,写入和销毁都必须由开发者手动控制。

要理解这一点,需要同时关注成员类型限制、对象生命周期管理和现代替代方案。很多看起来“能编译”的 union 写法,实际上会在运行时埋下崩溃或内存泄漏的隐患。
一、union 成员的基本限制:平凡类型与生命周期
在 C++11 之前,union 的非静态数据成员基本上必须是平凡类型,也就是没有用户提供的构造函数、析构函数、拷贝构造函数、拷贝赋值运算符以及虚函数。这个限制的原因很直接:union 的所有成员共用一段内存,编译器无法知道在任意时刻哪一个成员是活跃的,因此不能自动调用构造和析构函数。如果允许 std::string 这样的非平凡类型直接进入 union,编译器就不知道该在什么时候构造它、什么时候销毁它。
C++11 放宽了这个限制。现在 union 中的成员可以是具有非平凡特殊成员函数的类类型,例如 std::string、std::vector<int> 等。但放宽只是允许声明,不会自动生成正确的生命周期管理代码。一旦 union 中存在非平凡类型成员,union 自身的默认构造函数、复制构造函数、复制赋值运算符和析构函数都会变成弃置的(deleted),除非你显式定义它们。与此同时,union 本身仍然不能有虚函数、不能有基类,成员也不能是引用类型。下面这个声明只会在你补上构造函数和析构函数后才可能通过编译:
#include <string>
union U {
int n;
std::string text;
U() { new (&text) std::string; }
~U() { text.~basic_string(); }
};
这段代码虽然能编译,却并不安全。构造函数无条件把 text 构造出来,意味着 int 成员从未被激活。如果之后执行 u.n = 10;,就会覆盖一个正处在生命周期内的 std::string 对象,造成资源泄漏,且该 string 的析构函数不会执行。这说明了一个关键点:union 本身不会维护“当前活跃成员”的状态,这个状态必须由你自己维护。
另一个容易混淆的地方是标准布局。C++ 标准对 union 有标准布局的要求,但标准布局并不意味着成员必须平凡。真正影响 union 易用性的,是成员类型的平凡性以及特殊成员函数是否会被抑制。因此,判断一个类能不能安全放进 union,不能只看“有没有构造函数”,而要看放进 union 后,程序是否能在正确的时间点调用构造、析构和复制逻辑。
二、手动管理:placement new 与显式析构
在 union 中安全使用非平凡类成员的核心思路,是把对象生命周期管理完全接管过来。你需要三样东西:一个记录活跃成员类型的标记、一个在指定地址上构造对象的 placement new,以及一个显式析构调用。placement new 的语法是 new (&data_.s) std::string(v),它并不会分配新的堆内存,而是在 data_.s 已经占用的 union 存储地址上调用 std::string 的构造函数。使用结束后要手动写类似 data_.s.~basic_string(); 的析构调用,std::string 内部的堆缓冲区才会被释放。
下面是一个较完整的封装示例,它把 union 藏在一个普通类里,用枚举记录当前活跃成员,并实现拷贝构造和拷贝赋值:
#include <string>
#include <new>
#include <utility>
class Value {
public:
enum Type { Int, Str };
Value() : type_(Int) { new (&data_.i) int(0); }
explicit Value(int v) : type_(Int) { new (&data_.i) int(v); }
explicit Value(const std::string& v) : type_(Str) {
new (&data_.s) std::string(v);
}
~Value() { destroy(); }
Value(const Value& other) : type_(other.type_) {
if (type_ == Int) new (&data_.i) int(other.data_.i);
else new (&data_.s) std::string(other.data_.s);
}
Value& operator=(const Value& other) {
if (this == &other) return *this;
destroy();
type_ = other.type_;
if (type_ == Int) new (&data_.i) int(other.data_.i);
else new (&data_.s) std::string(other.data_.s);
return *this;
}
Type type() const { return type_; }
int as_int() const { return data_.i; }
const std::string& as_string() const { return data_.s; }
private:
union Storage {
Storage() {}
~Storage() {}
int i;
std::string s;
} data_;
Type type_;
void destroy() {
if (type_ == Str) data_.s.~basic_string();
}
};
这段代码的核心动作都围绕 type_ 展开。构造函数根据调用方传入的值决定要激活哪个成员,并用 placement new 启动对应对象的生命周期。析构函数不能直接写 delete 或依赖成员本身,因为 union 成员不会被自动析构;它必须检查 type_,只有在当前活跃成员是 std::string 时才调用 data_.s.~basic_string()。复制构造函数和复制赋值运算符也必须先销毁旧成员,再根据源对象状态重新构造目标成员。如果缺少移动构造函数、移动赋值运算符,这个类在传递时可能会走多余的拷贝路径,但至少不会崩溃。
从实践角度看,这种手写方式非常容易出错。切换成员前忘记析构旧成员、拷贝时没有处理自赋值、新增一个成员类型却漏改了 destroy(),都会导致未定义行为。因此在现代 C++ 中,除非有明确的性能或内存布局需求,否则不推荐手写 union 来管理非平凡类型。
三、匿名联合体、公共初始序列与类型双关
匿名 union 是一种常见的简化写法。它允许成员名字直接暴露到外围作用域,比如:
struct Token {
enum { Int, Double } type;
union {
int i;
double d;
};
};
这里 i 和 d 不需要再加一层名字,可以直接通过 Token 对象访问。匿名 union 对平凡类型非常友好,常用于表示 Variant 风格的数据结构。但匿名 union 同样不会自动管理非平凡成员的构造和析构。如果在匿名 union 中放入 std::string,外围类的构造函数和析构函数仍然需要手动判断活跃成员并显式调用对应操作,代码量不会减少多少。
公共初始序列是与 union 相关的另一个话题。C++ 允许在 union 中放置多个结构体,如果它们开头的若干成员类型一致、顺序一致,就可以通过任意一个成员读取这些公共字段。这个特性主要用来兼容 C 的结构体复用技巧。不过这种“类型双关”经常被滥用。比如下面这种写法:
struct Vec2 { float x, y; };
struct Point { float x, y; };
union Repr {
Vec2 v;
Point p;
};
Repr r;
r.v.x = 1.0f;
// 读取 r.p.x 在 C++ 严格别名规则下并不总是合法
即使两个结构体长得一模一样,通过非活跃成员读取数据也可能违反 C++ 的严格别名规则,除非满足标准布局且访问的确实是公共初始序列。实际开发中,想查看对象二进制表示或做安全类型转换,应优先使用 memcpy 或 C++20 的 std::bit_cast,而不是依赖 union 的类型双关,因为后者在不同编译器、不同优化级别下可能产生不同结果。
四、现代替代方案:std::variant 与选择建议
C++17 引入的 std::variant<int, std::string> 是用模板实现的一个类型安全联合体。它在内部维护一个索引,自动知道当前活跃成员是哪一个,并在构造、析构、复制、移动时自动调用对应类型的特殊成员函数。使用方式也比手写 union 直观得多:
#include <variant>
#include <string>
#include <iostream>
int main() {
std::variant<int, std::string> value;
value = 42;
if (std::holds_alternative<int>(value)) {
std::cout << std::get<int>(value) << '\n';
}
value = std::string("hello");
std::cout << std::get<std::string>(value) << '\n';
return 0;
}
相比手动 union,std::variant 有几个明显优势:它会自动管理对象的生命周期;用 std::get 访问错误类型时会抛出 std::bad_variant_access 异常;配合 std::visit 可以实现对所有可能类型的统一处理。代价是 variant 通常会多存储一个表示当前活跃类型的索引,并且类型访问有一定的间接成本。如果这些开销可以接受,variant 是绝大多数业务代码里更合理的选择。
但 union 并没有完全失去价值。在嵌入式系统、驱动开发、网络协议解析、C ABI 接口封装、以及需要精确控制对象内存布局的场景中,手写 union 仍然非常常见。这些场景通常只涉及平凡类型,或者已经用额外的状态字段严格维护了活跃成员,因此不需要 variant 的运行时索引。关键是要分清什么时候内存布局和 ABI 兼容优先,什么时候类型安全和代码可维护性优先。
常见“能编译但会踩坑”的写法是在 union 中不停切换成员,却没有先析构旧成员。下面的例子构造了 int 成员,然后直接给 std::string 成员赋值,这就是典型的未定义行为:
#include <string>
union BadUnion {
int n;
std::string text;
BadUnion() : n(0) {}
~BadUnion() {}
};
int main() {
BadUnion u;
u.n = 10; // int 活跃
u.text = "abc"; // text 未构造,这里会触发未定义行为
}
因此,如果你的类包含非平凡成员,一定不要在 union 外面绕过 placement new 直接赋值。正确路径永远是:先判断当前活跃成员,析构它,然后用 placement new 构造新成员,同时更新活跃标记。对于大多数上层 C++ 项目来说,把这种复杂度交给 std::variant 往往是更稳妥的选择。
C++联合体联合体成员限制placement new修改时间:2026-09-20 08:01:25