C陷阱与缺陷 拾遗

时间:2007-03-13 19:50:27  来源:本站搜集整理  作者:Eric

7 可移植性缺陷

    C被很多人实现并运行在很多机器上。这也正是在一个地方写的C程序应该能够很容易地转移到另一个编程环境中去的原因。

    然而,由于有很多的实现者,它们并不和其他人交流。此外,不同的系统有不同的需求,因此一台机器上的C实现和另一台上的多少会有些不同。

    由于很多早期的C实现都关系到UNIX操作系统,因此这些函数的性质都是专于该系统的。当一些人开始在其他系统中实现C时,他们尝试使库的行为类似于UNIX系统中的行为。

    但他们并不总是能够成功。更有甚者,很多人从UNIX系统的不同版本入手,一些库函数的本质不可避免地发生分歧。今天,一个C程序员如果想写出对于不同环境中的用户都有用的程序就必须知道很多这些细微的差别。

7.1 一个名字中都有什么?

    一些C编译器将一个标识符中的所有字符视为签名。而另一些在存贮标识符是会忽略一个极限之外的所有字符。C编译器产生的目标程序同将要被加载器进行处理以访问库中的子程序。加载器对于它们能够处理的名字通常应用自己的约束。

    一个常见的加载器约束是所有的外部名字必须只能是大写的。面对这样的加载器约束,C实现者会强制要求所有的外部名字都是大写的。这种约束在C语言参考手册中第2.1节由所描述。

一个标识符是一个字符和数字序列,第一个字符必须是一个字母。下划线_算作字母。大写字母和小写字母是不同的。只有前八个字符是签名,但可以使用更多的字符。可以被多种汇编器和加载器使用的外部标识符,有着更多的限制:

    这里,参考手册中继续给出了一些例子如有些实现要求外部标识符具有单独的大小写格式、或者少于八个字符、或者二者都有。

    正因为所有这些,在一个希望可以移植的程序中小心地选择标识符是很重要的。为两个 子程序选择print_fields和print_float这样的名字不是个好办法。

    考虑下面这个显著的函数:

char *Malloc(unsigned n) {
    char *p, *malloc();
    p = malloc(n);
    if(p == NULL)
        panic("out of memory");
    return p;
}

    这个函数是保证耗尽内存而不会导致没有检测的一个简单的办法。程序员可以通过调用Mallo()来代替malloc()。如果malloc()不幸失败,将调用panic()来显示一个恰当的错误消息并终止程序。

     然而,考虑当该函数用于一个忽略大小写区别的系统中时会发生什么。这时,名字malloc和Malloc是等价的。换句话说,库函数malloc()被上 面的Malloc()函数完全取代了,当调用malloc()时它调用的是它自己。显然,其结果就是第一次尝试分配内存就会陷入一个递归循环并随之发生混 乱。但在一些能够区分大小写的实现中这个函数还是可以工作的。

7.2 一个整数有多大?

    C为程序员提供三种整数尺寸:普通、短和长,还有字符,其行为像一个很小的整数。C语言定义对各种整数的大小不作任何保证:

整数的四种尺寸是非递减的。
普通整数的大小要足够存放任意的数组下标。
字符的大小应该体现特定硬件的本质。
    许多现代机器具有8位字符,不过还有一些具有7位获9位字符。因此字符通常是7、8或9位。

    长整数通常至少32位,因此一个长整数可以用于表示文件的大小。

    普通整数通常至少16位,因为太小的整数会更多地限制一个数组的最大大小。

    短整数总是恰好16位。

     在实践中这些都意味着什么?最重要的一点就是别指望能够使用任何一个特定的精度。非正式情况下你可以假设一个短整数或一个普通整数是16位的,而一个长整 数是32位的,但并不保证总是会有这些大小。你当然可以用普通整数来压缩表大小和下标,但当一个变量必须存放一个一千万的数字的时候呢?

    一种更可移植的做法是定义一个“新的”类型:

typedef long tenmil;

现在你就可以使用这个类型来声明一个变量并知道它的宽度了,最坏的情况下,你也只要改变这个单独的类型定义就可以使所有这些变量具有正确的类型。

