65.9K
CodeProject 正在变化。 阅读更多。
Home

C 语言代码的十个误区

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (74投票s)

2012年3月29日

CPOL

37分钟阅读

viewsIcon

111716

关于如何编写高质量代码,存在着不少误区。本文旨在揭露其中的一些。

引言

过去二十多年里,关于如何编写高质量C代码的内容已有不少。我自己早在1993年就开始着手写一本这方面的书。尽管有许多好的建议,但我认为普通代码的质量并没有得到显著提升。从我个人的观察来看,变化在于编写糟糕代码的方式变得更加多样化了。表面上看,许多代码似乎有所改善,但仔细审视后就会发现存在很多混淆之处。

问题的一部分在于,关于如何编写高质量代码,存在着不少误区。这些误区往往是好的想法被误解或错误应用了。

本文旨在揭露其中的一些。我所说的内容,您很可能不同意其中一些观点,但请保持开放的心态。如果您有充分的理由反驳我的观点,请留言讨论,但请勿进行人身攻击。(根据我个人的经验,我知道许多这些误区可以被奉为圭臬,甚至带有宗教般的狂热。)与其产生过多的争执,不如理性地探讨。

请注意,本文主要讨论的是C语言编程,但其中很多内容也适用于其他语言。在相关的地方,我也会提到一些关于C++和其他语言的特别说明。

1. 使用描述性的变量名

我想从一个简单的例子开始。对大多数人来说,这是不言而喻的,但我仍然偶尔会看到(通常是写得很糟糕的)代码,其中变量名非常长(例如30个字符或更多)。

我猜想这个误区源于另一个误区(这里不详述),即代码应具有自描述性,以避免或尽量减少注释的需要。想法是,注释如果存在,往往是错误的、过时的,或者根本没人看。那么何不干脆避免注释呢?

我不会对注释及其维护展开长篇大论,因为这方面已经写了很多。我只想指出,标识符也会像注释一样过时——而且维护者似乎比修复注释更不愿意重构代码。更糟糕的是,有些程序员会走得更远,创建临时变量,仅仅是为了给它们一个能解释代码的名称——我将在误区9中详细讨论不必要的临时变量,但总的来说,具有解释性注释(必要时可参考)的简单代码,要优于没有注释的复杂代码。

回到这个误区,我的观点是,试图在标识符中塞入过多信息是错误的方法。如果一个变量在代码中被频繁使用,您不希望被不断地提醒它有什么用。您需要一个简单易记、并且易于与同一代码段中的其他标识符区分开来的名称。

简而言之,使用简短的变量名可以使代码更易于扫描。使用注释(通常在变量声明处)来描述变量的用途。

许多编码标准中一个常见的陈述是“使用能够充分描述其用途的标识符”。尽管这有些模糊,我实际上同意对于很少见的项(如全局变量)来说,这是一个好主意,但总的来说,它是。大多数变量的用途都很难用几个词来概括。结果通常是一个长名称,试图描述其用途,但(为了简洁)是不完整或含糊的。更糟糕的是,程序员现在觉得在声明处没有给出适当的注释是合理的。

准则:

  • 为高频使用、作用域有限的变量使用相对简短、易读的标识符
  • 为不经常出现的标识符使用更长、更具描述性的名称
  • 使用能够反映其用途的变量名(参见 K&R 的示例)
  • 确保标识符足够长且易于区分
  • 确保标识符易于记忆(即不是随机的字母组合)
  • 在声明变量的地方,用注释提供其用途的完整描述

2. 在表达式中使用大量括号和/或空格

这实际上是两个紧密相关的误区。第一个误区是,在表达式中添加额外、多余的括号(也称为圆括号)以确保任何阅读代码的人都能理解运算顺序。第二个误区是,你应该在表达式中添加大量空格以提高可读性。

我们先来看括号。确实,C语言中某些运算符的优先级并不广为人知。例如,移位运算符(有些人认为其优先级高于加减法)、位运算符,以及赋值(=, +=, 等)、三元运算符(?:)和逗号运算符(,)的相对优先级。当你 unsure 时,应该使用括号,即使这意味着你有时会有多余的括号。

然而,在优先级已知的场合添加额外的括号会大大降低代码的可读性。(如果你喜欢很多括号,那你应该去写 Lisp。)我见过一些编码标准要求这样做

  if (((variable >= 10) && (variable < 20)) || ((variable >= 30) && (variable < 40)))
     ....

而不是

  if (variable >= 10 && variable < 20 || variable >= 30 && variable < 40)
     .... 

我认为你会同意后者至少稍微更易读一些。当使用更复杂的表达式时,额外的括号只会增加混乱。你应该改用空格来突出优先级。

