Dantangfan

C 语言编码风格和标准

0.摘要

本文根据google code翻译自《Recommended C Style and Coding Standards》整理

作者信息:

L.W. Cannon (Bell Labs)
R.A. Elliott (Bell Labs)
L.W. Kirchhoff (Bell Labs)
J.H. Miller (Bell Labs)
J.M. Milner (Bell Labs)
R.W. Mitze (Bell Labs)
E.P. Schan (Bell Labs)
N.O. Whittington (Bell Labs)
Henry Spencer (Zoology Computer Systems, University of Toronto)
David Keppel (EECS, UC Berkeley, CS&E, University of Washington)
Mark Brader (SoftQuad? Incorporated, Toronto)

本文是《Indian Hill C Style and Coding Standards》的更新版本,上面提到的最后三位作者对其进行了修改。本文主要介绍了一种C程序的推荐编码标准,内容着重于讲述编码风格,而不是功能组织(Functional Organization)。

1.简介

本文档修改于 AT&T Indian Hill 实验室内部成立的一个委员会的一份文档,旨在于建立一套通用的编码标准并推荐给 Indian Hill 社区。

本文主要讲述编码风格。良好的风格能够鼓励大家形成一致的代码布局,提高代码可移植性并且减少错误数量。

本文不关注功能组织,或是一些诸如如何使用 goto 的一般话题。我们尝试将之前的有关C代码风格的文档整合到一套统一的标准中,这套标准将适合于任何使用C语言的工程,当然还是会有部分内容是针对一些特定系统的。另外不可避免地是这些标准仍然无法覆盖到所有情况.经验以及广泛的评价十分重要,遇到特殊情况时,大家应该咨询有经验的C程序员,或者查看那些经验丰富的C程序员们的代码(最好遵循这些规则)。

本文中的标准本身并不是必需的,但个别机构或团体可能部分或全部采用该标准作为程序验收的一部分。因此,在你的机构中其他人很可能以一种相似的风格编码。最终,这些标准的目的是提高可移植性,减少维护工作,尤其是提高代码的清晰度。这里很多风格的选择都有些许武断。混合的编码风格比糟糕的编码风格更难于维护,所以当变更现有代码时,最好是保持与现有代码风格一致,而不是盲目地遵循本文档中的规则。

“清晰的是专业的;不清晰的则是外行的” — Sir Ernest Gowers

2.文件组织

一个文件包含的各个部分应该用若干个空行分隔。虽然对源文件没有最大长度限制,但超过1000行的文件处理起来非常不方便。编辑器很可能没有足够 的临时空间来编辑这个文件,编译过程也会因此变得十分缓慢。与回滚到前面所花费的时间相比,那些仅仅呈现了极少量信息的多行星号是不值得的,我们 不鼓励使用。超过79列的行无法被所有的终端都很好地处理,应该尽可能的避免使用。过长的行会导致过深的缩进,这常常是一种代码组织不善的症状。

2.1文件命名惯例

文件名由一个基础名、一个可选的句号以及后缀组成。名字的第一个字符应该是一个字母,并且所有字符(除了句号)都应该是小写的字母和数字。基础名 应该由八个或更少的字符组成,后缀应该由三个或更少的字符组成(四个,如果你包含句号的话)。这些规则对程序文件以及程序使用和产生的默认文件都 适用(例如,”rogue.sav”)。

一些编译器和工具要求文件名符合特定的后缀命名约定。下面是后缀命名要求:

C++使用编译器相关的后缀约定,包括.c,..c,.cc,.c.c 以及.C。由于大多 C 代码也是 C++代码,因此这里并没有一个明确的方案。

此外,我们一般约定使用”Makefile”(而不是”makefile”)作为 make(对于那些支持 make 的系统)工具的控制文件,并且使 用”README”作为简要描述目录内容或目录树的文件。

2.2 程序文件

下面是一个程序文件各个组成部分的推荐排列顺序:

文件的第一部分是一个序,用于说明该文件中的内容是什么。对文件中的对象(无论它们是函数,外部数据声明或定义,或是其他一些东西)用途的描述比 一个对象名字列表更加有用。这个序可选择地包含作者信息、修订控制信息以及参考资料等。

接下来是所有被包含的头文件。如果某个头文件被包含的理由不是那么显而易见,我们需要通过增加注释说明原因。大多数情况下,类似 stdio.h 这 样的系统头文件应该被放在用户自定义头文件的前面。

接下来是那些用于该文件的 defines 和 typedefs。一个常规的顺序是先写常量宏、再写函数宏,最后是 typedefs 和枚举 (enums)定义。

接下来是全局(外部)数据声明,通常的顺序如下:外部变量,非静态(non-static)全局变量,静态全局变量。如果一组定义被用于部分特定 全局数据(如一个标志字),那么这些定义应该被放在对应数据声明后或嵌入到结构体声明中,并将这些定义缩进到其应用的声明的第一个关键字的下一个 层次(译注:实在没有搞懂后面这句的含义)。

最后是函数,函数应该以一种有意义的顺序排列。相似的函数应该放在一起。与深度优先(函数定义尽可能在他们的调用者前后)相比,我们应该首选广度 优先方法(抽象层次相似的函数放在一起)。这里需要相当多的判断。如果定义大量本质上无关的工具函数,可考虑按字母表顺序排列。

2.3 头文件

头文件是那些在编译之前由 C 预处理器包含在其他文件中的文件。诸如 stdio.h 的一些头文件被定义在系统级别,所有使用标准 I/O 库的程序必须 包含它们。头文件还用来包含数据声明和定义,这些数据不止一个程序需要。头文件应该按照功能组织,例如,独立子系统的声明应该放到独立的头文件中。如果一组声明在代码从一种机器移植到另外一种机器时变动的可能性很大,那么这些声明也应该被放在独立的头文件中。

避免私有头文件的名字与标准库头文件的名字一样。下面语句:

#include "math.h"

当预期的头文件在当前目录下没有找到时,它将会包含标准库中的 math 头文件。如果这的确是你所期望发生的,那么请加上注释。包含头文件时不要使 用绝对路径。当从标准位置获取头文件时,请使用包含头文件;或相对于当前路径定义它们。C 编译器的"include- path"选项(在许多系统中为-l)是处理扩展私有库头文件的最好方法,它允许在不改变源码文件的情况下重新组织目录结构。

声明了函数或外部变量的头文件应该被那些定义了这些函数和变量的文件所包含。这样一来,编译器就可以做类型检查了,并且外部声明将总是与定义保持 一致。

在头文件中定义变量往往是个糟糕的想法,它经常是一个在文件间对代码进行低劣划分的症状。此外,在一次编译中,像 typedef 和经过初始化的数 据定义无法被编译器看到两次。在一些系统中,重复的没有使用 extern 关键字修饰的未初始化定义也会导致问题。当头文件嵌套时,会出现重复的声 明,这将导致编译失败。

头文件不应该嵌套。一个头文件的序应该描述其使用的其他被包含的头文件的实用特性。在极特殊情况下,当大量头文件需要被包含在多个不同的源文件中 时,可以被接受的做法是将公共的头文件包含在一个单独的头文件中。

一个通用的做法是将下面这段代码加入到每个头文件中以防止头文件被意外多次包含。

#ifndef EXAMPLE_H
#define EXAMPLE_H
...
/* body of example.h file */
#endif /* EXAMPLE_H */

我们不应该对这种避免多次包含的机制产生依赖,特别是不应该因此而嵌套包含头文件。

2.4 其他文件

还有一个惯例就是编写一个名为”README”的文件,用于描述程序的整体情况以及问题。例如,我们经常在 README 包含程序所使用的条件编译 选项列表以及相关说明,还可以包含机器无关的文件列表等。

3. 声明

全局声明应该从第一列开始。在所有外部数据声明的前面都应该放置 extern 关键字。如果一个外部变量是一个在定义时大小确定的数组,那么这个数 组界限必须在 extern 声明时显示指出,除非数组的大小与数组本身编码在一起了(例如,一个总是以0结尾的只读字符数组)。重复声明数组大小对 于一些使用他人编写的代码的人特别有益。

指针修饰符*应该与变量名在一起,而不是与类型在一起。

char *s, *t, *u;

替换

char* s, t, u;

后者是错误的,因为实际上 t 和 u 并未如预期那样被声明为指针。不相关的声明,即使是相同类型的,也应该独立占据一行。我们应该对声明对象的角色进行注释,不过当常量名本身足以说明角色时,使用#define 定义的常量列表则不需要注释。通常多行变量名、值与注释使用相同缩进,使得他们在一列直线上。尽量使用 Tab 字符而不是空格。结构体和联合体的声明时,每个元素应该单独占据一行,并附带一条注释。{应该与结构体的 tag 名放在同一行,}应该放在声明结尾的第一列。

struct boat {
int
 wllength; /* water line length in meters */
int type; /* see below */
long sailarea; /* sail area in square mm */
};
/* defines for boat.type */
#define KETCH (1)
#define YAWL (2)
#define SLOOP (3)
#define SQRIG (4)
#define MOTOR (5)

这些 defines 有时放在结构体内 type 声明的后面,并使用足够的 tab 缩进到结构体成员成员的下一级。如果这些实际值不那么重要的话,使用 enum 会更好。

enum bt { KETCH=1, YAWL, SLOOP, SQRIG, MOTOR };
struct boat {
int wllength; /* water line length in meters */
enum bt type;
long sailarea;
};
/* what kind of boat */
/* sail area in square mm */

任何初值重要的变量都应该被显式地初始化,或者至少应该添加注释,说明依赖 C 的默认初始值0。空初始化”{}”应该永远不被使用。结构体初始化应 该用大括号完全括起来。用于初始化长整型(long)的常量应该使用显式长度。使用大写字母,例如2l 看起来更像21,数字二十一。

int x = 1;
char *msg = "message";
struct boat winner[] = {
    { 40, YAWL, 6000000L },
    { 28, MOTOR, 0L },
    { 0 },
};

如果一个文件不是独立程序,而是某个工程整体的一部分,那么我们应该最大化的利用 static 关键字,使得函数和变量对于单个文件来说是局部范畴 的。只有在有清晰需求且无法通过其他方式实现的特殊情况时,我们才允许变量被其他文件访问。这种情况下应该使用注释明确告知使用了其他文件中的变 量;注释应该说明其他文件的名字。如果你的调试器遮蔽了你需要在调试阶段查看的静态对象,那么可以将这些变量声明为 STATIC,并根据需要决定 是否#define STATIC。

最重要的类型应该被 typedef,即使他们只是整型,因为独立的名字使得程序更加易读(如果只有很少的几个 integer 的 typedef)。 结构体在声明时应该被 typedef。保持结构体标志的名字与 typedef 后的名字相同。

typedef struct splodge_t {
int sp_count;
char *sp_name, *sp_alias;
} splodge_t;

总是声明函数的返回类型。如果函数原型可用,那就使用它。一个常见的错误就是忽略那些返回 double的外部数学函数声明。那样的话,编译器就会 假定这些函数的返回值为一个整型数,并且将 bit 位逐一尽职尽责的注意转换为一个浮点数(无意义)。

