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

7.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>=0且r<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>=0且b>0时,保证Irlclb似及 !>=0。后面部分的保证与性质2或者性质3比较起来,限制性要弱得多。

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

h = n % HASHSIZE;

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

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

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

7.8 随机数的大小

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

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

当时有两组人员同时分别在VAX-11计算机上实现C语言,他们做出的选择 互不相同。一组人员在加州大学伯克利分校,他们认为rand函数的返回值范围应 该包括该机器上所有可能的非负整数取值,因此他们设计版本的rand函数返回一 个介于0到231-1的整数。

另一组人员在AT&T,他们认为如果VAX-11计算机上的rand函数返回值范 围与PDP-11计算机上的一样,即介于0到215-1之间的整数,那么在PDP-11计 算机上所写的程序就能够较为容易移植到VAX-11计算机上。

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

7.9 大小写转换

库函数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软件开发部门的一个极具创新精神的人注意到,大多数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++)这样的表达式,可能造成不良后果。因此,他决定重写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.10 首先释放,然后重新分配

​ 大多数C语言实现都为使用者提供了 3个内存分配函数:malloc, realloc和 free。调用malloc(n)将返回一个指针,指向一块新分配的可以容纳n个字符的内 存,编程者可以使用这块内存。把malloc函数返回的指针作为参数传入给firee函 数,就释放了这块内存,这样就可以重新利用了。调用realloc函数时,需要把指 向一块己分配内存的区域指针以及这块内存新的大小作为参数传入,就可以调整 (扩大或缩小)这块内存区域为新的大小,这个过程中有可能涉及到内存的拷贝。

凡事皆有例外。UNIX系统参考手册第7版中描述的realloc函数的行为,与 上面所讲就略有不同;

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

如果ptr指向的是一块最近一次调用malloc, realloc或calloc分配的内存,即 使这块内存已被释放,realloc函数仍然可以工作。因此,可以通过调节free, malloc 和realloc的调用顺序,充分利用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程序到一个新的实现中时,必须注意到这一点。

7.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的1。进制表示的末位数字转换为字符形式时所用的方法。通过n%10来得 到末位数字的值,这一点没有什么问题;但是给它加上。来得到对应的字符表示 却不一定合适。程序中的加法操作实际上假定了在机器的字符集中数字是顺序排 列、没有间隔的,这样才有。+ 5的值与’5’的值相同,依次类推。这种假定, 对ASCU字符集和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设置为-启这个赋值操作有可能发生溢出,因为基于2的补码的计算机一般允 许表示的负数取值范围要大于正数的取值范围。具体来说,就是如果一个long型 整数有k位以及一个符号位,该long型整数能够表示-2*却不能表示2七

要解决这个问题,有好几种办法。最明显的一种办法是把-n赋给一个unsigned long型的变量,然后对这个变量进行操作。但是,我们不能对-n求值,因为这样 做将引起溢出!

无论是对基于1的补码还是基于2的补码(l,s complement and 2’s complement)的机器,改变一个正整数的符号都可以确保不会发生溢出。惟一的 麻烦来自于当改变一个负数的符号的时候。因此,如果我们能够保证不将n转换 为对应的正数,那么我们就能避免这一问题。

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

void printneg (long n, void (*p)())
{
		if (nv=-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函数:

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

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

可移植性强的软件比较不容易出错。本例中的代码改动看上去是提高软件的 可移植性,实际上大多数工作是确保边界条件的正确,即保证当piintnum函数的 参数是可能取到的最小负数时,它仍然能够正常工作。作者本人就见过一些商业 软件产品,正是因为对这种情况处理不好而出了大错。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

奈斯编程

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

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

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

打赏作者

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

抵扣说明:

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

余额充值