Menu

C++ 未定义行为是什么?常见原因与避免方法

未定义行为(UB)是 C++ 标准没有规定任何规则的代码:它可能崩溃、破坏数据,也可能看起来一切正常。了解常见原因,为什么“运行正常”什么也证明不了,以及能捕获 UB 的工具。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

“未定义行为”到底是什么意思

上一页展示了 try/catch 如何处理你的程序定义并有意抛出的错误。未定义行为正好相反:它是 C++ 标准拒绝赋予任何含义的那一类操作。没有可以捕获的异常,没有错误码,也不保证一定会崩溃。编译器可以假设 UB 永远不会发生,而当它真的发生时,编译器想怎么做都可以。

正是这种自由让 UB 如此危险。同一行有 bug 的代码,可能在你的笔记本上打印出“正确”答案,在服务器上返回垃圾值,在 -O2 下被优化器整个删掉。UB 不是“我们没写进文档的行为”,而是“语言对此不做任何承诺的行为”。你的任务是从一开始就不写出它。

int arr[3] = {1, 2, 3};
int x = arr[5];   // undefined behavior: reading past the end of the array

这里没有编译错误,很多次运行时它会悄悄给你一个随机的整数。这种表面上的成功正是陷阱所在。

越界读写

最常见的 UB 是访问不属于你的内存。内置数组和 std::vector::operator[] 不做任何边界检查:超过末尾的下标(或负数下标)立即就是 UB,无论你是读还是写。

要留意的 bug 是本该写 < 却写成了 <=:当 i == v.size() 时,你访问的是最后一个元素之后的位置,这就是 UB。不需要下标时,优先使用基于范围的 for 循环(前面讲过),它不可能越过末尾。当你确实要手动用下标访问并想要一张安全网时,v.at(i) 会抛出 std::out_of_range,而不是悄悄破坏内存:

排查 bug 时用 at();一旦确认下标都有效,在热点循环里再换回 []。

悬空指针与释放后使用

一个比它所指对象活得更久的指针或引用,就是悬空的。使用它就是 UB:那块内存可能已被重用、已被释放,或者根本不存在。这正是智能指针(上一章内容)帮你避开的陷阱,但原始指针仍然会让你掉进去。

最典型的情况是返回局部变量的地址。函数返回时局部变量就销毁了,调用者手里拿着的是一个指向虚无的指针:

int* makeNumber() {
    int n = 42;
    return &n;   // returns address of a local - it's gone after return
}
// Dereferencing the result is undefined behavior.

在 delete 之后,或者 vector 重新分配内存、让指向它内部的迭代器或指针失效之后,也会发生同样的事情:

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: undefined behavior

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // may reallocate - 'first' is now dangling
cout << *first;   // undefined behavior

防御手段你已经知道了:只要还有指针需要某个对象,就让它保持存活;优先使用引用和智能指针,而不是拥有所有权的原始指针;在任何可能改变容器大小的操作之后,重新获取指针或迭代器。

未初始化变量与有符号溢出

对内置类型来说,在赋值之前读取变量就是 UB:没有默认的 0。变量里装的是那块内存中原本就有的任意比特,而优化器可能假设你永远不会读取未初始化的值。

如果 sum 只是写成 int sum;,每次 sum += i 都会先读取一个不确定的值,这就是 UB,而且是出了名难查的 bug,因为它常常看起来能正常工作。养成初始化的习惯:int x = 0; 或 int x{};。

另一个悄无声息的元凶是有符号整数溢出。让有符号 int 超过它的最大值是 UB(无符号类型会按可预测的方式回绕,有符号类型则不会):

int big = 2147483647;   // INT_MAX on a 32-bit int
int oops = big + 1;     // signed overflow: undefined behavior

不要指望它会“回绕成负数”:编译器可以假设溢出不会发生,并据此进行优化。如果你需要有定义的回绕,请使用无符号类型,或者在相加之前先检查边界。

用 Sanitizer 和警告捕获 UB

你无法靠测试获得对 UB 的信心,因为一次成功的运行什么也保证不了。真正有效的办法,是用编译器的 Sanitizer(GCC 和 Clang 都提供)让 UB 在运行时大声暴露出来。

// AddressSanitizer: out-of-bounds, use-after-free, leaks
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: signed overflow, null deref, bad casts
g++ -fsanitize=undefined -g main.cpp -o app && ./app

用这些选项运行你现有的测试,那些“运行正常”的越界读取、释放后使用或有符号溢出,就会变成一份精确到文件和行号的报告。再配合 -Wall -Wextra,编译器还会在你运行之前就标出可疑的代码(比如可能读取了未初始化的值)。

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

把每一条 Sanitizer 报告都当作必须修复的 bug,而不是可以忽略的警告:它在告诉你,标准对那一行不做任何承诺。

小结

未定义行为是 C++ 中护栏消失的那一部分:越界访问、悬空指针、释放后使用、读取未初始化的值以及有符号溢出,产生的代码都没有任何定义好的含义,而“运行正常”永远不能证明它是对的。保持安全的办法是防御式编程:初始化每一个变量,遵守容器的边界,让智能指针拥有堆内存;然后用 -fsanitize=address、-fsanitize=undefined 和 -Wall -Wextra 加以验证,让悄无声息的 UB 变成响亮、可修复的报告。

“错误与调试”这一章到此结束。有了异常、try/catch,再加上对 UB 应有的警惕,你现在已经掌握了写出这样的 C++ 代码的工具:出错时大声地、有意地失败,而不是悄悄地、意外地失败。

常见问题

C++ 中的未定义行为是什么?

未定义行为(UB)是 C++ 标准明确不规定结果的任何操作,例如越过数组末尾读取,或者解引用一个悬空指针。编译器可以做任何事:崩溃、返回垃圾值、把代码优化掉,或者今天看起来正常、重新编译后就出错。它是你程序里的 bug,而不是语言的特性。

为什么我的 C++ 程序有未定义行为却还能正常运行?

“运行正常”对 UB 什么也证明不了。标准对结果没有任何保证,所以一个 UB bug 今天在你的机器、你的编译器上可能正好给出你期望的结果,换一个优化级别、平台或编译器版本就可能崩溃。永远不要把一次成功的运行当作 UB 无害的证据,要用 Sanitizer 真正把它找出来。

C++ 中如何捕获未定义行为?

开启 Sanitizer 编译:-fsanitize=address(AddressSanitizer)能发现越界读写和释放后使用,-fsanitize=undefined(UndefinedBehaviorSanitizer)能标记有符号溢出、空指针解引用和错误的类型转换。再打开警告(-Wall -Wextra),用这些选项运行你的测试,它们会把悄无声息的 UB 变成清晰的运行时报告。

Coddy 编程语言插图

和 Coddy 一起学编程

开始