这就引出了空格的使用。大家都同意使用空格能让C代码更容易阅读。我的论点是,过度使用空格会产生相反的效果。事实上,我进行的一些非正式测试表明,至少在某些情况下,添加过多的空格会使代码阅读更容易出错。

当然,如果你进行自己的研究,结果可能会有所不同——很大程度上取决于你习惯阅读的代码类型。我的论点是,所有C程序员都应该熟悉我所提倡的代码风格,因为它在K&R、C标准中的示例以及许多其他重要书籍(如The C++ Programming Language)中都有使用(Bjarne对指针和引用声明的独特空格用法除外)。

不幸的是,括号在C语言的语法中有太多用途——例如,用于函数调用、表达式中的分组、强制类型转换等。因此,使用空格来强调它们的不同用途是有益的。一个好的约定是,在后面跟有左括号的关键字(如if, while等)后始终保留一个空格,但在函数名和随后的左括号之间不加空格。(一个例外是类函数关键字,如sizeof。)这使得扫描代码更容易,且不易出错。

有些程序员遵循在左括号后加空格,在每个右括号前加空格的约定。我不介意这个约定太多,但我发现当需要很多括号时,它会使代码更难阅读,而不是更容易。

一个你应该始终添加空格的地方是逗号后面。首先,如果你不这样做,扫描长逗号分隔的列表会非常乏味。逗号也容易被误认为是一个点(尤其是在我使用的屏幕分辨率下),因此在某些类型的参数之间时,可能会被解释为成员运算符或小数点。

准则

  • 除非优先级令人困惑,否则不要添加多余的括号
  • 使用空格来提高可读性——例如,突出运算符的优先级
  • 不要添加过多的空格,因为它会损害可读性
  • 始终在逗号和关键字(sizeof除外)后加空格
  • 如有疑问,请查看 K&R 中括号和空格的使用方式

3. 使用“枪火式”初始化

“枪火式”初始化是指在创建所有变量时都将它们初始化为某个值。这样做是为了防止由于代码中的某个 bug 而导致变量从未被设置为其初始值。这是防御性编程的一个例子,在这种情况下,程序比变量包含随机值时更有可能从问题中恢复。(防御性编程是一种有用的策略,但它不能修复 bug;它只是减轻其影响。)

这种方法的一个问题是,它会削弱编译器在编译时检测未初始化(和未使用)变量的能力。防御性编程的另一个问题是,尽管(并且因为)程序能够优雅地从 bug 中恢复,但这种恢复本身可能会掩盖 bug 存在的事实——因此没有人会被要求去修复它。

然而,我的主要担忧是,这是一种冗余代码的例子,因为变量被初始化了两次(一次是任意值,通常是零,然后稍后是其正确的初始值)。冗余代码因多种原因而不好,其中之一是:它会混淆维护代码的任何人。例如,有人可能会修改代码,认为零值是正确的初始值。

请注意,在C++中,这个问题不常发生,因为你通常只在你第一次需要变量时声明它们,然后你可以立即初始化它们。它在C中很普遍,因为你只能在复合语句的开头声明变量(并按照惯例在函数开头声明——参见下面的误区8)。
[在C++的构造函数中仍然可能出现这个问题,如果所有成员变量的初始值在对象创建时未知的话。]

“枪火式”初始化在我最近的博客中有深入讨论。

准则:

  • 如果你在声明变量时不知道其正确的初始值,那么不要将其初始化为零之类的任意值
  • 让编译器设置局部变量的值(在调试构建中),以使 bug 更易于重现(例如,VC++ 使用 0xCC)
  • 尽可能在你知道变量的正确初始值时才声明它(即时声明)

4. 不要使用“魔法数字”

人们非常强调不要在代码中使用魔法数字,这是应该的。然而,有时这种做法会走得太远。我见过一些编码标准规定代码中不应有任何类型的字面量,这会导致出现像下面这样的怪事
#define ZERO 0
#define ONE  1

当然,编码标准的后续版本要求使用enum来定义整型常量。上面的代码不得不改为这样

enum { ZERO, ONE, };

显然,这样做很愚蠢。我们需要审视不使用魔法数字规则背后的原因,并决定何时适用。为此,我们首先需要理解避免魔法数字有两个原因——可理解性和可维护性。

可理解性

魔法数字之所以称为魔法数字,正是因为它们的含义是神秘的。消除这种神秘感(从而使代码更易理解)的一种方法是给它们一个有意义的名称。我猜想这也回到了代码应自描述的理念(参见上面的误区1)。然而,代码中的注释也可以同样好地解释其目的,除了代码和注释可能不同步的风险。

可维护性

