《C缺陷和陷阱》-笔记(8)

目录

预处理器

一、不能忽视宏定义中的空格

二、宏并不是函数

宏putc

二、宏并不是语句

三、宏并不是类型定义

​​​​​​


预处理器


在严格意义上的编译过程开始之前,C语言预处理器首先对程序代码作了必要的转换处理。因此,我们运行的程序实际上并不是我们所写的程序。预处理器使得编程者可以简化某些工作,它的重要性可以由两个主要的原因说明。


第一个原因是,当我们遇到这样的情况,需要将某个特定数量(例如,某个数据表的大小)在程序中出现的所有实例统统加以修改。我们希望能够通过在程序中只改动一处数值,然后重新编译就可以实现。预处理器要做到这一点可以说是轻而易举,即使这个数值在程序中的很多地方出现。我们只需要将这个数值定义为一个显式常量(manifestconstant),然后在程序中需要的地方使用这个常量即可。而且,预处理器还能够很容易地把所有常量定义都集中在一起,这样要找到这些常量也非常容易。

第二个原因是,大多数C语言实现在函数调用时都会带来重大的系统开销。因此,我们也许希望有这样一种程序块,它看上去像一个函数,但却没有函数调用的开销。举例来说,getchar和putchar经常被实现为宏,以避免在每次执行输入或者输出一个字符这样简单的操作时,都要调用相应的函数而造成系统效率的下降。


虽然宏非常有用,但如果程序员没有认识到宏只是对程序的文本起作用,那宏提供了一种对组成C程序的字符进行变换的方式,而并不作用于程序中的对象。因而,宏既可以使一段看上去完全不合语法的代码成为一个有效的C程序,也能使一段看上去正确的代码出现错误。


一、不能忽视宏定义中的空格


一个函数如果不带参数,在调用时只需在函数名后加上一对括号即可加以调用了。而一个宏如果不带参数,则只需要使用宏名即可,括号无关紧要。只要宏已经定义过了,就不会带来什么问题:预处理器从宏定义中就可以知道宏调用时是否需要参数。


与宏调用相比,宏定义显得有些“暗藏机关”。例如,下面的宏定义中f是否带了一个参数呢?
#define f(x) ((x)-1)答案只可能有两种:f(x)或者代表
((x)-1)
或者代表
(x) ((x)-1)
在上述宏定义中,第二个答案是正确的,因为在f和后面的(x)之间多了个空格!所以,如果希望定义f(x)为((x)-1),必须像下面这样写:
#define f(x) ((x)-1)

这一规则不适用于宏调用,而只对宏定义适用。因此,在上面完成宏定义后f(3)与f(3)求值后都等于 2。


二、宏并不是函数

因为宏从表面上看其行为与函数非常相似,程序员有时会禁不住把两者视为完全等同。因此,我们常常可以看到类似下面的写法:

#define abs(x) (((x)>=0)?(x}:-(x))或者,
#define max(a,b) ((a)>(b)?(a):(b))

请注意宏定义中出现的所有这些括号,它们的作用是预防引起与优先级有关的问题。例如,假设宏 abs被定义成了这个样子
#define abs(x) x>?x:-x

让我们来看 abs(a-b)求值后会得到怎样的结果。表达式
abs (a-b)
会被展开为
a-b>0?a-b:-a-b

这里的子表达式-a-b相当于(-a)-b,而不是我们期望的-(a-b),因此上式无疑会得到一个错误的结果。因此,我们最好在宏定义中把每个参数都用括号括起来。同样,整个结果表达式也应该用括号括起来,以防止当宏用于一个更大一些的表达式中可能出现的问题。如果不这样,
abs(a)+1
展开后的结果为:
a>0?a:-a+1

这个表达式很显然是错误的,我们期望得到的是-,而不是-a+1!abs的正确定义应该是这样的:
#define abs(x)(((x)>=0)?(x):-(x))这时,
abs (a-b)
才会被正确地展开为:
((a-b)>0?(a-b):-(a-b))

abs(a)+1

也会被正确地展开为:
((a)>0?(a):-(a))-1

即使宏定义中的各个参数与整个结果表达式都被括号括起来,也仍然还可能有其他问题存在,比如说,一个操作数如果在两处被用到,就会被求值两次。

例如,在表达式max(a,b)中,如果a大于b,那么a将被求值两次:第一次是在a与b比较期间,第二次是在计算max应该得到的结果值时。
这种做法不但效率低下,而且可能是错误的:
biggest = x[0];
i = 1;
while (i < n)
biggest =max(biggest,x[i++]);

