[C 陷阱与缺陷] (七) 可移植性缺陷

了解更多有关可移植可以参考《How to Write Portable Software in C》(Prentice-Hall)。

本章主要讨论几个常见的错误来源,重点放在语言属性上,而非函数库属性上。

一 可移植性缺陷

1. 应对 C 语言标准变更

这种语言标准的变更使得 C 程序的编写者面临一个两难境地:程序中是否应该用到新的特性呢? 如果使用它们,程序无疑更加容易编写,而且不大容易出错,但是那样做也有代价,那就是这些程序在较早的编译器上将无法工作。

本书的 4.4节讨论了一个这类例子:函数原型的概念。让我们回想一下 4.4 节中提到的 square 函数:

double
square (double x){
return x * x;
}

如果这样写,这个函数在很多编译器上都不能通过编译。如果我们按照旧风格来重写这个函数,因为 ANSI 标准为了保持和以前的用法兼容也允许这种形式,这就增强了它的可移植性:

double
square (x)
double x;
{
return x*x;
}

这种可移植性的获得当然也付出了代价。为了与旧用法保持一致, 我们必须在调用了 square 函数的程序中作如下声明:

double square();

函数声明中略去参数类型的说明,这在 ANSI C 标准中也是合法的。因为这样的声明并没有对参数类型做出任何说明,就意味着如果在函数调用时传入了错误类型的参数,函数调用就会不声不响地失败:

double square();

main(){
printf("%g\n",square(3));
}

函数square的声明中并没有对参数类型做出说明,因此在编译 main 函数时,编译器无法得知函数 square 的参数类型应该是 double,而不是 int 。这样,程序打印出的将是一堆 “垃圾信息”。要检测这类问题,有一个办法就是使用 lint 程序,前提是编程者的 C 语言实现提供了这一工具。

如果上面的程序被写成了这样:

double square (double);
main(){
printf("%g\n", square(3));
}

这里,3 会被自动转换为double类型。

另种改写的方式是,在这个程序中显式地给函数 square 传入一个 double 类型的参数:

double square() ;

main()
{
printf ("%g\n", square(3.0));
}

这样做程序就能得到正确的结果。即使是对于那些不允许在函数声明中包括参数类型的旧编译器,第二种写法也仍然能够使程序照常工作。

许多有关可移植性的决策都有类似的特点。一个程序员是否应该使用某个新的或特定的特性?使用该特性也许能给编程带来巨大的方便,但代价却是使程序失去了一部分潜在用户。

2. 标识符名称的限制

某些 C 语言实现把一个标识符中出现的所有字符都作为有效字符处理,而另一些 C实 现却会自动地截断一个长标识符名称的尾部。连接器也会对它们能够处理的名称强加限制,例如外部名称中只允许使用大写字母。C实现者在面对这样的限制时,一个合理的选择就是强制所有的外部名称必须是大写。事实上,ANSI C标准所能保证的只是,C实现必须能够区别出前 6 个字符不同的外部名称。而且,这个定义中并没有区分大写字母与其对应的小写字母

因为这个原因,为了保证程序的可移植性,谨慎地选择外部标识符的名称是重要的。比方说,两个函数的名称分别为 print_fieldsprint_float 这样的命名方式就不恰当;同理, 使用 State 与 STATE 这样的命名方式也不明智。

考虑以下函数:

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

上面的例子程序演示了一个确保检测到内存耗尽的异常情况的简单办法。编程者的想法是,在程序中应该调用 malloc 函数分配内存的地方,改为调用 Malloc 函数。如果 malloc 函数调用失败,则 panic 函数将被调用,panic 函数终止程序,并打印出一条恰当的出错消息。这样,客户程序就不必在每次调用malloc函数时都要进行检查。

然而,考虑一下如果这个函数的编译环境是不区分外部名称大小写的 C 语言实现,将会发生怎样的情况呢? 此时,函数malloc 与Malloc 实际上是等同的。也就是说,库函数 malloc将被上面的 Malloc 函数等效替换。当在 Malloc 函数中调用库函数 malloc 时,实际上调用的却是 Malloc 函数自身!当然,尽管函数 Malloc 在那些区分大小写的C语言实现上仍然能够正常工作,但在这种情况下结果却是:程序在第一次试图分配内存时对 Malloc 函数的调用将引起一系列的递归调用, 而这些递归调用又不存在一个返回点,最后引发灾难性的后果!

