C陷阱与缺陷 拾遗
4 语义缺陷
一个句子可以是精确拼写的并且没有语法错误,但仍然没有意义。在这一节中,我们将会看到一些程序的写法会使得它们看起来是一个意思,但实际上是另一种完全不同的意思。
我们还要讨论一些表面上看起来合理但实际上会产生未定义结果的环境。我们这里讨论的东西并不保证能够在所有的C实现中工作。我们暂且忘记这些能够在一些实现中工作但可能不能在另一些实现中工作的东西,直到第7节讨论可以执行问题为止。
4.1 表达式求值顺序
一些C运算符以一种已知的、特定的顺序对其操作数进行求值。但另一些不能。例如,考虑下面的表达式:
a < b && c < d
C语言定义规定a < b首先被求值。如果a确实小于b,c < d必须紧接着被求值以计算整个表达式的值。但如果a大于或等于b,则c < d根本不会被求值。
要对a < b求值,编译器对a和b的求值就会有一个先后。但在一些机器上,它们也许是并行进行的。
C 中只有四个运算符&&、||、?:和,指定了求值顺序。&&和||最先对左边的操作数进行求值,而右边的操作数只有在需 要的时候才进行求值。而?:运算符中的三个操作数:a、b和c,最先对a进行求值,之后仅对b或c中的一个进行求值,这取决于a的值。,运算符首先对左边 的操作数进行求值,然后抛弃它的值,对右边的操作数进行求值。
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。只要x和y都是1或0,x & y和x && y都具有相同的值。然而,如果当使用了出了1之外的非零值表示“真”时互换了这两个运算符,这个循环将不会工作。
其次,由于数组元素不会改变,因此越过数组最后一个元素进一个位置时是无害的,循环会幸运地停下来。失误的程序会越过数组的结尾,因为&不像 &&,总是会对所有的操作数进行求值。因此循环的最后一次获取tab[ i ]时i的值已经等于tabsize了。如果tabsize是 tab中元素的数量, 则会取到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 <= 10,a中的一个编号为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类型参数的函数,就必须仅传递给它float或double类型的参数。常数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程序通常将一个字符串转换为一个以空字符结尾的字符数组。假设我们有两个这样的字符串s和t,并且我们想要将它们连接为一个单独的字符串r。我们通常使用库函数strcpy()和strcat()来完成。下面这种明显的方法并不会工作:
char *r;
strcpy(r, s);
strcat(r, t);
这是因为r没有被 初始化为指向任何地方。尽管r可能潜在地表示某一块内存,但这并不存在,直到你分配它。
让我们再试试,为r分配一些内存:
char r[100];
strcpy(r, s);
strcat(r, t);
这 只有在s和t所指向的字符串不很大的时候才能够工作。不幸的是,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 more comprehensive term is used for a less comprehensive or vice versa; as whole for part or part for whole, genus for species or species for genus, etc.(将全面的单位用作不全面的单位,或反之;如整体对局部或局部对整体、一般对特殊或特殊对一般,等等。)”
这可以精确地描述C中通常将指针误以为是其指向的数据的错误。正将常会在字符串中发生。例如:
char *p, *q;
p = "xyz";
尽管认为p的值是xyz有时是有用的,但这并不是真的,理解这一点非常重要。p的值是指向一个有四个字符的数组中第0个元素的指针,这四个字符是'x'、'y'、'z'和'\0'。因此,如果我们现在执行:
q = p;
p和q会指向同一块内存。内存中的字符没有因为赋值而被复制。这种情况看起来是这样的:
<center><img src="/blog/images/CTraps/CTraps1.gif"></center>
要记住的是,复制一个指针并不能复制它所指向的东西。
因此,如果之后我们执行:
q[1] = 'Y';
q所指向的内存包含字符串xYz。p也是,因为p和q指向相同的内存。
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为字长。如果两个操作数都是带符号的,则结果是未定义的。
例如,假设a和b是两个非负整型变量,你希望测试a + b是否溢出。一个明显的办法是这样的:
if(a + b < 0)
complain();
通常,这是不会工作的。
一旦a + b发生了溢出,对于结果的任何赌注都是没有意义的。例如,在某些机器上,一个加法运算会将一个内部寄存器设置为四种状态:正、负、零或溢出。 在这样的机器上,编译器有权将上面的例子实现为首先将a和b加在一起,然后检查内部寄存器状态是否为负。如果该运算溢出,内部寄存器将处于溢出状态,这个 测试会失败。
使这个特殊的测试能够成功的一个正确的方法是依赖于无符号算术的良好定义,既要在有符号和无符号之间进行转换:
if((int)((unsigned)a + (unsigned)b) < 0)
complain();
4.9 移位运算符
两个原因会令使用移位运算符的人感到烦恼:
在右移运算中,空出的位是用0填充还是用符号位填充?
移位的数量允许使用哪些数?
第一个问题的答案很简单,但有时是实现相关的。如果要进行移位的操作数是无符号的,会移入0。如果操作数是带符号的,则实现有权决定是移入0还是移入符号位。如果在一个右移操作中你很关心空位,那么用unsigned来声明变量。这样你就有权假设空位被设置为0。
第二个问题的答案同样简单:如果待移位的数长度为n,则移位的数量必须大于等于0并且严格地小于n。因此,在一次单独的操作中不可能将所有的位从变量中移出。
例如,如果一个int是32位,且n是一个int,写n << 31和n << 0是合法的,但n << 32和n << -1是不合法的。
注意,即使实现将符号为移入空位,对一个带符号整数的右移运算和除以2的某次幂也不是等价的。为了证明这一点,考虑(-1) >> 1的值,这是不可能为0的。[译注:(-1) / 2的结果是0。]
5 库函数
每个有用的C程序都会用到库函数,因为没有办法把输入和输出内建到语言中去。在这一节中,我们将会看到一些广泛使用的库函数在某种情况下会出现的一些非预期行为。
5.1 getc()返回整数
考虑下面的程序:
#i nclude <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。但在c和EOF的比较中,它们却使用了getchar()的值!这样做的编译器会使这个事例程序看起来能够“正确地”工作。
5.2 缓冲输出和内存分配
当一个程序产生输出时,能够立即看到它有多重要?这取决于程序。
例如,终端上显示输出并要求人们坐在终端前面回答一个问题,人们能够看到输出以知道该输入什么就显得至关重要了。另一方面,如果输出到一个文件中,并最终被发送到一个行式打印机,只有所有的输出最终能够到达那里是重要的。
立即安排输出的显示通常比将其暂时保存在一大块一起输出要昂贵得多。因此,C实现通常允许程序员控制产生多少输出后在实际地写出它们。
这个控制通常约定为一个称为setbuf()的库函数。如果buf是一个具有适当大小的字符数组,则
setbuf(stdout, buf);
将告诉I/O库写入到stdout中的输出要以buf作为一个输出缓冲,并且等到buf满了或程序员直接调用fflush()再实际写出。缓冲区的合适的大小在<stdio.h>中定义为BUFSIZ。
因此,下面的程序解释了通过使用setbuf()来讲标准输入复制到标准输出:
#i nclude <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预处理器首先对其进行了转换。出于两个主要原因(和很多次要原因),预处理器为我们提供了一些简化的途径。
首先,我们希望可以通过改变一个数字并重新编译程序来改变一个特殊量(如表的大小)的所有实例。
其次,我们可能希望定义一些东西,它们看起来象函数但没有函数调用所需的运行开销。例如,putchar()和getchar()通常实现为宏以避免对每一个字符的输入输出都要进行函数调用。
6.1 宏不是函数
由于宏可以象函数那样出现,有些程序员有时就会将它们视为等价的。因此,看下面的定义:
#define max(a, b) ((a) > (b) ? (a) : (b))
注意宏体中所有的括号。它们是为了防止出现a和b是带有比>优先级低的表达式的情况。
一个重要的问题是,像max()这样定义的宏每个操作数都会出现两次并且会被求值两次。因此,在这个例子中,如果a比b大,则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。我们来看看在第一次循环时会发生什么。赋值语句会被扩展为:
biggest = ((biggest) > (x[i++]) ? (biggest) : (x[i++]));
首先,biggest与x[i++]进行比较。由于i是1而x[1]是3,这个关系是“假”。其副作用是,i增长到2。
由于关系是“假”,x[i++]的值要赋给biggest。然而,这时的i变成2了,因此赋给biggest的值是x[2]的值,即1。
避免这些问题的方法是保证max()宏的参数没有副作用:
biggest = x[0];
for(i = 1; i < n; i++)
biggest = max(biggest, x[ i ]);
还有一个危险的例子是混合宏及其副作用。这是来自UNIX第八版的<stdio.h>中putc()宏的定义:
#define putc(x, p) (--(p)->_cnt >= 0 ? (*(p)->_ptr++ = (x)) : _flsbuf(x, p))
putc ()的第一个参数是一个要写入到文件中的字符,第二个参数是一个指向一个表示文件的内部数据结构的指针。注意第一个参数完全可以使用如*z++之类的东 西,尽管它在宏中两次出现,但只会被求值一次。而第二个参数会被求值两次(在宏体中,x出现了两次,但由于 它的两次出现分别在一个:的两边,因此在putc()的一个实例中它们之中有且仅有一个被求值)。由于putc()中的文件参数可能带有副作用,这偶尔会 出现问题。不过,用户手册文档中提到:“由于putc()被实现为宏,其对待stream可能会具有副作用。特别是putc(c, *f++)不能正确地工作。”但是putc(*c++, f)在这个实现中是可以工作的。
有些C实现很不小心。例如,没有人能正 确处理putc(*c++, f)。另一个例子,考虑很多C库中出现的toupper()函数。它将一个小写字母转换为相应的大写字母,而其它字符不变。如果我们假设所有的小写字母和 所有的大写字母都是相邻的(大小写之间可能有所差距),我们可以得到这样的函数:
toupper(c) {
if(c >= 'a' && c <= 'z')
c += 'A' - 'a';
return c;
}
在很多C实现中,为了减少比实际计算还要多的调用开销,通常将其实现为宏:
#define toupper(c) ((c) >= 'a' && (c) <= 'z' ? (c) + ('A' - 'a') : (c))
很多时候这确实比函数要快。然而,当你试着写toupper(*p++)时,会出现奇怪的结果。
另一个需要注意的地方是使用宏可能会产生巨大的表达式。例如,继续考虑max()的定义:
#define max(a, b) ((a) > (b) ? (a) : (b))
假设我们这个定义来查找a、b、c和d中的最大值。如果我们直接写:
max(a, max(b, max(c, d)))
它将被扩展为:
((a) > (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))) ?
(a) : (((b) > (((c) > (d) ? (c) : (d))) ? (b) : (((c) > (d) ? (c) : (d))))))
这出奇的庞大。我们可以通过平衡操作数来使它短一些:
max(max(a, b), max(c, d))
这会得到:
((((a) > (b) ? (a) : (b))) > (((c) > (d) ? (c) : (d))) ?
(((a) > (b) ? (a) : (b))) : (((c) > (d) ? (c) : (d))))
这看起来还是写:
biggest = a;
if(biggest < b) biggest = b;
if(biggest < c) biggest = c;
if(biggest < d) biggest = d;
比较好一些。
6.2 宏不是类型定义
宏的一个通常的用途是保证不同地方的多个事物具有相同的类型:
#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b, c;
这允许程序员可以通过只改变程序中的一行就能改变a、b和c的类型,尽管a、b和c可能声明在很远的不同地方。
使用这样的宏定义还有着可移植性的优势——所有的C编译器都支持它。很多C编译器并不支持另一种方法:
typedef struct foo FOOTYPE;
这将FOOTYPE定义为一个与struct foo等价的新类型。
这两种为类型命名的方法可以是等价的,但typedef更灵活一些。例如,考虑下面的例子:
#define T1 struct foo *
typedef struct foo * T2;
这两个定义使得T1和T2都等价于一个struct foo的指针。但看看当我们试图在一行中声明多于一个变量的时候会发生什么:
T1 a, b;
T2 c, d;
第一个声明被扩展为:
struct foo * a, b;
这里a被定义为一个结构指针,但b被定义为一个结构(而不是指针)。相反,第二个声明中c和d都被定义为指向结构的指针,因为T2的行为好像真正的类型一样。特别推荐
热门信息
文章评论
共有 0 位网友发表了评论 此处只显示部分留言 点击查看完整评论页面