“C 语言的观点之一是程序员永远是对的” — Michael DeCorte

4. 函数声明

每个函数前面应该放置一段块注释,概要描述该函数做什么以及(如果不是很清晰)如何使用该函数。重要的设计决策讨论以及副作用说明也适合放在注释 中。避免提供那些代码本身可以清晰提供的信息。

函数的返回类型应该单独占据一行,(可选的)缩进一个级别。不用使用默认返回类型 int;如果函数没有返回值,那么将返回类型声明为 void。如 果返回值需要大段详细的说明,可以在函数之前的注释中描述;否则可以在同一行中对返回类型进行注释。函数名(以及形式参数列表)应该被单独放在一 行,从第一列开始。目的(返回值)参数一般放在第一个参数位置(从左面开始)。所有形式参数声明、局部声明以及函数体中的代码都应该缩进一级。函 数体的开始括号应该单独一行,放在开始处的第一列。

每个参数都应该被声明(不要使用默认类型 int)。通常函数中每个变量的角色都应该被描述清楚,我们可以在函数注释中描述,或如果每个声明单独一 行,我们可以将注释放在同一行上。像循环计数器”i”,字符串指针”s”以及用于标识字符的整数类型”c”这些简单变量都无需注释。如果一组函数 都拥有一个相似的参数或局部变量,那么在所有函数中使用同一个名字来标识这个变量是很有益处的(相反,避免在相关函数中使用一个名字标识用途不同 的变量)。不同函数中的相似参数还应该放在各个参数列表中的相同位置。

参数和局部变量的注释应该统一缩进以排成一列。局部变量声明应用一个空行与函数语句分隔开来。当你使用或声明变长参数的函数时要小心。目前在 C 中尚没有真正可移植的方式处理变长参数。最好设计一个使用固定个数参数的接口。如果一定要使用变 长参数,请使用标准库中的宏来声明具有变长参数的函数。

如果函数使用了在文件中没有进行全局声明的外部变量(或函数),我们应该在函数体内部使用 extern关键字单独对这些变量进行声明。

避免局部声明覆盖高级别的声明。尤其是,局部变量不应该在嵌套代码块中被重声明。虽然这在 C中是合法的,但是当使用-h 选项时,潜在的冲突可能性 足以让 lint 工具发出抱怨之声。

5. 空白

