1425 字
7 分钟
C++ 求值顺序与 UB
2024-04-20

有一类 bug 非常难排查:代码在 Debug 模式下运行正常,一开优化就崩溃,或者在不同编译器上行为完全不同。很多时候,根源是未定义行为(Undefined Behavior,UB),而触发 UB 最常见的姿势之一,就是对函数参数求值顺序做了错误的假设。

函数参数的求值顺序是”未指定”的#

先看一个简单的例子:

void f(int a, int b, int c);
f(a(), b(), c());

你可能会认为 a()b()c() 按从左到右的顺序调用。错了。C++ 标准明确规定,函数参数的求值顺序是 unspecified(未指定),编译器可以按任意顺序对各个参数求值。

实践中,x86 调用约定(cdecl)通常从右到左压栈,所以很多编译器默认从右到左求值——但这不是标准保证的行为,不同编译器、不同优化级别都可能不同。

对比一下其他语言:

语言参数求值顺序
C++unspecified
Cunspecified
Java从左到右(标准保证)
C#从左到右(标准保证)

C++ 之所以不做保证,是为了给编译器留下优化空间——编译器可以自由重排,生成更高效的代码。代价是程序员必须自己保证代码不依赖这个顺序。

什么时候会出问题#

只要你在一个表达式里对同一个变量产生了多次副作用,麻烦就来了:

int i = 0;
f(i++, i++); // 未定义行为!

这里 i++ 是有副作用的操作(读取并修改 i),两次 i++ 作用于同一个变量,而求值顺序不确定,标准对这种情况直接判定为未定义行为。不是”结果不确定”,是 UB——标准不对程序的任何后续行为做任何保证。

另一个经典例子:

std::cout << f() << g();

这里 <<结合方向是确定的(从左到右),但 f()g()调用顺序在 C++17 之前是未指定的。如果 f()g() 有共享状态(比如都修改同一个全局变量),结果就依赖于编译器的实现。

未定义行为:比你想象的更危险#

UB 的危险不在于”行为不对”,而在于编译器会假设 UB 不存在,并基于这个假设做激进优化。

一个经典的例子:

void process(int* ptr) {
*ptr = 42; // 解引用 ptr
if (ptr == nullptr) // 编译器:ptr 刚才被解引用,所以它不可能是 nullptr
return; // 编译器直接删掉这个分支
// ...
}

你以为加了空指针检查很安全,但编译器看到 *ptr = 42 之后,会推断”这里 ptr 一定不是 nullptr,否则前面就已经是 UB 了”,然后把后面的 nullptr 检查优化掉。结果:空指针保护形同虚设。

这不是 bug,是编译器正确地遵从了标准。

常见的 UB 类型#

有符号整数溢出:这是 UB,无符号溢出不是。

int x = INT_MAX;
x + 1; // UB!有符号溢出
unsigned int u = UINT_MAX;
u + 1; // 定义为 0,模运算,没问题

很多人写循环时以为 int 溢出会绕回来,但开优化之后编译器会假设溢出不发生,消除掉相关检查,循环可能变成死循环或者被完全优化掉。

越界访问

int arr[5];
arr[5] = 10; // UB!越界写

Use-after-free

int* p = new int(1);
delete p;
*p = 2; // UB!访问已释放的内存

空指针解引用

int* p = nullptr;
*p = 1; // UB!

数据竞争:多线程同时读写同一变量,没有同步保护,是 UB。

C++17 改进了部分规则#

C++17 对一些运算符的求值顺序做了明确规定:

  • <<>> 运算符:左侧先于右侧求值(C++17 起)
  • 函数调用:函数表达式先于参数列表求值(C++17 起)
  • = 赋值:右侧先于左侧求值(C++17 起)

所以 C++17 之后,std::cout << f() << g() 保证先调用 f() 再调用 g(),不再有顺序问题。

但有一点没变:函数参数之间的顺序依然是 unspecified。f(a(), b(), c())a()b()c() 的调用顺序,在 C++17 之后仍然不确定。

怎么写才安全#

规则很简单:不要在一个表达式里对同一个变量产生多次副作用,也不要让一个表达式里的子表达式之间存在顺序依赖。

把有副作用的操作拆成独立的语句:

// 危险
f(i++, i++);
// 安全:用临时变量显式控制顺序
int a = i++;
int b = i++;
f(a, b);

如果你需要特定的调用顺序,就显式写出来:

// 如果你需要 f() 先于 g() 调用
auto result_f = f();
auto result_g = g();
std::cout << result_f << result_g;

一般原则:一行代码只做一件事,尤其是涉及 ++/-- 或其他有副作用的操作时。

用工具检测 UB#

肉眼排查 UB 很难,用工具效率高得多。

UBSan(Undefined Behavior Sanitizer):Clang 和 GCC 都支持,在运行时检测 UB,找到就报告。

CMakeLists.txt
target_compile_options(your_target PRIVATE
-fsanitize=undefined,address
-fno-omit-frame-pointer
)
target_link_options(your_target PRIVATE
-fsanitize=undefined,address
)

ASan(Address Sanitizer):专门检测内存问题,越界、use-after-free、堆栈溢出都能找到。

CLion 在 CMake 项目里直接支持 Sanitizer 配置:在 Settings > Build, Execution, Deployment > CMake 里加上 -DCMAKE_CXX_FLAGS="-fsanitize=undefined,address" 就行。

养成习惯:在 Debug 配置里开 Sanitizer 跑测试,大量隐藏 bug 会自动暴露出来,不要等到生产环境才发现。


参数求值顺序和 UB 是 C++ 里少有的”你不知道你不知道”的坑——代码看起来能跑,实际上是在走钢丝。理解这些规则,配合 Sanitizer 工具,能帮你在坑之前就把问题解决掉。

博客桌宠