10 组织与文档
本章内容
- 如何为接口编写文档
- 如何解释实现
编程是一项重要的社会、文化和经济活动,要取得成功,就需要某种形式的组织。与编码风格一样,初学者往往低估代码与项目组织以及文档编写所需的精力。遗憾的是,我们许多人都经历过这样的时刻:代码写完一段时间后再回来读,却完全想不起它在做什么。
为程序代码编写文档,或者更一般地说,解释程序代码,并非易事。我们必须在提供上下文和必要信息,与乏味地陈述显而易见之事之间找到恰当平衡。请看下面两行:
u = fun4you(u, i, 33, 28); // ;)
++i; // 递增 i2
第一行不好,因为它使用了魔数、一个无法说明正在发生什么的函数名,以及至少对我而言没有多少含义的名称 u。笑脸注释表明程序员写这段代码时很开心,但对偶然读到它的人或维护者没有多少帮助。
第二行的注释多此一举,只是在陈述任何稍有经验的程序员都知道的 ++ 运算符含义。
再与下面的代码比较:
/* 33 和 28 很合适,因为二者互素。 */
u = nextApprox(u, i, 33, 28);
/* 定理 3 保证我们可以进入下一步。 */
++i;2
3
4
从这里可以推断出更多内容。我会猜想 u 是浮点值,很可能是 double,也就是说,它正接受某种近似过程。该过程分步进行,由 i 标出步骤,还需要几个满足互素条件的附加实参。
一般来说,我们依重要性从高到低,要遵循“做什么、为何做、怎样做、以何种方式做”的规则。
要点 10 #1(做什么)
函数接口描述要做什么。
要点 10 #2(为何做)
接口注释记录函数的用途。
要点 10 #3(怎样做)
函数代码展示函数如何组织。
要点 10 #4(以何种方式做)
代码注释解释函数细节以何种方式实现。
设想一个供他人使用的大型库项目:所有用户大概都会阅读接口规范(例如手册页的概要部分),大多数用户还会阅读这些接口的说明(手册页的其余部分)。查看源代码、了解某个具体接口实现怎样或以何种方式完成工作的用户,则要少得多。
这些规则首先带来一项结论:代码结构与文档相辅相成。接口规定与实现之间的区别尤其重要。
要点 10 #5
分离接口与实现。
这条规则体现为两种不同的 C 源文件:通常以 ".h" 结尾的头文件,以及以 ".c" 结尾的翻译单元(translation unit,TU)。
语法注释在这两种源文件中扮演两种不同角色,应当加以区分。
要点 10 #6
为接口编写文档;对实现作出解释。
10.1 接口文档
与 Java、Perl 等较新的语言不同,C 没有“内置”的文档标准。不过近年来,一个跨平台的公有领域工具在许多项目中得到广泛采用:Doxygen。它可以自动生成网页、PDF 手册、依赖图等大量内容。即使不使用 Doxygen 或其他同等工具,也应采用它的语法为接口编写文档。
要点 10.1 #1
详尽记录接口。
Doxygen 提供许多有助于文档编写的类别,但展开讨论远远超出本书范围。只看下面这个示例:
/**
** @brief 使用 Heron 过程,求 @a a 的 `1/k` 次幂近似值
**
** 换言之,这会计算 @a a 的 @f$k^{th}@f$ 次方根。
** 一项特殊功能是:如果 @a k 为 `-1`,则计算
** @a a 的乘法逆元。
**
** @param a 必须大于 `0.0`
** @param k 不应为 `0`,否则应介于
** `DBL_MIN_EXP*FLT_RDXRDX` 和
** `DBL_MAX_EXP*FLT_RDXRDX` 之间。
**
** @see FLT_RDXRDX
**/
double heron(double a, signed k) [[__unsequenced__]];117
118
119
120
121
122
123
124
125
126
127
128
129
130
Doxygen 会为该函数生成类似图 10.1 的在线文档,还能生成可纳入本书的格式化文本:
图 10.1:Doxygen 生成的文档
heron_k.h
heron
使用 Heron 过程,求 a 的
换言之,这会计算 a 的 k 为 -1,则计算 a 的乘法逆元。
形参:
a:必须大于0.0;k:不应为0,否则应介于DBL_MIN_EXP*FLT_RDXRDX和DBL_MAX_EXP*FLT_RDXRDX之间。
另见: FLT_RDXRDX
double heron(double a, signed k) [[__unsequenced__]];FLT_RDXRDX
FLT_RADIX 的以 2 为底的基数。
后面的一些代码在内部需要它。
#define FLT_RDXRDX something你大概已经猜到,以 @ 开头的单词对 Doxygen 具有特殊含义:它们引出关键字。这里有 @param、@a 和 @brief。第一个记录函数形参,第二个在文档其他部分引用这样的形参,最后一个提供函数的简短概要。
此外可以看到,注释内部具有一定的标记能力;Doxygen 还能识别翻译单元 "heron_k.c" 中定义该函数的位置,以及实现所涉及各函数的调用图。
要让项目组织良好,代码用户就应能够轻松找到相互关联的内容,而不必四处搜寻。
要点 10.1 #2
把代码组织成语义联系紧密的单元。
最常见的做法很简单:把处理某个特定数据类型的所有函数放入同一个头文件。用于 struct brian 的典型头文件 "brian.h" 如下:
#ifndef BRIAN_H
#define BRIAN_H 1
#include <time.h>
/** @file
** @brief 跟随松鸦 Brian
**/
typedef struct brian brian;
enum chap { sct, en, };
typedef enum chap chap;
struct brian {
struct timespec ts; /**< 时刻 */
unsigned counter; /**< 财富 */
chap masterof; /**< 职业 */
};
/**
** @brief 取得下一个时刻的数据
**/
brian brian_next(brian);
// ...
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这个文件包含使用该结构体所需的全部接口。它还包含编译这些接口可能需要的其他头文件,并用包含防护(这里是宏 BRIAN_H)防止重复包含。
10.2 实现
如果阅读优秀程序员写的代码(你应当经常这样做!),会发现其中的注释往往很少。不过,只要读者掌握 C 语言基础,代码仍可能相当易读。优秀的程序只需解释那些不显而易见的思想和前提(这才是困难部分)。代码结构本身会展示它做什么以及怎样做。
要点 10.2 #1
按字面直接实现。
C 程序是对“要做什么”的描述性文本。此前介绍的实体命名规则,对于让这段描述性文本可读、清晰起着关键作用。另一项要求是让控制流一目了然:复合语句用 {} 分组,在视觉上形成清楚可辨的结构,再由表意完整的控制语句连接起来。
要点 10.2 #2
控制流必须一目了然。
混淆控制流有许多方法,其中最重要的如下。
深埋跳转。 break、continue、return 和 goto[1] 语句深埋在 if 或 switch 语句的复杂嵌套结构中,有时还与循环结构组合在一起。
蝇斑表达式。 控制表达式以不同寻常的方式组合大量运算符(例如 !!++*p-- 或 a --> 0),以至于必须用放大镜检查,才能理解控制流接下来去往何处。
以下各节将集中讨论几项对 C 代码可读性和性能至关重要的概念:
- 宏可以方便地简写某项功能;但若使用不慎,也会混淆使用它的代码,并触发隐蔽缺陷(10.2.1 节)。
- 如前所述,函数是 C 中实现模块化的首选手段。某些函数的一项特殊性质在这里尤其重要:纯函数只通过接口与程序其余部分相互作用。因此,人和编译器都容易理解纯函数,而且纯函数通常会带来相当高效的实现(10.2.2 节)。
- 我们已经看到,可以用属性把更多信息附在代码上。10.2.3 节将作更详细的讨论。
10.2.1 宏
我们已经知道一种可能遭到滥用、进而混淆控制流的工具:宏。希望你还记得 5.6.3 节和 8.1.2 节,宏定义的是文本替换,其中几乎可以包含任何 C 文本。由于下面要展示的问题,许多项目完全禁止使用宏。不过,C 标准的演进方向并非如此。例如,我们已经看到,类型泛化宏是数值函数的现代接口(见 8.3 节)。宏应当用于初始化常量(5.6.3 节),或者用于实现编译器魔法(如 errno,见 8.1.3 节)。
所以,我们不应拒绝它,而应试着驯服这头野兽,制定几条简单规则,把可能的损害限制住。
要点 10.2.1 #1
宏不应以出人意料的方式改变控制流。
与初学者讨论时,偶尔会出现下面这种臭名昭著的示例:
#define begin {
#define end }
#define forever for (;;)
#define ERRORCHECK(CODE) if (CODE) return -1
forever
begin
// 做某件事
ERRORCHECK(x);
end2
3
4
5
6
7
8
9
10
不要这样做。C 程序员的视觉习惯和工具都难以适应这种代码;在复杂代码中使用它们,几乎肯定会出错。
这里的 ERRORCHECK 宏尤其危险。它的名称没有暗示其中可能隐藏着 return 这样的非局部跳转,实现则更加危险。考虑下面两行:
if (a) ERRORCHECK(x);
else puts("a is 0!");2
它们会重写成:
if (a) if (x) return -1;
else puts("a is 0!");2
else 子句(所谓的悬空 else)会与我们看不见的最内层 if 结合。因此,这等价于:
if (a) {
if (x) return -1;
else puts("a is 0!");
}2
3
4
这大概会令偶然读到代码的人相当意外。
这并不意味着宏中完全不该使用控制结构。它们只是不应隐藏,也不应产生出人意料的效果。下面这个宏本身也许并不那么直观,但它的使用不会带来意外:
#define ERROR_RETURN(CODE) \
do { \
if (CODE) return -1; \
} while (false)2
3
4
这个宏的名称明确表明其中可能有 return。替换文本还处理了悬空 else 问题:
if (a) ERROR_RETURN(x);
else puts("a is 0!");2
与前面的悬空 else 不同,接下来的示例会按预期组织代码,else 与第一个 if 关联:
if (a) do {
if (CODE) return -1;
} while (false);
else puts("a is 0!");2
3
4
do-while(false) 技巧显然很丑,不应滥用。不过,这是一个标准技巧:用 {} 复合语句包围一条或多条语句,既不改变肉眼可见的程序结构,也不会引发悬空 else 等问题。
要点 10.2.1 #2
类函数宏在语法上应当表现得像函数调用。
可能的陷阱包括:
有 if 而没有 else。 前面已经展示。
尾随分号。 它可能以出人意料的方式终止外层控制结构。
逗号运算符。 逗号在 C 中含义不唯一。多数上下文把它用作列表分隔符,例如函数调用、枚举项声明或初始化式;在表达式上下文中,它却是控制运算符。请避免使用。
可继续结合的表达式。 把表达式放入非平凡上下文后,它会以意想不到的方式与运算符结合。[练习 7] 在替换文本中,用圆括号包围形参和表达式。
多次求值。 宏是文本替换。如果宏形参使用两次(或更多次),其效果也会发生两次。[练习 8]
练习 7
设宏 sum(a, b) 实现为 a+b。sum(5, 2)*7 的结果是什么?
练习 8
设 max(a, b) 实现为 ((a) < (b) ? (b) : (a))。求取 max(i++, 5) 会发生什么?
10.2.2 纯函数
我们自行声明的 C 函数,如 size_min(4.5 节)和 gcd(7.3 节),在表达能力上受到限制:它们不操作对象,而是操作值。从某种意义上说,它们扩展的是表 4.1 中的值运算符,而不是表 4.2 中的对象运算符。
要点 10.2.2 #1
函数形参按值传递。
也就是说,调用函数时会求取所有实参,并用所得值初始化各个形参(函数的局部对象)。随后函数完成必须进行的工作,再通过返回值送回计算结果。
目前,要让两个函数操作同一个对象,唯一办法是声明一个对两个函数都可见的对象。这样的全局对象缺点很多:它们使代码缺乏灵活性(所操作的对象固定不变),使行为难以预测(修改位置四处分散),也难以维护。
要点 10.2.2 #2
全局对象不受欢迎。
具有以下两项性质的函数称为纯函数:
- 除返回值外,函数没有其他效果;
- 函数的返回值只取决于形参。
执行纯函数时,唯一值得关心的是结果,而结果只取决于所传实参。从优化角度看,纯函数可以挪动位置,甚至可以与其他任务并行执行。只要形参已经可用,就可以随时开始执行;只需在使用结果前完成即可。
凡是通过返回值以外的方式改变抽象状态机的效果,都会使函数失去纯函数资格。例如:
纯函数是执行小任务的绝佳函数模型,但需要完成更复杂的任务时,它们就相当受限。另一方面,优化器喜爱纯函数,因为只用形参和返回值便可描述它们对程序状态的影响。纯函数对抽象状态机的影响十分局部,很容易描述。
要点 10.2.2 #3
只要可能,就把小任务表述为纯函数。
如果初步愿意接受在各处复制一点数据,那么借助纯函数可以走得出乎意料地远,甚至能实现面向对象的编程风格。考虑下面用于有理数算术的结构体类型 rat:
struct rat {
bool sign;
size_t num;
size_t denom;
};9
10
11
12
这是这种类型的直接实现,除了本次学习体验之外,不应把它用作库。为简单起见,分子和分母采用相同类型(size_t),并在成员 sign 中记录数的符号。第一个纯函数 rat_get 接收两个数,并返回表示二者之商的有理数。
rat rat_get(signed sign, size_t num, size_t denom)
[[__unsequenced__]] {
rat ret = {
.sign = (sign < 0),
.num = num,
.denom = denom,
};
return ret;
}6
7
8
9
10
11
12
13
可以看到,这个函数相当简单。它只是用正确的符号、分子和分母值初始化一个复合字面量。请注意,以这种方式定义有理数时,多种表示会表示同一个有理数。例如,
为处理表示之间的这种等价关系,需要一些维护函数。核心思想是,这类有理数应当始终规范化,也就是采用让分子和分母所含因子最少的表示。这样不仅更便于人理解,也可能避免算术运算期间发生溢出。这里的 gcd 函数正是此前介绍的函数。
rat rat_get_normal(rat x) [[__unsequenced__]] {
size_t c = gcd(x.num, x.denom);
x.num /= c;
x.denom /= c;
return x;
}15
16
17
18
19
另一个函数进行规范化的逆操作,把分子和分母同乘一个冗余因子:
rat rat_get_extended(rat x, size_t f) [[__unsequenced__]] {
x.num *= f;
x.denom *= f;
return x;
}22
23
24
25
这样便可定义供他人使用的函数 rat_get_prod 和 rat_get_sum。rat_get_prod 先以简单方式计算结果表示,分别把分子和分母相乘。所得表示可能尚未规范化,所以返回结果时调用 rat_get_normal。
rat rat_get_prod(rat x, rat y) [[__unsequenced__]] {
rat ret = {
.sign = (x.sign != y.sign),
.num = x.num * y.num,
.denom = x.denom * y.denom,
};
return rat_get_normal(ret);
}28
29
30
31
32
33
34
rat_get_sum 稍微复杂一些。必须先找出公分母,才能计算结果的分子;还必须跟踪两个有理数的符号,判断分子应当怎样相加。
rat rat_get_sum(rat x, rat y) [[__unsequenced__]] {
size_t c = gcd(x.denom, y.denom);
size_t ax = y.denom / c;
size_t bx = x.denom / c;
x = rat_get_extended(x, ax);
y = rat_get_extended(y, bx);
assert(x.denom == y.denom);
if (x.sign == y.sign) {
x.num += y.num;
} else if (x.num > y.num) {
x.num -= y.num;
} else {
x.num = y.num - x.num;
x.sign = !x.sign;
}
return rat_get_normal(x);
}37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
可以看到,这些函数全都是纯函数,因此很容易使用,即使在这里自己的实现中也是如此。[练习 11][练习 12] 唯一要注意的是,必须始终把函数返回值赋给某个对象,例如第 40 行对 x 的赋值。否则,因为我们不操作对象 x,只操作它的值,函数执行期间的改动就会丢失。[4]
如前所述,反复复制可能使编译所得代码不如理论上高效。不过这丝毫不严重:优秀的编译器可以把复制操作的开销保持在相当低的水平。启用优化后,编译器通常可以在这类函数返回结构体时,直接就地操作该结构体。此外,这种担忧可能完全为时过早:程序本来就短小精悍,或者真正的性能问题在别处。以我们目前达到的编程技能层次,通常这样已经完全足够。以后会学习如何借助内联函数(16.1 节)和许多现代工具链提供的链接时优化,高效运用这项策略。
清单 10.1 列出此前见过的 rat 类型的所有接口(第一组)。我们也已经看过操作 rat 指针的其他函数接口;11.2 节将作更详细的解释。
练习 11
rat_get_prod 函数可能产生某些中间值,导致错误结果,即便乘法的数学结果可以用 rat 表示也是如此。这怎么可能?
练习 12
重新实现 rat_get_prod 函数,使数学结果能够用 rat 表示时,它始终产生正确结果。可以调用两次 rat_get_normal,而不是一次。
清单 10.1 用于有理数计算的类型
#ifndef RATIONALS_H
#define RATIONALS_H 1
#include <stdbool.h>
#include "euclid.h"
typedef struct rat rat;
struct rat {
bool sign;
size_t num;
size_t denom;
};
/* 返回 rat 类型值的函数。 */
rat rat_get(signed sign, size_t num, size_t denom)
[[__unsequenced__]];
rat rat_get_normal(rat x) [[__unsequenced__]];
rat rat_get_extended(rat x, size_t f) [[__unsequenced__]];
rat rat_get_prod(rat x, rat y) [[__unsequenced__]];
rat rat_get_sum(rat x, rat y) [[__unsequenced__]];
/* 操作 rat 指针的函数。 */
void rat_destroy(rat *rp) [[__unsequenced__]];
rat *rat_init(rat *rp,
signed sign,
size_t num, size_t denom) [[__unsequenced__]];
rat *rat_normalize(rat *rp) [[__unsequenced__]];
rat *rat_extend(rat *rp, size_t f) [[__unsequenced__]];
rat *rat_sumup(rat *rp, rat y) [[__unsequenced__]];
rat *rat_rma(rat *rp, rat x, rat y) [[__unsequenced__]];
/* 作为练习实现的函数。 */
/** @brief 把 @a x 打印到 @a tmp 中,并返回 tmp。 **/
char const *rat_print(size_t len, char tmp[len], rat const *x);
/** @brief 规范化并打印 @a x。 **/
char const *rat_normalize_print(size_t len, char tmp[len],
rat const *x);
rat *rat_dotproduct(rat rp[static 1], size_t n,
rat const A[n], rat const B[n]);
#endif2
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
10.2.3 属性
属性最近随 C23 出现。它们被设计成重要的注解工具,旨在帮助编译器和工具实现者开发新特性。例如,清单 10.1 中的 [[__unsequenced__]] 属性表示其中大多数函数都具有未定序性质,这是纯函数性质的推广。现在,编译只看得到该头文件的应用程序代码时,编译器仍可对函数作出很强的假设。如果函数没有指针形参,也不返回指针,它就是纯函数,返回值只取决于调用所传入的实参值。如果它有指针形参或返回指针(例如 rat_normalize,见后文讨论),与函数调用可能发生的相互干扰,也只限于通过这些指针可见的对象。
C23 引入了少量标准属性,并同时提供对宏更安全、带双下划线的变体,如 __unsequenced__:
deprecated fallthrough maybe_unused nodiscard
noreturn unsequenced reproducible2
我们已经见过其中大多数属性的实际用法。目前只有两个属性 [[deprecated]] 和 [[nodiscard]] 可以接收字符串形式的实参:
[[deprecated("tell me all about it")]]这些属性的含义如下:
[[deprecated]]属性表示不应直接使用它所附着的特性。原因可能有多种,主要原因大概是该特性已经过时(而且可能会删除),或者并非公共接口的一部分。使用带有该属性的特性时,编译器通常会发出诊断。不过,与类似的实现定义属性不同,在另一个同样弃用的特性内部使用弃用特性时,不应发出诊断。13.1.1 节有一个完整示例,突出展示[[deprecated]]的这一性质。- 如 3.3 节所见,
switch语句的控制流可能非常复杂。为避免错误,许多编码风格试图限制控制流,把没有break就从一个case贯穿到另一个case视为错误。[[fallthrough]]属性表明这种可能的控制流是有意为之,不应发出诊断。 - 有些地方可能声明或定义后文不一定使用的标识符,例如为文档目的而给出的未使用形参名,或者头文件中的
static函数,而包含该头文件的代码不一定总会使用它们。[[maybe_unused]]可以避免在这些情形中发出诊断。 [[nodiscard]]属性的主要用途,是表明函数的返回值很重要,始终都应考虑。对存储分配而言,这一点尤其重要;13.1.1 节也展示了这种用法。- 如 8.8 节所见,
[[noreturn]]属性同样与函数关联。它表示函数绝不会返回调用方,编译器因而可以优化周围代码。 [[unsequenced]]和[[reproducible]]属性将在 16.3 节详细讨论。
除标准属性外,还有带前缀的属性,其名称形式如下:
prefix::suffix其中 prefix 是通常由编译器或工具实现者选择的标识符;:: 是 C 中其他地方均未出现的新语法记号;suffix 是表示具体特性或性质的标识符。我目前所知的前缀来自三大编译器家族:clang、gnu 和 msvc。但也许令人意外的是,这并非严格边界。例如,Clang 编译器实现了 GNU 编译器的许多属性,并继续为它们保留 gnu 前缀。
一般来说,这类带前缀的属性也可以接收实参;唯一的语法约束,是实参中的 ()、[] 和 {} 必须正确嵌套、成对平衡。实现显然可以拒绝任何无法理解的内容,但从标准角度看,这就是唯一限制。例如,GCC 和 Clang 支持 format 属性:
#if __has_c_attribute(__gnu__::__format__)
[[__gnu__::__format__(__printf__, 3, 4)]]
#endif
int snprintf(char *buf, size_t size, const char *frmt, ...);2
3
4
这里,该属性表明 snprintf 在位置 3 处理一项 printf 风格的格式规定,可变参数列表从位置 4 开始。有了这些信息,如果格式不是字符串字面量,或者格式说明符收到错误类型的实参,编译器就可以发出警告。
C23 在引入属性特性的同时,还提供属性检验特性 __has_c_attribute。它可以像宏所用的 defined 一样,用在预处理条件指令中。在前面的示例中,只有平台支持特有属性 __gnu__::__format__ 时,才使用它。
为了确保检验特性本身存在,也可以对它查询。我们的后备头文件 <c23-fallback.h> 包含以下代码,以适应尚未实现该特性的平台。如果编译器没有 __has_c_attribute 特性,它几乎肯定也完全没有实现属性。
请注意,无论是标准属性,还是带前缀属性中的标识符,都不是关键字。因此,它们可以而且必然会与应用程序宏相互作用,后果可能是灾难性的。
要点 10.2.3 #1
属性中的标识符可能被预处理替换。
#ifndef __has_c_attribute
#define __has_c_attribute(X) 0
#endif320
321
因此,C23 为所有标准属性另行规定了前后都带双下划线的形式,并建议所有带前缀的属性也另外提供这种修改形式。带双下划线的标识符是保留标识符,因而保证不会与应用程序定义的宏发生相互作用。
要点 10.2.3 #2
在头文件中使用属性的双下划线形式。
小结
- 对程序的每个部分,都必须区分对象(我们在做什么?)、目的(为何做?)、方法(怎样做?)和实现(以何种方式做?)。
- 函数接口和类型接口是软件设计的精髓,日后修改代价高昂。
- 实现应当尽可能按字面直接表达,控制流应当尽可能一目了然。应避免复杂推理,并在必要时明确写出。
- 属性可以为接口和实现增添宝贵信息,从而改善诊断、安全性、安保能力和性能。