声明:由于身体报恙,本文使用 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, lcdrcd 分别是三个 std::vector<int> 类型变量,分别存储 结点的值、左子索引和右子索引。 没有对应孩子时,索引部分存 0。 而 mlc 则是 memory alloc 的缩写, 其在此处的意义是从三数组中分配出一个未 被使用的索引给这个新读到的结点。

值得注意的是,为了节省空间,此处的 cont, lcdrcd 三个数组在开始时都相当小, 在空间不足时会自动扩容。

该函数读取一个结点,然后递归构建其左右 子树,存下子树根结点索引,最后返回自身根索引。

但这段代码在实际使用中在 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 规约:

  1. 先求值 buildTree() → 递归执行完毕, 该扩容的扩容完了,所有 vector 存储稳定。
  2. 再求值 lcd[mlc_copy] → 拿到一份** 稳定存储上**的引用。
  3. 写入 → 安全。

所以本地跑一万遍都不会出问题。

但线上 OJ 通常用的什么编译器?看这题目 风格,多半是老旧的 g++ 5.x/7.x,默认 C++14 甚至 C++11。在这些标准下编写者, = 左右两边的求值顺序是未指定的。

如果编译器选择了这样求值:

  1. 先求值 lcd[mlc_copy] → 拿到当前存储 (容量已满,即将扩容)上的引用。
  2. 再求值 buildTree()emplace_back 触发扩容 → vector 扔掉旧 buffer,搬到新 位置 → 步骤 1 的引用指向已释放的旧内存。
  3. 往悬垂指针写入 → 未定义行为 (有的跑出 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 这种现代玩意吧。