3. 整数的大小

C语言中为编程者提供了3种不同长度的整数: short 型、int 型和 long 型,C 语言中的字符行为方式与小整数相似。C语言的定义中对各种不同类型整数的相对长度作了一些规定:

  1. 3种类型的整数其长度是非递减的。也就是说,short 型整数容纳的值肯定能够被 int 型整数容纳,int 型整数容纳的值也肯定能够被 long 型整数容纳。对于一个特定的 C 语言实现来说,并不需要实际支持 3 种不同长度的整数,但可能不会让 short 型整数大于 int 型整数,而 int 型整数大于 long 型整数。

  2. 一个普通(int 类型)整数足够大以容纳任何数组下标。

  3. 字符长度由硬件特性决定。

ANSI 标准要求 long 型整数的长度至少应该是 32 位,而 short 型和 int 型整数的长度至少应该是 16 位。因为大多数机器中字符长度是8位,对这些机器而言最方便的整数长度是 16 位和 32 位,因此所有早期的C编译器也都能够满足这些限制条件。

程序员当然可以用一个 int 型整数来表示一个数据表格的大小或者数组的下标。但如果一个变量需要存放可能是千万数量级的数值,又该如何呢?

要定义这样一个变量,可移植性最好的办法就是声明该变量为 long 型,但在这种情况下我们定义一个“新的”类型无疑更为清晰:

typedef long tenmil;

而且,程序员可以用这个新类型来声明所有此类变量,最坏的情形也不过是我们只需要改动类型定义,所有这些变量的类型就自动变为正确的了。

4. 字符是有符号整数还是无符号整数

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

只有在我们需要把一个字符值转换为一个较大的整数时,这个问题才变得重要起来。而在其他情况下,结果都是已定义的:多余的位将被简单地“丢弃”。编译器在转换 char 类型到 int 类型时,需要做出选择:应该将字符作为有符号数还是应该无符号数处理?如果是前一种情况,编译器在将 char 类型的数扩展到 int 类型时,应该同时复制符号位:而如果是后一种情况,编译器只需在多余的位上直接填充 0 即可。

如果一个字符的最高位是1,编译器是将该字符当作有符号数,还是无符号数呢?对于任何一个需要处理该字符的程序员来说,上述选择的结果非常重要。它决定着一个 8 位字符的取值范围是从 -128 到 127 ,还是从 0 到 255。而这一点,又反过来影响到程序员对哈希表或转换表等的设计方式。

如果编程者关注一个最高位是1的字符其数值究竟是正还是负,可以将这个字符声明为无符号字符(unsigned char)。这样,无论是什么编译器,在将该字符转换为整数时都只需将多余的位填充为 0 即可。而如果声明为一般的字符变量,那么在某些编译器上可能会作为有符号数处理,在另一些编译器上又会作为无符号数处理。

与此相关的一个常见错误认识是:如果 c 是一个字符变量,使用 (unsigned)c 就可得到与 c 等价的无符号整数。这是会失败的,因为在将字符 c 转换为无符号整数时,c 将首先被转换为 int 型整数,而此时可能得到非预期的结果。正确的方式是使用语句 (unsigned char)c,因为一个 unsigned char 类型的字符在转换为无符号整数时无需首先转换为int型整数,而是直接进行转换。

5. 移位运算符

使用移位运算符的程序员经常对这样两个问题感到困惑:

  1. 在向右移位时,空出的位是由 0 填充,还是由符号位的副本填充?
  2. 移位计数(即移位操作的位数)允许的取值范围是什么?

第一个问题的答案很简单,但有时却是与具体的 C 语言实现有关。如果被移位的对象是无符号数,那么空出的位将被0填充。如果被移位的对象是有符号数,那么 C 语言实现既可以用 0 填充空出的位,也可以用符号位的副本填充空出的位。编程者如果关注向右移位时空出的位,那么可以将操作的变量声明为无符号类型,那么空出的位都会被设置为0。