为值命名(使用#defineenumconst)也可以使您的代码更易于维护,如果相同的值在代码的不同地方使用。如果该值很可能,甚至只是可能,在未来发生变化,那么您应该在某处定义它。通常是在头文件中定义共享值,或者如果该值仅限于某个文件,则在.C文件的顶部定义。

static const int cards_per_deck = 53;  // includes a joker

这样一来,只需在一处更改值并重新编译受影响的部分即可。这可以避免创建 bug,如果您未能找到所有使用该值的地方。此外,如果例如该值不再需要是常量——即需要动态更改——这也使得代码的增强变得容易。

static int cards_per_deck = 53;
  ...
  if (players == 4)
     cards_per_deck = 43;
  else if (players == 6)
     cards_per_deck = 63;
  ...

误区

不使用魔法数字在代码中有巨大的优势,那么问题在哪里?当上述两个原因都不适用时,就没有理由在代码中嵌入字面量。此外,过度使用该技术会使代码非常难以阅读。

举个简单的例子,我曾经在头文件中看到过这样的代码

#define ENDSTR 0   // end of string character

然后代码中到处使用ENDSTR来测试字符串的结束。现在,即使是最基础的C程序员也应该知道,字符串的结束是由值为零的字符表示的,这通常写为'\0'。使用ENDSTR不仅比直接使用'\0'更令人困惑,而且使代码更难阅读。

一个更大的例子摘自我必须维护的一些用于生成要发送到SCSI设备的CBD(命令块)的代码。代码大量使用了类似这样的#define值:

  unsigned char cdb[LOG_SENSE_CDB_SIZE];

  cdb[OPCODE]   = LOG_SENSE_OPCODE;
  cdb[PCOFFSET] = (pc & LOG_SENSE_PC_BITS) << LOG_SENSE_PC_POS |
                  (pcode & LOG_SENSE_PCODE_BITS) << LOG_SENSE_PCODE_POS;

想象一下数千行这样的代码,你就能明白了。我发现我无法一眼看懂代码和SCSI标准。首先,长名称使一切都更难阅读。我还发现我总是需要检查头文件,以确保所有#define的值都是正确的,并且是我期望的值(而且存在一些错误)。

我后来用这种方式重写了部分代码

  unsigned char cdb[10];   // LOG SENSE CDB is 10 bytes

  cdb[0] = 0x4D;            // LOG SENSE = 4D
  cdb[1] = 0;
  cdb[2] = (pc & 0x3) << 6 | (pcode & 0x3F);

我认为任何人都会同意,这个改动使代码的意图更加清晰。而且它更安全,因为如果cbd[]的某个字节未初始化,将很明显。(例如,在上面的原始代码中,cdb[1]就没有设置。)

这些更改也没有使代码的可维护性降低,因为值(如LOG SENSE操作码)只在代码的一个地方使用(而且无论如何,它们是SCSI标准的一部分,所以永远不会改变)。对于随意查看源代码的人来说,这些值可能看起来是魔法的,但任何需要维护它的人都应该参考SCSI标准,该标准精确地解释了所有数字的含义。

准则

  • 当字面量可能更改时,永远不要将它们嵌入代码中
  • 如果嵌入字面量可以提高代码的可读性,则考虑嵌入它们

5. 使用 typedefs

与前一个误区类似,就是使用大量的typedefs 会使代码在某种程度上变得更好。Typedef 在某些情况下(例如函数指针)非常有用,但它经常被滥用。它不应该被用来创建透明类型(如许多structs 和指针)的别名。

[透明类型是指内部数据暴露的类型,例如struct中的字段或指针指向的对象类型。不透明类型是指您有一个int或指针,它充当对象的句柄。]

由于在Windows头文件中使用了typedef,以及某些作者的推广,这个想法获得了很大的关注。

有时为typedef一个 struct 提供的理由是它是信息隐藏。信息隐藏对于降低代码复杂度有很多好处(参见我关于处理设计复杂性的博客文章),但信息隐藏的本质是隐藏不相关的信息。当typedef一个struct时,信息通常不是不相关的,因此不应该被隐藏。即使是仅仅隐藏我们正在处理一个struct的事实,也会降低代码的可理解性。

C++

struct使用typedef的另一个论点是,它实际上与C++内置的功能是等效的。当你在C++中使用struct(或class)时,声明时不需要在标签前加上struct关键字。所以C++中这样做是允许的

  struct s { int a, b; };
  ...
  s s1, *ps;

区别在于,通常类(和许多结构体)是不透明类型。它们的使用方式往往与C代码中使用的struct不同,这也是为什么它们有私有成员等。然而,我建议在使用C++中的struct作为透明类型时,为了清晰起见,仍在声明前加上struct关键字。

标准头文件

我也看到过这样的论点,即在C中采用这种做法是合适的,因为它在标准C头文件中使用——例如FILE类型。
[实际上,标准C头文件并非良好实践的典范,所以这也不是一个好论据。]

虽然stdio.h定义FILE类型的方式一个好主意,但这是一种误导,因为FILE结构体的内部可以,也应该被隐藏。FILE typedef只是用于创建句柄(类型为FILE *),这些句柄在文件处理函数(fopen(), fread()等)之间传递。这是一个信息隐藏有效使用的好例子。

示例

我想是时候举个例子了。我将使用我在Windows头文件中遇到的第一个typedef

typedef struct tagSIZE
{
    LONG cx;
    LONG cy;
} SIZE, *PSIZE, *LPSIZE;

现在,如果我看到这样的声明

extern SIZE GetSize(HANDLE h, PSIZE inner);

我可能会假设GetSize()的返回值只是一个标量值。即使我知道SIZE有一个X和Y分量,我可能认为它们分别存储在long的高位和低位。也许我知道SIZE是一个结构体,但仍然很容易忽略inner是指向同一类型的指针,并且它实际上用于返回另一个SIZE

现在考虑

struct size { long cx, cy; };
extern struct size GetSize(HANDLE h, struct size *inner);

在这里,可以清楚地看到函数返回一个结构体。此外,由于inner是指针(而不是const指针),因此很明显通过*inner也返回了另一个大小。

因此,typedef的不当使用会隐藏有用的信息,并降低代码的可理解性。此外,对structtypedef会导致对数据大小的混淆(尤其是在嵌套结构体的情况下)。了解类型的粗略大小通常很重要,例如,要知道是按值传递还是使用const指针(或C++中的const引用)进行传递。复制大型结构体会显著降低速度。

准则

  • 永远不要使用typedef来隐藏重要信息
  • 对不透明类型(如FILE)使用typedef
  • 使用typedef来简化涉及函数指针的声明

6. 定义自己的整数类型

与此列表中的大多数误区一样,一个有用的想法在不合适的时候也被盲目使用。同样,这又回到了理解原始想法的意义,以便知道何时以及何时不使用它。

与上一个误区一样,这个误区也因为Windows头文件而广泛传播,它们typedef了很多整数类型(WORD, DWORD等)。然而,我自20世纪80年代初以来就看到了这个基本思想在编码标准中被推广。例如,在我作为C程序员的第一份工作中,编码标准要求不使用普通的C整数数据类型,而是要求我们使用具有精确定义大小的typedef值,称为INT8, INT16, INT32, UINT8, 等。

简而言之,这个想法可能对移植性和兼容性有益,但总的来说,你应该只使用int(如果int可能不够大,则使用long)。(类似地,对于浮点数据,只使用double,或者可能使用long double。)

可移植性

这个想法最初是为了使代码更具可移植性,事实上它有时在这方面很有用(见下文)。问题在于,人们对C语言在不同整数类型大小方面的模糊性存在担忧,而这是不正确的。事实上,C语言中的类型设计非常巧妙,可以实现高效且可移植的代码。
[我个人并不强求代码必须可移植,只要它不需要。但一般来说,写得好的代码通常也相当可移植。]

首先,我将举一个例子说明这种做法应该如何用于使代码可移植。典型的用途是C语言编写的库,它读写二进制数据文件,或为通信协议创建数据包。在这种情况下,至关重要的是,在所有目标系统上,代码的数据字段的大小始终保持一致。这使得在一个系统上创建的数据文件可以在另一个系统上使用,或者在不同类型的系统上运行的相同软件可以相互通信。(当然,还有其他考虑因素,如字节顺序等,我不想在这里深入讨论。)

这是一个例子。

#if SYSTEM1
   typedef int   INT16;
   ...
#else
   typedef short INT16;
   ...
#endif

struct packet
{
  UINT8   type;
  INT8    status;
  UINT16  number;
  ...
};

问题在于,当使用相同的技术来克服代码编写不佳的问题时。C代码中不必要地假设所使用数据类型的实例并不少见。例如,这段代码是为16位处理器编写的(即sizeof(int) == 2

  int  i = 0xFFFF;     // set all bits on

这段代码的问题在于,在32位处理器上,它只设置了低16位。为了使代码更具可移植性,人们常常会使用类似这样的方法

  INT16  i = 0xFFFF;

这里有很多我不喜欢的地方,但主要是因为它根本不必要,并且用非标准类型弄乱了代码。通常最好使用int,因为它被选为目标系统的自然大小。使用另一种类型(如short)在32位系统上可能效率较低,因为它可能需要额外的指令来截断结果,浪费时间和/或内存。

这里是一段简单、可移植且高效的代码

  int i = ~0;    // set all bits on

最后,我将提到最近我经常遇到的一个问题。我目前正在处理一些非常老的遗留代码。我不断遇到使用16位整数的代码片段(例如使用short或Windows的WORD类型),而使用int会更明显。现在这段代码在其原始(16位)环境中运行良好,但在移植到32位环境后,它被要求做更多的事情。我记不清有多少我修复的 bug 本来可以通过使用int而不是short来避免。

C语言问题

顺带一提,C语言已经显露老态,并且“int是处理器自然大小”的观念已经有些模糊。我猜想Dennis Ritchie设想有一天硬件会足够先进,使得short是16位,int是32位,long是64位。好吧,我们已经远远超出了这个阶段,现在我们拥有整数自然大小为64位的处理器,但我们可能仍然需要8、16和32位整数(以及可能128位)。

当然,上面提到的问题(代码编写不佳,对数据类型大小做出假设)意味着int永远不会大于32位。实际上,只有极少数编译器制造商敢于使用64位long——这就是为什么long long数据类型被发明出来,并最近被加入到C标准中。

准则

  • 避免编写依赖于数据类型(如int的大小)的假设性代码
  • 整数数据首选int
  • 另请参阅下一个误区的准则
  • 浮点数据首选double

7. 使用您所需的精确类型

这是一个不太常见的误区,但我认为应该把它揭示出来,因为它很少被讨论。我猜想这通常不是问题,因为人们遵循K&R和大多数(如果不是全部)C编程书籍的约定。但尽管有相反的证据,一些C程序员似乎认为使用各种“短”原始数据类型(unsigned char, short, float,等)有好处。一种更常见的变体是,如果整数永远不会为负数,就将其声明为unsigned(即使它们也永远不会有大的正值)。

请注意,这与上一个误区不同(但相关),因为它都涉及到标准C整数类型的用法(或滥用)。

让我们来看一个例子。这是我从实际代码中提取(稍作简化)的内容

void message(char *buf, unsigned short len, unsigned char message_type, short id)
{
   char is_blank = TRUE;
   short count = 0;
   ...

使用shortchar类型与直接使用int相比,作用甚微。

在解释之前,我将指出这段代码是为32位处理器编写的,所以编译器使用的尺寸是:char = 8 位,short = 16 位,指针/int/size_t = 32 位。重要的是要记住,32位处理器一次从内存读取和写入4个字节(即32位,或Windows术语中的双字)。这意味着要从内存中读取一个字节,处理器必须读取一个双字并提取所需的8位。C代码可以编译成另一种编译器/环境,如64位处理器,但基本原理是相同的。

当然,变量在内存中的排列方式取决于编译器,所以我们将考虑两种情况

1. 编译器将函数参数和局部变量连续地放在内存(栈)中,总内存需求为 4 + 2 + 1 + 2 + 1 + 2 = 10 字节。(当然,栈上还有其他东西,比如返回地址。)

在这种情况下,函数运行时最多节省14字节(在栈上)。然而,考虑到(在我的例子中)这段代码将在至少有2.56亿字节内存的系统上运行,节省的内存非常微不足道,而且只持续很短的时间(函数运行时)。

虽然优势微不足道,但存在显著的潜在劣势。处理器在加载32位内存值后,可能需要更多时间来提取相关位。处理器读取id时肯定需要两次内存读取,因为它跨越了4字节内存边界。

更糟糕的是,编译器可能需要生成额外的指令(掩码和移位)来访问变量的额外位。所以你可能节省了几字节的栈空间(仅在函数执行期间),但却在代码段中浪费了更多内存,用于额外的指令。这些内存将在程序终止前一直被使用(除非被交换到磁盘)。

2. 编译器将参数和局部变量存储在 DWORD(4字节)内存边界上,总内存需求为:4 + 4 + 4 + 4 + 4 + 4 = 24 字节。

在这种情况下,编译器基本上会忽略你声明的char和short,并将所有内容存储在32位内存位置。通常,这会产生与直接使用以下代码相同的汇编代码

void message(char *buf, int len, int message_type, int id)
{
    int is_blank = TRUE;
    int count = 0;
    ...

然而,对于第一段代码,编译器可能需要(取决于CPU指令集)或感到有义务创建额外的代码来处理较短的数据类型。

简而言之,无论编译器如何操作,使用char和short等类型都比使用int差,甚至可能更差。类似的论点通常也适用于使用float而不是double。

为了完整性,我将说这段代码最好这样写

void message(const char *buf, size_t len, enum message_type msg_type, int id)
{
   BOOL is_blank = TRUE;      // BOOL is equivalent to int
   int count = 0;
   ...

什么时候应该使用它们?

显然,signed char, short, float 等的出现是有原因的,所以它们必须有有效用途。只有当您需要大量不同的数字来节省空间(例如,内存或磁盘空间)或时间(例如,传输时间)时,才应该使用它们。例如,您可能有一个数百万个double的数组,但精度要求不高,所以您改用float数组。或者您可能有一个struct,它被反复存储在文件中,因此文件中可能有数十亿个该数据实例,分布在多个驱动器上——在这种情况下,您希望使用最小的可能整数类型(如short, unsigned char等),它们可以存储有效值的整个范围。如果您有非常具体的范围或想控制单个位(例如,用于布尔标志),那么您甚至可以使用位域

可理解性

尽管说了以上所有内容,我承认有时使用较短的类型是有用的。首先,在处理单个字节字符数据(如ASCII)时,您应该使用char。即使char本质上是一个8位整数,使用char而不是int可以更清楚地表明您在处理文本。(并且在处理字符串时,您必须使用char数组。)

另外,我仍然偶尔会将charshort声明为参数,当有多个具有不同用途的整数参数时。例如,考虑标准C库函数memchr()。我过去创建和遇到过几个 bug,原因是在传递给memchr()(和memset())的第2个和第3个参数时发生了顺序颠倒。这是memchr()的原型

void *memchr(const void *s, int c, size_t n);

第2个和第3个参数很容易混淆,编译器无法对此错误发出警告。因此,下面这个错误的调用不会被编译器标记

[实际上,C编译器可以在size_tint大小不同时发出警告,但对于大多数编译器来说,它们大小相同。有一个例外是很久以前的MSDOS C编译器——在使用大内存模型时,int是16位,而size_tptrdiff_t的大小是32位。]
  char *s, c;
  ...
  memchr(s, strlen(s), c); // ERROR: should be memchr(s, c, strlen(s))

当然,您不能更改标准C函数(如memchr())的原型(尽管您可以创建一个包装器),但您可以使用此方法使对您自己函数的调用更安全。

无符号

最后,有些人似乎有一种压倒一切的冲动,就是将整数值声明为unsigned,仅仅因为它们永远不会取负值。这是一种坏主意,除非在下面提到的一些特定情况下。

首先,代码以后可能需要修改,以便存储一个特殊值来表示未使用或错误状态。通常,对于整数,值-1用于此目的(即使零不是有效值)。

更重要的是,涉及unsigned int的表达式很容易产生意外行为。这段代码看起来很合理

  unsigned int i, first, last;
  ...
  for (i = last; i >= first; i--) ...

这里的问题是,当first为零时,代码将进入无限循环。

这里是另一个例子

  FILE_ADDRESS first, last, diff;
  ...
  diff = last - first;

这里的问题是,当FILE_ADDRESStypedefunsigned long时。如果first大于last,那么diff需要取负值,但由于它是unsigned,它实际上会得到一个非常大的正值。即使你注意到了这个问题并将diff声明为signed long,当你混合使用有符号和无符号类型时,你仍然可能会收到编译器的警告。

[让许多新C程序员感到困惑的一点是,为什么C标准库中的某些函数返回signed值,而unsigned似乎更合乎逻辑?例如,ftell()返回一个long,但文件长度绝不可能为负数,为什么它不返回unsigned long?这正是出于此处所述的原因。]

在追踪和修复了许多由于使用unsigned变量导致的 bug 后,我建议除非您正在进行某种位操作或进行模运算(如在加密、随机数生成器等中使用的),否则始终使用signed整数(即忽略溢出)。

准则:

  • 整数算术首选int,浮点数首选double
  • 如果您的代码需要可移植且值可能大于 32,767,则使用long
  • 如果值可能大于 2,147,483,647,则使用long long
  • 当您需要存储大量数据时,可以考虑使用char, shortfloat
  • 对于小于 128 的整数,使用char
  • 对于小于 256 且始终为零或正数的整数,使用unsigned char
  • 对于可能为负数但始终在 -127 到 127 范围内的整数,使用signed char
  • 对于小于 32,768 的整数,使用short
  • 对于可能大于 32,767 且始终小于 65,536 的正整数,使用unsigned short
  • 文本使用char
  • 当有大量小数值时,使用signed char(或可能unsigned char
  • 对位操作和模运算使用unsigned整数

8. 将所有局部声明放在函数开头

这个误区是我长期以来唯一真正遵循的。在C语言中,按照惯例(以及许多编码标准),所有局部变量都在函数顶部声明。目的是方便快速找到声明以供引用。

在相关的(附带)说明中,许多编码标准对其他事物的放置也有严格规定。例如,我见过有人说头文件必须按特定顺序排列各部分,比如在头文件顶部按字母顺序排列#includes,然后是#defines,然后是类型定义等。当它有助于提高可理解性时,组织好头文件是一个好主意,但如果规则过于僵化,它们就会适得其反。使用上述顺序会阻止将逻辑相关的部分组合在一起。

将变量声明放在函数开头有很多坏处。首先,正如在误区3中所解释的,在变量使用之前很早就声明它会导致问题,例如未初始化内存可能被意外使用的情况。

此外,通过声明比必要作用域更大的变量,您将承担它们被其他代码意外使用的风险。

  int i = something;
  if ...
  {
     ...
     i = something_else;   // code added which accidentally uses existing variable
  }

  use(i);                  // use of i did not expect something_else

此外,还有一种倾向,即在函数后续位置重用一个变量,当您知道它已经不再使用时。这种冲动是难以抗拒的。问题在于,您可能并不总是完全清楚变量的所有用途,或者后来的代码更改可能会导致问题。为了避免混淆,每个变量应该只用于一件事。

在C++中,这不是一个问题,因为您可以(也应该)推迟声明变量,直到您需要使用它为止。在C中,您只能在复合语句的开头声明变量,但您应该在它们被使用的最内层作用域中声明局部变量。

作为一般经验法则:尽量将相关的东西放在一起。一方面,这增加了发现问题的可能性。推迟声明变量直到需要它们就是其中一个例子。(在头文件中组合相关内容也是如此。)

准则:

  • 将变量声明放在它们第一次使用的地方附近

9. 永远不要使用 goto

最早的主流高级语言(Fortran和Cobol)最初使用goto来实现循环和分支。当我上大学的时候,goto语句被认为是纯粹的邪恶。那时Fortran和BASIC程序对goto的滥用程度令人难以置信,我相信结构化编程的出现和普遍接受(例如,远远超过OO)为我们行业的发展带来了巨大的好处。

即便如此,(如某些编码标准所述)全面禁止goto也是一个坏主意。一个常被引用的应该使用goto的例子是需要从嵌套循环中跳出。C语言的break语句只允许您退出最内层的循环(并且当您在switch语句中时,也无法跳出循环)。

标志变量

有时,为了避免使用goto,程序员会设置一个标志变量,用于以后控制程序的流程。这使得代码难以跟踪。(事实上,我认为这种技术是那种最可恶的程序员罪恶——自修改代码——的“穷亲戚”。)

  BOOL time_to_exit = FALSE;
  while (!time_to_exit)
  {
     ....
     do
     {
        if (some_error_condition_detected())
           time_to_exit = TRUE;
        else
           ...
     } while (not_finished && !time_to_exit);
  }
  ...  

去除标志变量可以使代码更简单易懂。您还可以避免标志被错误设置或意外修改的可能性。

  while (TRUE)
  {
     ....
     do
     {
        if (some_error_condition_detected())
           goto error_condition;
        ...
     } while (not_finished);
  }
error_condition:
  ...

请注意,标志变量在Pascal中似乎比在C中用得更多,这也是我讨厌阅读Pascal和Delphi程序的原因之一。这可能是因为Pascal不支持breakcontinuereturn语句,而goto(虽然允许)很少使用。

单出口点

goto的另一个有用之处是,当一个函数需要有单个出口点来清理资源时。许多函数需要从多个地方返回,通常是在检测到某种错误时。幸运的是,C语言使这种情况易于处理,可以使用return语句。然而,有时一个函数在返回前需要进行清理,这意味着必须在每个return之前重复清理代码。

void f()
{
   ...
   if (file_not_found())
   {
      // clean up code
      return;
   }
	
   while ...
   {
      ...
      if (unexpected_eof())
      {
         // clean up code
         return;
      }
   }
   // clean up code
} 

清理代码可以用于关闭文件、释放内存、释放其他类型的资源等。关键在于,在每个return语句处复制此代码是一个坏主意——它使得代码难以维护。更好的方法是通过一个单一的出口点。

[一些编码标准要求所有函数都应有单个出口点。我不同意这作为一项普遍规则,但这方面的辩论将在以后的文章中进行。]
void f()
{
   ...
   if (file_not_found())
      goto exit_f;
	
   while ...
   {
      ...
      if (unexpected_eof())
         goto exit_f;
   }
exit_f:
   // clean up code
} 

goto的类似用法是跳转到switch语句中的case标签。(在C#中,必须使用goto才能做到这一点,因为case之间没有贯穿。)考虑这个switch,其中不同的case共享公共代码

  switch (val)
  {
  case 1:
     // unique code 1
     // common code
     break;
  case 2:
     // unique code 2
     // common code
     break;
  case 3:
     // common code
     break;
  ...
  }  

消除重复代码总是个好主意。(另一种选择可能是将所有公共代码推到一个函数中,但对于几行重复代码或使用许多局部变量的代码来说,这可能不切实际。)

  switch (val)
  {
  case 1:
     // unique code 1
     goto common_code;
  case 2:
     // unique code 2
     goto common_code;
  case 3:
  common_code:
     // common code
     break;
  ...
  }  

再次使用goto可以避免在多个地方出现相同代码的不良做法。

准则:

  • 除非goto能使代码更容易理解和维护,否则避免使用它
  • 永远不要使用后向goto或跳转控制语句中

10. 始终返回错误状态

C语言历史上最普遍的问题是代码忽略了从函数返回错误值的可能性。没有一个C程序员没有在某个时候想过“我不需要检查这个函数的返回代码,因为它在这种情况下永远不会失败”。例如,我从未见过检查printf()返回值的代码,尽管它可能因各种(诚然不太可能的)错误而返回-1

更糟糕的是,那个对任何错误返回值都一无所知的程序员。表明您意识到了函数可能产生错误,但认为其不可能或极其不可能,是将返回值强制转换为void。这是一个很好的做法。

  (void)printf("No errors here!\n");

有时忽略错误返回值是安全的,但我见过许多情况,这种做法导致了糟糕的问题。如果幸运的话,未处理的错误可能会立即导致程序因运行时错误而中止——例如,通过解引用一个返回的NULL指针来指示错误。更糟糕的是,问题被悄悄地忽略,但后来导致了一个糟糕的意外,比如数据损坏。

现在,这个问题的一个重要部分是,许多函数在不需要时返回错误值(这鼓励了对检查错误返回值的不严谨态度)。这就是我想讨论的误区。我将探讨一些以实现“专业标准”错误处理为名的不良做法。

最坏情况

极端情况下,一个函数总是返回成功

int f()
{
    // some code that does not generate errors
    return SUCCESS;
} 

这样做的通常理由是,将来函数可能会被修改以执行可能产生错误的操作。我的第一个反驳是极端编程原则 YAGNI(你不需要它)。问题在于,您将处理错误的责任强加给了调用者,尽管根本没有错误。当然,很可能调用者会忽略返回值(因为它从不改变),其后果是,如果将来它被更改为返回错误,这些错误将不会被检测和处理。

这样要好得多:

void f()
{
    // some code that does not generate errors
    return;
} 

那么,即使将来函数被更改为返回错误,也更有可能找到所有调用点并进行相应修改。

人为制造错误

另一种糟糕的做法是将 bug 变成运行时错误。这是指软件检测到某种内部不一致,然后返回错误代码。例如

int display(const char *str)
{
    if (str == NULL)
        return -1;
		
    // display str
    return 0;
} 

现在很明显(并且应该被记录下来),display()应该总是传递一个有效的字符串。如果调用它的人传递了一个NULL指针,那就是一个 bug。它应该这样写

void display(const char *str)
{
    assert(str != NULL);
    // display str
    return;
} 

C++

请注意,与许多这些误区一样,这个问题在C++中可以避免,因为C++支持异常处理来处理错误。事实上,C++异常的一个主要好处(但尚未得到普遍认识)是,它不可能意外地忽略错误而悠闲地继续。(另一个主要好处是它使代码更易于理解,不被大量错误处理代码所干扰。)
[我可能还应该提到标准C库函数setjmp(),它是C++异常处理的简陋版本。然而,setjmp()并不常用,我一般不推荐它,因为它充满危险,即使你确切地知道如何使用它。而且它也不如C++异常有用。]

如果您正在使用C++,那么Scott Meyers在他的优秀著作Effective C++中关于消除错误可能性的内容还有更多。(参见第46项:优先选择编译时和链接时错误而非运行时错误。)

准则:

  • 尽可能避免创建错误返回值
  • 不要将 bug 变成运行时错误
  • 理解函数可能返回的错误,并且不要忽略它们

结论 

当我开始写这篇文章时,我并没有一个明确的10个误区列表。我甚至考虑过将标题改为“七个误区……”。然而,在我写文章的过程中(同时还在维护一些可怕的旧C代码),我考虑到了不少新的误区,所以正朝着再写十个的方向前进。有一个我可能应该提到的是匈牙利命名法,我二十年来一直说它是个坏主意,而且谢天谢地,连微软现在也说不要用了(不过最初的应用程序匈牙利命名法实际上是个非常好的主意)。如果兴趣足够,我可能会写一篇后续文章。

你可能甚至不同意上面列出的所有十个。然而,我恳请你至少思考一下我所说的话。这个列表基于大约30年的C编程经验,以及大量的思考和研究。如果你不喜欢列表中的某些内容,也许你没有真正理解我的意思。多读一遍可能会让你明白。如果你仍然不同意,请留言或发邮件说明原因。

© . All rights reserved.