一段代码,在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()执行"。
实际的执行顺序可能是:
- 先调用
a(),得到结果A - 调用
b(),得到结果B - 调用
c(),得到结果C - 计算B * C,得到结果D
- 计算A + D
也可能是:
- 先调用
b(),得到结果B - 调用
c(),得到结果C - 计算B * C,得到结果D
- 调用
a(),得到结果A - 计算A + D
甚至可能是:
- 调用
a()和b()交错执行 - 调用
c() - 计算结果
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)中:
- 函数表达式
f先求值 - 参数
a, b, c以不确定定序的方式求值(不可交错) - 所有参数求值完成后,函数体开始执行
这意味着以下代码在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()));
问题在于:编译器可能按以下顺序执行:
new T()(第一个)new T()(第二个)- 构造第一个
unique_ptr - 构造第二个
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_unique和std::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标准委员会的一句话:“未定义行为给了实现者许可,让他们不必捕捉某些程序错误,因为诊断这些错误很困难。“这是一把双刃剑:它让编译器可以生成更高效的代码,但也让程序员必须更加谨慎。
参考资料
- ISO/IEC 14882:2017 - Programming Language C++
- P0145R3: Refining Expression Evaluation Order for Idiomatic C++ (Gabriel Dos Reis, Herb Sutter, Jonathan Caves, 2016)
- cppreference.com - Order of evaluation
- Wikipedia - Sequence point
- Angelika Langer - Sequence Points and Expression Evaluation in C++
- Krister Walfridsson - How undefined signed overflow enables optimizations in GCC
- Eric Lippert - Precedence vs associativity vs order
- Stack Overflow - Undefined, unspecified and implementation-defined behavior
- Herb Sutter - Trip report: Winter ISO C++ standards meeting (Kona), C++17 is complete (2017)
- C++ Core Guidelines - ES.44: Don’t depend on order of evaluation for function arguments