`
isiqi
  • 浏览: 16026395 次
  • 性别: Icon_minigender_1
  • 来自: 济南
社区版块
存档分类
最新评论

C语言陷阱和缺陷

阅读更多

[1]


[译序]

那些自认为已经“学完”C语言的人,请你们仔细读阅读这篇文章吧。路还长,很多东西要学。我也是……

[概述]

C语言像一把雕刻刀,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C会伤到那些不能掌握它的人。本文介绍C语言伤害粗心的人的方法,以及如何避免伤害。

  • 0 简介
  • 1 词法缺陷
    • .1 = 不是 ==
    • 1.2 &| 不是 &&||
    • 1.3 多字符记号
    • 1.4 例外
    • 1.5 字符串和字符
  • 2 句法缺陷
    • 2.1 理解声明
    • 2.2 运算符并不总是具有你所想象的优先级
    • 2.3 看看这些分号!
    • 2.4 switch 语句
    • 2.5 函数调用
    • 2.6 悬挂else 问题
  • 3 链接
    • 3.1 你必须自己检查外部类型
  • 4 语义缺陷
    • 4.1 表达式求值顺序
    • 4.2 &&||! 运算符
    • 4.3 下标从零开始
    • 4.4 C并不总是转换实参
    • 4.5 指针不是数组
    • 4.6 避免提喻法
    • 4.7 空指针不是空字符串
    • 4.8 整数溢出
    • 4.9 移位运算符
  • 5 库函数
    • 5.1 getc() 返回整数
    • 5.2 缓冲输出和内存分配
  • 6 预处理器
    • 6.1 宏不是函数
    • 6.2 宏不是类型定义
  • 7 可移植性缺陷
    • 7.1 一个名字中都有什么?
    • 7.2 一个整数有多大?
    • 7.3 字符是带符号的还是无符号的?
    • 7.4 右移位是带符号的还是无符号的?
    • 7.5 除法如何舍入?
    • 7.6 一个随机数有多大?
    • 7.7 大小写转换
    • 7.8 先释放,再重新分配
    • 7.9 可移植性问题的一个实例
  • 8 这里是空闲空间
  • 参考
  • 脚注

 

0 简介

C语言及其典型实现被设计为能被专家们容易地使用。这门语言简洁并附有表达力。但有一些限制可以保护那些浮躁的人。一个浮躁的人可以从这些条款中获得一些帮助。

在本文中,我们将会看一看这些未可知的益处。这是由于它的未可知,我们无法为其进行完全的分类。不过,我们仍然通过研究为了一个C程序的运行所需要做的事来做到这些。我们假设读者对C语言至少有个粗浅的了解。

第一部分研究了当程序被划分为记号时会发生的问题。第二部分继续研究了当程序的记号被编译器组合为声明、表达式和语句时会出现的问题。第三部分研究 了由多个部分组成、分别编译并绑定到一起的C程序。第四部分处理了概念上的误解:当一个程序具体执行时会发生的事情。第五部分研究了我们的程序和它们所使 用的常用库之间的关系。在第六部分中,我们注意到了我们所写的程序也不并不是我们所运行的程序;预处理器将首先运行。最后,第七部分讨论了可移植性问题: 一个能在一个实现中运行的程序无法在另一个实现中运行的原因。

1 词法缺陷

编译器的第一个部分常被称为词法分析器 (lexical analyzer)。词法分析器检查组成程序的字符序列,并将它们划分为记号 (token)一个记号是一个有一个或多个字符的序列,它在语言被编译时具有一个(相关地)统一的意义。在C中, 例如,记号-> 的意义和组成它的每个独立的字符具有明显的区别,而且其意义独立于-> 出现的上下文环境。

另外一个例子,考虑下面的语句:

if(x > big) big = x;

该语句中的每一个分离的字符都被划分为一个记号,除了关键字if 和标识符big 的两个实例。

事实上,C程序被两次划分为记号。首先是预处理器读取程序。它必须对程序进行记号划分以发现标识宏的标识符。它必须通过对每个宏进行求值来替换宏调用。最后,经过宏替换的程序又被汇集成字符流送给编译器。编译器再第二次将这个流划分为记号。

在这一节中,我们将探索对记号的意义的普遍的误解以及记号和组成它们的字符之间的关系。稍后我们将谈到预处理器。

1.1 = 不是 ==

从Algol派生出来的语言,如Pascal和Ada,用:= 表示赋值而用= 表示比较。而C语言则是用= 表示赋值而用== 表示比较。这是因为赋值的频率要高于比较,因此为其分配更短的符号。

此外,C还将赋值视为一个运算符,因此可以很容易地写出多重赋值(如a = b = c ),并且可以将赋值嵌入到一个大的表达式中。

这种便捷导致了一个潜在的问题:可能将需要比较的地方写成赋值。因此,下面的语句好像看起来是要检查x 是否等于y

if(x = y)
foo();

而实际上是将x 设置为y 的值并检查结果是否非零。在考虑下面的一个希望跳过空格、制表符和换行符的循环:

while(c == ' ' || c = '\t' || c == '\n')
c = getc(f);

在与'\t' 进行比较的地方程序员错误地使用= 代替了== 。这个“比较”实际上是将'\t' 赋给c ,然后判断c 的(新的)值是否为零。因为'\t' 不为零,这个“比较”将一直为真,因此这个循环会吃尽整个文件。这之后会发生什么取决于特定的实现是否允许一个程序读取超过文件尾部的部分。如果允许,这个循环会一直运行。

一些C编译器会对形如e1 = e2 的条件给出一个警告以提醒用户。当你趋势需要先对一个变量进行赋值之后再检查变量是否非零时,为了在这种编译器中避免警告信息,应考虑显式给出比较符。换句话说,将:

if(x = y)
foo();

改写为:

if((x = y) != 0)
foo();

这样可以清晰地表示你的意图。

1.2 &| 不是 &&||

容易将== 错写为= 是因为很多其他语言使用= 表示比较运算。 其他容易写错的运算符还有&&& ,或||| ,这主要是因为C语言中的&| 运算符于其他语言中具有类似功能的运算符大为不同。我们将在第4节中贴近地观察这些运算符。

1.3 多字符记号

一些C记号,如/*= 只有一个字符。而其他一些C记号,如/*== ,以及标识符,具有多个字符。当C编译器遇到紧连在一起的/* 时,它必须能够决定是将这两个字符识别为两个分离的记号还是一个单独的记号。C语言参考手册说明了如何决定:“如果输入流到一个给定的字符串为止已经被识别为记号,则应该包含下一个字符以组成能够构成记号的最长的字符串”。因此,如果/ 是一个记号的第一个字符,并且/ 后面紧随了一个* ,则这两个字符构成了注释的开始,不管 其他上下文环境。

下面的语句看起来像是将y 的值设置为x 的值除以p 所指向的值:

y = x/*p /* p 指向除数 */;

实际上,/* 开始了一个注释,因此编译器简单地吞噬程序文本,直到*/ 的出现。换句话说,这条语句仅仅把y 的值设置为x 的值,而根本没有看到p 。将这条语句重写为:

y = x / *p /* p 指向除数 */;

或者干脆是

y = x / (*p) /* p指向除数 */;

它就可以做注释所暗示的除法了。

这种模棱两可的写法在其他环境中就会引起麻烦。例如,老版本的C使用=+ 表示现在版本中的+= 。这样的编译器会将

a=-1;

视为

a =- 1;

a = a - 1;

这会让打算写

a = -1;

的程序员感到吃惊。

另一方面,这种老版本的C编译器会将

a=/*b;

断句为

a =/ *b;

尽管/* 看起来像一个注释。

1.4 例外

组合赋值运算符如+= 实际上是两个记号。因此,

a + /* strange */ = 1

a += 1

是一个意思。看起来像一个单独的记号而实际上是多个记号的只有这一个特例。特别地,

p - > a

是不合法的。它和

p -> a

不是 同义词。

另一方面,有些老式编译器还是将=+ 视为一个单独的记号并且和+= 是同义词。

1.5 字符串和字符

单引号和双引号在C中的意义完全不同,在一些混乱的上下文中它们会导致奇怪的结果而不是错误消息。

包围在单引号中的一个字符只是书写整数的另一种方法。这个整数是给定的字符在实现的对照序列中的一个对应的值。因此,在一个ASCII实现中,'a' 和0141或97表示完全相同的东西。而一个包围在双引号中的字符串,只是书写一个有双引号之间的字符和一个附加的二进制值为零的字符所初始化的一个无名数组的指针的一种简短方法。

线面的两个程序片断是等价的:

printf("Hello world\n");

char hello[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '\n', 0 };
printf(hello);

使用一个指针来代替一个整数通常会得到一个警告消息(反之亦然),使用双引号来代替单引号也会得到一个警告消息(反之亦然)。但对于不检查参数类型的编译器却除外。因此,用

printf('\n');

来代替

printf("\n");

通常会在运行时得到奇怪的结果。

由于一个整数通常足够大,以至于能够放下多个字符,一些C编译器允许在一个字符常量中存放多个字符。这意味着用'yes' 代替"yes" 将不会被发现。后者意味着“分别包含yes 和一个空字符的四个连续存贮器区域中的第一个的地址”,而前者意味着“在一些实现定义的样式中表示由字符yes 联合构成的一个整数”。这两者之间的任何一致性都纯属巧合。

2 句法缺陷

要理解C语言程序,仅了解构成它的记号是不够的。还要理解这些记号是如何构成声明、表达式、语句和程序的。尽管这些构成通常都是定义良好的,但这些定义有时候是有悖于直觉的或混乱的。

在这一节中,我们将着眼于一些不明显句法构造。

2.1 理解声明

我曾经和一些人聊过天,他们那时在书写在一个小型的微处理器上单机运行的C程序。当这台机器的开关打开的时候,硬件会调用地址为0处的子程序。

为了模仿电源打开的情形,我们要设计一条C语句来显式地调用这个子程序。经过一些思考,我们写出了下面的语句:

(*(void(*)())0)();

这样的表达式会令C程序员心惊胆战。但是,并不需要这样,因为他们可以在一个简单的规则的帮助下很容易地构造它:以你使用的方式声明它

每个C变量声明都具有两个部分:一个类型和一组具有特定格式的期望用来对该类型求值的表达式。最简单的表达式就是一个变量:

float f, g;

说明表达式fg ——在求值的时候——具有类型float 。由于待求值的时表达式,因此可以自由地使用圆括号:

float ((f));

者表示((f)) 求值为float 并且因此,通过推断,f 也是一个float

同样的逻辑用在函数和指针类型。例如:

float ff();

表示表达式ff() 是一个float ,因此ff 是一个返回一个float 的函数。类似地,

float *pf;

表示*pf 是一个float 并且因此pf 是一个指向一个float 的指针。

这些形式的组合声明对表达式是一样的。因此,

float *g(), (*h)();

表示*g()(*h)() 都是float 表达式。由于()* 绑定得更紧密,*g()*(g()) 表示同样的东西:g 是一个返回指float 指针的函数,而h 是一个指向返回float 的函数的指针。

当我们知道如何声明一个给定类型的变量以后,就能够很容易地写出一个类型的模型(cast):只要删除变量名和分号并将所有的东西包围在一对圆括号中即可。因此,由于

float *g();

声明g 是一个返回float 指针的函数,所以(float *()) 就是它的模型。

有了这些知识的武装,我们现在可以准备解决(*(void(*)())0)() 了。 我们可以将它分为两个部分进行分析。首先,假设我们有一个变量fp ,它包含了一个函数指针,并且我们希望调用fp 所指向的函数。可以这样写:

(*fp)();

如果fp 是一个指向函数的指针,则*fp 就是函数本身,因此(*fp)() 是调用它的一种方法。(*fp) 中的括号是必须的,否则这个表达式将会被分析为*(fp()) 。我们现在要找一个适当的表达式来替换fp。

这个问题就是我们的第二步分析。如果C可以读入并理解类型,我们可以写:

(*0)();

但这样并不行,因为* 运算符要求必须有一个指针作为他的操作数。另外,这个操作数必须是一个指向函数的指针,以保证* 的结果可以被调用。因此,我们需要将0转换为一个可以描述“指向一个返回void 的函数的指针”的类型。

如果fp 是一个指向返回void 的函数的指针,则(*fp)() 是一个void 值,并且它的声明将会是这样的:

void (*fp)();

因此,我们需要写:

void (*fp)();
(*fp)();

来声明一个哑变量。一旦我们知道了如何声明该变量,我们也就知道了如何将一个常数转换为该类型:只要从变量的声明中去掉名字即可。因此,我们像下面这样将0转换为一个“指向返回void 的函数的指针”:

(void(*)())0

接下来,我们用(void(*)())0 来替换fp

(*(void(*)())0)();

结尾处的分号用于将这个表达式转换为一个语句。

在这里,我们就解决了这个问题时没有使用typedef 声明。通过使用它,我们可以更清晰地解决这个问题:

typedef void (*funcptr)();
(*(funcptr)0)();

2.2 运算符并不总是具有你所想象的优先级

假设有一个声明了的常量FLAG 是一个整数,其二进制表示中的某一位被置位(换句话说,它是2的某次幂),并且你希望测试一个整型变量flags 该位是否被置位。通常的写法是:

if(flags & FLAG) ...

其意义对于很多C程序员都是很明确的:if 语句测试括号中的表达式求值的结果是否为0。出于清晰的目的我们可以将它写得更明确:

if(flags & FLAG != 0) ...

这个语句现在更容易理解了。但它仍然是错的,因为!=& 绑定得更紧密,因此它被分析为:

if(flags & (FLAG != 0)) ...

这(偶尔)是可以的,如FLAG 是1或0(!)的时候,但对于其他2的幂是不行的[2]

假设你有两个整型变量,hl ,它们的值在0和15(含0和15)之间,并且你希望将r 设置为8位值,其低位为l ,高位为h 。一种自然的写法是:

r = h << 4 + 1;

不幸的是,这是错误的。加法比移位绑定得更紧密,因此这个例子等价于:

r = h << (4 + l);

正确的方法有两种:

r = (h << 4) + l;

r = h << 4 | l;

避免这种问题的一个方法是将所有的东西都用括号括起来,但表达式中的括号过度就会难以理解,因此最好还是是记住C中的优先级。

不幸的是,这有15个,太困难了。然而,通过将它们分组可以变得容易。

绑定得最紧密的运算符并不是真正的运算符:下标、函数调用和结构选择。这些都与左边相关联。

接下来是一元运算符。它们具有真正的运算符中的最高优先级。由于函数调用比一元运算符绑定得更紧密,你必须写(*p)() 来调用p 指向的函数;*p() 表示p 是一个返回一个指针的函数。转换是一元运算符,并且和其他一元运算符具有相同的优先级。一元运算符是右结合的,因此*p++ 表示*(p++) ,而不是(*p)++

在接下来是真正的二元运算符。其中数学运算符具有最高的优先级,然后是移位运算符、关系运算符、逻辑运算符、赋值运算符,最后是条件运算符。需要记住的两个重要的东西是:

  1. 所有的逻辑运算符具有比所有关系运算符都低的优先级。
  2. 一位运算符比关系运算符绑定得更紧密,但又不如数学运算符。

在这些运算符类别中,有一些奇怪的地方。乘法、除法和求余具有相同的优先级,加法和减法具有相同的优先级,以及移位运算符具有相同的优先级。

还有就是六个关系运算符并不具有相同的优先级:==!= 的优先级比其他关系运算符要低。这就允许我们判断ab 是否具有与cd 相同的顺序,例如:

a < b == c < d

在逻辑运算符中,没有任何两个具有相同的优先级。按位运算符比所有顺序运算符绑定得都紧密,每种与运算符都比相应的或运算符绑定得更紧密,并且按位异或(^ )运算符介于按位与和按位或之间。

三元运算符的优先级比我们提到过的所有运算符的优先级都低。这可以保证选择表达式中包含的关系运算符的逻辑组合特性,如:

z = a < b && b < c ? d : e

这个例子还说明了赋值运算符具有比条件运算符更低的优先级是有意义的。另外,所有的复合赋值运算符具有相同的优先级并且是自右至左结合的,因此

a = b = c

b = c; a = b;

是等价的。

具有最低优先级的是逗号运算符。这很容易理解,因为逗号通常在需要表达式而不是语句的时候用来替代分号。

赋值是另一种运算符,通常具有混合的优先级。例如,考虑下面这个用于复制文件的循环:

while(c = getc(in) != EOF)
putc(c, out);

这个while 循环中的表达式看起来像是c 被赋以getc(in) 的值,接下来判断是否等于EOF 以结束循环。不幸的是,赋值的优先级比任何比较操作都低,因此c 的值将会是getc(in)EOF 比较的结果,并且会被抛弃。因此,“复制”得到的文件将是一个由值为1的字节流组成的文件。

上面这个例子正确的写法并不难:

while((c = getc(in)) != EOF)
putc(c, out);

然而,这种错误在很多复杂的表达式中却很难被发现。例如,随UNIX系统一同发布的lint程序通常带有下面的错误行:

if (((t = BTYPE(pt1->aty) == STRTY) || t == UNIONTY) {

这条语句希望给t 赋一个值,然后看t 是否与STRTYUNIONTY 相等。而实际的效果却大不相同[3]

C中的逻辑运算符的优先级具有历史原因。B——C的前辈——具有和C中的&| 运算符对应的逻辑运算符。尽管它们的定义是按位的 ,但编译器在条件判断上下文中将它们视为和&&|| 一样。当在C中将它们分开后,优先级的改变是很危险的[4]

2.3 看看这些分号!

C中的一个多余的分号通常会带来一点点不同:或者是一个空语句,无任何效果;或者编译器可能提出一个诊断消息,可以方便除去掉它。一个重要的区别是在必须跟有一个语句的ifwhile 语句中。考虑下面的例子:

if(x[i] > big);
big = x[i];

这不会发生编译错误,但这段程序的意义与:

if(x[i] > big)
big = x[i];

就大不相同了。第一个程序段等价于:

if(x[i] > big) { }
big = x[i];

也就是等价于:

big = x[i];

(除非xibig 是带有副作用的宏)。

另一个因分号引起巨大不同的地方是函数定义前面的结构声明的末尾[译注:这句话不太好听,看例子就明白了]。考虑下面的程序片段:

struct foo {
int x;
}

f() {
...
}

在紧挨着f 的第一个} 后面丢失了一个分号。它的效果是声明了一个函数f ,返回值类型是struct foo ,这个结构成了函数声明的一部分。如果这里出现了分号,则f 将被定义为具有默认的整型返回值[5]

2.4 switch 语句

通常C中的switch 语句中的case 段可以进入下一个。例如,考虑下面的C和Pascal程序片断:

switch(color) {
case 1: printf ("red");
break;
case 2: printf ("yellow");
break;
case 3: printf ("blue");
break;
}

case color of
1: write ('red');
2: write ('yellow');
3: write ('blue');
end

这两个程序片断都作相同的事情:根据变量color 的值是1、2还是3打印redyellowblue (没有新行符)。这两个程序片断非常相似,只有一点不同:Pascal程序中没有C中相应的break 语句。C中的case 标签是真正的标签:控制流程可以无限制地进入到一个case 标签中。

看看另一种形式,假设C程序段看起来更像Pascal:

switch(color) {
case 1: printf ("red");
case 2: printf ("yellow");
case 3: printf ("blue");
}

并且假设color 的值是2。则该程序将打印yellowblue ,因为控制自然地转入到下一个printf() 的调用。

这既是C语言switch 语句的优点又是它的弱点。说它是弱点,是因为很容易忘记一个break 语句,从而导致程序出现隐晦的异常行为。说它是优点,是因为通过故意去掉break 语句,可以很容易实现其他方法难以实现的控制结构。尤其是在一个大型的switch 语句中,我们经常发现对一个case 的处理可以简化其他一些特殊的处理。

例如,设想有一个程序是一台假想的机器的翻译器。这样的一个程序可能包含一个switch 语句来处理各种操作码。在这样一台机器上,通常减法在对其第二个运算数进行变号后就变成和加法一样了。因此,最好可以写出这样的语句:

case SUBTRACT:
opnd2 = -opnd2;
/* no break; */
case ADD:
...

另外一个例子,考虑编译器通过跳过空白字符来查找一个记号。这里,我们将空格、制表符和新行符视为是相同的,除了新行符还要引起行计数器的增长外:

case '\n':
linecount++;
/* no break */
case '\t':
case ' ':
...

2.5 函数调用

和其他程序设计语言不同,C要求一个函数调用必须有一个参数列表,但可以没有参数。因此,如果f 是一个函数,

f();

就是对该函数进行调用的语句,而

f;

什么也不做。它会作为函数地址被求值,但不会调用它[6]

2.6 悬挂else 问题

在讨论任何语法缺陷时我们都不会忘记提到这个问题。尽管这一问题不是C语言所独有的,但它仍然伤害着那些有着多年经验的C程序员。

考虑下面的程序片断:

if(x == 0)
if(y == 0) error();
else {
z = x + y;
f(&z);
}

写这段程序的程序员的目的明显是将情况分为两种:x = 0x != 0 。在第一种情况中,程序段什么都不做,除非y = 0 时调用error() 。第二种情况中,程序设置z = x + y 并以z 的地址作为参数调用f()

然而, 这段程序的实际效果却大为不同。其原因是一个else 总是与其最近的if 相关联。如果我们希望这段程序能够按照实际的情况运行,应该这样写:

if(x == 0) {
if(y == 0)
error();
else {
z = x + y;
f(&z);
}
}

换句话说,当x != 0 发生时什么也不做。如果要达到第一个例子的效果,应该写:

if(x == 0) {
if(y ==0)
error();
}
else {
z = z + y;
f(&z);
}

3 链接

一个C程序可能有很多部分组成,它们被分别编译,并由一个通常称为链接器、链接编辑器或加载器的程序绑定到一起。由于编译器一次通常只能看到一个文件,因此它无法检测到需要程序的多个源文件的内容才能发现的错误。

在这一节中,我们将看到一些这种类型的错误。有一些C实现,但不是所有的,带有一个称为lint的程序来捕获这些错误。如果具有一个这样的程序,那么无论怎样地强调它的重要性都不过分。

3.1 你必须自己检查外部类型

假设你有一个C程序,被划分为两个文件。其中一个包含如下声明:

int n;

而令一个包含如下声明:

long n;

这不是一个有效的C程序,因为一些外部名称在两个文件中被声明为不同的类型。然而,很多实现检测不到这个错误,因为编译器在编译其中一个文件时并不 知道另一个文件的内容。因此,检查类型的工作只能由链接器(或一些工具程序如lint)来完成;如果操作系统的链接器不能识别数据类型,C编译器也没法过 多地强制它。

那么,这个程序运行时实际会发生什么?这有很多可能性:

  1. 实现足够聪明,能够检测到类型冲突。则我们会得到一个诊断消息,说明n 在两个文件中具有不同的类型。
  2. 你所使用的实现将intlong 视为相同的类型。典型的情况是机器可以自然地进行32位运算。在这种情况下你的程序或许能够工作,好象你两次都将变量声明为long (或int )。但这种程序的工作纯属偶然。
  3. n 的两个实例需要不同的存储,它们以某种方式共享存储区,即对其中一个的赋值对另一个也有效。这可能发生,例如,编译器可以将int 安排在long 的低位。不论这是基于系统的还是基于机器的,这种程序的运行同样是偶然。
  4. n 的两个实例以另一种方式共享存储区,即对其中一个赋值的效果是对另一个赋以不同的值。在这种情况下,程序可能失败。

这种情况发生的里一个例子出奇地频繁。程序的某一个文件包含下面的声明:

char filename[] = "etc/passwd";

而另一个文件包含这样的声明:

char *filename;

尽管在某些环境中数组和指针的行为非常相似,但它们是不同的 。在第一个声明中,filename 是一个字符数组的名字。尽管使用数组的名字可以产生数组第一个元素的指针,但这个指针只有在需要的时候才产生并且不会持续。在第二个声明中,filename 是一个指针的名字。这个指针可以指向程序员让它指向的任何地方。如果程序员没有给它赋一个值,它将具有一个默认的0值(null[译注:实际上,在C中一个为初始化的指针通常具有一个随机的值,这是很危险的!]

这两个声明以不同的方式使用存储区,他们不可能共存。

避免这种类型冲突的一个方法是使用像lint这样的工具(如果可以的话)。为了在一个程序的不同编译单元之间检查类型冲突,一些程序需要一次看到其所有部分。典型的编译器无法完成,但lint可以。

避免该问题的另一种方法是将外部声明放到包含文件中。这时,一个外部对象的类型仅出现一次[7]

4 语义缺陷

一个句子可以是精确拼写的并且没有语法错误,但仍然没有意义。在这一节中,我们将会看到一些程序的写法会使得它们看起来是一个意思,但实际上是另一种完全不同的意思。

我们还要讨论一些表面上看起来合理但实际上会产生未定义结果的环境。我们这里讨论的东西并不保证能够在所有的C实现中工作。我们暂且忘记这些能够在一些实现中工作但可能不能在另一些实现中工作的东西,直到第7节讨论可以执行问题为止。

4.1 表达式求值顺序

一些C运算符以一种已知的、特定的顺序对其操作数进行求值。但另一些不能。例如,考虑下面的表达式:

a < b && c < d

C语言定义规定a < b 首先被求值。如果a 确实小于bc < d 必须紧接着被求值以计算整个表达式的值。但如果a 大于或等于b ,则c < d 根本不会被求值。

要对a < b 求值,编译器对ab 的求值就会有一个先后。但在一些机器上,它们也许是并行进行的。

C中只有四个运算符&&||?:, 指定了求值顺序。&&|| 最先对左边的操作数进行求值,而右边的操作数只有在需要的时候才进行求值。而?: 运算符中的三个操作数:abc ,最先对a 进行求值,之后仅对bc 中的一个进行求值,这取决于a 的值。, 运算符首先对左边的操作数进行求值,然后抛弃它的值,对右边的操作数进行求值[8]

C中所有其它的运算符对操作数的求值顺序都是未定义的。事实上,赋值运算符不对求值顺序做出任何保证。

出于这个原因,下面这种将数组x 中的前n 个元素复制到数组y 中的方法是不可行的:

i = 0;
while(i < n)
y[i] = x[i++];

其中的问题是y[i] 的地址并不保证在i 增长之前被求值。在某些实现中,这是可能的;但在另一些实现中却不可能。另一种情况出于同样的原因会失败:

i = 0;
while(i < n)
y[i++] = x[i];

而下面的代码是可以工作的:

i = 0;
while(i < n) {
y[i] = x[i];
i++;
}

当然,这可以简写为:

for(i = 0; i < n; i++)
y[i] = x[i];

4.2 && || ! 运算符

C中有两种逻辑运算符,在某些情况下是可以交换的:按位运算符&|~ ,以及逻辑运算符&&||! 。一个程序员如果用某一类运算符替换相应的另一类运算符会得到某些奇怪的效果:程序可能会正确地工作,但这纯属偶然。

&&||! 运算符将它们的参数视为仅有“真”或“假”,通常约定0代表“假”而其它的任意值都代表“真”。这些运算符返回1表示“真”而返回0表示“假”,而且&&|| 运算符当可以通过左边的操作数确定其返回值时,就不会对右边的操作数进行求值。

因此!10 是零,因为10非零;10 && 12 是1,因为10和12都非零;10 || 12 也是1,因为10非零。另外,最后一个表达式中的12不会被求值,10 || f() 中的f() 也不会被求值。

考虑下面这段用于在一个表中查找一个特定元素的程序:

i = 0;
while(i < tabsize && tab[i] != x)
i++;

这段循环背后的意思是如果i 等于tabsize 时循环结束,元素未被找到。否则,i 包含了元素的索引。

假设这个例子中的&& 不小心被替换为了& ,这个循环可能仍然能够工作,但只有两种幸运的情况可以使它停下来。

首先,这两个操作都是当条件为假时返回0,当条件为真时返回1。只要xy 都是1或0,x & yx && y 都具有相同的值。然而,如果当使用了出了1之外的非零值表示“真”时互换了这两个运算符,这个循环将不会工作。

其次,由于数组元素不会改变,因此越过数组最后一个元素进一个位置时是无害的,循环会幸运地停下来。失误的程序会越过数组的结尾,因为& 不像&& ,总是会对所有的操作数进行求值。因此循环的最后一次获取tab[i]i 的值已经等于tabsize 了。如果tabsizetab 中元素的数量, 则会取到tab 中不存在的一个值。

4.3 下标从零开始

在很多语言中,具有n 个元素的数组其元素的号码和它的下标是从1到n 严格对应的。但在C中不是这样。

一个具有n 个元素的C数组中没有下标为n 的元素,其中的元素的下标是从0到n - 1 。因此从其它语言转到C语言的程序员应该特别小心地使用数组:

int i, a[10];
for(i = 1; i <= 10; i++)
a[i] = 0;

这个例子的目的是要将a 中的每个元素都设置为0,但没有期望的效果。因为for 语句中的比较i < 10 被替换成了i <= 10a 中的一个编号为10的并不存在的元素被设置为了0,这样内存中a 后面的一个字被破坏了。如果编译该程序的编译器按照降序地址为用户变量分配内存,则a 后面就是i 。将i 设置为零会导致该循环陷入一个无限循环。

4.4 C并不总是转换实参

下面的程序段由于两个原因会失败:

double s;
s = sqrt(2);
printf("%g\n", s);

第一个原因是sqrt() 需要一个double 值作为它的参数,但没有得到。第二个原因是它返回一个double 值但没有这样声名。改正的方法只有一个:

double s, sqrt();
s = sqrt(2.0);
printf("%g\n", s);

C中有两个简单的规则控制着函数参数的转换:(1)比int 短的整型被转换为int ;(2)比double 短的浮点类型被转换为double 。所有的其它值不被转换。确保函数参数类型的正确行使程序员的责任

因此,一个程序员如果想使用如sqrt() 这样接受一个double 类型参数的函数,就必须仅传递给它floatdouble 类型的参数。常数2是一个int ,因此其类型是错误的。

当一个函数的值被用在表达式中时,其值会被自动地转换为适当的类型。然而,为了完成这个自动转换,编译器必须知道该函数实际返回的类型。没有更进一步声名的函数被假设返回int ,因此声名这样的函数并不是必须的。然而,sqrt() 返回double ,因此在成功使用它之前必须要声名。

实际上,C实现通常允许一个文件包含include 语句来包含如sqrt() 这些库函数的声名,但是对那些自己写函数的程序员来说,书写声名也是必要的——或者说,对那些书写非凡的C程序的人来说是有必要的。

这里有一个更加壮观的例子:

main() {
int i;
char c;
for(i = 0; i < 5; i++) {
scanf("%d", &c);
printf("%d", i);
}
printf("\n");
}

表面上看,这个程序从标准输入中读取五个整数并向标准输出写入0 1 2 3 4。实际上,它并不总是这么做。譬如在一些编译器中,它的输出为0 0 0 0 0 1 2 3 4。

为什么?因为c 的声名是char 而不是int 。当你令scanf() 去读取一个整数时,它需要一个指向一个整数的指针。但这里它得到的是一个字符的指针。但scanf() 并不知道它没有得到它所需要的:它将输入看作是一个指向整数的指针并将一个整数存贮到那里。由于整数占用比字符更多的内存,这样做会影响到c 附近的内存。

c 附近确切是什么是编译器的事;在这种情况下这有可能是i 的低位。因此,每当向c 中读入一个值,i 就被置零。当程序最后到达文件结尾时,scanf() 不再尝试向c 中放入新值,i 才可以正常地增长,直到循环结束。

4.5 指针不是数组

C程序通常将一个字符串转换为一个以空字符结尾的字符数组。假设我们有两个这样的字符串st ,并且我们想要将它们连接为一个单独的字符串r 。我们通常使用库函数strcpy()strcat() 来完成。下面这种明显的方法并不会工作:

char *r;
strcpy(r, s);
strcat(r, t);

这是因为r 没有被 初始化为指向任何地方。尽管r 可能潜在地表示某一块内存,但这并不存在,直到你分配它

让我们再试试,为r 分配一些内存:

char r[100];
strcpy(r, s);
strcat(r, t);

这只有在st 所指向的字符串不很大的时候才能够工作。不幸的是,C要求我们为数组指定的大小是一个常数,因此无法确定r 是否足够大。然而,很多C实现带有一个叫做malloc() 的库函数,它接受一个数字并分配这么多的内存。通常还有一个函数成为strlen() ,可以告诉我们一个字符串中有多少个字符:因此,我们可以写:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t));
strcpy(r, s);
strcat(r, t);

然而这个例子会因为两个原因而失败。首先,malloc() 可能会耗尽内存,而这个事件仅通过静静地返回一个空指针来表示。

其次,更重要的是,malloc() 并没有分配足够的内存。一个字符串是以一个空字符结束的。而strlen() 函数返回其字符串参数 中所包含字符的数量,但不包括 结尾的空字符。因此,如果strlen(s)n ,则s 需要n + 1 个字符来盛放它。因此我们需要为r 分配额外的一个字符。再加上检查malloc() 是否成功,我们得到:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t) + 1);
if(!r) {
complain();
exit(1);
}
strcpy(r, s);
strcat(r, t);

4.6 避免提喻法

提喻法(Synecdoche, sin-ECK-duh-key)是一种文学手法,有点类似于明喻或暗喻,在牛津英文词典中解释如下:“a morecomprehensive term is used for a less comprehensive or vice versa; aswhole for part or part for whole, genus for species or species forgenus, etc.(将全面的单位用作不全面的单位,或反之;如整体对局部或局部对整体、一般对特殊或特殊对一般,等等。)”

这可以精确地描述C中通常将指针误以为是其指向的数据的错误。正将常会在字符串中发生。例如:

char *p, *q;
p = "xyz";

尽管认为p 的值是xyz 有时是有用的,但这并不是真的,理解这一点非常重要。p 的值是指向一个有四个字符的数组中第0个元素的指针 ,这四个字符是'x''y''z''\0' 。因此,如果我们现在执行:

q = p;

pq 会指向同一块内存。内存中的字符没有因为赋值而被复制。这种情况看起来是这样的:

要记住的是,复制一个指针并不能复制它所指向的东西

因此,如果之后我们执行:

q[1] = 'Y';

q 所指向的内存包含字符串xYzp 也是,因为pq 指向相同的内存。

4.7 空指针不是空字符串

将一个整数转换为一个指针的结果是实现相关的(implementation-dependent),除了一个例外。这个例外是常数0,它可以保证被转换为一个与其它任何有效指针都不相等的指针。这个值通常类似这样定义:

#define NULL 0

但其效果是相同的。要记住的一个重要的事情是,当用0作为指针时它决不能被解除引用 。换句话说,当你将0赋给一个指针变量后,你就不能访问它所指向的内存。不能这样写:

if(p == (char *)0) ...

也不能这样写:

if(strcmp(p, (char *)0) == 0) ...

因为strcmp() 总是通过其参数来查看内存地址的。

如果p 是一个空指针,这样写也是无效的:

printf(p);

printf("%s", p);

4.8 整数溢出

C语言关于整数操作的上溢或下溢定义得非常明确。

只要有一次操作数是无符号的,结果就是无符号的,并且以2n 为模,其中n为字长。如果两个操作数都是带符号的,则结果是未定义的

例如,假设ab 是两个非负整型变量,你希望测试a + b 是否溢出。一个明显的办法是这样的:

if(a + b < 0)
complain();

通常,这是不会工作的。

一旦a + b 发生了溢出,对于结果的任何赌注都是没有意义的。例如,在某些机器上,一个加法运算会将一个内部寄存器设置为四种状态:正、负、零或溢出。 在这样的机器上,编译器有权将上面的例子实现为首先将ab 加在一起,然后检查内部寄存器状态是否为负。如果该运算溢出,内部寄存器将处于溢出状态,这个测试会失败。

使这个特殊的测试能够成功的一个正确的方法是依赖于无符号算术的良好定义,既要在有符号和无符号之间进行转换:

if((int)((unsigned)a + (unsigned)b) < 0)
complain();

4.9 移位运算符

两个原因会令使用移位运算符的人感到烦恼:

  1. 在右移运算中,空出的位是用0填充还是用符号位填充?
  2. 移位的数量允许使用哪些数?

第一个问题的答案很简单,但有时是实现相关的。如果要进行移位的操作数是无符号的,会移入0。如果操作数是带符号的,则实现有权决定是移入0还是移入符号位。如果在一个右移操作中你很关心空位,那么用unsigned 来声明变量。这样你就有权假设空位被设置为0。

第二个问题的答案同样简单:如果待移位的数长度为n ,则移位的数量必须大于等于0并且严格地小于n 。因此,在一次单独的操作中不可能将所有的位从变量中移出。

例如,如果一个int 是32位,且n 是一个int ,写n << 31n << 0 是合法的,但n << 32n << -1 是不合法的。

注意,即使实现将符号为移入空位,对一个带符号整数的右移运算和除以2的某次幂也不是等价的。为了证明这一点,考虑(-1) >> 1 的值,这是不可能为0的。[译注:(-1) / 2 的结果是0。]

5 库函数

每个有用的C程序都会用到库函数,因为没有办法把输入和输出内建到语言中去。在这一节中,我们将会看到一些广泛使用的库函数在某种情况下会出现的一些非预期行为。

5.1 getc()返回整数

考虑下面的程序:

#include <stdio.h>

main() {
char c;

while((c = getchar()) != EOF)
putchar(c);
}

这段程序看起来好像要讲标准输入复制到标准输出。实际上,它并不完全会做这些。

原因是c 被声明为字符而不是整数。这意味着它将不能接收可能出现的所有字符包括EOF

因此这里有两种可能性。有时一些合法的输入字符会导致c 携带和EOF 相同的值,有时又会使c 无法存放EOF 值。在前一种情况下,程序会在文件的中间停止复制。在后一种情况下,程序会陷入一个无限循环。

实际上,还存在着第三种可能:程序会偶然地正确工作。C语言参考手册严格地定义了表达式

((c = getchar()) != EOF)

的结果。其6.1节中声明:

当一个较长的整数被转换为一个较短的整数或一个char 时,它会被截去左侧;超出的位被简单地丢弃。

7.14节声明:

存在着很多赋值运算符,它们都是从右至左结合的。它们都需要一个左值作为左侧的操作数,而赋值表达式的类型就是其左侧的操作数的类型。其值就是已经付过值的左操作数的值。

这两个条款的组合效果就是必须通过丢弃getchar() 的结果的高位,将其截短为字符,之后这个被截短的值再与EOF 进行比较。作为这个比较的一部分,c 必须被扩展为一个整数,或者采取将左侧的位用0填充,或者适当地采取符号扩展。

然而,一些编译器并没有正确地实现这个表达式。它们确实将getchar() 的值的低几位赋给c 。但在cEOF 的比较中,它们却使用了getchar() 的值!这样做的编译器会使这个事例程序看起来能够“正确地”工作。

5.2 缓冲输出和内存分配

当一个程序产生输出时,能够立即看到它有多重要?这取决于程序。

例如,终端上显示输出并要求人们坐在终端前面回答一个问题,人们能够看到输出以知道该输入什么就显得至关重要了。另一方面,如果输出到一个文件中,并最终被发送到一个行式打印机,只有所有的输出最终能够到达那里是重要的。

立即安排输出的显示通常比将其暂时保存在一大块一起输出要昂贵得多。因此,C实现通常允许程序员控制产生多少输出后在实际地写出它们。

这个控制通常约定为一个称为setbuf() 的库函数。如果buf 是一个具有适当大小的字符数组,则

setbuf(stdout, buf);

将告诉I/O库写入到stdout 中的输出要以buf 作为一个输出缓冲,并且等到buf 满了或程序员直接调用fflush() 再实际写出。缓冲区的合适的大小在<stdio.h> 中定义为BUFSIZ

因此,下面的程序解释了通过使用setbuf() 来讲标准输入复制到标准输出:

#include <stdio.h>

main() {
int c;

char buf[BUFSIZ];
setbuf(stdout, buf);

while((c = getchar()) != EOF)
putchar(c);
}

不幸的是,这个程序是错误的,因为一个细微的原因。

要知道毛病出在哪,我们需要知道缓冲区最后一次刷新是在什么时候。答案;主程序完成之后,作为库在将控制交回到操作系统之前所执行的清理的一部分。在这一时刻,缓冲区已经被释放了!

有两种方法可以避免这一问题。

首先,是用静态缓冲区,或者将其显式地声明为静态:

static char buf[BUFSIZ];

或者将整个声明移到主函数之外。

另一种可能的方法是动态地分配缓冲区并且从不释放它:

char *malloc();
setbuf(stdout, malloc(BUFSIZ));

注意在后一种情况中,不必检查malloc() 的返回值,因为如果它失败了,会返回一个空指针。而setbuf() 可以接受一个空指针作为其第二个参数,这将使得stdout 变成非缓冲的。这会运行得很慢,但它是可以运行的。

6 预处理器

运行的程序并不是我们所写的程序:因为C预处理器首先对其进行了转换。出于两个主要原因(和很多次要原因),预处理器为我们提供了一些简化的途径。

首先,我们希望可以通过改变一个数字并重新编译程序来改变一个特殊量(如表的大小)的所有实例[9]

其次,我们可能希望定义一些东西,它们看起来象函数但没有函数调用所需的运行开销。例如,putchar()getchar() 通常实现为宏以避免对每一个字符的输入输出都要进行函数调用。

6.1 宏不是函数

由于宏可以象函数那样出现,有些程序员有时就会将它们视为等价的。因此,看下面的定义:

#define max(a, b) ((a) > (b) ? (a) : (b))

注意宏体中所有的括号。它们是为了防止出现ab 是带有比> 优先级低的表达式的情况。

一个重要的问题是,像max() 这样定义的宏每个操作数都会出现两次并且会被求值两次。因此,在这个例子中,如果ab 大,则a 就会被求值两次:一次是在比较的时候,而另一次是在计算max() 值的时候。

这不仅是低效的,还会发生错误:

biggest = x[0];
i = 1;
while(i < n)
biggest = max(biggest, x[i++]);

max() 是一个真正的函数时,这会正常地工作,但当max() 是一个宏的时候会失败。譬如,假设x[0] 是2、x[1] 是3、x[2] 是1

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics