5.5. 去序从简

在结束本章前,还有两个重要细节需要说明。

第一,C++ 中函数参数的求值顺序是未指定的(unspecified),在调用一个具有多个参数的函数时,编译器可以自由选择以何种顺序对这些参数进行求值。这种不确定性在大多数情况下不会造成影响,但如果参数表达式中包含有副作用(例如修改了某个变量的值),就可能导致行为不一致,甚至引发难以察觉的错误。

请看以下示例程序:

#include <iostream>
int f (int a, int b, int c) {
  std::cout << "a="<<a<<" b="<<b<<" c="<<c<<std::endl;
  return a+b+c;
}
int main() {
  int i = 1;
  std::cout<<"f="<<f(i++, i++, i++)<<std::endl<<"i="<<i<<std::endl;
}

无论你认为这个程序的输出是什么,你的答案都很可能是错的。

原因正如前文所述:函数参数的求值顺序是未指定的(unspecified)。你或许会问:为什么C++要这样设计?这背后确实有着复杂的历史原因。不过在深入探讨这些之前,让我们先来看看不同编译器在如 gcc.godbolt.org 等平台上给出的实际输出结果:

编译器 输出
Microsoft Visual C++ (after 2005) a=1 b=1 c=1

f=3

i=4
Microsoft VS.NET 2003 a=3 b=2 c=1

f=6

i=4
Microsoft Visual C++ 6 a=1 b=1 c=1

f=3

i=4
ICC and Clang agree on this… f=a=1 b=2 c=3

6

i=4
GCC, after 6.5 f=a=3 b=2 c=1

6

i=4
GCC, before 6.5 a=3 b=2 c=1 f=

6

i=4
Turbo C Lite and Borland C++55 a=3 b=2 c=1

f=6

i=1

因此,我们面临着多种可能的选择 —— 有些较为直观,有些则相当特殊。所有这些看似奇怪的结果都宣称自己是“正统答案”,即便同一厂商的不同编译器版本也可能给出不同的结果。而它们的确都是正确的。

简而言之,允许编译器自由选择求值顺序,是为了使其能够进行我们开发者可能注意不到的性能优化:

  • 编译器可以重排指令以利用 CPU 流水线
  • 最小化寄存器使用
  • 提升缓存效率,严格指定的求值顺序会限制这些优化机会

不同的硬件架构可能需要不同的最优求值策略。不指定求值顺序,使 C++ 代码更容易适配多种架构,而无需修改代码本身。

此外,不指定求值顺序也保持了 C++ 语言规范的简洁性。如果为所有表达式指定严格的求值顺序,将大大增加语言定义的复杂性,并加重编译器开发者的负担。考虑到现行标准已接近 2000 页,或许没有必要再增加数百页来详细规定参数求值的复杂性。

不过,本节开头承诺要提到的第二个重点是:虽然运算符优先级和结合性决定了表达式的分组和解析方式,但它们并不控制求值顺序。这意味着,即使你知道表达式将如何分组,其各部分的实际求值顺序仍可能发生变化。

请看以下简短示例程序:

#include <iostream>
int main() {
  int i = 4;
  i = ++i + i++;
  std::cout << i << std::endl;
  return 0;
}

这段代码极其简短,但却包含了一些极具争议的表达式,尤其是 ++i + i++。这个表达式之复杂,以至于不同编译器对其求值顺序无法达成一致。

一些编译器选择先执行 ++i(使 i 变为 5,并将该值作为加法的左操作数),然后执行 i++(此时使用的是已递增后的 i 值 5,再将其递增到 6;但由于后缀递增的语义,加法的右操作数仍为 5)。最终结果是 5 + 5 = 10。

而另一些编译器则决定先执行 i++,于是加法的右操作数为 4,同时将 i 递增到 5。接着执行 ++i,此时看到 i 的值为 5,将其递增到 6,并将 6 作为加法的左操作数。因此得到 6 + 4 = 10。

从这些差异可以看出,不指定求值顺序的设计虽然在表面上增加了不确定性,但促使熟悉这一特性的开发者编写出不依赖特定求值顺序的代码。开发者必须避免对求值顺序产生隐含依赖,这有助于提升代码的健壮性和可移植性。

针对上述情况,正确的做法应是显式地将副作用分离,例如改写为如下形式:

#include <iostream>
int main() {
  int i = 4;
  int preIncrement = ++i; // i is now 5
  int postIncrement = i++; //postIncrement is 5, i is now 6
  i = preIncrement + postIncrement;
  std::cout << i << std::endl; // Output will be 10
  return 0;
}

虽然这种情况可能比较罕见(上述代码有点人为设计的痕迹),但它确实是个问题 —— 特别是遇到类似以下场景时:

int f() { std::cout << "f"; return 1; }
int g() { std::cout << "g"; return 2; }
int result = f() + g();

无论编译器如何决定两个函数的调用顺序,result 的值最终都会是 3,但程序的输出可能是 “fg” 或 “gf”。

在考虑了所有这些因素之后,我们或许会觉得自己已经完全掌握了 C++ 中的求值顺序规则。尽管本章努力涵盖了所有可能引发问题的隐含情况,但我们无法保证实践中不会遇到“不按常理出牌”的情形。C++ 作为一门涵盖范围极广、语法高度灵活的语言,如果有人有意挑战边界,可能就会触碰到某些编译器的“痛点”或出现实现差异。