11. C++17新特性-更严格的表达式求值顺序

张开发
2026/4/16 1:20:40 15 分钟阅读

分享文章

11. C++17新特性-更严格的表达式求值顺序
一、引言在 C 的漫长演进史中“未定义行为 (Undefined Behavior, UB)”和“未指定行为 (Unspecified Behavior)”始终是悬在开发者头顶的达摩克利斯之剑。其中由于“表达式求值顺序”不确定而引发的 Bug往往是最难排查的。因为这类 Bug 可能会随着编译器厂商GCC、Clang、MSVC的不同、优化等级的不同甚至操作系统的不同而呈现出完全不同的结果。C17 痛下决心在标准层面引入了一系列严格的**“先于...发生 (Sequenced Before)”** 规则极大地收紧了表达式求值顺序的自由度。本文将严谨地剖析这些规则的底层逻辑以及它们如何从根本上拯救我们的工程代码。二、历史痛点薛定谔的代码执行结果在 C17 之前编译器为了追求极致的优化被赋予了极大的指令重排自由。除了逻辑与、逻辑或||、三元运算符?:和逗号,有严格的求值顺序外其他大部分表达式的求值顺序都是未指定的。C17 之前的梦魇场景场景 1流输出顺序之谜#include iostream int f() { std::cout f() ; return 1; } int g() { std::cout g() ; return 2; } int main() { // C17 之前可能输出 f() g() 1 2也可能输出 g() f() 1 2 std::cout f() g() \n; }因为操作符本质上是函数调用operator(operator(std::cout, f()), g())而 C 之前对函数参数的求值顺序没有规定编译器完全可以先计算g()再计算f()。场景 2Map 插入的崩溃陷阱std::mapint, int myMap; int compute_value() { myMap.clear(); // 极其恶劣的副作用清空并导致底层重分配 return 42; } int main() { // C17 之前极有可能引发段错误 (Segmentation Fault) myMap[0] compute_value(); }在旧标准中编译器可能会先计算左边的myMap[0]获取到一个引用然后再去计算右边的compute_value()。但compute_value()内部清空了 Map导致刚才获取的引用变成了悬空引用接着执行赋值操作时程序直接崩溃。三、C17 的核心整顿严格的求值顺序规则为了终结这些混乱C17 明确规定了以下几类表达式的强求值顺序保证3.1 赋值表达式右侧严格先于左侧对于赋值运算符a b以及复合赋值a b,a * b等规则右操作数b的每一次值计算和副作用都严格先于Sequenced Before左操作数a的计算。这意味着在前面的 Map 示例中C17 保证一定会先执行完毕compute_value()然后再去评估myMap[0]。由于compute_value()已经执行完Map 被清空再去获取myMap[0]的引用并赋值是绝对安全且符合逻辑的。3.2 移位运算符左侧严格先于右侧对于移位运算符a b和a b规则左操作数a严格先于右操作数b求值。这直接拯救了std::cout f() g();。在 C17 中它被保证一定会从左到右执行输出结果毫无争议地是f() g() 1 2。3.3 后缀与成员访问从左向右推进对于a.b、a-b、a-*b和a[b]规则表达式a严格先于表达式b求值。如果你有链式调用obj.get_child().do_something()C17 保证obj.get_child()的执行及其所有的副作用一定在do_something()开始评估之前彻底完成。3.4 内存分配与构造消除隐蔽的内存泄漏对于new Type(expr)这种动态分配表达式规则内存分配动作调用operator new严格先于构造函数的参数expr的求值。这解决了一个隐蔽的内存分配陷阱如果在分配内存后、执行构造函数参数评估时抛出了异常C17 会保证已经分配的内存被正确回收即隐式调用operator delete防止了内存泄漏。四、科学严谨性防坑函数参数的求值顺序“依然”未指定这是绝大多数开发者在学习 C17 时最容易踩坑的盲区。很多人误以为 C17 规定了函数参数是从左向右求值的这是极其错误的理解。关键限制对于函数调用f(a, b, c)C17并没有规定a、b、c之间的先后顺序编译器依然可以随心所欲地按照c - a - b或b - c - a的顺序来求值。但是C17 做出了一个底线保证不交织Indeterminately Sequenced。void foo(int x, int y) {} int a() { /* ... */ return 1; } int b() { /* ... */ return 2; } int main() { foo(a(), b()); }C17 之前编译器甚至可以交织执行比如先执行a()的第一条汇编指令再执行b()的第一条指令导致极度混乱的竞态条件。C17 保证要么a()完整执行完再执行b()要么b()完整执行完再执行a()。它们作为独立的黑盒互相之间绝不会被切片交织。且函数名foo本身的解析一定先于所有参数的求值。工程规范建议由于函数参数间的求值顺序仍然未指定绝对不要在同一个函数的参数传递中依赖有副作用的表达式先后顺序。像foo(i, i)这种代码在 C17 中依然是未定义行为或产生不可预测的结果。如果参数之间存在依赖必须在函数调用前单独分行计算。五、总结C17 对表达式求值顺序的收紧是语言向现代软件工程安全性妥协的重要标志。它通过引入确定性的“Sequenced Before”规则消除了赋值操作、移位操作尤其是流输入输出和链式调用中大量潜在的未定义行为。作为现代 C 开发者我们应当充分利用这些保证来编写更直观、更安全的表达式同时也要时刻保持严谨牢记函数参数求值顺序依然未定这一最后边界。

更多文章