13.5 宏定义与宏替换

宏定义是指把某特定的标识符(叫做宏名)定义为某些单词(Token)的序列组合(叫做宏体)。

宏替换的意思是在编译预处理阶段,把代码中出现的宏名替换为宏体,这基本上相当于文字编辑过程中的“查找”与“替换”,只不过宏替换是由预处理器在编译之前自动进行的。

宏定义有两种形式:类似对象的宏(Object-like Macro)和类似函数的宏(Function-like Macro)。这两种宏在本书前面章节中都曾经浅尝辄止,下面将更全面深入地介绍预处理中的宏。

13.5.1 类似对象的宏

类似对象的宏(Object-like Macro)定义命令用如下方式给出。

13.5 宏定义与宏替换 - 图1

其中的标识符被称为宏名(The Name Of The Macro),这条命令的含义是把宏定义预处理命令后出现的标识符替换表(也叫宏体,The Body Of The Macro)替换。效果类似于文字处理软件“WORD”里的“查找”、“替换”。

很显然,代码中的字符串字面量、字符常量、注释、#include的文件名及其他常量中的内容由于并不是标识符,所以在这些语言成分的内部不发生替换,被替换的仅仅是标识符。

这条预处理命令可以用于定义符号常量,但其用法不仅限于此。替换表可以也是其他单词序列。比如

13.5 宏定义与宏替换 - 图2

如果替换表过长,由于合并物理行是在预处理过程中最早进行,所以可以把这样的预处理命令在形式上写成多行,但在逻辑含义上,它依然是一行。例如

13.5 宏定义与宏替换 - 图3

习惯上,宏名一般使用大写字母,这样在阅读代码时可以清楚地区分开作为对象名称的标识符和作为宏名的标识符。

此外要注意应该避免与编译器事先已经定义的宏重名,编译器事先定义的宏一般都是以下划线开头并结尾的,如“TIME”。此外,以两个下划线开头的宏名也应该避免使用,道理同前。

13.5.2 把东西都塞到柜子里去

这样“交换甲乙的值”部分的代码就可以简单地用一个宏名来表示了。程序代码13-21就是对“0_Tic-Tac-Toe.c”中代码的一个很粗糙的改进。

程序代码13-21(0_Tic-Tac-Toe.c(片段))

13.5 宏定义与宏替换 - 图4

13.5 宏定义与宏替换 - 图5

13.5 宏定义与宏替换 - 图6

由于预处理命令在逻辑上必须写在一行,所以,当因为顾虑到代码可读性等原因需要在物理上把代码写成多行的时候,在每行后面都需要写一续行标志“\”,相当于领导发言稿中的“(接下页)”。预处理器会把后面一行接到当前这行后面。

原来的“//”形式的注释改为“/**/”形式的注释是不得已的,否则注释行后面的续行标志“\”也会被注释掉。

这种办法无疑如同把房间中的杂物胡乱地塞到柜子里一样,至少表面变得整洁了——main()比刚才要清爽得多了,也更容易阅读了,凌乱的东西被转移到了另外一个地方。

这段代码的另一个瑕疵是,“交换甲乙的值”(JH_JYDZ)怪怪地而且很突兀地出现在代码中,后面也没有分号,显得非常不伦不类。尽管可以把这个问题留到稍后一些再解决,但是无论如何首先要知道这里有一处毛病。

“0_Tic-Tac-Toe.c”中代码的另一个毛病是它硕大的头部让人难以容忍,头重脚轻,看着就仿佛要跌倒似的,这绝对影响阅读main()里面代码的总体思路。所以在工程中再建立一个“0_Tic-Tac-Toe.h”文件,将main()之前的部分移动到这个文件中,然后在main()之前写上“#include "0_Tic-Tac-Toe.h"”。这有点像把摆在客厅的杂物箱藏到另一个房间里。

“交换甲乙的值”这个宏显然不具备通用性和复用性。如果代码中另有一处交换另外两个棋手变量值的代码,“交换甲乙的值”这个宏显然是不适用的。为了解决这个问题,使得宏更具有通用性和可复用性,下面学习使用类似函数的宏。

13.5.3 类似函数的宏

1.一般的写法

