C Traps and Pitfalls 摘记(3)

文章目录
  1. 1. 库函数
    1. 1.1. 返回整数的getchar函数
    2. 1.2. 缓冲输出与内存分配
  2. 2. 预处理器
    1. 2.1. 宏并不是函数
    2. 2.2. 宏不是语句
  3. 3. 可移植性缺陷
    1. 3.1. 除法运算时发生的截断

库函数

C语言中没有定义输入/输出语句,任何一个有用的C程序,都必须调用库函数来完成最基本的输入/输出操作。ANSI C标准毫无疑问地意识到了这一点,因而定义了一个包含大量标准库函数的集合。从理论上说,任何一个C语言实现都应该提供这些标准库函数。ANSI C中定义的标准库函数集合并不完备。例如,基本上所有的C语言实现都包括了执行“底层”I/O的readwrite函数,但是这些函数却并没有出现在ANSI C标准中。

(有关底层文件I/O,请参考这篇文章

返回整数的getchar函数

我们首先考虑下面的例子

1
2
3
4
5
6
7
#include <stdio.h>
int main()
{
char c;
while((c = getchar()) != EOF)
putchar(c);
}

getchar函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF(一个在头文件stdio.h中被定义的值,不同于任何一个字符)。这个程序乍一看似乎是把标准输入复制到标准输出,实则不然。

原因在于程序中的变量c被声明为char类型,而不是int类型。这意味着c无法容下所有可能的字符,特别是,可能无法容下EOF。这样导致结果存在三种可能:

  1. 某些合法输入的字符在被“截断”后使得c的取值与EOF相同。程序将在文件复制的中途终止。
  2. c根本不可能取到EOF这个值。程序将陷入一个死循环。
  3. 编译器将“截断”的值赋给了c,但是进行比较运算的是cgetchar函数的返回值。这种情况下,是编译器采取的做法出现了问题。但是这种情况下,程序表面上是可以正常工作的,但是并不正确。

缓冲输出与内存分配

C语言提供了两种程序输出方式,一种是即时处理方式,这种会将输出立即将输出展示给用户。但是可能会造成较高的系统负担。有些时候这种即时性的行为不是必要的,可以先暂存起来。而C语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。

这种控制能力一般是通过库函数setbuf实现的。如果buf是一个大小适当的字符数组,那么setbuf(stdout,buf)语句将通知输入/输出库,所有写入到stdout的输出都应该使用buf作为输出缓冲区,直到buf缓冲区被填满或者程序员直接调用fflush,buf缓冲区中的内容才实际写入到stdout中。缓冲区的大小由系统头文件stdio.h中的BUFSIZ定义。

那么自然,我们会写出这样的程序:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main()
{
int c;
char buf[BUFSIZ];
setbuf(stdout, buf);
while((c = getchar()) != EOF)
putchar(c);
}

但是,这个程序是错误的,buf缓冲区最后一次被清空在main函数结束之后,作为程序交回控制给操作系统之前C运行时库所必须进行的清理工作的一部分。但是,在此之前buf字符数组已经被释放。

要避免这种情况有两种解决方法:

  1. 将缓冲数组设置为静态数组,尝试添加static关键字或者将其声明完全移动到main函数之外。
  2. 使用malloc动态分配缓冲区,然后在程序中并不主动释放分配的缓冲区。

预处理器

在严格意义上的编译过程开始之前,C语言预处理器首先对程序代码作了必要的转换处理。因此,我们运行的程序实际上并不是我们所写的程序。预处理器使得编程者可以简化某些工作,它的重要性可以由两个主要的原因说明:

  1. 我们也许会遇到这种情况,需要将某个特定数量(比如某个数据表的大小)在程序中出现的所有实例统统加以修改。我们希望能够通过在程序中只改动一处数值,然后重新编译就可以实现。预处理器要做到这一点可以说是轻而易举,即使这个数值在程序中的很多地方出现。我们只需要将这个数值定义为一个显式常量(manifest constant),然后在程序中需要的地方使用这个常量即可。而且预处理器还能够很容易把所有常量定义都集中在一起,这样要找到这些常量也非常容易。
  2. 大多数C语言实现在函数调用时都会带来重大的系统开销。因此,我们也许希望有这样一种程序块,它看上去像一个函数,但却没有函数调用的开销。举例来说,getcharputchar经常被实现为宏,以避免在每次执行输入或者输出一个字符这样简单的操作时,都要调用相应的函数而造成系统效率的下降。

宏并不是函数

老生常谈的问题,我们拿最简单的abs宏来说:

1
#define abs(x) x>0?x:-x

对于一个数来说,表达一切正常,但是如果套上一个表达式呢

1
2
3
abs(a-b);
// 等价于
a-b>0?a-b:-a-b;

这里的-a-b就跟我们期望的-(a-b)不一样了。所以我们最好在宏定义中把每个参数都用括号括起来。同样,整个结果表达式也应该用括号括起来,以防止当宏用于一个更大一些的表达式可能出现的问题。例如:

1
2
3
abs(a)+1;
// 等价于
a>0?a:-a+1;

第二个重要的问题是要确保宏中的参数没有副作用。这里副作用主要指:

  1. 不要进行重复求值,导致效率低下。
  2. 不要进行重复运算,导致越界。

这两种情况都需要我们密切关注,将已经求好的值再放入宏中。

宏不是语句

编程者有时会试图定义宏的行为与语句类似,但这样做的实际困难往往令人吃惊!以assert宏为例,它的参数是一个表达式,如果该表达式为0,就使程序终止执行,并给出一条适当的出错消息。把assert作为宏来处理,这样就使得我们可以在出错信息中包括有文件名和断言失败处的行号。

那么接下来我们要开始尝试定义assert宏:

1
#define assert(e) if (!e) assert_error(__FILE__,__LINE__)

因为考虑到宏assert的使用者会加上一个分号,所以在宏定义中并没有包括分号。
但是如果将其放在if-else结构中,那么else的部分就会错误匹配,我们需要改进我们的定义。

我们可能可以试着加上大括号,把宏整体括起来,就能避免else的错误匹配,改进后的宏为:

1
#define assert(e) {if (!e) assert_error(__FILE__,__LINE__); }

但是这个定义,在上面那个例子中,会带来一个新的问题,如果那个例子如下:

1
2
3
4
5
6
7
8
9
10
if(condition)
assert(condition1);
else
assert(condition2);
// 会被展开为

if(condition)
{if (!condition1) assert_error(__FILE__,__LINE__); };
else
{if (!condition2) assert_error(__FILE__,__LINE__); };

花括号尾的分号是一个语法错误。如果要解决这个问题,一个办法是对assert的调用后面都不再跟一个分号,但是这样的用法显得有些“怪异”。

下面给出宏assert的正确定义,它很不直观,但是利用了||运算符对两侧操作数依次顺序求值的性质(短路运算)。

1
2
#define assert(e) \
((void)((e) || assert_error(__FILE__,__LINE__)))

可移植性缺陷

除法运算时发生的截断

假如我们让a除以b,商为q,余数为r

1
2
q = a / b;
r = a % b;

这里,不妨假定b 大于 0。

我们希望abqr之间维持怎样的关系呢?

  1. 最重要的一点,我们希望$ q \times b + r = a $,因为这是定义余数的关系。
  2. 如果我们改变$ a $的正负号,我们希望这会改变$ q $的符号,但这不会改变$ q $的绝对值。
  3. 当$ b > 0 $时,我们希望保证$ r >= 0 $且$ r < b $。例如,如果余数用于哈希表的索引,确保它是一个有效的索引值很重要。

这三条性质是我们认为整数除法和余数操作所应该具备的。很不幸的是,它们不可能同时满足。

例如,$3 / 2$,商$ 1 $,余数$ 1 $,此时,第1条性质得到了满足。但是$ \left( -3 \right) / 2 $的值应该是多少呢?如果要满足第2条性质,答案应该是$ -1 $,但是如果是这样,余数就必定是$ -1 $,这样第3条性质就无法满足。

因此,C语言或者其他语言在实现整数除法截断运算时,必须放弃上述三条原则中的至少一条。大多数程序设计语言选择放弃了第3条,而改为要求余数与被除数的正负号相同。大多数C编译器在实践中也都是这样做的。

然而,C语言的定义只保证了第1条性质,以及当$ a \geq 0 $且$ b > q $时,保证$ \left| r \right| < \left| b \right| $以及$ r \geq 0 $,后面部分的保证与第2条性质或者第3条性质比较起来,限制性要弱得多。