C陷阱与缺陷-疑难问题理解11

第7章 可移植性缺陷

​ C语言在许多不同的系统平台上都有实现。的确,使用C语言编写程序的一 个首要原因就是,C程序能够方便地在不同的编程环境中移植。

​ 然而,由于C语言实现是如此之多,各个实现之间有着或多或少的细微差别, 以至于没有两个实现是完全相同的。即使是写得最早的两个C语言编译器,它们 之间也有着很大区别。此外,不同的系统有不同的需求,因此我们应该能够料到, 机器不同则其上的C语言实现也有细微差别。ANSI C标准的发布能够在一定程 度上解决问题,但并不是万验灵药。

​ 早期的C语言实现都是由一个共同的“祖先”发展而来,因此在这些实现中 许多C库函数是由这个共同“祖先”形成的。此后人们开始在不同的操作系统上 实现C,他们仍然试图使C库函数的行为方式与早期程序中所使用的库函数保持 一致。

7.1 应对C语言标准变更

笔者在写作这本书的时候,ANSI委员会关于最新的C语言标准的工作也接 近尾声了。这个标准包括了许多新的语言概念,这些概念在目前的C编译器中并 不是普遍地得到了支持。而且,即使我们可以合理地假设C编译器销售商会逐渐 向ANSI C标准靠拢,很显然所有的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。这样,程序打印岀的将是一堆“垃圾信息”。要检测这类问题,有一个办法就是使用本书4.0节中提到的lint程序,前提是编程者的C语言实现提供了这一工具。

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

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

这里,3会被自动转换为double类型。另一种改写的方式是,在这个程序中显式地给函数square传入一个double类型的参数:

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

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

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

这个问题确实难于回答。程序的生命期往往超过了编程者最初的预料,即使 这个程序只是编程者出于自用的目的而编写的。因此,我们不能只看到当前的需 要,而忽视未来可能的需要。然而,我们从上面的例子中已经看到了:为了尽量 增加程序的可移植性,让过去的工具能够继续工作,而放弃现在可能的收益,这 种代价又未免过于昂贵。要解决这类有关决定的问题,最好的做法也许就是承认 我们需要下定决心才能做出选择,因此必须慎重对待,不能等闲视之。

7.2 标识符名称的限制

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

因为这个原因,为了保证程序的可移植性,谨慎地选择外部标识符的名称是 重要的。比方说,两个函数的名称分别为print_fields与print_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函数将被调用,panil函数终止程序, 并打印岀一条恰当的出错消息。这样,客户程序就不必在每次调用malloc函数时 都要进行检査。

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

7.3 整数的大小

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

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

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

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

现代大多数机器的字符长度是8位,也有一些机器的字符长度是9位。然而, 现在越来越多的C语言实现中的字符长度都是16位,以能够处理诸如日语之类 的语言的大字符集。

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

这些对编程实践有什么意义呢?最重要的一点,就是在这方面我们不能指望 拥有任何可用的精度。在非正式的情况下,我们可以说short型和int型整数(普 通整数)是16位,long型整数是32位,但即使是这些长度也是不能保证的。程 序员当然可以用一个int型整数来表示一个数据表格的大小或者数组的下标。但 如果一个变量需要存放可能是千万数量级的数值,又该如何呢?

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

typedef long tenrnil:

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

7.4 字符是有符号整数还是无符号整数

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

只有在我们需要把一个字符值转换为一个较大的整数时,这个问题才变得重 要起来。而在其他情况下,结果都是已定义的:多余的位将被简单地“丢弃”。编 译器在转换char类型到int类型时,需要做出选择:应该将字符作为有符号数还

是应该无符号数处理?如果是前一种情况,编译器在将char类型的数扩展到int 类型时,应该同时复制符号位;而如果是后一种情况,编译器只需在多余的位上 直接填充0即可。

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

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

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

正确的方式是使用语句(unsigned char) c,因为一个unsigned char类型的字符 在转换为无符号整数时无需首先转换为int型整数,而是直接进行转换。

7.5 移位运算符

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

  1. 在向右移位时,空出的位是由0填充,还是由符号位的副本填充?

  2. 移位计数(即移位操作的位数)允许的取值范围是什么?

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

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

举例来说,如果一个int型整数是32位,n是一个int型整数,那么n«31 和n«0这样写是合法的,而n«32和n«-l这样写是非法的。

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

mid =(low + high) >> 1

与下式

mid = (low + high) / 2;

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

7.6 内存位置0

null指针并不指向任何对象。因此,除非是用于赋值或比较运算,出于其他 任何目的使用null指针都是非法的。例如,如果p或q是一个nMl指针,那么 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"r *p);
}

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

图解编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值