int i;main(){for(;i["]
o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,ij,i/i);}

通常情况下,请使用纵向和横向的空白。缩进和空格应该反映代码的块结构。例如,在一个函数定义与下一个函数的注释之间,至少应该有两行空白。

如果一个条件分支语句过长,那就应该将它拆分成若干单独的行。

if (foo->next==NULL && totalcount<needed && needed<=MAX_ALLOT
&& server_active(current_input)) { ...

也许下面这样更好

if (foo->next == NULL
&& totalcount < needed && needed <= MAX_ALLOT
&& server_active(current_input))
{
...

类似地,复杂的循环条件也应该被拆分为不同行。

for (curr = *listp, trail = listp;
curr != NULL;
trail = &(curr->next), curr = curr->next )
{
...

其他复杂的表达式,尤其是那些使用了?:操作符的表达式,最好也能拆分成多行。

c = (a == b)
? d + f(a)
: f(b)  d;

当关键字后面有放在括号内的表达式时,应该使用空格将关键字与左括号分隔(sizeof 操作符是个例外)。在参数列表中,我们也应该使用空格显式 的将各个参数隔开。然而,带有参数的宏定义一定不能在名字与左括号间插入空格,否则 C 预编译器将无法识别后面的参数列表。

6. 例子

/*
* Determine if the sky is blue by checking that it isn't night.
* CAVEAT: Only sometimes right.
 May return TRUE when the answer
* is FALSE.
 Consider clouds, eclipses, short days.
* NOTE: Uses 'hour' from 'hightime.c'.
 Returns 'int' for
* compatibility with the old version.
*/
int
 /* true or false */
skyblue()
{
extern int hour;
 /* current hour of the day */
return (hour >= MORNING && hour <= EVENING);
}
/*
* Find the last element in the linked list
* pointed to by nodep and return a pointer to it.
* Return NULL if there is no last element.
*/
node_t *
tail(nodep)
node_t
 *nodep;
 /* pointer to head of list */
{
register node_t *np; /* advances to NULL */
register node_t *lp; /* follows one behind np */
if (nodep == NULL)
return (NULL);
for (np = lp = nodep; np != NULL; lp = np, np = np->next)
;
 /* VOID */
return (lp);
}

7. 简单语句

每行只应该有一条语句,除非多条语句关联特别紧密。

case FOO:
 oogle (zork);
 boogle (zork);
 break;
case BAR:
 oogle (bork);
 boogle (zork);
 break;
case BAZ:
 oogle (gork);
 boogle (bork);
 break;

for 或 while 循环语句的空体应该单独放在一行并加上注释,这样可以清晰的看出空体是有意而为,并非遗漏代码。

while (*dest++ = *src++)
;
 /* VOID */

不要对非零表达式进行默认测试,例如:

if (f() != FAIL)

比下面的代码更好

if (f())

即使 FAIL 的值可能为0(在 C 中0被认为是假)。当后续有人决定使用-1替代0作为失败返回值时,一个显式的测试将解决你的问题。即使比较的值 永远不会改变,我们也应该使用显式的比较;例如

if (!(bufsize % sizeof(int)))

应该被写成

if ((bufsize % sizeof(int)) == 0)

这样可以反映这个测试的数值(非布尔)本质。一个常见的错误点是使用 strcmp 测试字符串是否相同,这个测试的结果永远不应该被放弃。比较好的 方法是定义一个宏 STREQ。

#define STREQ(a, b) (strcmp((a), (b)) == 0)

对谓词或满足下面约束的表达式,非零测试经常被放弃:

用 isvalid 或 valid 称呼一个谓词,不要用 checkvalid。

一个非常常见的实践就是在一个全局头文件中声明一个布尔类型”bool”。这个特殊的名字可以极大地提高代码可读性。

typedef int bool;
#define FALSE 0
#define TRUE 1

typedef enum { NO=0, YES } bool;

即便有了这些声明,也不要检查一个布尔值与1(TRUE,YES 等)的相当性;可用测试与0(FALSE,NO 等)的不等性替代。绝大多数函数都 可以保证为假的时候返回0,但为真的时候只返回非零。

if (func() == TRUE) { ...

必须被写成

if (func() != FALSE) { ...

如果可能的话,最好为函数/变量重命名或者重写这个表达式,这样就可以显而易见的知道其含义,而无需再与 true 或 false 比较了(例如,重命 名为 isvalid())。

嵌入赋值语句也有用武之地。在一些结构中,在没有降低代码可读性的前提下,没有比这更好的方式来实现这个结果了。

while ((c = getchar()) != EOF) {
    process the character
}

++和–操作符可算作是赋值语句。这样,为了某些意图,实现带有副作用的功能。使用嵌入赋值语句也可能提高运行时的性能。不过,大家应该在提高 的性能与下降的可维护性之间做好权衡。当在一些人为的地方使用嵌入赋值语句时,这种情况会发生,例如:

a = b + c;
d = a + r;

不应该被下面代码替代:

d = (a = b + c) + r;

即使后者可能节省一个计算周期。在长期运行时,由于优化器渐获成熟,两者的运行时间差距将下降,而两者在维护性方面的差异将提高,因为人类的记忆 会随着时间的流逝而衰退。

在任何结构良好的代码中, goto 语句都应该保守地使用。使用 goto 带来好处最大的地方是从 switch、for 和 while 多层嵌套中跳出, 但这样做的需求也暗示了代码的内层结构应该被抽取出来放到一个单独的返回值为成功或失败的函数中。

for (...) {
    while (...) {
        ...
	if (disaster)
	    goto error;
    }
}
...
error:
    clean up the mess

当需要 goto 时候,其对应的标签应该被放在单独一行,并且后续的代码缩进一级。使用 goto 语句时应该增加注释(可能放在代码块的头)以说明它 的功用和目的。 continue 应该保守地使用,并且尽可能靠近循环的顶部。Break 的麻烦比较少。

非原型函数的参数有时需要被显式做类型提升。例如,如果函数期望一个32bit 的长整型,但却被传入一个16bit 的整型数,可能会导致函数栈不 对齐。指针,整型和浮点值都会发生此问题。

8. 复合语句

复合语句是一个由括号括起来的语句列表。有许多种常见的括号格式化方式。如果你有一个本地标准,那请你与本地标准保持一致,或选择一个标准,并持 续地使用它。在编辑别人的代码时,始终使用那些代码中使用的样式。

control {
    statement;
    statement;
}

上面的风格被称为”K&R 风格”,如果你还没有找到一个自己喜欢的风格,那么可以优先考虑这个风格。在 K&R 风格中, if- else 语句中的 else 部分以及 do-while 语句中的 while 部分应该与结尾大括号在同一行中。而其他大部分风格中,大括号都是单独占据 一行的。

当一个代码块拥有多个标签时,每个标签应该单独放在一行上。必须为 C 语言的 switch 语句的fall-through 特性(即在代码段与下一个 case 语句之前间没有 break)增加注释以利于后期更好的维护。最好是 lint 风格的注释/指示。

switch (expr) {
    case ABC:
    case DEF:
	statement;
	break;
    case UVW:
	statement;
    /*FALLTHROUGH*/
    case XYZ:
	statement;
	break;
}

这里,最后那个 break 是不必要的,但却是必须的,因为如果后续另外一个 case 添加到最后一个 case的后面时,它将阻止 fall- through 错误的发生。如果使用 default case,那么应该该 default case 放在最后,且不需要 break,如果它是最后一个 case。

一旦一个 if-else 语句在 if 或 else 段中包含一个复合语句,if 和 else 两个段都应该用括号括上(称为全括号(fully bracketed)语法)。

if (expr) {
    statement;
} else {
    statement;
    statement;
}

在如下面那样的没有第二个 else 的 if-if-else 语句序列里,括号也是不必可少的。如果 ex1后面的括号被省略,编译器解析将出错:

if (ex1) {
    if (ex2) {
	funca();
    }
} else {
    funcb();
}

一个带 else if 的 if-else 语句在书写上应该让 else 条件左对齐。

if (STREQ (reply, "yes")) {
    statements for yes
    ...
} else if (STREQ (reply, "no")) {
    ...
} else if (STREQ (reply, "maybe")) {
    ...
} else {
    statements for default
    ...
}

这种格式看起来像一个通用的 switch 语句,并且缩进反映了在这些候选语句间的精确切换,而不是嵌套的语句。

Do-while 循环总是使用括号将循环体括上。

下面的代码非常危险:

#ifdef CIRCUIT
#define CLOSE_CIRCUIT(circno) { close_circ(circno); }
#else
#define CLOSE_CIRCUIT(circno)
#endif
...
if (expr)
    statement;
else
    CLOSE_CIRCUIT(x)
++i;

注意,在 CIRCUIT 没有定义的系统上,语句++i 仅仅在 expr 是假的时候获得执行。这个例子指出宏用大写命名的价值,以及让代码完全括号化 的价值。

有些时候,通过 break,continue,goto 或 return,if 可以无条件地进行控制转移。else 应该是隐式的,并且代码不应该缩 进。

if (level > limit)
    return (OVERFLOW)
normal();
return (level);

平坦的缩进告诉读者布尔测试在密封块的其他部分是保持不变的。

9. 操作符

一元操作符不应该与其唯一的操作数分开。通常,所有其他二元操作符都应该使用空白与其操作树分隔开,但’.’和’->’例外。当遇到复杂表 达式的时候我们需要做出一些判断。如果内层操作符没有使用空白分隔而外层使用了,那么表达式也许会更清晰些。

如果你认为一个表达式很难于阅读,可以考虑将这个表达式拆分为多行。在接近中断点的最低优先级操作符处拆分是最好的选择。由于 C 具有一些想不到的 优先级规则,混合使用操作符的表达式应该使用括号括上。但是过多的括号也会使得代码可读性变差,因为人类不擅长做括号匹配。

二元逗号操作符也会被使用到,但通常我们应该避免使用它。逗号操作符的最大用途是提供多元初始化或操作,比如在 for 循环语句中。复杂表达式,例 如那些使用了嵌套三元?:操作符的表达式,可能引起困惑,并且应该尽可能的避免使用。三元操作符和逗号操作符在一些使用宏的地方很有用,诸如getchar。在三元操作符?:前的逻辑表达式的操作数应该被括起来,并且两个子表达式的返回值应该是相同类型。

10. 命名约定

毫无疑问,每个独立的工程都有一套自己的命名约定,不过仍然有一些通用的规则值得参考。

他们可能被当成函数使用。只有在宏的行为类似一 个函数调用时才允许小写命名的宏,也就是说它们只对其参数进行一次求值,并且不会给具名形式参数赋值。有些时候我们无法编写出一个具有函数行为的 宏,即使其参数也只是求值一次。

通常,全局名字(包括 enum)应该具有一个统一的前缀,通过该前缀名我们可以识别出这个名字归属于哪个模块。全局变量可以选择汇集在一个全局结 构中。typedef 的名字通常在结尾加一个’t’。

避免名字与各种标准库中的名字冲突。一些系统可能包含一些你所不需要的库。另外你的程序将来某天很可能也要扩展。

11. 常量

数值型常量不应该被硬编码到源文件中。应该使用 C 预处理器的#define 特性为常量赋予一个有意义的名字。符号化的常量可以让代码具有更好的可 读性。在一处地方统一定义这些值也便于进行大型程序的管理,这样常量值可以在一个地方进行统一修改,只需修改 define 的值即可。枚举数据类型 更适合声明一组具有离散值的变量,并且编译器还可以对其进行额外的类型检查。至少,任何硬编码的值常量必须具有一段注释,以说明该值的来历。

常量的定义应该与其使用是一致的;例如使用540.0作为一个浮点数,而不是使用540外加一个隐式的float 类型转换。有些时候常量0和1被 直接使用而没有用 define 进行定义。例如,一个 for 循环语句中用于标识数组下标的常量,

for (i = 0; i < ARYBOUND; i++)

上面代码是合理的,但下面代码

door_t *front_door = opens(door[i], 7);
if (front_door == 0)
    error("can't open %s\\\\n", door[i]);

是不合理的。在最后的那个例子中, front_door 是一个指针。当一个值是指针的时候,它应该与 NULL比较而不是与0比较。NULL 被定义 在标准 I/O 库头文件 stdio.h 中,在一些新系统中它在 stdlib.h 中定义。即使像1或0这样的简单值,我们最好也用 define 定义成 TRUE 和 FALSE 定义后再使用(有些时候,使用 YES 和 NO 可读性更好)。

简单字符常量应该被定义成字面值,不应该使用数字。不鼓励使用非可见文本字符,因为它们是不可移植的。如果非可见文本字符十分必要,尤其是当它们 在字符串中使用时,它们应该定义成三个八进制数字的转义字符(例如: ‘\007‘)而非一个字符。即使这样,这种用法也应该考虑其机器相关性,并按这里的方法处理。

12. 宏

复杂表达式可能会被用作宏参数,这可能会因操作符优先级顺序而引发问题,除非宏定义中所有参数出现的位置都用括号括上了。对这种因参数内副作用而 引发的问题,我们似乎也无能为例,除了在编写表达式时杜绝副作用(无论如何,这都是一个很好的主意)。如果可能的话,尽量在宏定义中对宏参数只进 行一次求值。有很多时候我们无法写出一个可像函数一样使用的宏。

一些宏也当成函数使用(例如,getc 和 fgetc)。这些宏会被用于实现其他函数,这样一旦宏自身发生变化,使用该宏的函数也会受到影响。在交 换宏和函数时务必要小心,因为函数参数是按值传递的,而宏参数则是通过名称替换。只有在宏定义时特别谨慎小心,才有可能减少使用宏时的担心。

宏定义中应该避免使用全局变量,因为全局变量的名字很可能被局部声明遮盖。对于那些对具名参数进行修改(不是这些参数所指向的存储区域)或被用作 赋值语句左值的宏,我们应该添加相应的注释以给予提醒。那些不带参数但引用变量,或过长或作为函数别名的宏应该使用空参数列表,例如:

#define OFF_A() (a_global+OFFSET)
#define BORK() (zork())
#define SP3() if (b) { int x; av = f (&x); bv += x; }

宏节省了函数调用和返回的额外开销,但当一个宏过长时,函数调用和返回的额外开销就变得微不足道了,这种情况下我们应该使用函数。

在一些情况下,让编译器确保宏在使用时应该以分号结尾是很有必要的。

if (x==3)
    SP3();
else
    BORK();

如果省略 SP3调用后面的分号,后面的 else 将会匹配到 SP3宏中的那个 if。有了分号,else 分支就不会与任何 if 匹配。SP3宏可以这样 安全地实现:

#define SP3() \\\\
    do { if (b) { int x; av = f (&x); bv += x; }} while (0)

手工给宏定以加上 do-while 包围看起来很别扭,而且很多编译器和工具会抱怨在 while 条件是一个常量值。一个用来声明语句的宏可以使得编 码更加容易:

#ifdef lint
    static int ZERO;
#else
#define ZERO 0
#endif
#define STMT( stuff ) do { stuff } while (ZERO)

我们可以用下面代码来声明 SP3宏:

#define SP3() \\\\
    STMT( if (b) { int x; av = f (&x); bv += x; } )

使用 STMT 宏可以有效阻止一些可以潜在改变程序行为的打印排版错误。除了类型转换、sizeof 以及上面那些技巧和手法,只有当整个宏用括号括上时才应该包含关键字。

13. 条件编译

条件编译在处理机器依赖、调试以及编译阶段设定特定选项时十分有用。不过要小心条件编译。各种控制很容易以一种无法预料的方式结合在一起。如果使 用#ifdef 判断机器依赖,请确保当没有机器类型适配时,返回一个错误,而不是使用默认机器类型(使用#error 并缩进一级,这样它可以一些老旧的编 译器下工作)。如果你#ifdef 优化选项,默认情况下应该是一个未经优化的代码,而不是一个不兼容的程序。确保测试的是未经优化的代码。

注意在#ifdef 区域内的文本可能会被编译器扫描(处理),即使#ifdef 求值的结果为假。但即使文件的#ifdef 部分永远不能被编译到(例如,#ifdef COMMENT),这部分也不该随意的放置文本。

尽可能地将#ifdefs 放在头文件中,而不是源文件中。使用#ifdef 定义可以在源码中统一使用的宏。例如,一个用于检查内存分配的头文件可能这样实现:(省略了 REALLOC 和 FREE):

#ifdef DEBUG
extern void *mm_malloc();
#define MALLOC(size) (mm_malloc(size))
#else
    extern void *malloc();
#define MALLOC(size) (malloc(size))
#endif

条件编译通常应该基于一个接一个的特性的。多数情况下,都应该避免使用机器或操作系统依赖。

#ifdef BSD4
long t = time ((long *)NULL);
#endif

上面代码之所以糟糕有两个原因:很可能在某个4BSD 系统上有更好的选择,并且也可能存在在某个非4BSD 系统中上述代码是最佳代码。我们可以通过定义诸 如 TIME_LONG 和 TIME_STRUCTD 等宏作为替代,并且在诸如 config.h 的配置文件中定义一个合适的宏。

14. 可移植性

“C语言结合了汇编的强大功能和可移植性” — 无名氏,暗指比尔.萨克。

可移植代码的好处是有目共睹的。这一节将阐述一些编写可移植代码的指导原则。这里”可移植的”是指一个源码文件能够在不同机器上被编译和执行,其 前提仅仅是在不同平台上可能包含不同的头文件,使用不同的编译器开关选项罢了。头文件包含的#define 和 typedef 可能因机器而异。一般 来说,一个新”机器”是指一种不同的硬件,一种不同的操作系统,一个不同的编译器,或者是这些的任意组合。参考1包含了很多关于风格和可移植 性方面的有用信息。下面是一个隐患列表,当你设计可移植代码时应该考虑避免这些隐患:

table1

有些机器针对某一类型可能有不止一个大小。其类型大小取决于编译器和不同的编译期标志。下面表展示了大多数系统的”安全”类型大小。无符号与带符 号数具有相同的大小(单位:bit)。

table2

int *p = (int *) malloc (sizeof(int));
free (p);
((int *) 2 )
((int *) 3 )

如果你需要’magic’指针而不是 NULL,要么分配一些内存,要么将指针视为机器相关的。

extern int x_int_dummy;
 /* in x.c */
#define X_FAIL (NULL)
#define X_BUSY (&x_int_dummy)
#define X_FAIL (NULL)
#define X_BUSY MD_PTR1
 /* MD_PTR1 from "machdep.h" */
    c = foo (getchar(), getchar());
char
foo (c1, c2, c3)
char c1, c2, c3;
{
    char bar = *(&c1 + 1);
    return (bar);
    /* often won't return c2 */
}
s = "/dev/tty??";
strcpy (&s[8], ttychars);
a[i] = b[i++];

在上面的例子中,我们只知道 b 的下标值没有被增加。a 的下标 i 值可能是自增后的值也可能是自增前的值。

struct bar_t { struct bar_t *next; } bar;
bar->next = bar = tmp;

在第二个例子中,bar->next 的地址很可能在 bar 被赋值之前被计算使用。

bar = bar->next = tmp;

第三个例子中,bar 可能在 bar->next 之前被赋值。虽然这可能有悖于”赋值从右到左处理”的规则,但这确是一个合法的解析。考虑下 面的例子:

long i;
short a[N];
i = old
i = a[i] = new;

赋给 i 的值必须是一个按照从右到左的处理顺序进行赋值处理后的值。但是 i 可能在 ai 被赋值前而被赋值为”(long) (short)new”。不同编译器作法不同。

#define FOO(string) (printf("string = %s",(string)))
...
FOO(filename);

只是在有些时候会扩展为

(printf("filename = %s",(filename)))

小心。诡异的预处理器在一些机器上可能导致宏异常中断。下面是一个宏的两种不同实现版本:

#define LOOKUP(chr) (a['c'+(chr)]) /* Works as intended. */
#define LOOKUP(c) (a['c'+(c)]) /* Sometimes breaks. */

第二个版本的 LOOKUP 可能以两种不同的方式扩展,并且会导致代码异常中断。

15. 标准 C

现代 C 编译器支持一些或全部的 ANSI 提议的标准 C。无论何时可能的话,尽量用标准 C 编写和运行程序,并且使用诸如函数原型,常量存储以及 volatile(易失性)存储等特性。标准 C 通过给优化器提供有有效的信息以提升程序的性能。标准 C 通过保证所有编译器接受同样的输入语言以及提供相关 机制隐藏机器相关内容或对于那些机器相关代码提供警告的方式提升代码的可移植性。

15.1 兼容性

编写很容易移植到老编译器上的代码。例如,有条件地在 global.h 中定义一些新(标准中的)关键字,比如 const 和 volatile。标准编译器预 定义了预处理器符号 STDC(见脚注8)。void 类型很难简单地处理正确,因为很多老编译器只理解 void,但不认识 void。

最简单的方法就是定义一 个新类型 VOIDP(与机器和编译器相关),通常在老编译器下该类型被定义为 char*。

#if __STDC__
typedef void *voidp;
#define COMPILER_SELECTED
#endif
#ifdef A_TARGET
#define const
#define volatile
#define void int
typedef char *voidp;
#define COMPILER_SELECTED
#endif
#ifdef ...
...
#endif
#ifdef COMPILER_SELECTED
#undef COMPILER_SELECTED
#else
{ NO TARGET SELECTED! }
#endif

注意在 ANSI C 中,#必须是同一行中预处理器指示符的第一个非空白字符。在一些老编译器中,它必须是同一行中的第一个字符。

当一个静态函数具有前置声明时,前置声明必须包含存储修饰符。在一些老编译器中,这个修饰符必须是”extern”。对于 ANSI 编译器,这个存储修饰符 必须为 static,但全局函数依然必须声明为 extern。因此,静态函数的前置声明应该使用一个#define,例如 FWD_STATIC,并通 过#ifdef 适当定义。

一个”#ifdef NAME”应该要么以”#endif”结尾,要么以”#endif / NAME /结尾,不应该用”#endif NAME”结尾。对于短小的#ifdef 不应该使用注释,因为通过代码我们可以明确其含义。

ANSI 的三字符组可能导致内容包含??的字符串的程序神秘的中断。

15.2 格式化

ANSI C 的代码风格与常规 C 一样,但有两点意外:存储修饰符(storage qualifiers)和参数列表。

由于 const 和 volatile 的绑定规则很奇怪,因此每个 const 或 volatile 对象都应该单独声明。

int const *s; /* YES */
int const *s, *t; /* NO */

具备原型的函数将参数声明和定义归并在一个参数列表中了。应该在函数的注释中提供各个参数的注释。

/*
* `bp': boat trying to get in.
* `stall': a list of stalls, never NULL.
* returns stall number, 0 => no room.
*/
int
enter_pier (boat_t const *bp, stall_t *stall)
{
...

15.3 原型

应该使用函数原型使得代码更加健壮并且运行时性能更好。不幸地是原型的声明

extern void bork (char c);

与定义不兼容。

void bork (c)
char c;
...

原型中 c 应该以机器上最自然的类型传入,很可能是一个字节。而非原型化(向后兼容)的定义暗示 c总是以一个整型传入。如果一个函数具有可类型提升的参数, 那么调用者和被调用者必须以相等地方式编译。要么都必须使用函数原型,要么都不使用原型。如果在程序设计时参数就是可以提升类型的,那么问题就可以被避 免,例如 bork 可以定义成接受一个整型参数。

如果定义也是原型化的,上面的声明将工作正常。

void bork (char c)
{
...

不幸地是,原型化的语法将导致非 ANSI 编译器拒绝这个程序。

但我们可以很容易地通过编写外部声明来同时适应原型与老编译器。

#if __STDC__
#define PROTO(x) x
#else
#define PROTO(x) ()
#endif
extern char **ncopies PROTO((char *s, short times));

注意 PROTO 必须使用双层括号。

最后,最好只使用一种风格编写代码(例如,使用原型)。当需要非原型化的版本时,可使用一个自动转换工具生成。

15.4 Pragmas

Pragmas 用于以一种可控的方式引入机器相关的代码。很显然,pragma 应该被视为机器相关的。不幸地是,ANSI pragmas 的语法使得我们无法将其隔离到机器相关的头文件中了。

Pragmas 分为两类。优化相关的可以被安全地忽略。而那些影响系统行为(需要 pragmas)的 Pragmas则不能忽略。需要的 pragmas 应该结合#ifdef 使用,这样如果一个 pragma 都没有选到,编译过程将退出。

两个编译器可能通过两个不同的方式使用同一个给定的 pragma。例如,一个编译器可能使用 haggis发出一个优化信号。而另一个可能使用它暗示一个特 定语句,一旦执行到此,程序应该退出。不过,一旦使用了 pragma,它们必须总是被机器相关的#ifdef 包围。对于非 ANSI 编译器,Pragmas 必须总是被#ifdef。确保对#pragma 的#进行缩进,否则一些较老的预处理器处理它时会挂起。

#if defined(__STDC__) && defined(USE_HAGGIS_PRAGMA)
#pragma (HAGGIS)
#endif

“ANSI 标准中描述的’#pragma’命令具有任意实现定义的影响。在 GNU C 预处理中,’#pragma’首先尝试运行游戏’rogue’;如果失败,它将尝试运行游戏’hack’;如果失败,它将尝试运行 GNU Emacs显示汉诺塔;如果失败,它将报告一个致命错误。无论如何,预处理将不再继续。” —– GNU CC 1.34 C 预处理手册。

16. 特殊考虑

这节包含一些杂项:‘做’与’不做’。

abool = bbool;
if (abool) { ...

当嵌入的赋值表达式使用时,确保测试是显式的,这样后续它就无法被”修复”了。

while ((abool = bbool) != FALSE) { ...
while (abool = bbool) { ... /* VALUSED */
while (abool = bbool, abool) { ...

显式地注释那些在正常控制流之外被修改的变量,或其他可能在维护过程中中断的代码。

现代编译器会自动将变量放到寄存器中。对于你认为最关键的变量慎用寄存器。在极端情况下,用寄存器标记2-4个最为关键的值,并且将剩余的标记为 REGISTER。后者在那些具有较多寄存器的机器上可以#define 为寄存器。

17. Lint

Lint 是一个 C 程序检查工具,用于检查 C 语言源码文件,探测和报告诸如类型不兼容、函数定义与调用不一致以及潜在的 bug 等情况。强烈建议在所 有程序上使用 lint 工具,并且期望大多数工程将lint 作为官方验收程序的一部分。

应该注意的是使用 lint 的最好方法不是将 lint 作为官方验收之前的一道必须跨过的栅栏,而是作为一个在代码发生添加或变更之后使用的工具。 Lint 可以发现一些隐藏的 bug 并且可以在问题发生前保证程序的可移植性。lint 产生的许多信息确实暗示了一些事情是错误的。一个有意思的故 事是关于一个漏掉了 fprintf 的一个参数的程序:

fprintf ("Usage: foo -bar <file>\n");

作者从未有过一个问题。但每当一个正常用户在命令行上犯错,这个程序就会产生一个 core。许多版本的 lint 工具都能发现这个问题。

大多 lint 选项都值得我们学习。一些选项可能在合法的代码上给出警告,但它们也会捕捉到许多把事情搞遭的代码。注意’–p’只能为库的一个子 集检查函数调用和类型的一致性,因此程序为了最大化的覆盖检查,应该同时进行带–p 和不带–p 的 lint 检查。

Lint 也可以识别代码里的一些特殊注释。这些注释可以强制让 lint 在发现问题时关闭警告输出,还可以作为一些特殊代码的文档。

18. Make

另外一个非常有用的工具是 make。在开发过程中,make 只会重新编译那些上次 make 后发生了改变的模块。它也可以用于自动化其他任务。一些 常见的约定包括:

除此之外,通过命令行也可以定义 Makefile 使用的值(如”CFLAGS”)或源码中使用的值(如”DEBUG”)。

19. 工程相关的标准

除了这里提到内容外,每个独立的工程都期望能建立附加标准。下面是每个工程程序管理组需要考虑的问题中的一部分:

20. 结论

这里描述了一套 C 语言编程风格的标准。其中最重要的几点是:

无论采用哪种标准,如果认为该标准有用就必须遵循它。如果你觉得遵循某条标准时有困难,不要仅仅忽略它们,而是在和你当地的大师或组织内的有经验的程序员讨论后再做决定。

21. 参考资料

  1. B.A. Tague, C Language Portability, Sept 22, 1977. This document issued by department 8234 contains three memos by R.C. Haight, A.L. Glasser, and T.L. Lyon dealing with style and portability.
  2. S.C. Johnson, Lint, a C Program Checker, Unix Supplementary Documents, November 1986.
  3. R.W. Mitze, The 3B/PDP-11 Swabbing Problem, Memorandum for File, 1273-770907.01MF, September 14, 1977.
  4. R.A. Elliott and D.C. Pfeffer, 3B Processor Common Diagnostic Standards- Version 1, Memorandum for File, 5514-780330.01MF, March 30, 1978.
  5. R.W. Mitze, An Overview of C Compilation of Unix User Processes on the 3B, Memorandum for File, 5521-780329.02MF, March 29, 1978.
  6. B.W. Kernighan and D.M. Ritchie, The C Programming Language, Prentice Hall 1978, Second Ed. 1988, ISBN 0-13-110362-8.
  7. S.I. Feldman, Make — A Program for Maintaining Computer Programs, UNIXSupplementary Documents, November 1986.
  8. Ian Darwin and Geoff Collyer, Can’t Happen or / NOTREACHED / or Real Programs Dump Core, USENIX Association Winter Conference, Dallas 1985 Proceedings.
  9. Brian W. Kernighan and P. J. Plauger The Elements of Programming Style. McGraw-Hill, 1974, Second Ed. 1978, ISBN 0-07-034-207-5.
  10. J. E. Lapin Portable C and UNIX System Programming, Prentice Hall 1987, ISBN 0-13-686494-5.
  11. Ian F. Darwin, Checking C Programs with lint, O’Reilly & Associates, 1989. ISBN 0-937175-30-7.
  12. Andrew R. Koenig, C Traps and Pitfalls, Addison-Wesley, 1989. ISBN 0-201-17928-8.



blog comments powered by Disqus