7.3 字符是带符号的还是无符号的?

    很多现代计算机支持8位字符,因此很多现代C编译器将字符实现为8位整数。然而,并不是所有的编译器都按照同将的方式解释这些8位数。

     这些问题在将一个char制转换为一个更大的整数时变得尤为重要。对于相反的转换,其结果却是定义良好的:多余的位被简单地丢弃掉。但一个编译器将一个 char转换为一个int却需要作出选择:将char视为带符号量还是无符号量?如果是前者,将char扩展为int时要复制符号位;如果是后者,则要将 多余的位用0填充。

    这个决定的结果对于那些在处理字符时习惯将高位置1的人来说非常重要。这决定着8位的字符范围是从-128到127还是从0到255。这又影响着程序员对哈希表和转换表之类的东西的设计。

    如果你关心一个字符值最高位置一时是否被视为一个负数,你应该显式地将它声明为unsigned char。这样就能保证在转换为整数时是基0的,而不像普通char变量那样在一些实现中是带符号的而在另一些实现中是无符号的。

     另外,还有一种误解是认为当c是一个字符变量时,可以通过写(unsigned)c来得到与c等价的无符号整数。这是错误的,因为一个char值在进行任 何操作(包括转换)之前转换为int。这时c会首先转换为一个带符号整数在转换为一个无符号整数,这会产生奇怪的结果。

    正确的方法是写(unsigned char)c。

7.4 右移位是带符号的还是无符号的?

    这里再一次重复:一个关心右移操作如何进行的程序最好将所有待移位的量声明为无符号的。

7.5 除法如何舍入?

    假设我们用b除a得到商为q余数为r:

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

我们暂时假设b > 0。

    我们期望a、b、q和r之间有什么关联?

最重要的,我们期望q * b + r == a,因为这是对余数的定义。
如果a的符号发生改变,我们期望q的符号也发生改变,但绝对值不变。
我们希望保证r >= 0且r < b。例如,如果余数将作为一个哈希表的索引,它必须要保证总是一个有效的索引。
    这三点清楚地描述了整数除法和求余操作。不幸的是,它们不能同时为真。

    考虑3 / 2,商1余0。这满足第一点。而-3 / 2的值呢?根据第二点,商应该是-1,但如果是这样的话,余数必须也是-1,这违反了第三点。或者,我们可以通过将余数标记为1来满足第三点,但这时根据第一点商应该是-2。这又违反了第二点。

    因此C和其他任何实现了整数除法舍入的语言必须放弃上述三个原则中的至少一个。

    很多程序设计语言放弃了第三点,要求余数的符号必须和被除数相同。这可以保证第一点和第二点。很多C实现也是这样做的。

     然而,C语言的定义只保证了第一点和|r| < |b|以及当a >= 0且b > 0时r >= 0。 这比第二点或第三点的限制要小, 实际上有些编译器满足第二点或第三点,但不太常见(如一个实现可能总是向着距离0最远的方向进行舍入)。

     尽管有些时候不需要灵活性,C语言还是足够可以让我们令除法完成我们所要做的、提供我们所想知道的。例如,假设我们有一个数n表示一个标识符中的字符的一 些函数,并且我们想通过除法得到一个哈希表入口h,其中0 <= h <= HASHSIZE。如果我们知道n是非负的,我们可以简单地写:

h = n % HASHSIZE;

然而,如果n有可能是负的,这样写就不好了,因为h可能也是负的。然而,我们知道h > -HASHSIZE,因此我们可以写:

h = n % HASHSIZE;
if(n < 0)
    h += HASHSIZE;

    同样,将n声明为unsigned也可以。

7.6 一个随机数有多大?

    这个尺寸是模糊的,还受库设计的影响。在PDP-11机器上运行的仅有的C实现中,有一个称为rand()的函数可以返回一个(伪)随机非负整数。PDP-11中整数长度包括符号位是16位,因此rand()返回一个0到215-1之间的整数。

    当C在VAX-11上实现时,整数的长度变为32位长。那么VAX-11上的rand()函数返回值范围是什么呢?

    对于这个系统,加利福尼亚大学的人认为rand()的返回值应该涵盖所有可能的非负整数,因此它们的rand()版本返回一个0到231-1之间的整数。

    而AT&T的人则觉得如果rand()函数仍然返回一个0到215之间的值 则可以很容易地将PDP-11中期望rand()能够返回一个小于215的值的程序移植到VAX-11上。

    因此,现在还很难写出不依赖实现而调用rand()函数的程序。

7.7 大小写转换

    toupper()和tolower()函数有着类似的历史。他们最初都被实现为宏:

#define toupper(c) ((c) + 'A' - 'a')
#define tolower(c) ((c) + 'A' - 'a')

当 给定一个小写字母作为输入时,toupper()将产生相应的大写字母。tolower()反之。这两个宏都依赖于实现的字符集,它们需要所有的大写字母 和对应的小写字母之间的差别都是常数的。这个假设对于ASCII和EBCDIC字符集来说都是有效的,可能不是很危险,因为这些不可移植的宏定义可以被封 装到一个单独的文件中并包含它们。

    这些宏确实有一个缺陷,即:当给定的东西不是一个恰当的字符,它会返回垃圾。因此,下面这个通过使用这些宏来将一个文件转为小写的程序是无法工作的:

int c;
while((c = getchar()) != EOF)
    putchar(tolower(c));

我们必须写:

int c;
while((c = getchar()) != EOF)
    putchar(isupper(c) ? tolower(c) : c);

    就这一点,AT&T中的UNIX开发组织提醒我们,toupper()和tolower()都是事先经过一些适当的参数进行测试的。考虑这样重写这些宏:

#define toupper(c) ((c) >= 'a' && (c) <= 'z' ? (c) + 'A' - 'a' : (c))
#define tolower(c) ((c) >= 'A' && (c) <= 'Z' ? (c) + 'a' - 'A' : (c))

但要知道,这里c的三次出现都要被求值,这会破坏如toupper(*p++)这样的表达式。因此,可以考虑将toupper()和tolower()重写为函数。toupper()看起来可能像这样:

int toupper(int c) {
    if(c >= 'a' && c <= 'z')
        return c + 'A' - 'a';
    return c;
}

tolower()类似。

    这个改变带来更多的问题,每次使用这些函数的时候都会引入函数调用开销。我们的英雄认为一些人可能不愿意支付这些开销,因此他们将这个宏重命名为:

#define _toupper(c) ((c) + 'A' - 'a')
#define _tolower(c) ((c) + 'a' - 'A')

这就允许用户选择方便或速度。

    这里面其实只有一个问题:伯克利的人们和其他的C实现者并没有跟着这么做。 这意味着一个在AT&T系统上编写的使用了toupper()或tolower()的程序,如果没有为其传递正确大小写字母参数,在其他C实现中可能不会正常工作。

    如果不知道这些历史,可能很难对这类错误进行跟踪。

7.8 先释放,再重新分配

     很多C实现为用户提供了三个内存分配函数:malloc()、realloc()和free()。调用malloc(n)返回一个指向有n个字符的新分配 的内存的指针,这个指针可以由程序员使用。给free()传递一个指向由malloc()分配的内存的指针可以使这块内存得以重用。通过一个指向已分配区 域的指针和一个新的大小调用realloc()可以将这块内存扩大或缩小到新尺寸,这个过程中可能要复制内存。

    也许有人会想,真相真是有点微妙啊。下面是System V接口定义中出现的对realloc()的描述:

realloc改变一个由ptr指向的size个字节的块,并返回该块(可能被移动)的指针。 在新旧尺寸中比较小的一个尺寸之下的内容不会被改变。

而UNIX系统第七版的参考手册中包含了这一段的副本。此外,还包含了描述realloc()的另外一段:

如果在最后一次调用malloc、realloc或calloc后释放了ptr所指向的块,realloc依旧可以工作;因此,free、malloc和realloc的顺序可以利用malloc压缩存贮的查找策略。

因此,下面的代码片段在UNIX第七版中是合法的:

free (p);
p = realloc(p, newsize);

    这一特性保留在从UNIX第七版衍生出来的系统中:可以先释放一块存储区域,然后再重新分配它。这意味着,在这些系统中释放的内存中的内容在下一次内存分配之前可以保证不变。因此,在这些系统中,我们可以用下面这种奇特的思想来释放一个链表中的所有元素:

for(p = head; p != NULL; p = p->next)
    free((char *)p);

而不用担心调用free()会导致p->next不可用。

     不用说,这种技术是不推荐的,因为不是所有C实现都能在内存被释放后将它的内容保留足够长的时间。然而,第七版的手册遗留了一个未声明的问题: realloc()的原始实现实际上是必须要先释放再重新分配的。出于这个原因,一些C程序都是先释放内存再重新分配的,而当这些程序移植到其他实现中时 就会出现问题。

7.9 可移植性问题的一个实例

    让我们来看一个已经被很多人在很多时候解决了的问题。下面的程序带有两个参数:一个长整数和一个函数(的指针)。它将整数转换位十进制数,并用代表其中每一个数字的字符来调用给定的函数。

void printnum(long n, void (*p)()) {
    if(n < 0) {
        (*p)('-');
        n = -n;
    }
    if(n >= 10)
        printnum(n / 10, p);
    (*p)(n % 10 + '0');
}

     这个程序非常简单。首先检查n是否为负数;如果是,则打印一个符号并将n变为正数。接下来,测试是否n >= 10。如果是,则它的十进制表示中包含两个或更多个数字,因此我们递归地调用printnum()来打印除最后一个数字外的所有数字。最后,我们打印最后 一个数字。

    这个程序——由于它的简单——具有很多可移植性问题。首先是将n的低位数字转换成字符形式的方法。用n % 10来获取低位数字的值是好的,但为它加上'0'来获得相应的字符表示就不好了。这个加法假设机器中顺序的数字所对应的字符数顺序的,没有间隔,因此 '0' + 5和'5'的值是相同的,等等。尽管这个假设对于ASCII和EBCDIC字符集是成立的,但对于其他一些机器可能不成立。避免这个问题的方法是使用一个 表:

void printnum(long n, void (*p)()) {
    if(n < 0) {
        (*p)('-');
        n = -n;
    }
    if(n >= 10)
        printnum(n / 10, p);
    (*p)("0123456789"[n % 10]);
}

    另一个问题发生在当n < 0时。这时程序会打印一个负号并将n设置为-n。这个赋值会发生溢出,因为在使用2的补码的机器上通常能够表示的负数比正数要多。例如,一个(长)整数有k位和一个附加位表示符号,则-2k可以表示而2k却不能。

    解决这一问题有很多方法。最直观的一种是将n赋给一个unsigned long值。然而,一些C便一起可能没有实现unsigned long,因此我们来看看没有它怎么办。

    在第一个实现和第二个实现的机器上,改变一个正整数的符号保证不会发生溢出。问题仅出在改变一个负数的符号时。因此,我们可以通过避免将n变为正数来避免这个问题。

    当然,一旦我们打印了负数的符号,我们就能够将负数和正数视为是一样的。下面的方法就强制在打印符号之后n为负数,并且用负数值完成我们所有的算法。如果我们这么做,我们就必须保证程序中打印符号的部分只执行一次;一个简单的方法是将这个程序划分为两个函数:

void printnum(long n, void (*p)()) {
    if(n < 0) {
        (*p)('-');
        printneg(n, p);
    }
    else
        printneg(-n, p);
}

void printneg(long n, void (*p)()) {
    if(n <= -10)
        printneg(n / 10, p);
    (*p)("0123456789"[-(n % 10)]);
}

    printnum()现在只检查要打印的数是否为负数;如果是的话则打印一个符号。否则,它以n的负绝对值来调用printneg()。我们同时改变了printneg()的函数体来适应n永远是负数或零这一事实。

     我们得到什么?我们使用n / 10和n % 10来获取n的前导数字和结尾数字(经过适当的符号变换)。调用整数除法的行为在其中一个操作数为负的时候是实现相关的。因此,n % 10有可能是正的!这时,-(n % 10)是正数,将会超出我们的数字字符数组的末尾。

    为了解决这一问题,我们建立两个临时变量来存放商和余数。作完除法后,我们检查余数是否在正确的范围内,如果不是的话则调整这两个变量。printnum()没有改变,因此我们只列出printneg():

void printneg(long n, void (*p)()) {
    long q;
    int r;
    if(r > 0) {
        r -= 10;
        q++;
    }
    if(n <= -10) {
        printneg(q, p);
    }
    (*p)("0123456789"[-r]);
}

8 这里是空闲空间

    还有很多可能让C程序员误入迷途的地方本文没有提到。如果你发现了,请联系作者。在以后的版本中它会被包含进来,并添加一个表示感谢的脚注。

参考
     《The C Programming Language》(Kernighan and Ritchie, Prentice-Hall 1978)是最具权威的C著作。它包含了一个优秀的教程,面向那些熟悉其他高级语言程序设计的人,和一个参考手册,简洁地描述了整个语言。尽管自1978 年以来这门语言发生了不少变化,这本书对于很多主题来说仍然是个定论。这本书同时还包含了本文中多次提到的“C语言参考手册”。

    《The C Puzzle Book》(Feuer, Prentice-Hall, 1982)是一本少见的磨炼人们文法能力的书。这本书收集了很多谜题(和答案),它们的解决方法能够测试读者对于C语言精妙之处的知识。

    《C: A Referenct Manual》(Harbison and Steele, Prentice Hall 1984)是特意为实现者编写的一本参考资料。其他人也会发现它是特别有用的——因为他能从中参考细节。


-------------

脚注
    本文是基于图书《C Traps and Pitfalls》(Addison-Wesley, 1989, ISBN 0-201-17928-8)的一个扩充,有兴趣的读者可以读一读它。
    因为!=的结果不是1就是0。
    感谢Guy Harris为我指出这个问题。
    Dennis Ritchie和Steve Johnson同时向我指出了这个问题。
    感谢一位不知名的志愿者提出这个问题。
    感谢Richard Stevens指出了这个问题。
    一些C编译器要求每个外部对象仅有一个定义,但可以有多个声明。使用这样的编译器时,我们何以很容易地将一个声明放到一个包含文件中,并将其定义放到其它地方。这意味着每个外部对象的类型将出现两次,但这比出现多于两次要好。
    分离函数参数用的逗号不是逗号运算符。例如在f(x, y)中,x和y的获取顺序是未定义的,但在g((x, y))中不是这样的。其中g只有一个参数。它的值是通过对x进行求值、抛弃这个值、再对y进行求值来确定的。
    预理器还可以很容易地组织这样的显式常量以能够方便地找到它们。
    PDP-11和VAX-11是数组设备集团(DEC)的商标。

相关文章

文章评论

共有  0  位网友发表了评论 此处只显示部分留言 点击查看完整评论页面