声明:由于身体报恙,本文使用 AIGC 辅助创作。
二更:人工审稿后发现此文过程全错,但至少结果是对的。 烦请各位高抬贵手,将就看看吧。确实没精力改了。
今天刷算法题,题目要求从 stdin 读入一棵树,具体格式是前序遍历, 空子结点用 -1 表示,于是有了如下代码:
std::function<int()> buildTree = [&]() {
int val;
if (!(std::cin >> val) || (val == -1)) {
return 0;
}
int mlc_copy = mlc++;
if (mlc_copy >= cont.size()) {
cont.emplace_back(0);
lcd.emplace_back(0);
rcd.emplace_back(0);
}
cont[mlc_copy] = val;
lcd[mlc_copy] = buildTree();
rcd[mlc_copy] = buildTree();
return mlc_copy;
};
这其中,cont, lcd 和 rcd 分别是三个
std::vector<int> 类型变量,分别存储
结点的值、左子索引和右子索引。
没有对应孩子时,索引部分存 0。
而 mlc 则是 memory alloc 的缩写,
其在此处的意义是从三数组中分配出一个未
被使用的索引给这个新读到的结点。
值得注意的是,为了节省空间,此处的 cont,
lcd 和 rcd 三个数组在开始时都相当小,
在空间不足时会自动扩容。
该函数读取一个结点,然后递归构建其左右 子树,存下子树根结点索引,最后返回自身根索引。
但这段代码在实际使用中在
lcd[mlc_copy] = buildTree(); 一行出现
段错误,线上 OJ 平台也挂了几个测试点,
有 SIGSEGV 也有 WA。
乍一看,这像是经典的
"std::vector 扩容导致引用失效"问题:
lcd[mlc_copy] 返回一个 int&(本质是指向
vector 内部缓冲区的指针),然后递归调用
buildTree() 触发 emplace_back → 缓冲区
重新分配 → 老指针变为悬垂 → 写入时爆炸。
但真的这么简单吗?
等等,= 的运算顺序是怎样的?
lcd[mlc_copy] = buildTree() 这条语句中,
左右操作数谁先求值?
很多人(包括最初的我)第一反应是: "赋值当然先算左边再算右边啊"。但 C++ 标准并不这么保证——至少老的版本不保证。
对于 operator=:
- C++17 之前:左、右操作数的求值顺序 未指定(unspecified)。编译器可能先算左边, 也可能先算右边,完全取决于实现。
- C++17 起:标准明确规定右操作数 先于左操作数求值,所有副效应在左侧求值 之前完成。
本地为什么能过?OJ 为什么炸?
来看本地编译环境:
$ g++ --version
g++ (GCC) 16.1.1
$ g++ -dM -E -x c++ /dev/null | grep __cplusplus
#define __cplusplus 202002L // 默认 C++20
本地 g++ 16 默认 C++20。= 的求值顺序
遵守 C++17 规约:
- 先求值
buildTree()→ 递归执行完毕, 该扩容的扩容完了,所有 vector 存储稳定。 - 再求值
lcd[mlc_copy]→ 拿到一份** 稳定存储上**的引用。 - 写入 → 安全。
所以本地跑一万遍都不会出问题。
但线上 OJ 通常用的什么编译器?看这题目
风格,多半是老旧的 g++ 5.x/7.x,默认 C++14
甚至 C++11。在这些标准下编写者,
= 左右两边的求值顺序是未指定的。
如果编译器选择了这样求值:
- 先求值
lcd[mlc_copy]→ 拿到当前存储 (容量已满,即将扩容)上的引用。 - 再求值
buildTree()→emplace_back触发扩容 → vector 扔掉旧 buffer,搬到新 位置 → 步骤 1 的引用指向已释放的旧内存。 - 往悬垂指针写入 → 未定义行为 (有的跑出 SIGSEGV,有的恰好写到旧内存里 看起来像 WA,全看编译器心情和内存布局)。
这不是编译器 bug,是标准版本差异。
也不是「vector 扩容导致悬垂引用」这个问题 本身有什么猫腻——这确实是 vector 扩容导致 悬垂引用,问题的奇诡之处在于它只在老的 C++ 标准下才会发生。在 C++17 及以后, 求值顺序的保障让它意外地变得安全了。
修复
把递归调用和写入拆开,对所有标准版本都安全:
int lchild = buildTree();
int rchild = buildTree();
lcd[mlc_copy] = lchild;
rcd[mlc_copy] = rchild;
写左值时 vector 存储已经稳定,无论编译器 以什么顺序求值,都不会有悬垂引用。
教训
C++ 的语言细节很多。= 的求值顺序看似
基础到没人会去查,但在递归 + 扩容的语境下
就成了你摸不着头脑的 bug。本地能过真的
只是因为你有个好编译器(或者说,好的默认标准)。
求求学校用点 C++17 这种现代玩意吧。