一段代码,在GCC下输出1 2,在Clang下输出2 1,在MSVC下又是另一种结果。这不是编译器的bug,而是C/C++语言规范有意为之的设计——或者说,是一个困扰了程序员三十多年的"特性"。

从一个真实的困惑说起

2014年,C++标准化委员会收到了一份提案。提案开篇展示了一段看似无害的代码:

std::string s = "but I have heard it works even if you don't believe in it";
s.replace(0, 4, "")
 .replace(s.find("even"), 4, "only")
 .replace(s.find(" don't"), 6, "");

这段代码出自Bjarne Stroustrup本人编写的《The C++ Programming Language》第四版。它的意图很明确:链式调用replace方法,依次替换字符串中的内容。然而,根据当时的C++标准,这段代码的行为是未指定的——编译器可以选择任意顺序执行这些操作,导致不同的结果。

这不是一个孤立的问题。类似的"陷阱"潜伏在C和C++的每一个角落:printf("%d %d\n", ++i, i++)a[i] = i++f(i++, i++)……这些表达式在不同编译器、不同优化级别下可能产生截然不同的结果。

问题的根源,在于C/C++语言规范中一个核心却常被误解的概念:表达式求值顺序

运算符优先级≠求值顺序

在深入讨论之前,必须先澄清一个最常见的误区。

很多人认为,运算符优先级决定了表达式的求值顺序——乘法优先于加法,所以a() + b() * c()会先计算b() * c(),再计算a() + ...。这是一个完全错误的理解。

运算符优先级决定的是结合方式,而非执行顺序。表达式a() + b() * c()会被解析为a() + (b() * c()),但这只意味着"加法操作的对象是a()的值和b() * c()的值",而不是"b() * c()先于a()执行"。

实际的执行顺序可能是:

  1. 先调用a(),得到结果A
  2. 调用b(),得到结果B
  3. 调用c(),得到结果C
  4. 计算B * C,得到结果D
  5. 计算A + D

也可能是:

  1. 先调用b(),得到结果B
  2. 调用c(),得到结果C
  3. 计算B * C,得到结果D
  4. 调用a(),得到结果A
  5. 计算A + D

甚至可能是:

  1. 调用a()b()交错执行
  2. 调用c()
  3. 计算结果

C和C++标准允许编译器在这些顺序中自由选择,目的是给优化器留出最大的空间。

序列点:C语言的原始设计

C语言最初设计时,引入了"序列点"(Sequence Point)的概念来界定副作用的边界。

序列点是程序执行中的一个点,在这个点上,之前所有表达式的副作用都已完成,之后的副作用尚未开始。副作用包括修改变量的值、写入文件、调用I/O函数等。

在C89/C90标准中,定义了以下序列点:

序列点位置 说明
分号(语句结束) 每个完整表达式结束时
&&的逻辑与运算符 左操作数求值后,右操作数求值前
||的逻辑或运算符 左操作数求值后,右操作数求值前
,逗号运算符 左操作数求值后,右操作数求值前
?:条件运算符 第一个操作数求值后,第二/三个操作数求值前
函数调用 所有参数求值完成后,函数体执行前
函数返回 返回值复制完成后

在两个序列点之间,如果一个标量对象被修改了多次,或者被修改后又读取(读取不是为了确定新值),行为就是未定义的

这就是为什么i = i++a[i] = i++是未定义行为——在同一个序列点间隔内,同一个变量被多次修改或同时被读取和修改。

未定义行为:编译器的"空白支票"

C/C++标准中定义了三种"不确定"的行为:

未定义行为(Undefined Behavior):标准对行为没有任何要求。编译器可以做任何事:崩溃、产生错误结果、假装这段代码不存在、甚至"让恶魔从鼻子里飞出来"(这是C标准委员会的著名玩笑)。

未指定行为(Unspecified Behavior):标准定义了几种可能的行为,编译器可以选择其中一种,但不需要说明选择了哪一种。行为是确定的,但程序员无法预测。

实现定义行为(Implementation-Defined Behavior):类似于未指定行为,但编译器必须在文档中明确说明选择了哪种行为。

为什么C/C++要保留未定义行为?这与语言的设计哲学密切相关。

C语言诞生于资源受限的时代,设计者希望在各种硬件平台上都能生成高效的代码。如果标准规定太多细节,编译器将无法利用特定平台的特性进行优化。

以有符号整数溢出为例。在大多数现代处理器上,有符号整数溢出会回绕(wrap around),比如INT_MAX + 1会变成INT_MIN。但在某些历史架构(如DSP处理器)上,溢出可能触发异常。如果标准规定溢出必须回绕,那么在这些平台上每次加法操作都需要额外的检查指令,严重影响性能。

因此,C标准将溢出定义为未定义行为。编译器可以假设溢出永远不会发生,从而进行激进优化:

int foo(int x) {
    if (x > 0) return x + 1;
    return x - 1;
}
// 编译器可以假设 x + 1 不会溢出
// 因此 x 在第一个分支中一定 > 1
// 可以优化为:if (x > 0) return x + 1; else return x - 1;

