我见过一种挺烦的 bug。
程序不崩。
也不报警。
输出看着还挺像回事。
但你其实是在用一种类型的眼镜。
去读另一种类型的内存。
如果你只学过 C,这事多半发生在 union 上。
如果你刚开始写 C++,第二次坑往往来自:把 std::string 塞进 union。
先补一个关键背景:union 不等于“同时拥有所有成员”
union 的核心规则其实就一句。
同一时刻,只能有一个成员处于“有效状态”。
你读错了成员。
就是未定义行为(UB)。
看个小例子。
union U1 { int i; double d; };
U1 u;
u.i = 0x3f800000;
double x = u.d;
use_double(x);
这段代码看起来像“类型转换”。
但它不是。
它是把 int 的字节按 double 的格式去解释。
结果可能是乱七八糟的数,也可能碰巧没露馅。
UB 就是这种味道。
旧写法:tagged union(C 风格)
直觉做法就是打个补丁。
加一个 tag,自己记住“现在是哪种”。
enum class Kind { Int, Double };
struct V {
Kind kind;
union { int i; double d; } u;
};
这在 C 里很常见。
但它把一个必须成立的不变量交给了你:
kind 必须永远匹配 u 里当前活着的成员。
举个小例子。
V v;
v.kind = Kind::Int;
v.u.d = 3.14;
use_int(v.u.i);
你看到的不是 3。
也不是 314。
你看到的是“把 double 的字节当 int 读出来”的碎片。
这类 bug 往往不会立刻崩。
它只会让你线上出鬼数据。
读者常见疑问:那我每次都 switch(kind) 不就好了?
你当然可以。
void handle(const V& v) {
if (v.kind == Kind::Int) use_int(v.u.i);
else use_double(v.u.d);
}
问题是。
这个 if 会散落到很多地方。
你新增一个 Kind::String,总有地方忘了改。
tag 和数据就再次分家。
C++ 的第二次踩坑:union 里放非平凡类型
到了 C++,你很快就不满足 const char* 了,会想换成 std::string。
这时你会碰到一个硬事实:
std::string 需要构造和析构。
而 union 不会替你自动做对。
先把“正确姿势”写出来。
#include <string>
union U2 {
int i;
std::string s;
U2() {}
~U2() {}
};
这个 U2() / ~U2() 看起来有点怪。
它的意义很朴素:这段 union 自己不会帮你自动构造/析构 std::string。
接下来你得明确告诉它:现在活着的是 s。
U2 u;
new (&u.s) std::string("hi");
use_string(u.s);
u.s.~basic_string();
new (&u.s) T(...) 这种写法叫 placement new。
就是“在这块地址上原地构造一个对象”。
这就意味着:
只要你忘了一次析构。
就泄漏。
只要你析构错了成员。
就 UB。
更隐蔽的坑:切换成员时,顺序也能把你坑死
现在我们把它塞进 tagged union。
很多人会这么写 setter。
#include <string>
enum class Kind2 { Int, String };
struct V2 {
Kind2 kind = Kind2::Int;
union U { int i; std::string s; U() {} ~U() {} } u;
};
我只写最要命的两行。
先改 tag。
再构造 string。
void set_string_bad(V2& v, const char* p) {
v.kind = Kind2::String;
new (&v.u.s) std::string(p); // 这里可能抛异常
}
对比一下更稳的顺序。
void set_string_ok(V2& v, const char* p) {
new (&v.u.s) std::string(p);
v.kind = Kind2::String;
}
我这里故意省略了“如果之前是 string,要先析构”的那堆细节。
真实代码里,你还得在切换前把旧的 std::string 正确析构掉。
因为核心点不是“代码长什么样”。
而是顺序:
你到底是先改 tag。
还是先把新对象构造出来。
如果构造过程可能抛异常(std::string 就可能)。
你先改 tag,就可能留下一个“tag 说是 string,但 string 没构造成功”的半残对象。
这不是崩溃。
这是状态机被你写坏了。
所以我们到底缺什么?
你缺的不是“更小心”。
你缺的是一个机制。
让 tag 和对象生命周期永远绑在一起。
你写不出“tag 说是 string,但里面没 string”的状态。
来源借鉴:Boost.Variant / sum type
这事在 C++17 之前就有人解决过。
Boost 里有 boost::variant。
函数式语言里更早就有 sum type(和类型)。
它们做的事情很像:
把“可能的类型集合”固定成一个类型。
然后把“当前是哪种”以及“该怎么销毁/拷贝/移动”封装起来。
C++17:std::variant
std::variant 就是标准库版本。
先来最小用法。
#include <variant>
#include <string>
using Value = std::variant<int, std::string>;
Value v = 42;
v = std::string("hi");
你不需要手写 tag。
也不需要手写析构。
它保证:
当前活着的对象类型,和它记录的“当前是哪种”是一致的。
概念拆解:get / get_if
新手第一反应是 std::get<T>。
// int x = std::get<int>(v); // 类型不对会抛异常
它没错。
但如果你想写出“像 C 的 switch(kind)”那种控制流。
我更推荐 std::get_if。
if (auto p = std::get_if<int>(&v)) {
use_int(*p);
}
类型对就给指针。
类型不对就给 nullptr。
这比把控制流写成异常更顺手。
概念拆解:visit(把分支收口到一个地方)
当你需要覆盖所有备选类型。
std::visit 就是“类型安全的 switch”。
std::visit([](const auto& x) {
use(x);
}, v);
这里的 x 可能是 int。
也可能是 std::string。
编译器会为每个备选类型生成对应分支。
如果你写成“每个类型一个分支”的重载形式,漏掉一种类型时,往往能在编译期被拽回来。
概念拆解:monostate(把“空值”也做成一种状态)
当年我们会加一个 Invalid。
在 variant 里也一样。
using MaybeInt = std::variant<std::monostate, int>;
MaybeInt m = std::monostate{};
这表示。
要么空。
要么一个 int。
一个边界:valueless_by_exception
variant 通常很稳。
但在少数“赋值/切换类型过程中抛异常”的路径上,它可能变成空壳。
if (v.valueless_by_exception()) {
recover();
}
你不一定会遇到。
但你做核心链路时,知道它存在会更安心。
横向对比:variant / any / 虚函数
variant 适合“类型集合是封闭的”。
也就是:你能在代码里列出所有可能类型。
你想要的是编译期的穷尽检查。
std::any 适合“类型集合是开放的”。
代价是:错误往往变成运行期(比如 any_cast 失败)。
虚函数/继承适合“你关心的是行为而不是数据形状”。
代价是:你更可能走到指针语义、堆分配、对象寿命管理那套复杂度里。
迁移建议:把手搓 tagged union 换成 variant 的最小改法
你不需要一次性重构所有逻辑。
先把数据结构换掉。
using Value = std::variant<int, std::string>;
然后把“读数据”的地方从 switch(kind) 收口成两类。
if (auto p = std::get_if<int>(&v)) use_int(*p);
else if (auto s = std::get_if<std::string>(&v)) use_string(*s);
你会立刻少掉一大类“tag 写错/忘更新”的事故。
关键结论
std::variant 的价值不是“更炫”。
而是把你原本靠脑子维护的不变量。
写进了类型系统。