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

第6章 预处理器

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

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

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

虽然宏非常有用,但如果程序员没有认识到宏只是对程序的文本起作用,那 么他们很容易对宏的作用感到迷惑。也就是说,宏提供了一种对组成C程序的字 符进行变换的方式,而并不作用于程序中的对象。因而,宏既可以使一段看上去 完全不合语法的代码成为一个有效的C程序,也能使一段看上去无害的代码成为 一个可怕的怪物。

6.1 不能忽视宏定义中的空格

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

与宏调用相比,宏定义显得有些“暗藏机关”。例如,下面的宏定义中f是否 带了一个参数昵?

\#define f (x) ((x)-1)

答案只可能有两种:f(x)或者代表

((x)-1)

或者代表

(X)((x)-1)

在上述宏定义中,第二个答案是正确的,因为在f和后面的(X)之间多了一 个空格!所以,如果希望定义f(x)为((x)-l),必须像下面这样写:

#define f(x) ((x)-1)

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

6.2 宏并不是函数

因为宏从表面上看其行为与函数非常相似,程序员有时会禁不住把两者视为

完全等同。因此,我们常常可以看到类似下面的写法:

#define abs(x) (((x))?(x):-(x))

或者,

#define max(a,b) ((a)>(b)?(a):(b))

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

#define abs(x) x>0?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,而不是-a+11 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;

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

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 = x[0]for (i=1; i < n; i++)
  if (x[i] > biggest)
			biggest = x[i];

下面是另外一个例子,其中因为混合了宏和递增运算的副作用,使代码显得 岌岌可危。这个例子是宏putc的一个典型定义:

#define putc(x,p) \
(--(p)->_cnt>=0?(*(p)->_ptr++=(x)):_flsbuf(x,p))

宏putc的第一个参数是将要写入文件的字符,第二个参数是一个指针,指向 一个用于描述文件的内部数据结构。请注意这里的第一个参数x,它极有可能是 类似于*z++这样的表达式。尽管x在宏putc的定义中两个不同的地方出现了两次,但是因为这两次出现的地方是在运算符:的两侧,所以x只会被求值一次。

第二个参数P则恰恰相反,它代表将要写入字符的文件,总是会被求值两次。 因为文件参数P一般不需要作递增递减之类有副作用的操作,所以这很少引起麻 烦,不过,ANSI C标准中还是提出了警告:putc的第二个参数可能会被求值两次。某些C语言实现对宏putc的定义也许不会像上面的定义那样小心翼翼,putc的第一个参数很可能被不止一次求值,这样实现是可能的。编程者在给putc一个可能有副作用的参数时,应该考虑一下正在使用的C语言实现是否足够周密。

再举一个例子,考虑许多C库文件中都有的toupper函数,该函数的作用是 将所有的小写字母转换为相应的大写字母,而其他的字符则保持原状。如果我们 假定所有的小写字母和所有的大写字母在机器字符集中都是连续排列的(在大小 写字母之间可能有一个固定的间隔),那么我们可以这样实现toupper函数:

toupper(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))

在许多情况下,这样做确实比把toupper实现为函数要快得多。然而,如果 编程者试图这样使用

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)?(c):(d)))))?
(a):(((b)>(((c)>(d)?(c):(d)))?(b):(((c)>(d)?(c):(d))))))

确实,这个式子太长了!如果我们调整一下,使上式中操作数左右平衡:

max (max(a, b),max(c,d))

现在这个式子展开后还是较长:

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

其实,写成以下代码似乎更容易一些:

biggest = a;
if (biggest < b)  biggest =  b;
if (biggest < c)  biggest =  c;
if (biggest < d)  biggest =  d;

6.3 宏并不是语句

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

assert(x>y);

在x大于y时什么也不做,其他情况下则会终止程序。

下面是我们定义assert宏的第一次尝试*

#define assert(e) if(!e) assert_error (_FILE_,_LINE_)

因为考虑到宏assert的使用者会加上一个分号,所以在宏定义中并没有包括 分号。_FILE_和_LINE_是内建于C语言预处理器中的宏,它们会披扩展为 所在文件的文件名和所处代码行的行号。

宏assert的这个定义,即使用在一个再明白直接不过的情形中,也会有一些 难于察觉的错误:

if (x > 0 && y > 0)
  assert(x > y);
else
assert(y > x);

上面的写法似乎很合理,但是它展开之后就是这个样子:

if (x > 0 && y > 0)
  if(!(x > y)) assert_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"r 39);}

在else之前的分号是一个语法错误。要解决这个问题,一个办法是对assert的调用后面都不再跟一个分号,但这样的用法显得有些“怪异”:

y = distance(p, q);
assert(y > 0)
x = sqrt(y);

宏assert的正确定义很不直观,编程者很难想到这个定义不是类似于一个语 句,而是类似一个表达式

#define assert(e) \

((void) ((e) || _assert_error (_FILE_, _LINE_)))

这个定义实际上利用了II运算符对两侧的操作数依次顺序求值的性质。如果e 为true (真),表达式:

(void) ( (e) ||_assert_error (_FILE_,_LINE_))

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

_assert_error (_FILE_,_LINE_))

的值的情况下就可以确定最终的结果为真。如果e为false (假),右侧表达式

_assert_error (_FILE_,_LINE_))

的值必须求出,此时_assert_error将被调用,并打印出一条恰当的"断言失败”的出错消息。

6.4 宏并不是类型定义

宏的一个常见用途是,使多个不同变量的类型可在一个地方说明:

#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b,c;

这样,编程者只需在程序中改动一行代码,即可改变a、b、c的类型,而与a、b、c在程序中的什么地方声明无关。

宏定义的这种用法有一个优点——可移植性,得到了所有C编译器的支持。 但是,我们最好还是使用类型定义:

typedef struct foo FOOTYPE;

这个语句定义了FOOTYPE为一个新的类型,与struct foo完全等效。

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

#define T1 struct foo *
typedef struct foo *T2;

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

T1 a, b;
T2 a, b;

第一个声明被扩展为:

struct foo * a, b; 

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

你可能感兴趣的:(C陷阱与缺陷-疑难问题理解)