这种设计哲学带来的代价是:程序员必须清楚地知道哪些操作是未定义行为,并主动避免它们。

C++11的革新:从序列点到Sequenced-Before

C++11引入了线程支持,原有的"序列点"概念在多线程环境下变得难以精确描述。标准化委员会决定用更形式化的"定序关系"(Sequencing Relationship)取代序列点。

新的术语包括:

Sequenced Before:如果A sequenced before B,则A的所有值计算和副作用都在B的任何值计算和副作用开始之前完成。

Indeterminately Sequenced:如果A和B是不确定定序的,则要么A sequenced before B,要么B sequenced before A,但具体是哪一种未指定。两者不会交错执行。

Unsequenced:如果A和B是未定序的,则它们的执行顺序可以是任意的,甚至可以交错执行(在单线程中,编译器可以交错生成指令)。

这个变化不仅是术语上的更新,它提供了更精确的语义描述能力。然而,C++11/C++14在表达式求值顺序方面的规则仍然相当宽松,特别是函数参数的求值顺序——这仍然是未指定的。

C++17:三十年后的修正

C++17对表达式求值顺序进行了重大调整,这是C++历史上首次系统性解决这个问题的尝试。提案P0145R3(《Refining Expression Evaluation Order for Idiomatic C++》)由Gabriel Dos Reis、Herb Sutter和Jonathan Caves共同撰写,于2016年6月提交并获得批准。

C++17的核心变更如下:

后缀表达式从左到右求值

a.b           // a 先求值,然后访问 b
a->b          // a 先求值,然后访问 b
a->*b         // a 先求值,然后求值 b
a[b]          // a 先求值,然后求值 b
a << b        // a 先求值,然后求值 b
a >> b        // a 先求值,然后求值 b

赋值表达式从右到左求值

a = b         // b 先求值,然后求值 a,最后赋值
a += b        // b 先求值,然后求值 a,最后执行复合赋值

函数调用的定序规则

在函数调用f(a, b, c)中:

  1. 函数表达式f先求值
  2. 参数a, b, c不确定定序的方式求值(不可交错)
  3. 所有参数求值完成后,函数体开始执行

这意味着以下代码在C++17中行为确定:

std::cout << a() << b() << c();
// 解析为 ((std::cout << a()) << b()) << c()
// C++17保证 a(), b(), c() 按此顺序调用

new表达式的变更

new T(a, b, c)
// C++17: 内存分配先于参数求值
// 这意味着如果参数求值抛出异常,不会有内存泄漏

然而,C++17并没有解决所有问题。f(i++, i++)仍然是不确定行为(虽然不再是未定义行为),因为两个参数的求值顺序仍然未指定。

实际案例:unique_ptr的内存泄漏陷阱

C++17之前,以下代码存在潜在的资源泄漏风险:

// C++14及之前:可能内存泄漏
void f(std::unique_ptr<T> p1, std::unique_ptr<T> p2);

f(std::unique_ptr<T>(new T()), std::unique_ptr<T>(new T()));

问题在于:编译器可能按以下顺序执行:

  1. new T()(第一个)
  2. new T()(第二个)
  3. 构造第一个unique_ptr
  4. 构造第二个unique_ptr

如果第二步抛出std::bad_alloc,第一步分配的内存就泄漏了——因为第一个unique_ptr还没构造,无法自动释放。

这正是C++14引入std::make_unique<T>()的原因之一:它将内存分配和智能指针构造合并为一个原子操作,消除了泄漏风险。

C++17通过新的求值顺序规则部分解决了这个问题:函数参数求值不可交错,意味着一个参数完全求值后才开始下一个。但对于裸new表达式,风险依然存在:

// C++17仍然可能泄漏
f(std::unique_ptr<T>(new T()), otherFunction());
// 如果otherFunction()先执行并抛出异常,new T()的内存就泄漏了

这也是为什么现代C++最佳实践推荐使用std::make_uniquestd::make_shared,而不是直接使用new

编译器的实际行为

理解标准是一回事,了解编译器的实际行为是另一回事。主流编译器在表达式求值顺序上的实现差异明显。

GCC:函数参数从右到左求值(这源于x86 calling convention的历史原因)。printf("%d %d %d\n", f(), g(), h())会按h, g, f的顺序调用。

Clang:同样从右到左求值函数参数。

MSVC:也遵循从右到左的顺序。

但这只是"当前"的行为,编译器没有任何义务保持这种行为不变。实际上,不同优化级别可能导致不同的求值顺序,而且这种变化不违反标准。

以下是一个真实的例子(GCC在不同优化级别下的行为差异):

#include <iostream>

int f() { std::cout << "f"; return 1; }
int g() { std::cout << "g"; return 2; }
int h() { std::cout << "h"; return 3; }

int main() {
    int x = f() + g() * h();
    std::cout << " = " << x << std::endl;
    return 0;
}

使用-O0可能输出fgh = 7,使用-O2可能输出ghf = 7。两种结果都符合标准,因为乘法和加法的操作数求值顺序未指定。

如何避免求值顺序陷阱

理解规则后,避免问题就变得相对简单:

1. 避免在单个表达式中多次修改同一变量

// 错误:未定义行为
i = i++;
a[i] = i++;
printf("%d %d\n", i, ++i);

// 正确:拆分为多个语句
i++;
int temp = i;
a[temp] = temp;

2. 避免在函数参数中使用有副作用的表达式

// 危险:参数求值顺序未指定
f(i++, i++);

// 安全:拆分为多个语句
int a = i++;
int b = i++;
f(a, b);

3. 理解C++17的保障范围

C++17解决了部分问题,但并非全部:

// C++17: 良定义(链式调用从左到右)
std::cout << f() << g() << h();

// C++17: 良定义(赋值从右到左)
x = y = f();  // f()先求值,然后赋值给y,再赋值给x

// C++17: 仍然是不确定行为
f(i++, i++);  // 两个i++的顺序未指定,但不会交错执行

4. 使用静态分析工具

现代静态分析工具可以检测许多未定义行为:

  • Clang Static Analyzer
  • Cppcheck
  • PVS-Studio
  • GCC/Clang的-Wall -Wextra警告选项

特别是GCC和Clang提供的-Wsequence-point警告选项,可以检测序列点相关的未定义行为。

语言设计的权衡

C/C++保留宽松的求值顺序规则,本质上是性能可预测性之间的权衡。

完全指定求值顺序(如Java和C#的做法)会限制编译器的优化空间。以表达式a + b + c为例,如果强制从左到右求值:

  • 必须先计算a,保存结果
  • 再计算b,保存结果
  • 计算a + b,保存结果
  • 计算c,保存结果
  • 最终计算结果

如果允许重排,编译器可以根据寄存器使用情况选择最优顺序,甚至可以合并某些操作。

C/C++选择了性能优先的路径。这在系统编程领域是合理的——程序员通常愿意承担更多责任以换取更高的效率。但对于应用层开发,这种选择带来的复杂性往往是过度的。

其他语言的设计选择

不同语言对这个问题有不同的处理方式:

Java:严格规定从左到右求值。表达式f() + g() * h()保证按f, g, h的顺序求值。代价是某些优化机会被放弃。

C#:同样从左到右求值。C#规范明确规定了每个表达式的求值顺序。

Rust:大多数情况下未指定求值顺序,但通过严格的别名规则和所有权系统,避免了与C/C++类似的问题。

Go:严格规定从左到右求值,函数参数按位置顺序求值。

这些设计选择反映了语言的目标领域:Java和C#面向企业应用,可预测性比性能更重要;Rust虽然面向系统编程,但通过所有权系统提供了更强的安全保证;Go追求简单性,明确的求值顺序是设计哲学的一部分。

C++的未来:继续演进

C++标准化委员会并未停止改进。C++20和C++23继续在安全性和可预测性方面进行增强。虽然表达式求值顺序的核心规则在C++17之后相对稳定,但相关的工作仍在继续,包括更好的编译器诊断、静态分析工具的支持,以及对常见错误的模式识别。

C++26引入了"错误行为"(Erroneous Behavior)概念,这是一种介于未定义行为和良定义行为之间的新类别。当程序执行错误行为时,实现可以(且被推荐)发出诊断信息,并可以在该操作之后的某个时间终止执行。这为处理诸如未初始化变量读取等问题提供了更温和的方式。

结语

表达式求值顺序问题看似是一个技术细节,实则反映了语言设计的深层哲学。C/C++选择信任程序员、优先性能,这带来了无与伦比的效率,也埋下了无数陷阱。

C++17的变革是一个重要的里程碑——它修复了许多困扰程序员数十年的问题,让链式调用、流操作等常见模式变得可靠。然而,理解规则仍然是每个C/C++程序员的必修课。

编写可靠代码的关键,不是依赖编译器的特定行为,而是理解标准的边界。当规则说"未定义"时,永远不要假设任何特定的结果。拆分复杂表达式、避免副作用交错、使用现代工具检测问题,这些是防御性编程的基本功。

最后,借用C标准委员会的一句话:“未定义行为给了实现者许可,让他们不必捕捉某些程序错误,因为诊断这些错误很困难。“这是一把双刃剑:它让编译器可以生成更高效的代码,但也让程序员必须更加谨慎。


参考资料

  1. ISO/IEC 14882:2017 - Programming Language C++
  2. P0145R3: Refining Expression Evaluation Order for Idiomatic C++ (Gabriel Dos Reis, Herb Sutter, Jonathan Caves, 2016)
  3. cppreference.com - Order of evaluation
  4. Wikipedia - Sequence point
  5. Angelika Langer - Sequence Points and Expression Evaluation in C++
  6. Krister Walfridsson - How undefined signed overflow enables optimizations in GCC
  7. Eric Lippert - Precedence vs associativity vs order
  8. Stack Overflow - Undefined, unspecified and implementation-defined behavior
  9. Herb Sutter - Trip report: Winter ISO C++ standards meeting (Kona), C++17 is complete (2017)
  10. C++ Core Guidelines - ES.44: Don’t depend on order of evaluation for function arguments