第 15 章:程序失效
本章介绍:
- 导致失效的不当操作;
- 程序状态劣化;
- 不幸事件;
- 预先检查错误;
- 清理。
C 程序可能以截然不同的方式失效:悄无声息、时断时续、可以预见,或者大张旗鼓。人们往往把大量注意力放在程序失效后可能发生什么,却忽视程序是否失效以及为何失效。这一点从一个经常被用于——甚至被误用于——此类情形的术语便可看出:未定义行为,行话中简称 UB。顾名思义,它关注错误发生后程序(更准确地说,整个系统)的行为,而非造成错误的原因。
这有些像通过恐吓驾驶员会被罚款或监禁来改善道路安全,而不是推动定期车辆安检、要求取得驾驶执照,或者教育驾驶员与乘客系安全带的好处。
本章区分三种程序失效形式。第一种,是直接造成失效的具体动作,即不当操作(第 15.1 节)。第二种情形中,执行状态逐步劣化,并不存在某个理应独自为程序失效负责的动作(第 15.2 节)。最后,最困难也最复杂的情形,是程序相距甚远的不同部分各自行为孤立看来都有效,却在不幸的组合下失效(第 15.3 节)。末尾还会简要讨论程序失效怎样显现;令人意外的是,外在表现与失效形式几乎没有关联(第 15.5 节)。
本章不处理编译、链接或启动阶段的失效。例如,把声明相互矛盾的翻译单元链接起来——包括 [[noreturn]] 等不一致属性——可能生成无效可执行文件;使用不正确的字符常量或字符串常量,或者把无效函数作为 atexit 处理程序,也会造成此类问题。这些失效甚至可能在程序表面上成功启动后才暴露,而程序其实早已注定失败,只是尚未察觉。
本章继续从一个与计算机截然不同的领域取材,希望以足够丰富的类比帮助理解问题:汽车、火车、飞机或火箭的交通运输。这个领域由一组与程序相似的约束支配:
- 它由众多参数描述,包括空间、速度、加速度、能耗和密度;
- 它具有动态性,情况每秒都在变化;某一时刻的正确动作,下一刻可能造成灾难;
- 它依赖数量有限的资源,例如可用空间、燃料或电力、氧气、时间、驾驶人员、车辆和轨道;
- 它具有多个目标,包括速度、吞吐量、舒适度、安全性和乐趣;
- 它受不同类型的规律与规则支配,包括物理规律、刑法、社会规则和迷信。
这里讨论的各种失效有一项共同点:它们在程序执行期间显现,而且可能出现在编译时无法检测的情况下。违反语言约束的错误通常会在编译期间得到诊断,反而没有那么令人担忧。
15.1 不当操作
第一类程序失效无疑最容易理解:某个动作或事件——或者本应发生的动作没有发生——直接导致失效。一般而言,如果汽车驶离笔直道路并撞上树木,我们会责怪驾驶员,怀疑其有某种不当操作,例如操纵失误、超速、酒后驾驶或类似违法行为。车辆或道路本身也有一丝出错的可能,所以没有其他明显原因时,也可以调查它们。但只有在极少数情况下,才应当让报道事故的记者、地球自转,或者那棵被撞的老橡树为损失负责。
15.1.1 算术违规
在程序执行可能出现的不当操作中,最简单的一类是 C 标准所称的异常条件,也就是操作所用操作数不存在已定义的数学结果。最常见的可能是:
- 除以零;
- 对零取模。
对于我们表示的数,这些操作没有数学定义。浮点算术另见下文。
还有几项类似操作,其问题与把数表示成有限位集合的具体方式有关,例如:
- 对
INT_MIN或类似负值取负; - 位移操作的第二操作数为负,或者大于或等于第一操作数的宽度;
- 试图把某个位移入有符号整数类型的符号位。
历史上,不同平台处理这种非法操作的策略差异很大。一种十分常见的策略是直接崩溃;最好的策略则是避免它们。
要点 15.1.1-1
程序执行只应完成在底层类型范围内具有数学定义的算术操作。
毫不意外,涉及浮点算术时,“具有数学定义”在 C 中有特殊解释。如果平台提供表示“无穷大”的浮点值,那么除以零就有定义,其结果为无穷大,所有这类操作都有效。<float.h> 中的宏 INFINITY——C23 以前位于 <math.h>——还可以用来检验是否支持无穷大:
bool const has_inf =
#ifdef INFINITY
(1.0 / 0.0 == INFINITY)
#else
false
#endif
;2
3
4
5
6
7
头文件 <fenv.h>(floating-point environment,浮点环境)提供多个宏,编码除以零等浮点异常条件:
int excepts[] = {
#ifdef FE_DIVBYZERO
FE_DIVBYZERO,
#endif
#ifdef FE_INEXACT
FE_INEXACT,
#endif
#ifdef FE_INVALID
FE_INVALID,
#endif
#ifdef FE_OVERFLOW
FE_OVERFLOW,
#endif
#ifdef FE_UNDERFLOW
FE_UNDERFLOW,
#endif
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
从异常名称可以猜到,并非所有异常都对应潜在的算术违规,后文还会看到其他情况。这些常量可以作为位集合使用;把它们按位或进一个整数值,就能管理异常条件的组合。
在交通类比中,一项无效操作是高速行驶后猛踩刹车。如果车辆不够先进——对应不提供 INFINITY 的平台——就很可能冲出道路撞上树木。如果车辆配有防抱死制动系统(ABS)——对应处理 FE_DIVBYZERO 的平台——或许能幸运地留在路面上。但无论哪种情况,先超速再猛踩刹车都不是好主意。
受支持的浮点异常通常不会造成程序失效;该头文件还提供接口 fetestexcept 和 feclearexcept,用于查询或管理异常:
void print_except(void) {
char const* name[] = {
#ifdef FE_DIVBYZERO
"divbyzero",
#endif
#ifdef FE_INEXACT
"inexact",
#endif
#ifdef FE_INVALID
"invalid",
#endif
#ifdef FE_OVERFLOW
"overflow",
#endif
#ifdef FE_UNDERFLOW
"underflow",
#endif
};
int except = fetestexcept(FE_ALL_EXCEPT);
if (except) {
printf("[");
for (unsigned j = 0; except; except &= ~excepts[j], ++j) {
if (excepts[j] & except) {
printf("%s ", name[j]);
}
}
printf("]");
}
}
int main(int argc, char* argv[static argc + 1]) {
printf("division by zero is %sequal to INFINITY\n",
has_inf ? "" : "un");
for (unsigned i = 1; i < argc; ++i) {
feclearexcept(FE_ALL_EXCEPT);
double x = strtod(argv[i], nullptr);
printf("%g ", x);
print_except();
feclearexcept(FE_ALL_EXCEPT);
printf(": %g ", 1.0 / x);
print_except();
puts("");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
要点 15.1.1-2
平台的浮点环境决定哪些浮点操作会造成程序失效。
因此,使用可能失效、也可能不失效的浮点操作时,数值程序的可移植性是真正需要担忧的问题,必须通过特性检验谨慎处理。
另一组可能失效的算术操作作用于指针值。把指针天真地理解成伪装起来的整数,并不是完整真相。由于地址空间分段,指针值的加法或比较可能比整数上的类似操作受到更多限制。以下情形存在问题:
- 地址与整数相加或相减后越过数组边界;
- 比较没有指向同一数组对象的指针。
即使理论结果表示一个有效地址,操作本身也可能已经无效。
要点 15.1.1-3
指针操作应当始终停留在数组对象边界以内。
一般而言,想读取数组的特定元素时,无论直接访问还是通过指针访问,最好使用数组索引 A[i] 表达意图,而不是显式算术 *(A + i)。现代编译器可能更容易捕获无效索引。
要点 15.1.1-4
只要可能,就使用数组索引,不要组合指针算术与解引用。
另一项指针算术操作是指针减法,它同样要求两个指针指向同一数组对象。在同一次程序运行中,两个指针值甚至可能先后具有不同状态:如果恰好位于同一数组中,它们有效;如果目标缓冲区随后得到回收,二者转而指向不同数组对象,那么完全相同的值参与同一操作也可能出错。
与指针算术相似,关系运算符 <、<=、>= 和 > 要求两个操作数都指向同一数组。
指针减法还依赖类型表示。如果结果无法放入 ptrdiff_t,操作就是错误的。某些平台的 ptrdiff_t 整数类型极为受限,例如只有 16 位;在那里,支持的两指针最大距离是
15.1.2 无效转换
当某个值改用与初始类型不同的类型解释,却不存在合理解释时,就会发生无效转换。请记住,如果目标类型是无符号类型,转换始终按模运算完成,结果总有明确定义。其他目标类型则可能产生错误:
- 从整数类型无效转换到有符号类型。 例如把
UINT_MAX转换成signed。结果由实现定义:某些实现采用无符号值的位模式,另一些实现可能停止执行。因此,尽管各实现分别明确定义了行为,它仍不可移植,应当视为错误情形。 - 浮点值与整数之间的无效转换。 浮点值可能超出目标整数类型范围,大整数值也可能拥有远超浮点类型处理能力的精度。
- 不同浮点类型之间的无效转换。 精度可能丢失,源值也可能超出目标类型值域。
- 把指针转换成位数不足的整数。 只有整数类型窄于指针类型宽度时才会发生。如果目标类型是
uintptr_t,并且该类型存在,转换总有定义。 - 把指针转换成另一类型的指针。 源指针可能没有为目标类型正确对齐。
所有这些失效都直接发生在相应操作处,与是否使用转换结果无关。反过来,转换有效也不意味着结果值可以任意使用。例如,指针到指针转换成功,并不表示新指针所引用的对象可以通过新指针值访问。
15.1.3 值违规
错误值可能造成的另一大类程序失效,来自对 C 库函数的调用。许多函数具有以下概念:
- 调用实参不允许采用的无效值,例如空指针、过大的数,或者为分配函数传入零大小;
- 使请求操作结果无法表示的值。
现代编译器和某些静态分析工具可能足够先进,能够检测其中一部分违规;但一般而言,必须仔细阅读规范才能发现此类情形。
15.1.4 类型违规
C 的类型系统相对严格,以错误类型访问对象或函数,在大多数情况下都是错误。无论对象还是函数,这类访问都只有在把指针转换成另一类型指针时才可能发生。因此,阻止此类失效最简单的方式是避开这些操作。
要点 15.1.4-1
除非不可避免,否则不要转换指针。
第 12.6 节已经介绍通过不同类型访问对象的规则。调用 C 函数的规则则非常简单。
要点 15.1.4-2
始终按照函数定义所用的原型调用函数。
用不同原型调用函数的一种方式,是先形成函数指针,再把该函数指针值强制转换成另一类型。这有些像采用特定轨距的铁路系统:如果为机车填写与真实轨距不同的规格,即使煞有介事地把谎言写进文件和车头面板,也无法改变事实。如果两项规格相差不大——例如只有几毫米——机车或许最初还能运行几米,却会在第一处弯道或道岔脱轨。
避免这种情形最简单的方式,是根本不使用函数指针;如果不得不用,绝不要通过强制转换抹掉类型。
要点 15.1.4-3
通过函数名称调用函数。
还有一种可能:不同翻译单元使用同一个函数名,却给出不同原型。在这种情况下,只要执行碰到对该函数的调用,便会失效。
15.1.5 访问违规
访问违规很可能是 C 程序中最常见、直接源自不当操作的失效。具体情况很多,而且绝大多数错误访问都可能带来灾难性后果。交通中的“禁止驶入”标志是一个恰当类比:驶入只有标志保护的道路并不困难,也没有力量直接阻止你,但结果可能极其惨烈。
C 中的访问违规通常通过指针或数组发生,否则严格的类型系统已经会在编译时强制给出诊断。以下列表体现了使用指针编程所承担的特殊责任:
- 解引用空指针;
- 访问已经不存在的对象,例如通过悬空指针访问局部对象、访问已经释放的存储,或者访问已经改变的系统对象(如区域设置指针);
- 从未定序的子表达式修改并读取同一个对象;
- 越界访问数组之后紧邻位置上的元素;[1]
- 修改不可变对象,例如受
const限定的对象、字符串字面量或临时对象; - 通过非
volatile左值访问volatile对象; - 通过并非基于同一指针的左值,访问以
restrict指针为基础的对象; - 访问原子结构体或联合体的成员;
- 从相互重叠的对象存储,例如使用
=、scanf或memcpy; - 试图访问没有元素的柔性数组成员(见第 13.1.3 节);
- 通过带属性原型访问不具有相应性质的函数;
- 调用通过已经死亡的函数语境初始化的
longjmp; - 因计算异常进入信号处理程序后,从该处理程序返回;
- 对已经释放的指针调用
free。
面对这些潜在访问违规,使用者代码应当追踪允许访问所需的条件;只要可能,就增加检验,阻止访问违规发生。
15.1.6 值解释错误
如果对象存储的位模式无法按访问该对象的类型得到已知的有效解释,就会发生值解释错误。C23 称这种状态为不确定表示。
值解释出错最重要的情形,是对象尚未获得任何解释,也就是程序访问了尚未初始化的存储。它可能来自函数内部定义但没有初始化的对象(见要点 5.5-1),也可能来自使用 malloc 而非 calloc 的动态分配。第 16 章会更详细地讨论这些问题。
还有一些对象类型并非所有可能位模式都表示有效值,这种模式称为非值表示。[2] 按定义就拥有多于值域所需表示位的标准类型包括 bool、atomic_flag,以及 N 不是 CHAR_BIT 倍数的 _BitInt(N) 类型。
例如,bool 只有两个正确值 false 和 true,却至少拥有 8 位。把最低有效位之外的位设为非零,可能造成布尔值解释错误。在某些语境中,编译器可能方便地检验全零位模式;另一些语境中,它可能只检查最低有效位。因此,随意改动 bool 对象的表示字节会造成严重破坏。
要点 15.1.6-1
不要在 bool 对象中存储 0 和 1 以外的值。
此前未列出的标准类型(例如浮点类型)也可能存在非值表示,但如今相对少见。尽管如此,保持谨慎依然有益。
要点 15.1.6-2
不要直接改变对象的表示字节。
15.1.7 显式声明不可到达
C23 引入了一项标注潜在失效的新工具:宏 unreachable。调用它,就是断言任何执行都绝不会抵达这条控制路径。考虑以下函数:
ptrdiff_t ptr_dist(void const* p, void const* q) {
if (!p || !q) {
unreachable();
}
unsigned char const* P = p;
unsigned char const* Q = q;
return P - Q;
}2
3
4
5
6
7
8
条件语句及 unreachable() 调用表明:该函数绝不会收到空指针形参值。遗憾的是,目前没有办法在 void 指针的函数原型中补充这项信息,所以必须完全由使用者保证它绝不发生。
要点 15.1.7-1
只有掌握证明时才能使用 unreachable()。
特别是,只要可能,就不要依赖外部信息作出这种判断。前例更适合使用带边界的数组形参,明确两个指针都不能为空,还能帮助编译器在调用位置判断这项性质:
[[maybe_unused]]
static inline ptrdiff_t ptr_dist_uchar(
unsigned char const p[static 1],
unsigned char const q[static 1]) {
if (!p || !q) {
unreachable();
}
return p - q;
}
#define ptr_dist(P, Q) \
ptr_dist_uchar((void const*){(P)}, (void const*){(Q)})2
3
4
5
6
7
8
9
10
11
12
调用 unreachable 与其他任何使执行进入未定义状态或显式结束执行的操作都不相同。其他操作——经常是除以零——可能具有 C 标准以外的实体所提供的定义,例如编译器实现或操作系统;exit 或 abort 等显式终止程序的操作也有其他规定行为,例如清理或引发信号。这些都不等同于告诉编译器执行绝不会来到这里。二者的差异,近似于把一条街标成死路,与这里根本不存在街道的差异。
要点 15.1.7-2
只能用 unreachable() 标记绝不会采用的控制路径,不要使用其他操作代替。
还要注意,真正的不当操作不是调用 unreachable 本身,而是此前导致执行来到这里的决策。掉进兔子洞造成的后果,不应当怪罪那只兔子。
15.2 程序状态劣化
程序状态劣化比不当操作难处理得多,因为无法归咎于某个个体或动作,而是多个动作相互作用的结果。遇到交通拥堵时,不应把局面怪在前车驾驶员身上;所有汽车都对劣化局面作出了同等贡献,车辆数量可能只是超过了道路容量。柏林一座每天发生拥堵的桥上曾经写着:“不是你身处拥堵之中;你就是拥堵本身。”
15.2.1 无界递归
第 7.3 节已经看到,程序状态劣化的一个重要示例是递归。如果不能明确判断每次调用相对于上次调用是否取得进展,也没有妥善定义递归触底条件,函数调用资源最终就会耗尽,使执行崩溃。请始终牢记要点 7.3-2,避免这种情况。
15.2.2 存储耗尽
无界递归通常耗尽的资源,是平台提供函数调用语境的能力。该资源通常称为栈,因为增加和移除函数调用语境的行为近似栈数据结构:调用把新语境压入栈,返回则从栈中弹出当前语境。因此,无界递归是栈溢出的一种特殊形式:栈的有限资源最终无法提供足够空间保存新的执行语境。
函数语境中包含变长数组时,也可能发生栈溢出。对象大小动态依赖数组边界,因此很难估计还有多少存储可用,程序状态又会多快劣化。请注意,这项问题只会发生在 VLA 本身,不会发生在其他变长修改(VM)类型上。另一方面,用 VLA 代替大型定长数组,可能显著降低栈用量。尽管如此,VLA 在 C 社区部分群体中的名声依然不佳;因此,与 VM 类型不同,VLA 在 C23 中仍是可选特性,可以通过宏 __STDC_NO_VLA__ 检验。
C 标准没有提供预见栈耗尽的工具,但 C 库另一套通常称为堆的存储系统,其耗尽可以检测。分配内存的标准函数 malloc、calloc、realloc、aligned_alloc、strdup 和 strndup 会在失败时返回空指针。但这类失败未必应归咎于当前调用,此前可能只是已经发出了太多调用。即便如此,这种情形仍优于执行状态无声劣化,因为它允许以受控方式关闭执行。
15.2.3 其他稀缺资源
除了存储,程序执行的运行时环境通常还拥有其他可能耗尽的稀缺资源:
| 资源 | 预留 | 释放 | 限制 |
|---|---|---|---|
| 调用语境 | va_start、va_copy | va_end | |
| 流 | fopen | fclose | FOPEN_MAX |
| 临时文件 | tmpfile | TMP_MAX | |
| 文件 | fopen、freopen | remove | |
| 线程语境 | thrd_create | thrd_join、thrd_detach | |
| 互斥 | mtx_init | mtx_destroy | |
| 条件量 | cnd_init | cnd_destroy | |
| 线程专属存储 | tss_create | tss_delete |
如果始终没有调用“释放”一栏所列函数,这些资源的使用就会不断累积。注意,fopen 会预留两种不同资源:一条流——通过返回指针表示——以及一个文件——通过第一个实参表示。大多数预留资源的函数也提供失败形式,使我们能够检测资源耗尽。遗憾的是,va_start 和 va_copy 不提供这项信息。
15.3 不幸事件
不当操作和资源耗尽至少在理论上能够局部捕获;不幸事件则来自时空上相距甚远的事件以某种方式对齐,共同造成执行失效。它们通常很少发生,而且难以追踪。
这类失效可以类比交通碰撞。在没有交通信号灯或空中交通管制员的世界中,两辆汽车或两架飞机各自独立规划轨迹,可能在距离规划位置很远的地方相撞。因为双方都无法看见拐角之后——或者恰好处在彼此盲区——它们各自都认为资源并不拥挤。接近危险区域(交叉口)后,再作调整可能已经太迟,最终相互碰撞。
15.3.1 状态劣化升级
最常见的不幸事件之一,实际上发生在状态已经劣化之后。例如,栈或堆耗尽后程序执行仍然继续,程序状态就会进一步劣化,导致系统反应紊乱,甚至可能伤害当前执行范围之外的事物。
这类似汽车的制动故障指示灯亮起。此时减速,不仅可能挽救自己和同车乘员的生命,还可能保护恰好位于车辆行进路线上的无辜旁观者。
15.3.2 碰撞与竞争条件
修改对象时,C 程序通常无须考虑程序执行的另一部分会同时改变该对象。这项性质建立在定序之上(见第 4.6 节);重要的是不要对编译器要求过多。
要点 15.3.2-1
不要在同一个算术表达式中读取并修改同一个对象。
带副作用的子表达式是典型示例,前文已经把它们列为不当操作:
printf("%lld\n", x++ + x); // 不要这样做!如果没有把它检测为失效,更新对象 x 的操作可能发生在第二次读取 x 之前、之后或同时。因此,打印值可能是初始值的两倍、该值加一,或者某个高位字和低位字来自不同计算阶段的怪异值。问题在于表达式可能很复杂,编译器不一定能够发现未定序访问。不过,只要通过对象名称使用对象,这类失效仍然只是某项不当操作的直接结果。
涉及指针时,情况更加复杂:
printf("%lld\n", (*p)++ + (*q)); // *p 和 *q 是否不同?p 和 q 可能碰巧相等,于是 *p 和 *q 引用同一个对象。因此,在代码某个角落给 p 设置特定值,再在完全不同的位置设置 q,可能在第三个毫不相干的位置产生致命结果。第 12.3 节和第 19.2 节会给出更多细节。
原则上,前述情形仍可通过预先检验 p == q 防范,从而避开未定序访问。但还有一些无法检验的类似情形,称为竞争条件。信号处理程序(见第 19.6 节)和被它中断的代码访问同一个对象时,就可能发生竞争。信号处理程序对对象的访问,与程序执行其余部分未定序;因此,只有满足特殊条件并采用特殊机制,这些访问才有效。不同执行线程并发访问同一个对象时,也会发生类似问题(第 20 章)。
15.3.3 不恰当的库调用和宏调用
C 库中的某些函数只允许在特定语境调用。例如,多线程程序不允许调用 signal。使用 signal 的函数可能在某些情况下链接进使用线程的程序;若使用线程的程序收到信号,整次执行就可能陷入危险。
另一个示例是第 19.5 节将详细介绍的宏 setjmp,它只允许出现在表达式中的少数特定位置。高质量实现很可能会诊断自己无法处理的 setjmp 宏调用位置,较为简单的实现却未必如此。因此,为保证可移植性,最安全的做法是遵守 C 标准施加的限制。
15.3.4 死锁
多个实体试图访问资源,并陷入循环依赖链时,就会发生死锁失效。在道路交通中,环岛完美体现了一种可能出错的资源冲突解决策略。它很适合解决局部资源冲突:若进入环岛的车辆优先级低于已经位于环岛内的车辆,就能避免访问环岛内某处的竞争条件。欧洲各地如今随处可见环岛,很可能正是因为它能以较低成本避免资源冲突以及由此造成的碰撞。
交通量不大时,这套策略效果良好;但在高度拥堵时,它会彻底失灵。如果环岛挤满了准备从下一出口离开的车辆,所有车辆都无法移动,也就不可能取得进展。因此,在交通高度拥堵的城市环境中,环岛通常较少,而会改用交通信号灯控制的交叉口。
幸运的是,用 C 编程时,死锁只能出现在多线程语境中(见第 20 章)。证明某个多线程程序不存在死锁是一项艰巨任务,本书无法给出令人完全满意的处理。不过,第 20.7 节会通过一个具体示例的情况分析提供若干方向。
15.4 一连串不幸事件
一种格外棘手的失效,是程序执行在有限状态集合上无休止循环,却没有取得可见进展。
要点 15.4-1
如果程序执行在有限状态集合上循环,而且没有可观察副作用,它就已经失效。
这有些像飞机在风暴眼中盘旋。眼前的处境看起来并不特别危险,但外界可能已经把飞机计为失踪。无论不可避免的坠毁在过去还是未来发生,确切时刻都不重要,因为我们再也见不到这架飞机了。
第 5.7.5 节已经见过一个可能没有取得足够进展的循环。稍微修改该示例,更仔细地观察各种可能:
void obscure(unsigned);
for (unsigned i = 0; true; ++i) {
obscure(i);
}
unreachable();2
3
4
5
6
这个循环是否失效,取决于函数 obscure。仅凭给定规格,编译器无法判断循环是否会系统性失效。根据 i 的值,该函数可能产生可见副作用,例如:
- 完成某项输入输出;
- 改变某项全局状态;
- 调用
exit。
所以,虽然这个 for 循环没有明显终止条件,却不能据此断定它必然失效。只有掌握函数的附加信息时——例如函数带有 [[unsequenced]] 属性——编译器才可能作出这种假定并给出诊断。否则,程序员必须保证 obscure 至少对 i 的某个值取得进展。无论如何,循环只能通过退出整次执行而终止,unreachable 调用绝不会到达。
若把 i 的类型从 unsigned 改成 signed,它甚至不再是无限循环:
for (signed i = 0; true; ++i) {
obscure(i);
}
unreachable();2
3
4
编译器可以假定:程序会在 i 的某个正值处终止;否则,当 i 等于 INT_MAX 时就会发生算术异常。
无界递归也可能是缺少进展的程序特例。对于某些递归函数,编译器可能发现无须为每次调用分别保存状态;这样的无界递归调用就可能替换成不再分配额外资源的循环。与普通循环类似,优秀编译器可能警告递归调用会导致系统性程序失效。
多线程程序还可能因一连串不幸事件而发生更加复杂的失效:活锁。它的效果与前面讨论的死锁相似——基本上看不到任何事情发生——但活锁中程序各组成部分的交互更加复杂。
可以把它类比成由改道路线组成循环模式的大型交通系统。想象城市街区四角的四个交叉口,每处都设置改道。单看任何一条改道都像是好主意,完整组合却形成陷阱:抵达任一交叉口的车辆都会永远绕街区行驶。即使只有一辆车,每一刻看起来都在正常前进,却永远逃不出旋涡。
这只是极其简单的活锁示例。真实情况可能有多辆汽车参与,改道路线也可能把它们送遍全城。车辆抵达次序和行驶速度不同,还可能在某处交换顺序或走上不同路线,却始终无法逃离。这类情况更难发现和预防。第 20.7 节会通过情况分析,展示怎样为实际示例证明不存在活锁。
15.5 应对失效
C 程序失效可能具有不同迹象,也可能完全不被察觉。事实上,应对失效最简单的方法就是避免它。
要点 15.5-1
保证可能失效的操作满足全部前置条件。
也就是说,第 15.1 节列出的所有不当操作都不应在正常程序执行中出现。应当检验前置条件,并在不满足时执行替代操作。保证所有前置条件可能并不总是容易,但总体策略应当如此。
同样,一般也应检测程序状态劣化,不过这次只能在已经发生后检测。
要点 15.5-2
可能耗尽资源的操作,应当检查其返回值是否表示错误。
这种错误通常有两类指示:C 库函数的错误返回(见第 8.1.3 节),以及 errno 的非零值。第 15.6 节将介绍处理策略。
不幸事件要难处理得多。按其本性,几乎没有能够检测局面的指示。不过,现代系统拥有一些可以限制系统损害的特性:
- 信号是硬件与软件特性的一种古老而神秘的混合体,它会在给定位置中断程序执行,并允许使用者提供随后执行、用于挽救局面的后备代码(见第 19.6 节)。
- 陷阱同样会立即中断执行流。与信号不同,执行会直接结束;是否至少进行部分清理,在很大程度上取决于系统。
- C 为程序执行提供终止函数
exit、quick_exit、_Exit和abort,第 8.8 节已经见过。清理程度取决于具体函数,从使用者定义的清理函数(exit和quick_exit)到几乎完全不清理(abort)不等。 - 同样,可以用
thrd_exit单独终止线程,第 20.6 节会介绍。 - 最后,发生拜占庭失效后,执行仍会继续,系统状态却不断恶化。此时无法捕获失效;整个系统可能受到严重破坏,敏感信息可能丢失,现实世界中甚至可能启动灾难性动作。
要点 15.5-3
只有谨慎设计算法,才能避免不幸事件。
15.6 错误检查与清理
检验操作前置条件或 C 库调用结果是否有效时,C 程序可能遇到许多错误条件。错误可能来自程序设计错误、编译器或操作系统软件缺陷、硬件错误、资源耗尽(例如内存不足),或者这些因素的任意恶意组合。要让程序可靠,必须检测错误条件并妥善处理。
先看函数 fprintnumbers 的说明,它延续第 14.1 节讨论的一系列函数。该函数区分四种错误条件,通过返回负常量值表示。这些值的宏通常由平台在 <errno.h> 中提供,而且都以大写字母 E 开头。遗憾的是,C 标准只强制提供 EOF(负值),以及 EDOM、EILSEQ 和 ERANGE(正值)。
fprintnumbers
使用 printf 格式 form,在流 stream 上打印 nums 中的一系列数;各数之间以字符 sep 分隔,并以换行字符结束。
成功时返回写入 stream 的字符数,出错时返回负错误值。若 len 为 0,打印空行并返回 1。
可能返回:
- 若
stream尚未准备好写入,返回EOF(负值); - 若需要写入超过
INT_MAX个字符,包括len大于INT_MAX的情况,返回-EOVERFLOW; - 若
stream是空指针,返回-EFAULT; - 若
nums是空指针且len非零,返回-EFAULT; - 若发生内存错误,返回
-ENOMEM。
该函数使 errno 保持进入函数时的值。
int fprintnumbers(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict len]);2
3
4
5
平台不一定提供其他错误值。因此,代码开头用一系列预处理语句为缺失值提供默认定义,并保证这些宏的值彼此不同:
#include <errno.h>
#ifndef EFAULT
#define EFAULT EDOM
#endif
#ifndef EOVERFLOW
#define EOVERFLOW (EFAULT - EOF)
#if EOVERFLOW > INT_MAX
#error EOVERFLOW constant is too large
#endif
#endif
#ifndef ENOMEM
#define ENOMEM (EOVERFLOW + EFAULT - EOF)
#if ENOMEM > INT_MAX
#error ENOMEM constant is too large
#endif
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
清单 15.1:打印数值数组
int fprintnumbers(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict len]) {
if (!stream) {
return -EFAULT;
}
if (len && !nums) {
return -EFAULT;
}
if (len > INT_MAX) {
return -EOVERFLOW;
}
size_t tot = (len ? len : 1) * strlen(sep);
int err = errno;
char* buf = nullptr;
if (len) {
/* 计算各个数所需字符数。 */
for (size_t i = 0; i < len; ++i) {
tot += snprintf(nullptr, 0, form, nums[i]);
}
/* 返回类型是 int,所以必须限制最大大小。 */
if (tot > INT_MAX) {
return error_cleanup(EOVERFLOW, err);
}
}
buf = malloc(tot + 1);
if (!buf) {
return error_cleanup(ENOMEM, err);
}
sprintnumbers(tot, buf, form, sep, len, nums);
/* 一次打印整行。 */
if (fputs(buf, stream) == EOF) {
tot = EOF;
}
free(buf);
return tot;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
整项函数实现中,错误处理几乎占据主要工作量。开头三段处理进入函数时已存在的错误,它们反映未满足的前置条件;用附录 K 的语言说,就是运行时约束违规。
动态运行时错误更难处理。特别是,C 库中的某些函数可能通过伪对象 errno 传达错误条件。如果希望捕获并修复所有错误,就必须避免改变执行的任何全局状态,其中包括 errno。做法是进入函数时保存当前值,发生错误时通过小函数 error_cleanup 恢复:
static inline int error_cleanup(int err, int prev) {
errno = prev;
return -err;
}2
3
4
函数核心用 for 循环遍历输入数组,计算应当打印的总字节数。循环体通过空指针和大小为 0 的缓冲区调用 snprintf,计算每个数所需大小;再用第 14.1 节的 sprintnumbers 生成大字符串,并通过 fputs 打印。
请注意,成功调用 malloc 后没有错误出口。即使 fputs 返回时检测到错误,也只是把信息存入 tot,不会跳过 free。因此,即使发生输出错误,也不会留下分配内存泄漏。这里处理潜在输入输出错误相对简单,因为 fputs 调用离 free 很近。
函数 fprintnumbers_opt 需要更加谨慎。它试图进一步优化过程,直接打印数值,不再预先统计所需字节。随着执行推进,可能遇到更多错误条件;必须保证最终调用 free。第一个条件是初始分配的缓冲区太小:若通过 realloc 扩大缓冲区失败,就必须谨慎退出。总字符串长度超过 INT_MAX 这种不太可能发生的情况也一样。
清单 15.2:打印数值数组的优化版本
int fprintnumbers_opt(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict static len]) {
if (!stream) {
return -EFAULT;
}
if (len && !nums) {
return -EFAULT;
}
if (len > INT_MAX) {
return -EOVERFLOW;
}
int err = errno;
size_t const sep_len = strlen(sep);
size_t tot = 0;
size_t max_tot = len * (sep_len + 10);
char* buf = malloc(max_tot);
if (!buf) {
return error_cleanup(ENOMEM, err);
}
for (size_t i = 0; i < len; ++i) {
tot += sprintf(&buf[tot], form, nums[i]);
++i;
if (i >= len) {
break;
}
if (tot > max_tot - 20) {
max_tot *= 2;
char* new_buf = realloc(buf, max_tot);
if (new_buf) {
buf = new_buf;
} else {
tot = error_cleanup(ENOMEM, err);
goto CLEANUP;
}
}
memcpy(&buf[tot], sep, sep_len);
tot += sep_len;
if (tot > INT_MAX) {
tot = error_cleanup(EOVERFLOW, err);
goto CLEANUP;
}
}
buf[tot] = 0;
/* 一次打印整行。 */
if (fputs(buf, stream) == EOF) {
tot = EOF;
}
CLEANUP:
free(buf);
return tot;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
两种错误情况下,函数都用 goto 跳到清理代码,再由后者调用 free。这是 C 中久经考验的技术:它既能保证清理发生,也能避免难以阅读的嵌套 if-else 条件。goto 规则相对简单。
要点 15.6-1
goto 的标签在包含它的整个函数内可见。
要点 15.6-2
goto 只能跳到同一个函数内的标签。
要点 15.6-3
goto 不应跳过对象初始化。
自 Dijkstra [1968] 的文章开始,程序设计语言是否应使用 goto 及类似跳转一直备受争论。今天仍然有人强烈反对这里给出的代码,但不妨务实看待:无论有无 goto,代码都可能丑陋难懂。核心思路是尽量不干扰函数的“正常”控制流,并通过 goto 或 return 清楚标出只在异常情况下发生的控制流改变。
第 19.5 节还会看到 C 中另一项能够作出更剧烈控制流改变的工具:setjmp/longjmp。它允许跳到调用函数栈上的其他位置。
本章小结
- 程序失效可能源自不当操作、程序状态劣化或不幸事件。
- 谨慎检查前置条件,可以避免潜在的不当操作。
- 对可能导致状态劣化的 C 库函数调用,应检查错误返回。
- 不幸事件没有补救办法;必须通过谨慎的程序设计保证它们不会出现。
- 处理错误条件可能导致复杂的情况分析。可以为每个函数设置专门的清理代码块,并通过
goto语句跳转到该处,从而组织这些逻辑。