尽管 C 语言问世已近 30 年,但它的魅力仍未减退。C 语言继续吸引着众多的人们,他们为了编写新的应用程序,或者移植或维护现有的应用程序而必须学习新技能。

简介

本文是为了满足开发人员的需要而写的。我们总结了一套指南,无论作为开发人员还是顾问,这些指南多年来一直都很好地指导着我们,我们把它们作为建议提供给您,希望对您的工作有所帮助。您也许不赞同其中的某些指南,但我们希望您会喜欢其中的一些并在您的编程或移植项目中使用它们。

风格与指南

l          使用一种使代码具有可读性和一致性的源代码风格。如果没有团队代码风格或自己的风格,您可以使用与大多数 C 程序员采用的 Kernighan Ritchie 风格相似的风格。然而,举一个极端的例子,有可能最终会写出与下面相似的代码:

    int i;main(){for(;i["]<i;++i){--i;}"];read('-'-'-',i+++"hell\

    o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,i---j,i/i);

1984 年模糊 C 代码大赛“差劲奖”。应代码作者要求匿名。

l          通常将主例程定义为 main()。对应的 ANSI 编写方式是 int main(void)(如果不考虑命令行参数的话)或 int main( int argc, char **argv )ANSI 以前的编译器会省略 void声明,或列出变量名以及紧随其后的声明。

l          空格

充分利用水平和垂直空格。缩进和空格间距应反映出代码的块结构。

应将条件运算符的长字符串分割成单独的几行。例如:

 

    if (foo->next==NULL && number < limit      && limit <=SIZE

        && node_active(this_input)) {...

 

最好改成:

 

    if (foo->next == NULL

          && number < limit && limit <= SIZE

          && node_active(this_input))

        {

         ...

 

同样,应将描述得很详细的 for 循环分割成不同的行:

 

    for (curr = *varp, trail = varp;

        curr != NULL;

       trail = &(curr->next), curr = curr->next )

     {

           ...

 

对于其它复杂表达式(如使用三元运算符 ?:的表达式),最好也将其分割成数行。

 

    z = (x == y)

        ? n + f(x)

         : f(y) - n;

                     

 

l          注释

注释应描述正在发生什么事、如何完成它、参数表示什么、使用了哪些全局变量以及任何限制或错误。但要避免不必要的注释。如果代码比较清晰,并且使用了良好的变量名,那么它应该能够较好地说明自身。因为编译器不检查注释,所以不保证它们是正确的。与代码不一致的注释会起到相反的作用。过多的注释会使代码混乱。

下面是一种多余的注释风格:

 

    i=i+1;        /* Add one to i */

 

很明显变量 i递增了 1。还有更糟的注释方法:

 

 

         /************************************

         *                                   *

         *          Add one to i           *

         *                                   *

           ************************************/

 

                    i=i+1;

 

l          命名约定

具有前导和尾随下划线的名称是为系统用途而保留的,不应当用于任何用户创建的名称。约定规定:

1.          #define 常量应全部大写。

2.          enum 常量应以大写字母开头或全部大写。

3.          函数、类型定义(typedef)和变量名以及结构(struct)、联合(union)和枚举(enum)标记名称应小写。

为清晰起见,避免使用仅在大小写上有区别的名称,如 foo Foo。同样,避免使用 foobar foo_bar 这样的名称。避免使用看上去相似的名称。在许多终端和打印机上,“l”、“1”和“I”看上去非常相似。使用名为“l”的变量非常不明智,因为它看上去非常象常量“1”。

 

l          变量名

选择变量名时,长度不重要,但清晰的表达很重要。长名称可用于全局变量,因为它不常用,而将在每行循环上要使用的数组下标命名为 i 就完全够了。如果使用“index”或“elementnumber”的话,不仅输入得更多,而且会使计算的细节不明确。如果使用长变量名,有时候会使代码更难理解。比较:

 

 

     for(i=0 to 100)

          array[i]=0

 

 

 

     for(elementnumber=0 to 100)

          array[elementnumber]=0;

 

l          函数名

函数名应反映函数执行什么操作以及返回什么内容。函数在表达式中使用,通常用于 if子句,因此它们的意图应一目了然。例如:

 

 

     if (checksize(x))

 

没有帮助作用,因为它没有告诉我们 checksize 是在出错时返回 true 还是在不出错时返回 true;而

 

     if (validsize(x))

 

则使函数的意图很明确。

l          声明

所有的外部数据声明前都应加上 extern关键字。

“指针”限定符“*”应紧邻变量名而不是类型。例如,应使用

 

 

    char        *s, *t, *u;

 

而不是

 

    char*   s, t, u;

 

后一条语句没有错,但可能不是我们期望的,因为没有将“t”和“u”声明为指针。

l          头文件

头文件应按功能组织在一起,即,对单独子系统的声明应在单独的头文件中。此外,当代码从一个平台移植到另一个平台时有可能发生更改的声明应位于单独的头文件中。

 

避免使用与库头文件名相同的专用头文件名。语句 #include "math.h" 如果在当前目录中找不到所期望文件的话,会包括标准库 math 头文件。如果这是您期望的结果,可以注释掉这行 include 语句。

 

最后说明一点,对头文件使用绝对路径名不是一个好主意。C 编译器的“include-path”选项(在许多系统上为 -I 大写的 i)是处理众多专用头文件库的首选方法;它允许在不改变源文件的情况下重新组织目录结构。

l          scanf

在重要的应用程序中永远不要使用 scanf。它的错误检测不够完善。请看下面的示例:

 

 

    #include <stdio.h>

 

    int main(void)

    {

        int i;

        float f;

 

        printf("Enter an integer and a float: ");

        scanf("%d %f", &i, &f);

 

        printf("I read %d and %f\n", i, f);

        return 0;

    }

 

测试运行

Enter an integer and a float: 182 52.38

I read 182 and 52.380001

另一个测试运行

Enter an integer and a float: 6713247896 4.4

I read -1876686696 and 4.400000

l          ++ --

当对语句中的变量使用递增或递减运算符时,该变量不应在语句中出现一次以上,因为求值的顺序取决于编译器。编写代码时不要对顺序作假设,也不要编写在某一机器上能够如期运作但没有明确定义的行为的代码:

 

    int i = 0, a[5];

 

    a[i] = i++; /* assign to a[0]? or a[1]? */

 

l          不要被表面现象迷惑

请看以下示例:

 

 

        while (c == '\t' || c = ' ' || c == '\n')

            c = getc(f);

 

乍一看, while子句中的语句似乎是有效的 C 代码。但是,使用赋值运算符而不是比较运算符却产生了语义上不正确的代码。= 的优先级在所有运算符中是最低的,因此将以下列方式解释该语句(为清晰起见添加了括号):

 

        while ((c == '\t' || c) = (' ' || c == '\n'))

            c = getc(f);

 

赋值运算符左边的子句是:

 

        (c == '\t' || c)

 

它不会产生左值。如果 c 包含制表符,则结果是“true”,并且不会执行进一步的求值,而“true”不能位于赋值表达式的左边。

l          意图要明确。

当您编写的代码可以解释成另一种意图时,使用括号或用其它方法以确保您的意图清楚。如果您以后必须处理该程序的话,这有助于您理解您当初的意图。如果其他人要维护该代码,这可以让维护任务变得更简单。

用能预见可能出现错误的方式编码,有时是可行的。例如,可以将常量放在比较等式的左边。即,不编写:

 

    while (c == '\t' || c == ' ' || c == '\n')

        c = getc(f);

 

而是编写:

 

    while ('\t' == c || ' ' == c || '\n' == c)

         c = getc(f);

 

用以下方法却会得到编译器诊断:

 

    while ('\t' = c || ' ' == c || '\n' == c)

         c = getc(f);

 

这种风格让编译器发现问题;上面的语句是无效的,因为它试图对“\t”赋值。

l          意想不到的麻烦。

各种 C 实现通常在某些方面各有不同。坚持使用语言中可能对所有实现都是公共的部分会有帮助。通过这样做,您更容易将程序移植到新的机器或编译器,并且不大会遇到编译器特殊性所带来的问题。例如,考虑字符串:

 

    /*/*/2*/**/1

 

这里利用了“最大适合(maximal munch)”规则。如果可以嵌套注释,则可将该字符串解释为:

    /* /* /2 */ * */ 1

 

两个 /* 符号与两个 */ 符号匹配,因此该字符串的值为 1。如果注释不嵌套,那么在有些系统上,注释中的 /* 就被忽略。在另一些系统上会针对 /* 发出警告。无论哪种情况,该表达式可解释为:

 

    /* / */ 2 * /* */ 1

 

2 * 1 求值得 2

l          清空输出缓冲区

当应用程序异常终止时,其输出的尾部常常会丢失。应用程序可能没有机会完全清空它的输出缓冲区。输出的某一部分可能仍在内存中,并且永远不会被写出。在有些系统上,这一输出可能有几页长。

以这种方式丢失输出会使人误解,因为它给人的印象是程序在它实际失败很久之前就失败了。解决这一问题的方法是强制将输出从缓冲区清除,特别是在调试期间。确切的方法随系统的不同而有所不同,不过也有常用的方法,如下所示:

 

    setbuf(stdout, (char *) 0);

 

必须在将任何内容写到标准输出之前执行该语句。理想情况下,这将是主程序中的第一条语句。

l          getchar() 宏还是函数

以下程序将其输入复制到其输出:

 

 

    #include <stdio.h>

 

    int main(void)

    {

        register int a;

 

        while ((a = getchar()) != EOF)

            putchar(a);

    }

 

从该程序除去 #include 语句将使该程序无法编译,因为 EOF 将是未定义的。

我们可以用以下方法重新编写该程序:

 

 

    #define EOF -1

 

    int main(void)

    {

        register int a;

 

        while ((a = getchar()) != EOF)

            putchar(a);

    }

 

这在许多系统上都可行,但在有些系统上运行要慢很多。

因为函数调用通常要花较长时间,所以常常把 getchar实现为宏。这个宏定义在 stdio.h中,所以当除去 #include <stdio.h>时,编译器就不知道 getchar 是什么。在有些系统上,假设 getchar 是返回一个 int的函数。

实际上,许多 C 实现在其库中都有 getchar函数,部分原因是为了防止这样的失误。于是,在 #include <stdio.h>遗漏的情况下,编译器使用 getchar的函数版本。函数调用的开销使程序变慢。 putchar有同样的问题。

l          空指针

空指针不指向任何对象。因此,为了赋值和比较以外的目的而使用空指针都是非法的。

不要重新定义 NULL 符号。NULL 符号应始终是常量值零。任何给定类型的空指针总是等于常量零,而与值为零的变量或与某一非零常量的比较,其行为由实现定义。

反引用 null 指针可能会导致奇怪的事情发生。

l          a+++++b 表示什么?

解析它的唯一有意义的方法是:

 

        a ++ + ++ b

 

然而,“最大适合”规则要求将它分解为:

 

        a ++ ++ + b

 

这在语法上是无效的:它等于:

 

        ((a++)++) + b

 

a++ 的结果不是 左值,因此作为 ++ 的操作数是不可接受的。于是,解析词法不明确性的规则使得以语法上有意义的方式解析该示例变得不可能。当然,谨慎的办法实际上是在不能完全确定它们的意义的情况下,避免这样的构造。当然,添加空格有助于编译器理解语句的意图,但(从代码维护的角度看)将这一构造分割成多行更可取:

 

    ++b;

    (a++) + b;

 

l          小心处理函数

函数是 C 中最常用的结构概念。它们应用于实现“自顶向下的”问题解决方法 即,将问题分解成越来越小的子问题,直到每个子问题都能够用代码表示。这对程序的模块化和文档记录有帮助。此外,由许多小函数组成的程序更易于调试。

如果有一些函数参数还不是期望的类型,则将它们强制转换为期望的类型,即使您确信没有必要也应该这样做,因为(如果不转换的话)它们可能在您最意料不到的时候给您带来麻烦。换句话说,编译器通常将函数参数的类型提升和转换成期望的数据类型以符合函数参数的声明。但是,在代码中以手工方式这样做可以清楚地说明程序员的意图,并且在将代码移植到其它平台时能确保有正确的结果。

如果头文件未能声明库函数的返回类型,那就自己声明它们。用 #ifdef/#endif 语句将您的声明括起来,以备代码被移植到另一个平台。

函数原型应当用来使代码更健壮,使它运行得更快。

l          悬空 else

除非知道自己在做什么,否则应避免“悬空 else”问题:

 

 

        if (a == 1)

            if (b == 2)

                printf("***\n");

            else

                printf("###\n");

 

 

规则是 else 附加至最近的 if。当有疑虑时,或有不明确的可能时,添加花括号以说明代码的块结构。

l          数组界限

检查所有数组的数组界限,包括字符串,因为在您现在输入“fubar”的地方,有人可能会输入“floccinaucinihilipilification”。健壮的软件产品不应使用 gets()

C 下标以零作为开始的这一事实使所有的计数问题变得更简单。然而,掌握如何处理它们需要花些努力。

l          空语句

for while 循环的空语句体应当单独位于一行并加上注释,这样就表明这个空语句体是有意放置的,而不是遗漏了代码。

 

    while (*dest++ = *src++)

    ;   /* VOID */

 

l          测试真(true)还是假(false

不要以缺省方式测试非零值,即:

 

    if (f() != FAIL)

 

优于

 

    if (f())

 

尽管 FAIL 的值可能是 0(在 C 中视为假(false))。(当然,应当在这一风格与“函数名”一节中演示的构造之间作出权衡。)当以后有人认为失败的返回值应该是 -1 而不是 0 时,显式的测试对您会有帮助。

常见的问题是使用 strcmp 函数测试字符串是否相等,决不应该以缺省方式处理它的结果。更可取的方法是定义宏 STREQ

 

#define STREQ(str1, str2) (strcmp((str1), (str2)) == 0)

 

用这种方法,语句

 

    If ( STREQ( inputstring, somestring ) ) ...

 

就具有隐含的行为,该行为不大会在您不知情的情况下改变(人们往往不会重新编写或重新定义象 strcmp()这样的标准库函数)。

不要用 1 检查相等性的布尔值(TRUE YES 等);而要用 0 测试不等性(FALSE NO 等)。绝大多数函数被确保在条件为假(false)时返回 0,但仅在条件为真(true)时才返回非零。因此,最好将

 

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

 

写成

 

    if (func() != FALSE)

 

l          嵌入语句

使用嵌入赋值语句要看时间和地点。在有些构造中,如果不使用更多且不易阅读的代码就没有更好的方法来实现结果:

 

    while ((c = getchar()) != EOF) {

    process the character

    }

 

使用嵌入赋值语句来提高运行时性能是可能的。但是,您应当在提高速度和降低可维护性之间加以权衡,在人为指定的位置使用嵌入赋值语句会导致可维护性降低。例如:

 

    x = y + z;

    d = x + r;

 

不应被替换为:

 

    d = (x = y + z) + r;

 

即使后者可能节省一个周期也不行。最终,这两者之间在运行时间上的差异将随着优化器的增强而减少,易维护性的差异却将增加。

l          goto 语句

应保守地使用 goto。从数层 switch for while嵌套中跳出来时,使用该语句很有效,不过,如果有这样的需要,则表明应将内部构造分解成单独的函数。

 

        for (...) {

             while (...) {

           ...

                  if (wrong)

                       goto error;

       

                    }

         }

         ...

            error:

          print a message

 

当必须使用 goto时,随附的标号应单独位于一行,并且同后续代码的左边相距一个制表符或位于一行的开头。对 goto语句和目标都应加上注释,说明其作用和目的。

l          switch 中的“落空”(fall-through

当一块代码有数个标号时,将这些标号放在单独的行。这种风格与垂直空格的使用一致,并且使重新安排 case 选项(如果那是必需的话)成了一项简单的任务。应对 C switch 语句的“落空”特征加以注释,以便于以后的维护。如果这一特性曾给您带来“麻烦”,那么您就能够理解这样做的重要性!

 

    switch (expr) {

    case ABC:  

    case DEF:

         statement;

         break;

    case UVW:

        statement; /*FALLTHROUGH*/

    case XYZ:

         statement;

     break; 

    }

 

尽管从技术上说,最后一个 break 不是必需的,但是,如果以后要在最后一个 case 之后添加了另一个 case,那么一致地使用 break 可以防止“落空”错误。如果使用 default case 语句的话, 它应当永远是最后一个,并且(如果它是最后的语句)不需要最后的 break 语句。

l          常量

符号常量使代码更易于阅读。应尽量避免使用数字常量;使用 C 预处理器的 #define 函数给常量赋予一个有意义的名称。在一个位置(最好在头文件中)定义值还会使得管理大型程序变得更容易,因为只需更改定义就可以统一地更改常量值。可以考虑使用枚举数据类型作为对声明只取一组离散值的变量的改进方法。使用枚举还可以让编译器对您枚举类型的任何误用发出警告。任何直接编码的数字常量必须至少有一个说明值的出处的注释。

常量的定义与它的使用应该一致;例如,将 540.0 用于浮点数,而不要通过隐式浮点类型强制转换使用 540。也就是说,在有些情况下,常量 0 1 可以以本身的形式直接出现,而不要以定义的形式出现。例如,如果某个 for循环遍历一个数组,那么:

 

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

 

非常合理,而代码:

 

    gate_t *front_gate = opens(gate[i], 7);

    if (front_gate == 0)

       error("can't open %s\n", gate[i]);

 

就不合理。在第二个示例中,front_gate 是指针;当值是指针时,它应与 NULL 比较而不与 0 比较。即使象 1 0 这样的简单值,通常最好也使用象 TRUE FALSE 这样的定义来表示(有时 YES NO 读起来更清楚)。

不要在需要离散值的地方使用浮点变量。这是由于浮点数不精确的表示决定的(请参阅以上 scanf中的第二个测试)。使用 <= >= 测试浮点数;精确比较(== !=)也许不能检测出“可接受的”等同性。

应将简单的字符常量定义为字符文字而不是数字。不提倡使用非文本字符,因为它们是不可移植的。如果必须使用非文本字符,尤其是在字符串中使用它们,则应使用三位八进制数(不是一个字符)的转义字符(例如“\007”)来编写它们。即便如此,这样的用法应视为与机器相关,并且应按这一情况来处理。

l          条件编译

条件编译可用于机器相关性、调试以及在编译时设置某些选项。可以用无法预料的方式轻易地组合各种控制。如果将 #ifdef 用于机器相关性,应确保当没有指定机器时会出错,而不是使用缺省的机器。#error 伪指令可以较方便地用于这一用途。如果使用 #ifdef 进行优化,缺省值应是未优化的代码而不是不可编译或不正确的程序。要确保对未优化的代码进行了测试。

 

其它

l          Make这样用于编译和链接的实用程序极大简化了将应用程序从一个环境移到另一个环境的任务。在开发期间, make仅对那些自上次使用 make 以来发生了更改的模块进行重新编译。

l          经常使用 lint lint C 程序检查器,它检查 C 源文件以检测并报告函数定义和调用之间类型的不匹配和不一致,以及可能存在的程序错误等。

l          此外,研究一下编译器文档,了解那些使编译器变得“吹毛求疵”的开关。编译器的工作是力求精确,因此通过使用适当的命令行选项让它报告可能存在的错误。

l          使应用程序中全局符号的数量最少。这样做的好处之一是与系统定义的函数冲突的可能性降低。

l          许多程序在遗漏输入时会失败。对所有的程序都应进行空输入测试。这也可能帮助您理解程序的工作原理。

l          不要对您的用户或您所用的语言实现有任何过多的假设。那些“不可能发生”的事情有时的确会发生。健壮的程序可以防范这样的情形。如果需要找到某个边界条件,您的用户将以某种方式找到它!

l          永远不要对给定类型的大小作任何假设,尤其是指针。

l          当在表达式中使用 char类型时,大多数实现将它们当作无符号类型,但有些实现把它们作为有符号的类型。当在算术表达式使用它们时,建议始终对它们进行类型强制转换。

l          不要依靠对自动变量和 malloc返回的内存进行的初始化。

l          使您程序的目的和结构清晰。

l          要记住,可能会在以后要求您或别的人修改您的代码或在别的机器上运行它。细心编写您的代码,以便能够将它移植到其它机器。

 

结束语

应用程序的维护要花去程序员的大量时间,这是众所周知的事。部分原因是由于在开发应用程序时,使用了不可移植和非标准的特性,以及不令人满意的编程风格。在本文中,我们介绍了一些指南,多年来它们一直给予我们很大帮助。我们相信,只要遵守这些指南,将可以使应用程序维护在团队环境中变得更容易。

参考资料

l          Obfuscated C and Other Mysteries,由 Don Libes 编写,John Wiley and Sons, Inc. ISBN 0-471-57805-3

l          The C Programming LanguageSecond Edition,由 Brian W. Kernighan Dennis M. Ritchie 撰写,Prentice-HallISBN 0-13-110370-9

l          Safer C,由 Les Hatton 编写,McGraw-HillISBN 0-07-707640-0

l          C Traps and Pitfalls Andrew Koenig 编写,AT&T Bell LaboratoriesISBN 0-201-17928-9

作者简介

Shiv Dutta IBM Systems Group 的一名技术顾问,他帮助独立软件供应商在 pSeries 服务器启用他们的应用程序。Shiv 有作为软件开发人员、系统管理员和讲师的丰富经验。他在 AIX 的系统管理、问题确定、性能调优和规模指导方面提供支持。Shiv AIX 诞生之时就从事这方面的工作。他从 Ohio University 获得物理博士学位,可以通过 sdutta@us.ibm.com与他联系。

Gary R. Hook IBM 的高级技术顾问,为独立软件供应商提供应用程序开发、移植和技术援助。Hook 先生的职业经历主要在基于 Unix 的应用程序开发方面。在 1990 年加入 IBM 时,他在位于得克萨斯州 Southlake AIX Technical Support 中心工作,为客户提供咨询和技术支持服务,重点在 AIX 应用程序体系结构方面。Hook 先生现在居住在奥斯汀,在 1995 2000 年期间,他是 AIX Kernel Development 团队的一员,专门研究 AIX 链接程序、装入程序和通用应用程序开发工具。可以通过 ghook@us.ibm.com与他联