定义类似函数的宏时,宏名后必须紧跟“(”,之间不可以有空格,比如定义一求某个数m的平方的宏:

13.5 宏定义与宏替换 - 图7

“()”中的m被叫做宏的参数(后面也将把它叫做形参)。在代码中如果出现“PINGFANG(2)”,它将被展开为“2*2”。

然而必须说明的是,从实践的角度看,前面定义的宏是一个很拙劣的宏。理由是如果在代码中出现“PINGFANG(1+2)”,那么它将被展开为“1+21+2”,而不是“(l+2)(l+2)”。由于这个原因,所以通常在被展开替换的单词序列中,参数都用“()”括起来。

13.5 宏定义与宏替换 - 图8

这样就不会产生前面说的那种可能的误用了。此外,如果这个宏是用来计算某个表达式的值,严谨的程序员会另外加上一对“()”,防止产生某些意外的后果。

13.5 宏定义与宏替换 - 图9

这可以预防下面那样使用时产生的误会。

13.5 宏定义与宏替换 - 图10

对于“#define PINGFANG(m) ((m)(m))”来说,“36/PINGFANG(3)”得到的是“36/((3)(3))”,其值为“4”;而对于“#define PINGFANG(m) (m)(m)”来说,“36/PINGFANG(3)”得到的是“36/(3)(3)”,其值为“36”。

在调用宏的时候,有些运算符会产生不知所云的后果,比如:

13.5 宏定义与宏替换 - 图11

没人知道这表示什么含义,事实上这个宏展开之后得到的是“n++*n++”,然而这是一个行为没有定义的错误的表达式。

一般来说,调用宏不应该或者至少应该极其谨慎地使用具有副效应的表达式。比如:

13.5 宏定义与宏替换 - 图12

如果按照下面的方法调用就会出现严重的问题。

13.5 宏定义与宏替换 - 图13

原因就在于求参数的值时,副效应会导致getchar()被调用不止一次。

类似函数的宏可以有多个参数。如有多个参数则用“,”分隔。比如:

13.5 宏定义与宏替换 - 图14

写宏的目的只有一个,就是使代码更有效率、更简洁、更容易阅读、更不容易出错。从这个角度看,下面用来计算圆面积的宏虽然语法上没什么问题,但与使用宏的目的却是南辕北辙。

13.5 宏定义与宏替换 - 图15

至于:

13.5 宏定义与宏替换 - 图16

研究MAX(++a, ++b)的结果这样的行为,基本上属于自虐行为。写宏不是为了把问题搞复杂,而是为了写代码更简单;不是为了冒险走钢丝,而是希望代码更可靠。没必要追究那些复杂的没有实际意义的宏的意义。

有了前面的基础,可以轻易地写出代码中用于交换“甲”、“乙”两个“旗手”类型变量值的宏定义。

程序代码13-22(0_Tic-Tac-Toe.h(片段))

13.5 宏定义与宏替换 - 图17

“0_Tic-Tac-Toe.c”文件现在变为:

程序代码13-23(0_Tic-Tac-Toe.c)

13.5 宏定义与宏替换 - 图18

由于“交换(甲,乙)”的宏体是一个用“{}”括起来的“块”,所以调用这个宏时,就如同写函数调用语句一样在后面加上“;”,这是没什么妨碍的。然而仔细思考会发现,“交换(棋手甲,棋手乙);”展开之后等价于下面的结构。

13.5 宏定义与宏替换 - 图19

换句话说,这是两句话,后面的“;”是没什么用处的空语句;这个小小的瑕疵看起来似乎无关紧要,然而在下面的调用中显然会出问题。

13.5 宏定义与宏替换 - 图20

当然,你可以抬杠说,在调用的时候小心点,每次都按照“{交换值(棋手甲,棋手乙);}”的形式调用就可以了。诚然你说的并不错。然而在真正写代码的时候千头万绪,这个自己给自己定的规矩很容易忘记且不说,团队开发的时候也无法保证所有人都虔诚地对待这条规矩。写代码的一个原则是为后面减少麻烦而不是自找麻烦。

再退一步说,“{交换值(棋手甲,棋手乙);}”这种形式非常不美,看起来很丑。“宏”本来就被许多人视为很“卑鄙”(6),现在你又把它弄得很丑陋……我就无须再多说什么了吧?

2.漂亮的改进

不知道是谁想出来的,下面这种办法可以很优雅地解决前面提到的问题。

程序代码13-24(0_Tic-Tac-Toe.h(片段))

13.5 宏定义与宏替换 - 图21

这是一个特别奇妙的想法,形式上保证了调用“交换值(棋手甲,棋手乙);”刚好是一个语句,所采用的结构又恰好保证了“{}”块中的内容刚好被执行一次。现在不用对那种函数调用语句形式的宏调用“交换值(棋手甲,棋手乙);”再有什么后顾之忧了。

3.宏的利弊

类似函数的宏与函数很相似,尤其是在“调用”的时候。要弄清什么时候应该使用宏什么时候应该使用函数,必须首先清楚它们之间的区别。

使用函数时,如果写了函数原型,编译器会对实参进行类型检查。如果发现有类型或个数不符合的情况则会提出警告或报告错误,这可以帮助我们避免许多失误。这是使用函数的一个优点。然而宏只是进行简单的替换,无法发现参数数据类型方面的错误(参数的个数错误可以发现)。这种错误通常要到运行时才能发现。

但是函数在程序运行时需要一些额外的资源(内存和时间),而宏不需要这些,因为宏是在原地展开编译的。所以宏的速度通常比函数要快,这是使用宏最有力的理由。如果代码中多次出现某个宏,可以想象的是,编译之后的可执行文件的代码要更长些。因为函数的多次调用使用的是同一段代码,而宏不是。

宏很容易被误用,以至于不少人把宏形容为“卑鄙的、邪恶的”。从Java这个从C语言中衍化出的语言干脆取消了宏,以及C99增加了“inline”关键字来看,也许能够感觉到许多编程者对宏的一种态度。

如果希望保留函数可以检查数据类型的优点,又希望代码可以在本地编译以便提高速度,这时候可以定义“inline”函数。不过编译器不保证一定会在本地编译,“inline”只是对编译器的一个强烈呼吁,希望保留数据类型检查的同时提高程序速度,编译器会尽量进行速度优化编译,但能否办到是另一回事情。此外“inline”函数是C99才支持的新特性。

然而,宏无法检查参数类型有时候倒是个优点,比如求数组a的尺寸的时候,显然用:

13.5 宏定义与宏替换 - 图22

更为方便,因为这个表达式的值显然与数组或数组元素的类型无关,所以“SIZE(a)”对于各种数组都是一个正确的求数组尺寸的表达方法。如果使用函数呢?不难设想,可能需要为“int”类型的数组写一个这样的函数,还需要为“double”类型的数组再写一个这样的函数,需要为一维数组写一个,还需要为二维数组再写一个……显然这是不现实的。在C语言中,这种情况最好还是用宏完成,而不是函数。

不过前面的“交换(甲,乙)”这个宏只对“棋手”这种数据类型成立,能否写出针对所有可交换的数据类型的这种宏呢?也可以。这个问题不难解决,只要把数据类型也作为一个参数就可以了。

程序代码13-25(0_Tic-Tac-Toe.h(片段))

13.5 宏定义与宏替换 - 图23

在使用这个宏的地方只要改成“交换(棋手类型,棋手甲,棋手乙);”就可以了。这个宏对其他数据类型的可交换值的变量也成立。

这样修改之后,尽管依然是把“杂物藏到了柜子里”,但是这次藏得很规矩。

13.5.4 显示空的棋盘

现在main()已经变得非常干净整洁了,抽签完成之后就可以考虑如何解决“显示(空的)棋盘”这个问题了。现在首先需要一张空着的棋盘,这个棋盘是具象的棋盘。

按照前面一贯的思路,目前应该进行的工作的工作线路如下所示。

(1)在“0Tic-Tac-Toe.h”中增加预处理命令“#include "4具象棋盘.h"”。

(2)在工程中添加“4_具象棋盘.h”文件,在其中定义“具象棋盘”类型。

在考虑这个被输出的数据对象的定义时,实际上应该考虑到输出窗口的大小,显示的行数、列数以及各行的字符数目等因素(写个程序要考虑的因素真多啊)。这应该是在总体设计的时候就考虑到的问题。限于篇幅,这里只简单地假设输出窗口大小至少为25行80列个字符。棋盘不得超过这个范围,假设棋盘最大可占16行80列的输出窗口界面。

显然可以用“char[16][80]”这种数据类型来表示这样一个棋盘。这样“4_具象棋盘.h”中的内容应该如下所示。

程序代码13-26(4_具象棋盘.h)

13.5 宏定义与宏替换 - 图24

按照前面约定的工作流程,现在应该在main()中定义具象棋盘类型的变量(假如就叫“具象棋盘”),给出这个变量的初始值,写出“显示棋盘(具象棋盘)”这样的函数调用;在“4具象棋盘.h”中建立“显示棋盘();”的函数原型;在工程中添加“4具象棋盘行为.c”文件,根据main()对“显示棋盘(具象棋盘)”函数功能的要求在其中写出函数原型。

但是为了讲解C语言中的外部变量的用法,这里将不按照前面叙述的步骤继续,而打算用另外一种方法等效完成后面几步的工作。为此,在“0Tic-Tac-Toe.h”,中写出“显示棋盘()”的函数调用、在“4具象棋盘.h”,写出“显示棋盘()”函数原型之后,首先来介绍一下C语言中外部变量的知识。