第十二章 程序质量
# 第十二章 程序质量
在接下来的几个问题中,我们将讨论C++程序的不同可观察行为,比如未指明行为(unspecified behaviour)和未定义行为(undefined behaviour)等。
# 问题99:代码的可观察行为(observable behavior)是什么?
根据标准,“可观察行为”这一术语的含义如下:
- 对易失性对象(volatile objects)的访问(读和写)严格按照其所在表达式的语义进行。特别地,在同一线程中,它们不会与其他易失性访问重新排序。
- 在程序终止时,写入文件的所有数据必须与按照抽象语义执行程序可能产生的结果之一相同。
- 交互式设备的输入和输出动态应以这样一种方式进行:在程序等待输入之前,提示输出实际上已经交付。构成交互式设备的内容由实现定义(implementation-defined)。
“as-if规则”(“as-if rule”)与可观察行为密切相关。简而言之,任何不改变程序可观察行为的代码转换都是允许的。
C++标准精确地定义了每个不属于以下类别的C++程序的可观察行为:
- 格式错误(ill-formed)
- 格式错误,无需诊断(ill-formed, no diagnostic required)
- 实现定义行为(implementation-defined behaviour)
- 未指明行为(unspecified behaviour)
- 未定义行为(undefined behaviour)
# 问题100:格式错误的C++程序有哪些特征?
昨天我们了解到C++标准定义了C++程序的行为。如果程序格式不正确,其行为会属于其他五种情况之一。其中一种是“格式错误”,另一种是“格式错误,无需诊断”。
# 格式错误
在这种情况下,程序存在语法错误和/或可诊断的语义错误。编译器会提示这些错误。被违反的规则在标准中以“应”(shall)、“不应”(shall not)或“格式错误”来表述。
# 格式错误,无需诊断
在这一类别下,不会有编译器错误。程序没有语法错误,只有语义错误,但一般来说,编译器无法诊断这些错误。
这些语义错误要么在链接时可检测到,要么如果程序被执行,会导致未定义行为。
这类问题违反了“单一定义规则”(One Definition Rule),该规则规定在任何一个翻译单元中,任何变量、函数、类类型、枚举类型、概念(自C++20起)或模板都只允许有一个定义。可以有多个声明,但只能有一个定义。查看参考资料,了解关于单一定义规则(ODR)的更多详细信息。
# 问题101:什么是未指明行为(unspecified behaviour)?
当标准未指定应该发生什么,或者指定了多种可能的情况时,程序的行为就被称为未指明行为。确切的行为取决于具体实现,仅检查程序源代码可能无法完全确定。
即使对于特定的实现,编译器也无需记录它如何处理这些情况。
这意味着根据所使用的实现不同,不同的结果集可能都是完全有效的。
但无论发生什么,都可以保证未指明代码的影响严格限制在受影响的命令范围内。而对于未定义行为,编译器可以自由删除整个执行分支,情况则并非如此。
来看两个未指明行为的例子。
int x;
int y;
bool isHigher = &x > &y;
2
3
换句话说,如果你取两个局部变量,并且出于某种原因想要比较它们的内存地址,那么哪个变量的地址更高是完全未指明的。没有正确或确定的答案,这取决于具体实现,而且实现也无需对此进行记录。
另一个例子与表达式求值顺序有关。看下面这段代码:
#include <iostream>
int x = 333;
int add(int i, int j) {
return i + j;
}
int left() {
x = 100;
return x;
}
int right() {
++x;
return x;
}
int main() {
std::cout << add(left(), right()) << std::endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在这个例子中,你调用了一个加法函数,它有两个参数,这两个参数都是同一行上函数调用的结果。它们的求值顺序没有被标准指定,并且由于被调用的函数对一个全局变量进行操作,结果取决于求值顺序。
不同的编译器可能会给出不同的结果。
如果你想快速在不同的编译器和版本上运行代码,我推荐使用Wandbox (opens new window)。
顺便说一下,在最近的标准中,很多类似的情况已经有了明确的规定。查看参考资料了解更多详细信息。
# 问题102:什么是实现定义行为(implementation - defined behaviour)?
在很多方面,实现定义行为与未指定行为(unspecified behaviour)类似。首先,标准并没有规定相关内容应该如何实现,这取决于编译器。
同样,实现定义行为的影响仅限于相关的指令,所以不会像未定义行为(undefined behaviour)那样删除整个执行分支。
未指定行为和实现定义行为的区别在于,实现方必须记录什么是有效的结果集。
让我们来看一些实现定义行为的例子,暂时跳出C++的范畴。
想想SQL中ORDER BY
对NULL
值的处理方式,你会发现不同的实现有所不同。有些会将NULL
值放在有序结果集的开头,有些则会将其放在末尾。你不必通过尝试去弄清楚,在文档中就能找到,这就是实现定义行为。
在C++中,sizeof(int)
以及一般整数类型的大小是由实现指定的,甚至字节的大小也是如此。
参考资料:
- Quora:未定义行为、未指定行为和实现定义行为之间的区别是什么?
- Stack Overflow:未定义行为、未指定行为和实现定义行为
- 《STL中的未定义行为》——桑多尔·达戈(2020年C++ on Sea会议)
# 问题103:C++中的未定义行为是什么?
在未指定行为、实现定义行为和未定义行为中,未定义行为是最危险的。
当你编写的程序引发了未定义行为,基本上意味着对整个程序的行为没有任何要求。其可能产生的影响不仅限于那些行为未指定的调用,编译器可以为所欲为,所以你的软件可能会:
- 看似毫无缘由地崩溃。
- 返回逻辑上不可能的结果。
- 出现不确定的行为。
实际上,编译器可以删除整个执行路径。假设你有一个很大的函数,其中有一行代码引发了未定义行为,那么整个函数都有可能从编译后的代码中被删除。
当代码中存在未定义行为时,你就违反了语言规则,而且直到运行时才会被发现。尽可能打开更多的编译器警告通常有助于消除未定义行为。
一些未定义行为的例子:
- 访问未初始化的变量。
- 在对象生命周期结束后访问对象。
- 通过没有虚析构函数的基类指针删除对象。
要像躲避瘟疫一样避免未定义行为。
# 问题104:未定义行为存在的原因是什么?
首先,我们要注意到未定义行为的概念并非由C++引入,C语言中就已经存在了。
毕竟C语言是什么呢?它不过是一种高级汇编语言,必须在完全不同的平台和架构上运行。
从语言设计者的角度来看,未定义行为是一种应对不同编译器和不同平台之间显著差异的方式。有些人甚至将那个时代称为混乱时代。不同的编译器对语言的处理方式不同,为了实现较好的向后兼容性,很多细节(比如布局、字节序)都没有定义或指定。
这给了编译器编写者很大的灵活性,他们过去可以,现在仍然可以利用这种自由发挥创造力。他们可以利用这一点来简化、缩短编译代码并提高其运行速度,同时又不违反任何规则。
# 问题105:如何避免未定义行为?
也许我们可以先讨论哪些方法行不通。
使用try - catch
块是行不通的,未定义行为不是关于错误处理异常的问题。同样,一些对输入容器的显式检查也无济于事。
std::copy_if(numbers21.begin(), numbers22.end(),
std::back_inserter(copiedNumbers),
[](auto number) {return number % 2 == 1;});
2
3
没有有效的检查可以避免这种输入错误,但确实通过一些防御性编程可以处理这种情况。
std::vector<int> doCopy(std::vector<int> numbers) {
std::vector<int> copiedNumbers;
std::copy_if(numbers.begin(), numbers.end(),
std::back_inserter(copiedNumbers),
[](auto number) {return number % 2 == 1;});
return copiedNumbers;
}
// . . .
auto copiedNumbers = doCopy(numbers);
2
3
4
5
6
7
8
9
10
这只是一个简单的例子,但关键是通过限制函数的作用域、减少可用变量的数量,可以减少可能出现的编译时输入错误。然而,把每个算法都这样包装起来看起来并不美观。
那么如何应对未定义行为呢?
- 倾听编译器的提示!尽可能打开各种警告(如
-Wall
、-Wextra
、-Wpedantic
),并把它们当作错误对待。这些警告能捕获很多未定义行为。 - 使用检查工具,
g++
和clang
都提供了一些相关工具。 - 遵循编码最佳实践和命名规范。在上面的例子中,输入错误之所以会出现,只是因为变量名质量低(
numbers21
和numbers22
)。遗留代码中有很多这样的命名。使用恰当、描述性强的名字,就不会犯这样的输入错误。 - 理解语言背后的概念。如果你认为在C++中不应该为不用的东西付出代价,那么就很容易理解为什么
std::binary_search
要求输入的范围是有序的。 - 实践契约式编程。如果你想使用标准库中的元素(应该这么做),查看它提出的契约,换句话说,阅读它们的文档,了解它们期望的输入类型。
- 分享知识!如果你学到了什么,就和队友分享。组织专门的会议,利用团队聊天和代码审查来交流。
# 问题106:什么是迭代器失效(iterator invalidation)?举几个例子。
当你声明一个指向容器中某个元素的迭代器时,可能期望它一直有效。有些迭代器确实始终有效,并且会指向你期望的位置。
例如,插入迭代器(如std::back_insert_iterator
)只要所有插入操作都是通过这个迭代器进行的,并且没有发生其他使迭代器失效的独立事件,就保证一直有效。即使容器在增长过程中需要重新分配内存(比如std::vector
),它也仍然有效。
此外,只读方法永远不会使迭代器或引用失效。而修改容器内容的方法可能会使迭代器和 / 或引用失效。
查看这个表格,能找到容器迭代器失效情况的完整列表。
以下是一个例子:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers { 1, 2, 3, 4, 5, 6, 4};
int val = 4;
std::vector<int>::iterator it;
for (it = numbers.begin(); it != numbers.end(); ++it) {
if (*it == val) {
numbers.erase(it);
numbers.shrink_to_fit();
}
}
std::cout << "numbers after erase:";
for (const auto num : numbers) {
std::cout << num << " ";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
当使用it
调用erase
时,it
的位置就失效了,下一次迭代中会发生什么属于未定义行为。你可能会发现一切正常,也可能发现结果不一致,甚至可能会遇到段错误。编译上述代码,自己检查一下,尝试不同的输入。