如果 max 是一个真正的函数,上面的代码可以正常工作;而如果 max 是一个宏,那么就不能正常工作。要看清楚这一点,我们首先初始化数组x中的一些元素:
x[0]= 2;
x[1]= 3;
x[2]= 1;

然后考察在循环的第一次迭代时会发生什么。上面代码中的赋值语句将被扩展为:

biggest =((biggest)>(x[i++])?(biggest):(x[i++]));
首先,变量biggest将与x[i++]}比较。因为i此时的值是1,x[1]的值是 3,而变量 biggest 此时的值是 x[0]即2,所以关系运算的结果为false(假)。

这里,因为 i++的副作用,在比较后i递增为2。
因为关系运算的结果为 false(假),所以x[i++]的值将被赋给变量 biggest。然而,经过 i+-+的递增运算后,i此时的值是2。所以,实际上赋给变量biggest的值是 x[2],即 1。这时,又因为i++的副作用,i的值成为 3。

解决这类问题的一个办法是,确保宏 max中的参数没有副作用:
biggest =x[0];
for(i = 1;i<n; i++)
biggest =max(biggest,x[i]):


另一个办法是让max作为函数而不是宏,或者直接编写比较两数取较大者的运算的代码:
biggest =xl0];for (i= l;i< n: i++)
if (x[i]> biggest)biggest =x[i]:

宏putc

下面是另外一个例子,其中因为混合了宏和递增运算的副作用,使代码显得岌岌可危。这个例子是宏putc的一个典型定义:
#define pute(x,p)
(--(p)->_cnt>=0?(*(p)->_ptr++=(x)):_flsbuf(x,p))

宏putc 的第一个参数是将要写入文件的字符,第二个参数是一个指针,指向一个用于描述文件的内部数据结构。请注意这里的第一个参数x,它极有可能是类似于*z++这样的表达式。尽管x在宏 putc 的定义中两个不同的地方出现了两次,但是因为这两次出现的地方是在运算符:的两侧,所以x只会被求值一次。
第二个参数p则恰恰相反,它代表将要写入字符的文件,总是会被求值两次。因为文件参数p一般不需要作递增递减之类有副作用的操作,所以这很少引起麻烦。不过,ANSIC标准中还是提出了警告:putc的第二个参数可能会被求值两次。某些C语言实现对宏 putc的定义也许不会像上面的定义那样小心翼翼,putc的第一个参数很可能被不止一次求值,这样实现是可能的。编程者在给putc一个可能有副作用的参数时,应该考虑一下正在使用的C语言实现是否足够周密。
再举一个例子,考虑许多C库文件中都有的toupper函数,该函数的作用是将所有的小写字母转换为相应的大写字母,而其他的字符则保持原状。如果我们假定所有的小写字母和所有的大写字母在机器字符集中都是连续排列的(在大小写字母之间可能有一个固定的间隔),那么我们可以这样实现toupper 函数:
toupperl(int c)
if(c>='a'&& c <= 'z')C+= "A’?'a';
return c;

在大多数C语言实现中,toupper函数在调用时造成的系统开销要大大多于函数体内的实际计算操作。因此,实现者很可能禁不住要把toupper实现为宏:
#define toupper(c)
((C)>='a’ && (c)<='z'? (c)+('A'?a'):(c))

在许多情况下,这样做确实比把touppcr实现为函数要快得多。然而,如果编程者试图这样使用
toupper(*p++)
则最后的结果会让所有人都大吃一惊!

使用宏的另一个危险是,宏展开可能产生非常庞大的表达式,占用的空间远远超过了编程者所期望的空间。例如,让我们再看宏max的定义:
#define max(a,b)((a)>(b)?(a):(b))

假定我们需要使用上面定义的宏 max,来找到a、b、c、d四个数的最大者最显而易见的写法是:
max(a,max(b,max(c,d)))
上面的式子展开后就是:
((a}>(((b)>(((c)>(d)?(c): (d))}?(b}:(((c)>{d)?(e):(d)))))?

(a):(((b)>(((e)>(d)?(c) :(d} }}?(b) :(({c)>{d)?(c):(d))))))

确实,这个式子太长了!如果我们调整一下,使上式中操作数左右平衡:
max(max{a,b),max(c,d))
现在这个式子展开后还是较长:

{{(a)>() ?(a):(b)))>({(c)>(d)?(c):(d)))?

{((a)> (b)?(a):(b))):(((c)>(ā)?(c):(d))))

其实,写成以下代码似乎更容易一些
biggest =a;
if (biggest <b)biggest =b;
if(biggest<c)biggest=c;
if(biggest <d)biggest =d;

二、宏并不是语句


编程者有时会试图定义宏的行为与语句类似,但这样做的实际困难往往令人吃惊!举例来说,考虑一下 assen 宏,它的参数是一个表达式,如果该表达式为0,就使程序终止执行,并给出一条适当的出错消息。把assent作为宏来处理,这样就使得我们可以在出错信息中包括有文件名和断言失败处的行号。也就是说,
assert(x>y);

在x大于y时什么也不做,其他情况下则会终止程序。
下面是我们定义 assert 宏的第一次尝试:
#define assert(e)if(!e)assert_error( _FILE__LINE_)

  因为考虑到宏 assert 的使用者会加上一个分号,所以在宏定义中并没有包括分号。-FIE_和LINE_是内建于C语言预处理器中的宏,它们会被扩展为所在文件的文件名和所处代码行的行号。
宏asser 的这个定义,即使用在一个再明白直接不过的情形中,也会有一些难于察觉的错误:
if(x>0 && Y > 0)
       assert(x>Y);
else
     assert(y >x);

上面的写法似乎很合理,但是它展开之后就是这个样子:
if (x > 0 && Y > 0)
if(!(x>y))essert_error("foo.c",37);
else
if(!(y >x))assert_error("foo.c”,39);

 

把上面的代码作适当的缩排处理,我们就能够看清它实际的流程结构与我们期望的结构有怎样的区别:
if (x >0 && y > 0)

if(!(x > Y))assert_error("foo.c",37);
else
if(!(y > x))
assert_error("foo.c",39);
读者也许会想到,在宏assert 的定义中用大括号把宏体整个给“括”起来,就能避免这样的问题产生:
#define assert(e)
(if(!e)assert_error(
_FILE__LINE_);)

然而,这样做又带来了一个新的问题。我们上面提到的例子展开后就成了:
if (x > 0 && Y > 0){ if(!(x >y))assert_error("foo.c",37););
else
{if(!(y >x))assert_error("foo.c",39);)


在 else 之前的分号是一个语法错误。要解决这个问题,一个办法是对 assert 的调用后面都不再跟一个分号,但这样的用法显得有些“怪异”:
y= distance(p,g):
assert(y>0)
x = sqrt(y);


宏 assent 的正确定义很不直观,编程者很难想到这个定义不是类似于一个语句,而是类似一个表达式
#define assert(e)
((void)((e)l!_assert_error(_FILE_,_LINE_)))

这个定义实际上利用了||运算符对两侧的操作数依次顺序求值的性质。

如果e为true(真),表达式:
(void)((e)ll_assert_error(_FILE_,_LINE_))

的值在没有求出其右侧表达式

_assert_error(_FILE_,_LINE_))的值的情况下就可以确定最终的结果为真。如果e为false(假),右侧表达式
_assert_error( _FILE_,_LINE_))的值必须求出,此时_asser_error将被调用,并打印出一条恰当的“断言失败”的出错消息。

三、宏并不是类型定义


宏的一个常见用途是,使多个不同变量的类型可在一个地方说明:
#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b,c;

这样,编程者只需在程序中改动一行代码,即可改变a、b、c的类型,而与b、c在程序中的什么地方声明无关。
宏定义的这种用法有一个优点--可移植性,得到了所有C编译器的支持但是,我们最好还是使用类型定义:
typedef struct foo FOOTYPE;
这个语句定义了FOOTYPE为一个新的类型,与stuctfoo完全等效


这两种命名类型的方式似乎都差不多,但是使用typedef的方式要更加通用一些。例如,考虑下面的代码:
#define Ti struet foo *
typedef struct foo *T2;

从上面两个定义来看,T1和T2从概念上完全符同,都是指向结构foo的指针。但是,当我们试图用它们来声明多个变量时,问题就来了:
T1 a,b;
T2 a,b;

第一个声明被扩展为:
struct foo *a,b;

这个语句中a被定义为一个指向结构的指针,而b却被定义为一个结构(而不是指针)。第二个声明则不同,它定义了a和b都是指向结构的指针,因为这里T2的行为完全与一个真实的类型相同。


评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值