宏的使用

一、 宏的概念

宏,即 macro 的翻译。该词汇使用范围目前比较广泛,例如,微软把 Office 系列产品中的 VBA 脚本代码也使用宏来指称;一些其他软件也会把某些执行动作的序列录制为脚本,称之为宏(例如常用的文本编辑器 UltraEdit);在宏汇编语言(如 MASM 或者 TASM)中,宏的使用形式上更接近于子函数。这些都不是我们今日讨论的宏。我们要讨论的,是像 C/C++ 这样的语言中所使用的宏,而且主要以 C/C++ 语言为讨论的覆盖范围。之所以这么说,是因为有其他的语言(如 Delphi,或者 C#)也都实现了相同的机制,而且也称为宏,但不在我们的讨论范围内。

宏是一项历史久远的技术,C 语言一般认为是在 1973 出现第一个比较成熟的版本,而宏的技术和 C 语言一样古老(面试的时候经常会遇到一个要和宏对比优缺点的东西 —— typedef —— 直到 1976 年才出现)。我没有查到资料,所以不清楚 C 语言的前身 B 语言中是否也有与宏等价的事物。

准确地说,宏的技术包含在另外一个指称范围更广的技术之中,即编译预处理技术;只是由于宏是其最重要的部分,因此有时这两个术语也互换使用。在本文中,也没有作很严格的区分。

二、 宏的辉煌

宏在很长一段时间内都仅仅是作为常规编程的一个补充手段,而且没有引起大多数人的注意,直到微软公司的 MFC 类库的出现。这个类库的构造过程中,由于几方面的因素(1、对 C++ 语言的底层机制需要深入使用,如 CCmdTarget 类对 OLE 的支持实现;2、对 C++ 语言当时的特性混乱需要统筹考虑兼容性,如对异常的支持 TRY/CATCH/THROW;3、对运行平台的资源占用和运行效率的考虑,如消息映射表的实现),使得实现代码中使用了大量的精心构造的宏。在一段时间内,分析这些宏成了很多程序员乃至技术专家的兴趣所在。

三、 宏的误解

有一些程序员,受所谓“纯面向对象”的影响太深,对宏的应用反感很强烈,这是不应该的。从实用的角度看,有很多场合是无法使用其他的手段来取代宏的。如果要做跨平台的库或者应用,宏就更是无法避免。迄今为止我还没有看到任何跨平台的 C/C++ 代码中不使用宏的。事实上,连 C# 这样的新式语言中,也还是加入了对宏(实际上是预编译技术)的支持。

四、 宏的本质

宏的本质就是具有一定规则的文本替换。具体实例详见后文。

4.1. 惯例以及好的习惯

4.1.1. 名字一般全部大写
4.1.2. 后面不使用 ;
4.1.3. # 位于行首,不参与缩进;如果希望也有缩进,从 # 之后的内容开始
4.1.4. 被包含文件(通常是头文件)的末尾单独有一个空行

4.2. 特性

4.2.1. 可嵌套

在一个宏定义中,引用之前已经定义过的另外一个宏是可以的。例如:

4.2.2. 可取消

如果不想让某个宏继续存在,则可以使用 #undef 指令将之取消。

4.3. 注意事项

4.3.1. 宏名和参数的括号间不能有空格
4.3.2. 注意对参数适当地使用括号进行保护(否则可能会导致运算优先级混乱)
4.3.3. #if#ifdef 是不同的

4.4. 在定义数据类型时,和 typedef 相比较的一些优缺点

4.4.1. 易判断。因为可以通过 #ifdef 或者 #ifndef 来判断此宏是否已经定义过了。
4.4.2. 可修改。即使定义过了,也可以使用 #undef 将之前的定义取消,自己重新定义。
4.4.3. 易出错。这是由于宏的本质决定的,例如:

则如下变量声明会有问题:

将会被展开为:

p2 的数据类型为 char

而用:

则不会导致此问题。

不过也有 typedef 束手无策的时候,例如遇到 void,就不能使用

的形式,只好乖乖地用宏。

注意:此处的示例在某些新的编译器上是可以编译过去的,如 VS2008。 VS2005 我没有做验证,但是应该也可以。原因如下。

假如有以下代码段:

在较老的编译器上无法通过,因为我们原来的概念中,函数 foo()void 返回值是指“没有类型”,因此无法通过 return 返回;但上述代码在 VS2005 中是可以编译通过的。很显然,编译器把 void 作为一种特殊的类型进行了处理,同理, typedef void VOID; 才可以是合法的。不知道是不是最新的 C++ 标准作了修订。

五、 宏的一般用法

5.1. 不带参数

不带参数的宏,有时也被称为“对象模样的宏”。(具体示例参见后文。)

5.2. 带参数

带参数的宏,有时也被称为“函数模样的宏”。(具体示例参见后文。)

5.3. 常用实例

5.3.1. 字面值替换(特定重复使用的值,数组维度的表示等)

这是最常用的用法。例如:

这样做的最大好处是维护方便,例如将来为了提高精度,可以将数值改为 3.14159,重新编译即可。如果不使用宏,则势必需要将所有的源文件中出现该值的地方都修改一遍。

5.3.2. 头文件防卫

为了防卫重复声明和 / 或重复定义,通常会在头文件的首尾添加防卫(guard)宏,如:

这种方法在某些较新的编译器中有了替代方案,如对于微软公司的编译器,现在则提倡使用 #pragma once 预编译指令来完成相同的工作,据说会做得更好。

但宏的方式则兼容性最好。

顺便说一下这个宏的名字的生成规则。一般说来,其中的字母是文件名的大写, h 之前的扩展名分隔符句号被替换为下划线。如文件 file.h,则此宏的名字就是 FILE_H。不过,基于其他的因素,通常还有别的变体。例如,为了美观,有很多人会在前后增加两个(也有人用一个)下划线,形如 __FILE_H__ 或者 _FILE_H_;但是,这样又在一定程度上违背了起始下划线通常保留给平台或者编译器内部使用的惯例,所以又有人提出了修正方案,即前面不加,只加在后面,像这样: FILE_H__ 或者 FILE_H_。我个人一直倾向于前后都加两个下划线的方法,看起来美观。就拿 FILE_H__FILE_H__ 来看,前者两头悬崖峭壁,有危楼的感觉,后者则呈等腰梯形,风雨不动安如山,给人以稳定感。

5.3.3. 条件编译

条件编译也是最常用的预处理技术之一。理论上讲,条件编译可以不依赖于宏,不过事实是,如果离开宏定义,条件编译也就没有了实际作用,唯一的作用可能就剩下用 #if 0 的形式来注释代码了。

条件编译的基本形式如下:

上面的 #elif 节和 #else 节是可选的。标识符通常都是宏,包括预定义的以及自定义的。

顺便说一下, #elif#else if 的简写,但不是 #else ifdef 的简写(请参看上文, #if#ifdef 是不同的); #ifdef#if defined() 的简写,如果需要同时判断两个宏是否被定义,就不能使用 #ifdef 了(尽管可以嵌套解决,不过看起来笨拙),就可以用 #if defined(标识符 1) && defined(标志符 2) 的形式。显然,预编译中的逻辑操作符与语言本身保持了一致。

条件编译大量用在同一份代码需要在不同平台和/或不同编译器(甚至只是同一编译器的不同版本)下编译的情况中。有时也用于实现对某一功能的不同实现的编译期选择上。可以用在注释代码上,前文已经提及,使用 #if 0 预编译指令,好处是可以嵌套。

实际上,上一条的“头文件防卫”其实是条件编译的具体应用之一。

5.3.4. 获取元素个数

和上面的积累比起来,这个只是一个单个的应用示例,不过却相当常见。

同义异名的宏通常还有:ArrayOf、ARRAY_OF、ARRAY_SIZE、DIM_OF 等等。

5.3.5. 标准宏定义

下面列出的,是几乎所有的编译器都支持的预定义宏的名称,其各自的含义见同行上的说明。

1、 __FILE__,被替换为当前正在编译的文件名
2、 __LINE__,被替换为当前正在编译的代码行号
3、 __DATE__,被替换为当前日期
4、 __TIME__,被替换为当前时间
5、 __STDC__,指示编译器是否遵循标准 C
6、 __FUNCTION__,被替换为当前代码所处的函数的名字

注意:后两个是比较新的编译器才支持。

六、 宏的高级用法

6.1. 展开规则

在介绍其他的高级使用方式之前,先大致介绍一下宏的几条特殊展开规则。

6.1.1. 如果宏的名字出现在了其定义当中,则这种宏被称作自引用宏(Self-Referential Macros)。自引用宏在展开的过程中,只要发现自己的名字出现(无论是直接还是间接),则立即停止展开。

如:

则展开后, foo 会被替换为 (4 + foo),而不会被无限展开为 (4 + (4 + (4 + )))

再如:

则展开后, foo 会被替换为 (4 + (2 * foo))bar 会被替换为 (2 * (4 + bar))

提示:如果想看到宏展开后的源文件实况,在 Visual C++ 编译器下,在命令行上追加 /P(要保证是大写)参数即可生成对应的 .i 文件。

6.1.2. 如果一个像函数的宏在使用时没有出现括号,那么预处理器就不作展开。

如以下代码:

上述代码中, S1 会被展开,而 S2 不会。

6.1.3. 宏的参数允许在使用时留空,尽管通常这会导致生成的代码通不过编译。

假如有如下定义:

那么,则有如下展开结果:

6.1.4. 宏的参数会被预先扫描。

注意:本小节的示例中使用了 ###,关于这两个特殊符号的用法,请参阅下一节。

当一个宏参数被放进宏的定义体时,这个宏参数会首先被全部展开(有例外,见下文)。当展开后的宏参数被放进宏的定义体时,预处理器对新展开的宏定义体进行第二次扫描,并继续展开。例如:

因为 _PARAM(1) 是作为 PARAM 的宏参数,所以先将 _PARAM(1) 展开为 _1,然后再将 _1 放进 PARAM

例外情况是,如果 PARAM 宏的定义里对自己的参数使用了 ###,那么参数就不会再被展开,如:

使用这个规则可以打印出宏被展开后的样子,便于分析代码:

TO_STRING 首先会将 x 全部展开(如果 x 也是一个宏的话),然后再传给 _TO_STRING 转换为字符串。

如果在 Visual Studio 中,现在你可以这样把宏的展开后的文本输出到调试控制台:

6.2. # 和 ##

# 和 ## 出现在宏定义中时,有特定的含义。

单 # 号,如果后面跟有宏的某个参数的名字,则将会将之转换为字符串形式的常量。稍标准些的描述:# 的功能是将其后面的宏参数进行字符串化(stringification)操作。

双 # 号,即 ##,则是用来把后面的参数按字面串接起来,以形成新的标识符。稍标准些的描述:## 被称为连接符(concatenator),用来将两个标识符(token)连接为一个标识符。注意这里连接的对象是标识符就行,而不一定是宏的变量。

这种抽象的描述很难让人搞明白,其实一个简单的例子就可以说明。

假设我们要实现一个文本协议的网络服务器(就像 FTP 服务器一样),需要根据客户端发出的文本命令来执行相应的动作(出于清晰目的,暂且不考虑处理命令的函数有不同参数的情况)。

我们可以这样写:

如果使用了串接符 # 和 ## ,则会减少输入量,如下:

6.3. 表达式参数

宏的参数还可以是比较复杂些的表达式。如下例:

FUNC_DECL 的作用是声明任意函数, ret_t 是函数的返回值类型, name 是函数的名字, para_list 是函数的参数列表。

在实际的应用中,我们声明了一个计算整数相加的函数 IntegerAdd,请注意,参数列表(尽管不止一个参数)被作为一个整体被处理了。虽然这个例子的实际意义并不大,但在某些特殊情况下,这种方式还是有用的。

6.4. 可变参数

注意 :此形式仅从 C99 语言标准才开始支持,术语为 Variadic Macro。

示例代码:

变参宏有两种写法:

在第二种写法中,我们为 可变参数指定了名字,如果没有指定,则可以使用默认的 __VA_ARGS__

同 C 语言的函数调用一样,宏的变参必须作为参数表的最后一项出现。当上面的宏中我们只能提供第一个参数 fmt 时,C 标准要求我们必须写成:

的形式。这时的替换过程为如下,

被替换为:

这就引入了一个语法错误,不能正常编译。这个问题一般有两个解决方法。

首先, GNU CPP 提供的解决方法允许上面的宏调用写成:

而上述的宏的使用仍然会被替换变成:

很明显,这里还会产生编译错误(据称非本例的某些情况下不会产生编译错误)。

除了这种方式外,C99 和 GNU CPP 都支持下面的宏定义方式:

这时,## 这个连接符号充当的作用就是当 __VAR_ARGS__ 为空的时候,消除前面的那个逗号。那么此时的替换过程如下:

被转化为:

这样将不会产生编译错误。

6.5. #include 的本质

实际上,在现有的任何流行观点中,都会把 #include 预编译指令和宏分别对待(有一种方法是,把预编译技术分为划分宏定义、文件包含、条件编译三部分;不过,这种方法漏掉了相当重要但各编译器的实现又相当差异化的一个部分: #pragma)。不过,从实质上来说,我个人更倾向于它是宏的一种特殊形式。道理很简单, #include 也是实现文本替换,替换的目标是 #include 所在行,替换的来源是所指定的包含文件中的内容。

基于此,我们应该可以想到。 #include 可以包含任何文本。假设有文本文件 array.dat ,其内容为:

我们就可以在另一个源文件中这样写代码:

另:需要注意文件包含时的 ”” 和 <> 的区别。

6.6. 高级用法实例

6.6.1. 判断目标平台或者编译器以及版本

1、 __cplusplus,此宏可以用来判断当前编译器是否是以 C++ 语言来对待正在编译的代码
2、 _WIN32,这个宏可以用来判断是不是要编译的目标平台是否是 Windows 平台
3、 _WIN32_WCE/ WIN32_PLATFORM_PSPC/ WIN32_PLATFORM_WFSP,这三个宏可以分别用来判断 Windows CE 平台、 Windows Mobile 的 Pocket PC 平台和 Windows Mobile 的 Smartphone 平台
4、 __SYMBIAN32__,这个宏可以用来判断是不是要编译的目标平台是否是 Symbian OS 平台
5、 _MSC_VER,此宏可以用来判断当前的编译器是否是 Visual C++
6、 __GNUC__,此宏可以用来判断当前的编译器是否是 GCC
7、 __CC_ARM,此宏可以用来判断当前的编译器是否是 RVCT
8、 __BORLANDC__,此宏可以用来判断当前的编译器是否是 Borland C++
9、 __MWERKS__,此宏可以用来判断当前的编译器是否是 CodeWarrior

6.6.2. 使宏无效

在实际的编程过程中,我们有时还会有让某个宏(通常是“函数模样的宏”,下文不再特别指出)不发生作用的需要。当然,最彻底的办法是把对该宏的引用全部删除(或者注释掉)。但这种方法的代价很高,如果是一个使用比较普遍的宏的话,可能会需要修改非常多的源代码。这还不是要命的,更要命的是,第二天就发现需要恢复回来。在这种情形下,我们就需要能够构造出一种宏定义,可以保证在宏的引用仍然存在的情况下,宏本身不发生任何作用。

我们使用输出调试信息的 TRACE 宏来作为讨论的对象。根据我们以往的知识,我们对于 TRACE 应具有如下共识:

1. 可以格式化输出。也就是说,应该具有可变参数
2. 仅在调试模式的编译中生效

假定我们的输出信息是使用 printf 的话,我们最初可以这样写:

这样写貌似正确,但是却有隐患。原因在于 TRACE 的参数中,极有可能存在具有操作性质的代码。如果只是将 TRACE 定义为空白,那么参数列表中的运算/函数调用将会被保留,这往往不是我们想要的结果。于是,我们可以做以下修订,将无参数形式改为有参数形式:

这个形式虽然解决了上面的问题,却又带来了新的问题,就是参数只能有一个(此处暂不考虑最新的支持可变参数的语法)。

这是个相当棘手的问题,也令不少专家为难,目前最好的解决方案是:

虽然看起来古怪了些,但确实管用,跨编译器的兼容性也非常强。究竟 sizeof 是怎么解决上述几个问题的,留作大家的思考。

另外,在微软的编译器下,一度存在一个让宏无效的标准写法:

它除了有参数限制之外(不过 Visual C++ 编译器在宏的参数个数不符合定义时,默认的告警级别仅发出警告,不作为错误),还有一个很要命的缺点,就是 GCC 不支持这种写法。微软最新的编译器中,为了解决此问题,竟然引入了一个新的保留字 __noop,其作用是:“The __noop intrinsic specifies that a function should be ignored and the argument list be parsed but no code be generated for the arguments. It is intended for use in global debug functions that take a variable number of arguments.”(引自 MSDN)。当然,微软还是很坦诚地表明了这个属于“Microsoft Specific”。

6.6.3. 解决分号问题(do {} while 形式)

通常情况下,为了使函数模样的宏(参见 5.2 节)在表面上看起来像一个通常的 C 语言函数调用一样,我们会在宏的后面加上一个分号。比如下面的宏:

但是如果是下面的定义情况:

而又进行了如下的使用:

这样就会由于多出的那个分号而产生编译错误。为了避免出现编译错误,同时又能兼容这种写法,我们可以把宏定义为如下形式:

这样就不会有分号引起的问题。

6.4. 突破类的访问作用域

这是个很简单的应用实例,却极具杀伤力:

如果你把这几行代码放在源文件的最开头,那么,对于 Symbian 系统来说,各个基类的私有成员将完全向你开放,不会再有任何障碍。

至于最后一个宏为什么是必须的,也留作思考。

6.5. 取得结构成员的偏移(单个应用)

同义异名的宏通常还有: FIELD_OFFSETMEMBER_OFFSET 等。

6.6. 交换整形数值(单个应用)

这种应用大概可以属于奇技淫巧的范畴了。

七、 宏的缺点

7.1. 语法检查不足

由于宏的实质是文本替换,先于编译器编译源文件之前执行(这也是为什么称之为预编译的原因),所以我们不能寄望于它可以对宏定义代码的合法性/合理性进行必要的检查。

在后续的 C++ 语言以及编译器技术发展过程中,内联函数可以部分解决此问题。

7.2. 可能多次执行

如果宏的参数是一个函数,或者一个表达式,那么就有可能被调用/运算多次从而达到不一致的结果,甚至会发生更严重的错误。

比如:

这时 foo() 函数就被调用了两次。为了解决这个潜在的问题,我们应当这样写 min(x, y) 这个宏:

({...}) 的作用是将内部的几条语句中最后一条的值返回,它也允许在内部声明变量(因为它通过大括号组成了一个局部作用域)。

注意:直到目前为止,微软的编译器(包括 VS2008)都还不能支持 ({...}) 这样的语句形式。

这是函数调用作为参数的情况。表达式作为参数的情况就更常见,例如我们这样调用:

7.3. 增加代码量

由于宏的本质是文本替换,而我们的使用通常又是用少量文本来表示更多文本的方式,从而在事实上形成代码量的非直观性的增加,有可能会对最终生成的二进制代码的大小产生影响,导致一定程度的膨胀。

八、 附录

不使用临时变量完成两个整形变量的值交换操作。这也是一个会被经常面试到的问题。通常情况下,我们都会借助于一个临时变量来完成这一操作。解法如下:

这种算法易于理解,特别适合帮助初学者了解计算机程序的特点,是赋值语句的经典应用。在实际软件开发当中,此算法简单明了,不会产生歧义,便于程序员之间的交流,一般情况下碰到交换变量值的问题,都应采用此算法,我们可以称之为标准算法。

但上题的要求恰恰是不能使用临时变量,于是有人用算术运算达到了相同的目的:

通过以上运算, ab 中的值就进行了交换。表面上看起来很简单,但是不容易想到,尤其是在习惯标准算法之后。

它的原理是:把 ab 看做数轴上的点,围绕两点间的距离来进行计算。

具体过程:

1、第一句 a=b-a 求出 ab 两点的距离,并且将其保存在 a 中;
2、第二句 b=b-a 求出 a 到原点的距离( b 到原点的距离与 ab 两点距离之差),并且将其保存在 b 中;
3、第三句 a=b+a 求出 b 到原点的距离( a 到原点距离与 ab 两点距离之和),并且将其保存在 a 中。

交换完成。此算法与标准算法相比,多了三个计算的过程,但是没有借助临时变量。

而我们在前文给出的算法,即如下定义:

是基于更为精巧的一种解法。此算法能够实现是由异或运算的特点决定的,通过异或运算能够使数据中的某些位翻转,其他位不变。这就意味着任意一个数与任意一个给定的值连续异或两次,值不变。

即: a ^ b ^ b = a。将 a = a ^ b 代入 b = a ^ b 则得 b = a ^ b ^ b = a;同理,可以得到 a = b ^ a ^ a = b。轻松完成交换。

九、 参考资料

[1] http://developer.apple.com/documentation/DeveloperTools/gcc-4.0.1/cpp/Macros.html
[2] http://predef.sourceforge.net/

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注