第二个问题的答案同样也很简单:如果被移位的对象长度是 n 位,那么移位计数必须大于或等于 0,而严格小于 n。因此,不可能做到在单次操作中将某个数值中的所有位都移出。为什么要有这个限制呢?因为只要加上了这个限制条件, 我们就能够在硬件上高效地实现移位运算。举例来说,如果一个 int 型整数是 32 位,n 是一个 int 型整数,那么 n<<31
n<<0 这样写是合法的,而 n<<32n<<-1 这样写是非法的。

需要注意的是,即使 C 实现将符号位复制到空出的位中,有符号整数的向右移位运算也并不等同于除以 2 的某次幂。要证明这一点, 让我们考虑 (-1)>>1,这个操作的结果一般不可 能为 0,但是 (-1)/2 在大多数 C 实现上求值结果都是 0。这意味着以除法运算来代替移位运算,将可能导致程序运行速度大大减慢。举例而言,如果已知下面表达式中的 low+high 为非负,那么

mid = (1ow + high) >> 1;

与下式

mid = (low + high) / 2;

完全等效,而且前者的执行速度也要快得多。

6. 内存位置 0

null 指针并不指向任何对象。因此,除非是用于赋值或比较运算,出于其他任何目的使用 null 指针都是非法的。例如,如果 p 或 q 是一个 null 指针,那么 strcmp(p, q) 的值就是未定义的。

在这种情况下究竟会得到什么结果呢?不同的编译器有不同的结果。某些 C 语言实现对内存位置 0 强加了硬件级的读保护,在其上工作的程序如果错误使用了一个 null 指针,将立即终止执行。其他一些 C 语言实现对内存位置 0 只允许读,不允许写。在这种情况下,一个 null 指针似乎指向的是某个字符串,但其内容通常不过是一堆“垃圾信息”。还有一些 C 语言实现对内存位置0既允许读,也允许写。在这种实现上面工作的程序如果错误使用了一个 null 指针,则很可能覆盖了操作系统的部分内容,造成彻底的灾难!

严格说来,这并非一个可移植性问题:在所有的C程序中,误用 null 指针的效果都是未定义的。然而,这样的程序有可能在某个 C 语言实现上“似乎”能够工作,只有当该程序转移到另一台机器上运行时才会暴露出问题来。要检查出这类问题的最简单办法就是,把程序移到不允许读取内存位置 0 的机器上运行。下面的程序将揭示出某个 C 语言实现是如何处理内存地址 0 的:

#include <stdio.h>
main{)
char *p;
p = NULL;
printf("Location 0 contains %d\n", *p) ;
}

在禁止读取内存地址 0 的机器上,这个程序将会执行失败。在其他机器上,这个程序将会以 10 进制的格式打印出内存位置 0 中存储的字符内容。

7. 除法运算时发生的截断

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

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

这里,不妨假定 b 大于 0。我们希望a、b、q、r之间维持怎样的关系呢?

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

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

考虑一个简单的例子: 3 / 2, 商为 1,余数也为 1。此时,第 1 条性质得到了满足。(-3)/2 的值应该是多少呢?如果要满足第 2 条性质,答案应该是 -1,但如果是这样,余数就必定是 -1,这样第 3 条性质就无法满足了。如果我们首先满足第 3 条性质,即余数是 1,这种情况下根据第 1 条性质则商是 -2,那么第 2 条性质又无法满足了。

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

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

C 语言的定义虽然有时候会带来不需要的灵活性,但大多数时候,只要编程者清楚地知道要做什么、该做什么,这个定义对让整数除法运算满足其需要来说还是够用了的。例如,假定我们有一个数 n ,它代表标识符中的字符经过某种函数运算后的结果,我们希望通过除法运算得到哈希表的条目 h,满足 0 <= h < HASHSIZE。又如果已知 n 恒为非负,那么我们只需要像下面一样简单地写:

h = n % HASHSIZE;

然而,如果 n 有可能为负数,而此时 h 也有可能为负,那么这样做就不一定总是合适的了。不过,我们已知 h >= -HASHSIZE,因此我们可以这样写:

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

更好的做法是,程序在设计时就应该避免 n 的值为负这样的情形,并且声明 n 为无符号数。

8. 随机数的大小

