这是简短却重要的一章,涵盖了一组对于“正确地使用C++编写健壮且可靠的程序”起重要作用的相关主题。本章没有参考部分,因为整章同时扮演了参考和教程的角色。然而,本章以一套方针(guidelines)结束,帮助你避开序列点(sequence points)和求值顺序(order of evaluation)可能造成的大多数陷阱。这些方针对于实践目的而言足够了,如果你希望透彻地理解它们,或希望有时忽略它们,那么本章的其余部分将为你提供必备的基础知识。那些编写C++代码供他人使用的的程序员需要熟悉这些内容。本章没有习题,因为这些主题本身不适于练习。“实验”项目只是为了帮助你检查那些可以检查的方面,而非不可预知的方面。
6.1 行为的类型
C++将源代码的预期行为划分成四类:完全定义的(fully defined)、由实现定义的(implementation-defined)、未定义的(undefined)以及未指定的(unspecified)。
6.1.1 完全定义的行为
这是由C++标准完全指定的行为;如果一款编译器不能编译具有完整定义的行为的源代码,使其依照C++标准的规定做事情,那么该编译器必然含有臭虫(buggy)。比方说,C++标准要求以下源代码可以通过编译,并得到一个输出为2的程序:

注意,我们需要#include <istream>,因为标准并没有指定<iostream>要为输入和输出对象提供完全的行为(尽管许多专家认为标准应当这么做)。标准仅仅要求<iostream>声明std命名空间中的名字cout、cerr、clog、cin、wcout、wcerr、wclog和wcin。大多数实现都在<iostream>中包含了<istream>和<ostream>。尽管标准允许如此,但未作要求。
我之所以挑选上面的例子,是因为如果用C++编译器来编译,则等价的C程序

拥有完全同样的要求,但如果用一个旧款C编译器来编译,要求则有所不同。第一个问题是,C++指定了main()函数缺少返回语句时的行为(相当于在该函数末尾加上return 0;)。但C没有指定,因此在旧的C中return语句的缺失被视为一个错误。C的最新版本(常称为C99,以区别于仍广为使用的、于1989/90年标准化、于1994年修订的版本)具有与C++相同的行为:离开main的末尾等价于return 0;。
第二个问题是,在C中字符字面量的类型是int而不是char。int的大小是由实现定义的,在多数系统上是2或4个字节。然而,以上代码的以下变体,不论使用C或C++编译器,都必须输出2。

这是因为C和C++标准都将sizeof char定义为1,char占用两种语言中可直接访问的最小内存。
6.1.2 由实现定义的行为
这是一种可以随着不同的实现而变化的行为,但实现者需要在文档中说明细节。前面见过这方面的一个例子:int对象使用的内存量与char对象使用的内存量的比率是由实现定义的。换句话说,
![]()
所产生的结果是由实现定义的。
由实现定义的行为的另一个例子是,char的行为是和带符号的小整数(即signed char)一样,还是和不带符号的小整数(unsigned char)一样。许多编译器允许程序员决定char具有哪种行为。
实验
用手头的编译器和IDE尝试如下代码,探索你正在使用的配置提供哪种形式的char。

我没有为异常处理而费心,因为这是个极小的一次性程序。究竟如何以-1初始化c,有赖于char被视为带符号的还是无符号的整型。如果char是带符号的类型,那么-1就被当作-1 存储(系统究竟以何种方式表示负一,依赖于带符号整数是用补码、反码还是原码表示)。如果char被视为无符号的类型,则-1将位于char的最大有效值附近。注意,对于char的任何无符号表示法而言,最大的可能值毫无疑问大于200。
6.1.3 未指定的行为
它涵盖了这样一种情况:编译器可以自由选择任意一种合理的动作,实现工具也不需要在文档中说明编译器将选择哪一种动作。这很常见,因为所有可能的选择通常将拥有完全相同的结果。然而,这常常可能会导致构建出这样的代码:其行为随着编译器做出不同的选择而发生变化。
未指定的行为的一个例子是C++(与像Java这样的某些别的语言不同)不指定子表达式(subexpressions)(更大的表达式的组成部分)的求值顺序。考察以下源代码:

C++标准要求按某种顺序调用add1_to_global()和add2_to_global()(即不能并行地将两个调用发送给两个不同的CPU处理),但它没有指定先求解哪个表达式。在这种情况下,结果将依赖于编译器做出的选择。如果它按从左到右的顺序调用两个函数,则结果为4,但如果它以相反的顺序调用它们,则结果为5。无论编译器做何选择,最终存储在global中的值都将是3。
因为C++标准未指定子表达式(本例中是两个函数调用)的求值顺序,所以4和5均是上述程序的正确输出。实际上,你正在使用的编译器(假定你我所用为同一版本)将生成一个输出为5的程序。这表明,编译器创建的代码先调用add2_to_global()而后调用add1_to_global()。(译注:译者使用CD提供的MDS所得结果为4)
如果你还不知道全局变量可能引起严重的问题,那么这个例子应当向你示范了优秀的程序员避开它们的原因之一。
尽管C++为“在表达式求值期间操作符的应用顺序”提供了规则,但它仅为子表达式的求值顺序规定了极少的要求:对子表达式的求解,发生在操作符需要该子表达式的值之前。许多程序员忽视了这条规则的完整含意。比方说,使用上述函数,
![]()
也具有未指定的行为。输出可以是“13”或“23”,这依赖于按什么顺序调用这两个函数。此外,子表达式(在本例中是函数调用)的求值顺序可能会随着使用地点的不同而发生变化。
警告!
对子表达式的求值顺序做任何假定都是不安全的。运行测试代码也不会告诉你任何更多的信息,因为你知道的只是这些子表达式在该测试代码中的求值顺序而已。如果顺序很重要,你必须设法强制顺序,比方说将求值放到分开的语句中。例如,

必定导致输出“13”,因为不存在重排整个语句的执行顺序的自由。
6.1.4 未定义的行为
这是个潜伏在太多太多的代码中的大问题,而编写这种代码的人却以为知道自己在做什么。任何时候当你在做C++标准没有提供要求的事情时,你就已经身处未定义行为的地带了。我经常看见程序员为自己申辩,理由是他们测试过代码而且程序如预期的样子运行。那正是未定义行为邪恶的方面。它之所以能够隐藏好多年,是因为没有什么东西去激发问题的出现。未定义行为会危害真实系统的典型例子数不胜数。比方说,我曾经改编过一块十分昂贵的显卡上的EPROM,实际上就是用了下面这样的程序:

我剥去了所有其他代码,并以这样的方式给函数命名,是为了说服即使最马虎的读者也永远不要使用它。C程序员可以读懂这段代码做了什么,它在C++中也做了同样的事。简而言之,它分配足够的局部存储区来存储下一个“No”,然后继续试图在相同的地方写“yes”。它为“No”分配的空间存不下“yes”,所以最后一个字符会溢出。在许多系统上,那个溢出可能不会带来实际的伤害。在我当时使用的系统上,它将数据写到函数返回地址的顶部。结果,函数没有返回给调用者,而是返回到别的什么地方。不幸的是,它返回到的地方恰巧就是搞破坏的可执行代码。当然,现代操作系统往往能侦测这样的野蛮行为,并简单地结束程序。但是:
警告!
程序员绝不能依赖于操作系统介入以保护自己的程序和其他程序,从而避免具有未定义行为的代码所导致的后果。包含未定义行为的程序随时都可能导致事故的发生。