最早的C语言实现运行于 PDP-11 计算机上,它提供了一个称为 rand 的函数,该函数的作用是产生一个(伪)随机非负整数。PDP-11 计算机上的整数长度为16位(包括了符号位),因此 rand 函数将返回一个介于 0 到2^15 - 1 之间的整数。

当在 VAX-11 计算机上实现 C 语言时,因为该种机器上整数的长度为 32 位,这就带来了一个实现方面的问题: VAX-11 计算机上 rand 函数的返回值范围应该是多少呢?

当时有两组人员同时分别在 VAX-11 计算机上实现 C 语言,他们做出的选择互不相同。一组人员在加州大学伯克利分校,他们认为 rand 函数的返回值范围应该包括该机器上所有可能的非负整数取值,因此他们设计版本的 rand 函数返回一
个介于 0 到 2^31 - 1 的整数。另一组人员在 AT&T,他们认为如果 VAX-11计算机上的 rand 函数返回值范围与 PDP-11计算机上的一样,即介于 0 到 2^15 - 1之间的整数,那么在PDP-11计算机上所写的程序就能够较为容易移植到 VAX-11 计算机上。

这样造成的后果是,如果我们的程序中用到了 rand 函数,在移植时就必须根据特定的C语言实现作出“剪裁”。ANSI C 标准中定义了一个常数 RAND_ MAX, 它的值等于随机数的最大取值,但是早期的 C 实现通常都没有包含这个常数。

9. 大小写转换

库函数 touppertolower 也有与随机数类似的历史。他们起初被实现为宏:

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

然而,这些宏确实有一个不足之处:如果输入的字母大小写不对,那么它们返回的就都是无用的垃圾信息。考虑下面的程序段,其作用是把一个文件中的大写字母全部转换为小写字母,这个程序段看上去没什么问题,但实际上却无法工作:

int c;

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

我们应该写成这样才对:

int c;

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

有一次, AT&T 软件开发部门的一一个极具创新精神的人注意到,大多数 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 被求值 1 到 3 次。如果遇到类似 toupper(*p++) 这样的表达式,可能造成不良后果。因此,他决定重写 touppertolower 为函数,重写后的 toupper 函数看上去大致像这样:

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

这样改动之后程序的健壮性无疑得到了增强,而代价是每次使用这些函数时却又引入了函数调用的开销。他意识到某些人也许不愿意付出效率方面损失的代价,因此他又重新引入了这些宏,不过使用了新的宏名:

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

这样,宏的使用者就可以在速度与方便之间自由选择。

10. 首先释放,然后重新分配

Unix 系统参考手册第七版中描述 realloc :

Realloc 函数把指针 ptr 所指向内存块的大小调整为 size 字节,返回一个指向调整后内存块(可能该内存块已经被移动过了)的指针。假定这块内存原来大小为 oldsize,新的大小为 newsize ,这两个数之间较小者为min(oldsize, newsize),那么内存块中 min(oldsize,newsize) 部分存储的内容将保持不变。

如果 ptr 指向的是一块最近一次调用 malloc, realloccalloc 分配的内存,即使这块内存已被释放, realloc 函数仍然可以工作。因此,可以通过调节 free, mallocrealloc 的调用顺序,充分利用 malloc 函数的搜索策略来压缩存储空间。

也就是说,这一实现允许在某内存块被释放之后重新分配其大小,前提是内存重分配(reallocation) 操作执行得必须足够早。因此,在符合第7版参考手册描述的系统中,下面的代码就是合法的:

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

在一个有这样特殊性质的系统中,我们可以用下面这个多少有些“怪异”的办法,来释放一个链表中的所有元素:

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

这里,我们不必担心调用 free 之后,会使 p->next 变得无效。

当然,这种技巧不值得推荐,因为并非所有的 C 实现在某块内存被释放后还能较长时间的保留之。不过,第7版参考手册还有一点没有提到:早期的realloc 函数的实现要求待重新分配的内存区域必须首先被释放。因为这个原因,仍然还
有一些较老的C程序是首先释放某块内存,然后再重新分配这块内存。当我们移植这样一个较老的C程序到一个新的实现中时,必须注意到这一点。

11 可移植性问题的一个例子

让我们来看这样一个问题,这个问题许多人都遇到过,也被解决过许多次,因此非常具有代表性。下面的程序接受两个参数: 一个 long 型整数和一个函数指针。这段程序的作用是把给出的long 型整数转换为其10进制表示,并且对10进制表示中的每个字符都调用函数指针所指向的函数:

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

if(n >= 10){
printnum(n / 10, p);
}

(*p)((int)(n % 10) + '0');
}

这段程序写得非常明白直接。首先,我们检查n是否为负;如果是负数,就打印出一个负号,然后让 n 反号,即 -n。接着,我们检查n是否大于等于 10;如果是的,那么 n 的 10 进制表示要包含两个或两个以上数字,然后我们递归调用 printnum函数打印出 n 的 10 进制表示中除最后一位以外的所有数字。最后,我们打印出 n 的 10 进制表示中的末位数字。为了使*p 能够处理正确参数类型,这里把表达式 n%10 的类型转换为 int 类型。这一点在 ANSI C 标准中其实并不必要,之所以进行类型转换主要是为了避免某些人可能只是简单地改写一下 printnum 的函数头,就将程序移植到早期的C实现上。

这个程序尽管简单,却存在几个可移植性方面的问题。第一个问题出在该程序把 n 的 10 进制表示的末位数字转换为字符形式时所用的方法。通过 n%10 来得到末位数字的值,这一点没有什么问题;但是给它加上 0 来得到对应的字符表示却不一定合适。程序中的加法操作实际上假定了在机器的字符集中数字是顺序排列、没有间隔的,这样才有 ‘0’ + 5 的值与 ‘5’ 的值相同,依次类推。这种假定,对 ASCII 字符集和 EBCDIC 字符集是正确的,对符合 ANSI 的 C 实现也是正确的,但对某些机器却有可能出错。要避免这个问题,解决办法是使用一张代表数字的字符表。因为一个字符串常量可以用来表示一个字符数组,所以在数组名出现的地方都可以用字符串常量来替换。下面例子中 printnum 函数的这个表达式虽然有些令人吃惊,却是合法的:

"0123456789" [n % 10]

我们把前面的程序进行如下改写,就解决了第一个可移植性问题:

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 的补码的计算机一般允许表示的负数取值范围要大于正数的取值范围。具体来说,就是如果一个 long 型整数有 k 位以及一个符号位,该 long 型整数能够表示 -2^k 却不能表示 2^k。要解决这个问题,有好几种办法。最明显的一种办法是把 -n 赋给一个 unsigned long 型的变量,然后对这个变量进行操作。但是,我们不能对 -n 求值,因为这样做将引起溢出!无论是对基于 1 的补码还是基于 2 的补码 ( 1’s complement and 2’scomplement) 的机器,改变一个正整数的符号都可以确保不会发生溢出。惟一的麻烦来自于当改变一个负数的符号的时候。因此,如果我们能够保证不将 n 转换为对应的正数,那么我们就能避免这一问题。

我们当然可以做到以同样的方式来处理正数和负数,只不过 n 为负数时需要打印出一个负号。要做到这一点,程序在打印负号之后强制 n 为负数,并且让所有的算术运算都是针对负数进行的。也就是说,我们必须保证打印负号的操作所对应的程序只被执行一次,最简单的办法就是把程序分解为两个函数。现在,printnum 函数只是检查 n 是否为负,如果是的就打印一个负号。无论 n 为正为负,printnum 函数都将调用 printne g函数,以 n 的绝对值的相反数为参数。这样, printneg 函数就满足了 n 总为负数或零的条件:

void printneg(long n, void (*p)())
{
if (n <= -10) {
printneg(n / 10, p);
}

(*p)("0123456789"[-(n % 10)]);
}


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

这样写还是有在可移植性方面的问题。我们曾经在程序中使用 n/10 和 n%10 来分别表示 n 的首位数字与末位数字,当然还需要适当改变符号。回忆一下, 本章前面提到了:当整数除法运算中的一个操作数为负时,它的行为表现与具体的实现有关。因此,当 n 为负数时,n%10 完全有可能是一个正数!此时,-(n % 10) 就是一个负数,"0123456789"[-(n % 10)]就不在数字数组之中。要解决这个问题,我们可以创建两个临时变量来分别保存商和余数。在除法运算完成之后,检查余数是否在合理的范围内:如果不是,则适当调整两个变量。printnum 函数不需要进行修改,需要改动的是 printneg 函数,因此下面我们只写出了printneg 函数:函数就满足了 n 总为负数或零的条件:

void printneg(long n, void (*p)())
{
long q;
int r;

q = n / 10;
r = n % 10;

if (r > 0) {
r -= 10;
q++;
}

if (n <= 10)
printneg(q, p);

(*p)("0123456789"[-r]);
}

我们为什么要如此不辞劳苦地精益求精地修改呢?因为我们所处的是一个编程环境不断改变的世界,尽管软件看上去不像硬件那么实在,但大多数软件的生命期却要长于它运行其上的硬件。而且,我们很难预言未来硬件的特性。因此,努力
提高软件的可移植性,实际上是延长了软件的生命期。

可移植性强的软件比较不容易出错。本例中的代码改动看上去是提高软件的可移植性,实际上大多数工作是确保边界条件的正确,即保证当 printnum 函数的参数是可能取到的最小负数时,它仍然能够正常工作。

二 练习

练习7-1

本章第 3 节中说,如果一个机器的字符长度为 8 位,那么其整数长度很可能是 16 位或 32 位。请问原因是什么?

某些计算机为每个字符分配一个惟一的内存地址, 而另一些机器却是按字来对内存寻址。按字寻址的机器通常都存在不能有效处理字符数据的问题,因为要从内存中取得一个字符, 就必须读取整个字的内容,然后把不需要用到的部分都丢弃。

由于按字符寻址的机型在字符处理方面的效率优势,它们相对于按字寻址的机型,近年来要更为流行。然而,即使对于按字符寻址的机器,字的概念在进行整数运算的时候也仍然是重要的。因为字符在内存中的存储位置是连续的,所以一个字中包含的字符数,将决定在内存中连续存放的字的地址。

如果一个字中包含的字符数是 2 的某次幂,因为乘以 2 的某次幂的运算可以转换为移位运算,所以计算机硬件就能很容易地完成从字符地址到字地址的转换。因此,我们可以合理地预期,字的长度是字符长度的 2 的某次冪。

那么整数的长度为什么不是 64 位呢?当然,某些时候这样做无疑是有用的。但是,对于那些支持浮点运算的硬件的机器,这样做的意义就不大了;而且考虑:到我们并不经常需要用到 64 位整数这样的精度,实现 64 位整数的代价就过于昂贵。如果只是偶尔用到,我们完全可以用软件来仿真 64 位(或者更长)的整数,而且丝毫不影响效率。

练习7-2

函数 atol 的作用是,接受一个指向以 null 结尾的字符串的指针作为参数,返回一个对应的 long 型整数值。假定:

  • 作为输入参数的指针,指向的字符串总是代表一个 合法的 long 型整数值,因此 atol 函数无须检查该输入是否越界。
  • 惟一合法的输入字符是数字和正负号。输入字符串在遇到第一个非法字符时结束。

请写出atol函数的一个可移植版本。

我们不妨假定在机器的排序序列中,数字是连续排列的:任何一种现代计算机都是这样实现的,而且 ANSI C 标准中也是这样要求的。因此,我们面临的主要问题就是避免中间结果发生溢出,即使最终的结果在取值范围之内也是如此。

正如 printum 函数中的情形,如果 long 型负数的最小可能取值与正数的最大可能取值并不相匹配,问题就变得棘手了。特别地,如果我们首先把一个值作为正数处理,然后再使它为负,对于负数的最大可能取值的情况,在很多机器上都会发生溢出。

下面这个版本的 atol 函数,只使用负数(和零)来得到函数的结果,从而避免了溢出:

long atol(char* str) {

long l;
int neg = 0;

switch (*str) {
case '-':
neg = 1;
case '+':
str++;
break;
}

while (*str >= '0' && *str <= '9') {
int n = *str++ - '0';
if (neg) {
n = -n;
}
l = l * 10 + n;
}

return l;
}

参考资料《C 缺陷与陷阱》