专题十三、预处理器

本专题介绍 C 语言中预处理器的工作原理,并且给出一些会影响预处理指令的通用规则,然后介绍预处理器最主要的两种能力:宏定义和条件编译。

参考资料:《C 语言程序设计 · 现代方法 第 2 2 2 版》

1. 预处理器的工作原理

预处理器的行为是由预处理指令(由 # 字符开头的一些命令)控制的。我们已经遇见过两种预处理指令,即 #define#include

#define 指令定义了一个 —— 用来代表其他东西的一个名字,例如常量或常用的表达式。预处理器会通过将宏的名字和它的定义存储在一起来响应 #define 指令。当这个宏在后面的程序中使用到时,预处理器 “扩展” 宏,将宏替换为其定义值。

#include 指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分 “包含” 进来。例如,代码行

#include <stdio.h>

指示预处理器打开一个名字为 stdio.h 的文件,并将它的内容加到当前的程序中。stdio.h 包含了 C 语言标准输入 / 输出函数的原型。

下图说明了预处理器在编译过程中的作用。预处理器的输入是一个 C 语言程序,程序可能包含指令。预处理器会执行这些指令,并在处理过程中删除这些指令。预处理器的输出是另一个 C 程序:原程序编辑后的版本,不再包含指令。预处理器的输出被直接交给编译器,编译器检查程序是否有错误,并将程序翻译为目标代码(机器指令)。
在这里插入图片描述
为了展现预处理器的作用,我们将它应用于专题一中的程序 celsius.c。下面是原来的程序:

/* Converts a Fahrenheit temperature to Celsius */

#include <stdio.h>

#define FREEZING_PT 32.0f
#define SCALE_FACTOR (5.0f / 9.0f)

int main(void)
{
    float fahrenheit, celsius;
	
	printf("Enter Fahrenheit temperature: ");
	scanf("%f", &fahrenheit);

    celsius = (fahrenheit - FREEZING_PT) * SCALE_FACTOR;

    printf("Celsius equivalent: %.1f\n", celsius);

    return 0;
}

预处理结束后,程序是下面的样子:

空行
空行
从 stdio.h 中引入的行
空行
空行
空行
空行
int main(void)
{
    float fahrenheit, celsius;
	
	printf("Enter Fahrenheit temperature: ");
	scanf("%f", &fahrenheit);

    celsius = (fahrenheit - 32.0f) * (5.0f / 9.0f);

    printf("Celsius equivalent: %.1f\n", celsius);

    return 0;
}

预处理器通过引入 stdio.h 的内容来响应 #include 指令。预处理器也删除了 #define 指令,并且替换了该文件中稍后出现在任何位置上的 FREEZING_PTSCALE_FACTOR。注意预处理器并没有删除包含指令的行,而是简单地将它们替换为空。

预处理器不仅仅是执行了指令,还将每一处注释都替换为一个空格字符。有一些预处理器还会进一步删除不必要的空白字符,包括每一行开始用于缩进的空格符和制表符。

在 C 语言较早的时期,预处理器是一个单独的程序,它的输出提供给编译器。如今,预处理器通常和编译器集成在一起,而且其输出也不一定全是 C 代码,例如包含 <stdio.h> 之类的标准头使得我们可以在程序中使用相应头中的函数,而不需要把头的内容复制到程序的源代码中。大部分 C 编译器都提供了一种方法,使用户可以看到预处理器的输出。在指定某个特定的选项(GCC 编译器用的是 -E)时编译器会产生预处理器的输出。其他一些编译器会提供一个类似于集成的预处理器的独立程序。

预处理器仅知道少量 C 语言的规则。因此,它在执行指令时非常有可能产生非法的程序。经常是原始程序看起来没问题,但错误查找起来很难。对于较复杂的程序,检查预处理器的输出可能是找到这类错误的有效途径。

2. 预处理指令

大多数预处理指令都属于下面 3 3 3 种类型之一。

  • 宏定义#define 指令定义一个宏,#undef 指令删除一个宏定义。
  • 文件包含#include 指令导致一个指定文件的内容被包含到程序中。
  • 条件编译#if#ifdef#ifndef#elif#else#endif 指令可以根据预处理器可以测试的条件来确定是将一段文本块包含到程序中还是将其排除在程序之外。

剩下的 #error#line#pragma 指令是更特殊的指令,较少用到。

在进一步讨论之前,先来看几条适用于所有指令的规则。

  • 指令都以 # 开始# 符号不需要在一行的行首,只要它之前只有空白字符就行。在 # 后是指令名,接着是指令所需要的其他信息。
  • 在指令的符号之间可以插入任意数量的空格或水平制表符。例如,下面的指令是合法的:
    #		define		N		100
    
  • 指令总是在第一个换行符处结束,除非明确地指明要延续。如果想在下一行延续指令,我们必须在当前行的末尾使用 \ 字符。例如,下面的指令定义了一个宏来表示硬盘的容量,按字节计算:
    #define DISK_CAPACITY (SIDES *				\
    					   TRACKS_PER_SIDE *	\
    					   SECTORS_PER_TRACK *	\
    					   BYTES_PER_SECTOR)
    
  • 指令可以出现在程序中的任何地方。但我们通常将 #define#include 指令放在文件的开始,其他指令则放在后面,甚至可以放在函数定义的中间。
  • 注释可以与指令放在同一行。实际上,在宏定义的后面加一个注释来解释宏的含义是一种比较好的习惯:
    #define FREEZING_PT 32.0f		/* freezing point of water */
    

3. 宏定义

3.1 简单的宏

简单的宏(C 标准中称为对象式宏)的定义有如下格式:

#define 标识符 替换列表

替换列表是一系列的预处理记号。宏的替换列表可以包括标识符、关键字、数值常量、字符常量、字符串字面量、操作符和排列。当预处理器遇到一个宏定义时,会做一个标识符代表替换列表的记录。在文件后面的内容中,不管标识符在哪里出现,预处理器都会用替换列表代替它。

不要在宏定义中放置任何额外的符号,否则它们会被作为替换列表的一部分。一种常见的错误是在宏定义中使用 =

#define N = 100			/*** WRONG ***/
...
int a[N];				/* becomes int a[= 100]; */

在上面的例子中,我们错误地把 N 定义成了两个记号 =100

在宏定义的末尾使用分号结尾是另一个常见错误:

#define N 100;			/*** WRONG ***/
...
int a[N];				/* becomes int a[100;]; */

这里 N 被定义为 100; 两个记号。

编译器可以检测到宏定义中绝大多数由多余符号所导致的错误。但是,编译器只会将每一个使用这个宏的地方标为错误,而不会直接找到错误的根源 —— 宏定义本身,因为宏定义已经被预处理器删除了。

简单的宏主要用来定义那些被 Kernighan \text{Kernighan} Kernighan Ritchie \text{Ritchie} Ritchie 称为 “明示常量”( manifest constant \text{manifest constant} manifest constant)的东西。我们可以使用宏给数值、字符值和字符串值命名。

#define STE_LEN 80
#define TRUE 1
#define FALSE 0
#define PI 3.14159
#define CR '\r'
#define EOS '\0'
#define MEM_ERR "Error: not enough memory"

使用 #define 来为常量命名有许多显著的优点。

  • 程序会更易读。一个认真选择的名字可以帮助读者理解常量的意义。否则,程序将包含大量的 “魔法数”,很容易迷惑读者。
  • 程序会更易于修改。我们仅需要改变一个宏定义,就可以改变整个程序中出现的所有该常量的值。“硬编码的” 常量会更难于修改,特别是当它们以稍微不同的形式出现时。
  • 可以帮助避免前后不一致或键盘输入错误。假如数值常量 3.14159 在程序中大量出现,它可能会被意外地写成 3.14163.14195

虽然简单的宏常用于定义常量名,但是它们还有其他应用。

  • 可以对 C 语法做小的修改。我们可以通过定义宏的方式给 C 语言符号添加别名,从而改变 C 语言的语法。当然,改变 C 语言的语法通常不是个好主意,因为它会使程序很难被其他程序员理解。

  • 对类型重命名。虽然有些程序员会使用宏定义的方式来实现此目的,但类型定义仍然是定义新类型的最佳方法。

  • 控制条件编译。宏在控制条件编译中起重要的作用。例如,在程序中出现的下面这行宏定义可能表明需要将程序在 “调试模式” 下进行编译,并使用额外的语句输出调试信息:

    #define DEBUG
    

    顺便提一下,如上面的例子所示,宏定义中的替换列表为空是合法的。

当宏作为常量使用时,C 程序员习惯在名字中只使用大写字母。但是并没有任何将用于其他目的的宏大写的统一做法。由于宏(特别是带参数的宏)可能是程序中错误的来源,所以一些程序员更喜欢全部使用大写字母来引起注意。有些人则倾向于小写,即按照 Kernighan \text{Kernighan} Kernighan Ritchie \text{Ritchie} Ritchie 编写的 T h e   C   P r o g r a m m i n g   L a n g u a g e The\ C\ Programming\ Language The C Programming Language 一书中的风格。

3.2 带参数的宏

带参数的宏(也称为函数式宏)的定义有如下格式:

#define 标识符(x1, x2, ..., xn) 替换列表

其中 x1, x2, ..., xn 是标识符(宏的参数)。这些参数可以在替换列表中根据需要出现任意次。在宏的名字和左括号之间必须没有空格,如果有空格,预处理器会认为是在定义一个简单的宏,把 (x1, x2, ..., xn) 当成是替换列表的一部分。

当预处理器遇到带参数的宏时,会将宏定义存储起来以便后面使用。在后面的程序中,如果任何地方出现了标识符(y1, y2, ..., yn) 格式的宏调用,预处理器会使用替换列表替代 —— 使用 y1 替换 x1y2 替换 x2,依此类推。

例如,假定我们定义了如下的宏:

#define MAX(x, y) ((x) > (y) ? (x) : (y))
#define IS_EVEN(n) ((n) % 2 == 0)

现在如果后面的程序中有如下语句:

i = MAX(j + k, m - n);
if (IS_EVEN(i)) i++;

预处理器会将这些行替换为

i = ((j + k) > (m - n) ? (j + k) : (m - n));
if (((i) % 2 == 0)) i++;

如这个例子所示,带参数的宏经常用来作为简单的函数使用。MAX 类似一个从两个值中选取较大值的函数,IS_EVEN 则类似于一种当参数为偶数时返回 1 1 1,否则返回 0 0 0 的函数。

带参数的宏可以包含空的参数列表,如下例所示:

#define getchar() getc(stdin)

空的参数列表不是必需的,但这样可以使 getchar 更像一个函数。

使用带参数的宏替代真正的函数有两个优点。

  • 程序可能会稍微快些。程序执行时调用函数通常会有些额外开销 —— 存储上下文信息、复制参数的值等,而调用宏则没有这些运行开销。C99 的内联函数为我们提供了一种不使用宏而避免这一开销的办法。
  • 宏更 “通用”。与函数的参数不同,宏的参数没有类型。因此,只要预处理后的程序依然是合法的,宏可以接受任何类型的参数。

但是带参数的宏也有一些缺点。

  • 编译后的代码通常会变大。每一处宏调用都会导致插入宏的替换列表,由此导致程序的源代码增加,因此编译后的代码变大。宏使用得越频繁,这种效果就越明显。当宏调用嵌套时,这个问题会相互叠加从而使程序更加复杂。
  • 宏参数没有类型检查。当一个函数被调用时,编译器会检查每一个参数来确认它们是否是正确的类型。如果不是,那么将参数转换成正确的类型,要么由编译器产生一条出错消息。预处理器不会检查宏参数的类型,也不会进行类型转换。
  • 无法用一个指针来指向一个宏。C 语言允许指针指向函数,这在特定的编程条件下非常有用。宏会在预处理过程中被删除,所以不存在类似的 “指向宏的指针”。
  • 宏可能会不止一次地计算它的参数。函数对它的参数只会计算一次,而宏可能会计算两次甚至更多次。如果参数有副作用,多次计算参数的值可能会产生不可预知的结果。考虑语句 n = MAX(i++, j);,预处理之后的结果是 n = ((i++)>(j)?(i++):(j));,如果 i 大于 j,那么 i 可能会被错误地增加两次,同时 n 可能被赋予错误的值。

由于多次计算宏的参数而导致的错误可能非常难于发现,因为宏调用和函数调用看起来是一样的。更糟糕的是,这类宏可能在大多数情况下可以正常工作,仅在特定参数有副作用时失效。为了自我保护,最好避免使用带有副作用的参数。

带参数的宏不仅适用于模拟函数调用,还经常用作需要重复书写的代码段模式。如果我们已经写烦了语句 printf("%d\n", i);,我们可以定义宏 #define PRINT_INT(n) printf("%d\n", n),使显示整数变得简单些。

3.3 # 运算符

宏定义可以包含两个专用的运算符:###。编译器不会识别这两种运算符,它们会在预处理时被执行。

# 运算符将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。假设我们决定在调试过程中使用 PRINT_INT 宏作为一个便捷的方法来输出整型变量或表达式的值。# 运算符可以使 PRINT_INT 为每个输出的值添加标签。下面是改进后的 PRINT_INT

#define PRINT_INT(n) printf(#n " = %d\n", n)

n 之前的 # 运算符通知预处理器根据 PRINT_INT 的参数创建一个字符串字面量。因此,调用 PRINT_INT(i / j); 会变为 printf("i / j" " = %d\n", i / j);,C 语言中相邻的字符串字面量会被合并,因此这条语句等价于:printf("i / j = %d\n", i / j);

3.4 ## 运算符

## 运算符可以将两个记号(如标识符)“粘合” 在一起,成为一个记号。因此 ## 运算符被称为 “记号粘合”。如果其中一个操作数是宏参数,“粘合” 会在形式参数被相应的实际参数替换后发生。考虑下面的宏:

#define MK_ID(n) i##n

MK_ID 被调用时,比如 MK_ID(1),预处理器首先使用实际参数 1 替换形式参数 n。接着,预处理器将 i1 合并成为一个记号 i1。下面的声明使用 MK_ID 创建了 3 3 3 个标识符:

int MK_ID(1), MK_ID(2), MK_ID(3);

预处理后这一声明变为:

int i1, i2, i3;

我们来重新思考前面提到过的 MAX 宏,当 MAX 的参数有副作用时会无法正常工作,一种解决方法是用 MAX 宏来写一个 max 函数。遗憾的是,进一个 max 函数是不够的,我们可能需要一个实际参数是 int 值的 max 函数、一个实际参数为 float 值的 max 函数,等等。除了实际参数的类型和返回值的类型之外,这些函数都一样,因此,这样定义每一个函数似乎是个很蠢的做法。

解决的办法是定义一个宏,并使它展开后成为 max 函数的定义。宏只有一个参数 type,表示实际参数和返回值的类型。这里还有个问题,如果我们用宏来创建多个 max 函数,程序将无法编译,C 语言不允许在同一文件中出现两个同名的函数。为了解决这个问题,我们用 ## 运算符为每个版本的 max 函数构造不同的名字。下面是宏的形式:

#define GENERIC_MAX(type)				\
type type##_max(type x, type y) {		\
	return x > y ? x : y;				\
}

现在,假如我们需要一个针对 float 值的 max 函数,下面是使用 GENERIC_MAX 宏来定义这一函数的方法:

GENERIC_MAX(float)

预处理器会将这行展开为下面的代码:

float float_max(float x, float y) { return x > y ? x : y; }

3.5 宏的通用属性

  • 宏的替换列表可以包含对其他宏的调用。例如,我们可以用宏 PI 来定义宏 TWO_PI

    #define PI 3.14159
    #define TWO_PI (2 * PI)
    

    当预处理器在后面的程序中遇到 TWO_PI 时,会将它替换成 (2 * PI)。接着,预处理器会重新检查替换列表,看它是否包含其他宏的调用。预处理器会不断重新检查替换列表,直到将所有的宏名字都替换掉为止。

  • 预处理器只会替换完整的记号,而不会替换记号的片断。因此,预处理器会忽略嵌在标识符、字符常量、字符串字面量之中的宏名。例如,假设程序含有如下代码行:

    #define SIZE 256
    ...
    int BUFFER_SIZE;
    ...
    if (BUFFER_SIZE > SIZE)
    	puts("Error: SIZE exceeded");
    

    预处理后这些代码行会变为

    int BUFFER_SIZE;
    ...
    if (BUFFER_SIZE > 256)
    	puts("Error: SIZE exceeded");
    

    尽管标识符 BUFFER_SIZE 和字符串 "Error: SIZE exceeded" 都包含 SIZE,但是它们没有被预处理影响。

  • 宏定义的作用范围通常到出现这个宏的文件末尾。由于宏是由预处理器处理的,它们不遵从通常的作用域规则。定义在函数中的宏并不是仅在函数内起作用,而是作用到文件末尾。

  • 宏不可以被定义两遍,除非新的定义与旧的定义是一样的。小的间隔上的差异是允许的,但是宏的替换列表和参数中的记号都必须一致。

  • 宏可以使用 #undef 指令 “取消定义”#undef 指令有如下形式:

    #undef 标识符
    

    其中标识符是一个宏名。例如,指令 #undef N 会删除宏 N 当前的定义。如果 N 没有被定义成一个宏,#undef 指令没有任何作用。#undef 指令的一个用途是取消宏的现有定义,以便于重新给出新的定义。

3.6 宏定义中的圆括号

在宏的替换列表中使用大量的圆括号是绝对必要的。如果我们少用几个圆括号,宏有时可能会得到意想不到的结果。

对于在一个宏定义中哪里要加圆括号有两条规则要遵守。首先,如果宏的替换列表中有运算符,那么始终要将替换列表放在括号中:

#define TWO_PI (2 * 3.14159)

其次,如果宏有参数,每个参数每次在替换列表中出现时都要放在圆括号中:

#define SCALE(x) ((x) * 10)

没有括号的话,我们将无法确保编译器会将替换列表和参数作为完整的表达式,编译器可能会不按我们期望的方式应用运算符的优先级和结合性规则。

为了展示为替换列表添加圆括号的重要性,考虑下面的宏定义,其中的替换列表没有添加圆括号:

#define TWO_PI 2 * 3.14159

在预处理时,语句 conversion_factor = 360 / TWO_PI; 变为 conversion_factor = 360 / 2 * 3.14159;,除法会在乘法之前执行,产生的结果并不是期望的结果。

当宏有参数时,仅给替换列表添加圆括号是不够的。参数的每一次出现都要添加圆括号,否则可能会导致与上述问题类似的错误。

3.7 创建较长的宏

在创建较长的宏时,可以使用逗号运算符来使替换列表包含一系列表达式。例如,下面的宏会读入一个字符串,再把字符串显示出来:

#define ECHO(s) (gets(s), puts(s))

gets 函数和 puts 函数的调用都是表达式,因此使用逗号运算符连接它们是合法的。我们甚至可以把 ECHO 宏当作一个函数来使用:

ECHO(str);		/* becomes (gets(str), puts(str)); */

如果不想在 ECHO 的定义中使用逗号运算符,我们还可以将 gets 函数和 puts 函数的调用放在花括号中形成复合语句:

#define ECHO(s) { gets(s); puts(s); }

遗憾的是,这种方式并未奏效。假如我们将 ECHO 宏用于下面的 if 语句:

if (echo_flag)
	ECHO(str);
else
	gets(str);

ECHO 宏替换会得到下面的结果:

if (echo_flag)
	{ gets(s); puts(s); };
else
	gets(str);

编译器会将头两行作为完整的 if 语句:

if (echo_flag)
	{ gets(s); puts(s); }

编译器会将跟在后面的分号作为空语句,并且对 else 子句产生出错消息,因为它不属于任何 if 语句。记住永远不要在 ECHO 宏后面加分号我们就可以解决这个问题。但是这样做会使程序看起来有些怪异。

逗号运算符可以解决 ECHO 宏的问题,但并不能解决所有宏的问题。假如一个宏需要包含一系列的语句,而不仅仅是一系列的表达式,这时逗号运算符就起不了作用了,因为它只能连接表达式,不能连接语句。解决的方法是将语句放在 do 循环中,并将条件设置为假,因此语句只会执行一次:

do { ... } while (0)

注意,这个 do 语句是不完整的 —— 后面还缺了一个分号。为了看到这个技术的实际作用,让我们将它用于 ECHO 宏中:

#define ECHO(s)			\
		do {			\
			gets(s);	\
			puts(s);	\
		} while (0)

当使用 ECHO 宏时,一定要加分号以使 do 语句完整:

ECHO(str);		/* becomes do { gets(str); puts(str); } while (0); */

3.8 预定义宏

C 语言有一些预定义宏,每个宏表示一个整数常量或字符串字面量。这些宏提供了当前编译或编译器本身的信息。

名字描述
__LINE__被编译的文件中的行号
__FILE__被编译的文件名
__DATE__编译的日期(格式 "MM dd yyyy"
__TIME__编译的时间(格式 hh:mm:ss
__STDC__如果编译器符合 C 标准(C89 或 C99),那么值为 1 1 1

我们可以使用 __LINE__ 宏和 __FILE__ 宏来找到错误。考虑被零除的定位问题。当 C 程序因为被零除而导致终止时,通常没有信息指明哪条除法运算导致错误。下面的宏可以帮助我们查明错误的根源:

#define CHECK_ZERO(divisor)		\
	if (divisor == 0)			\
		printf("*** Attempt to divide by zero on line %d "	\
			   "of line %s ***\n", __LINE__, __FILE__)

CHECK_ZERO 宏应该在除法运算前被调用:

CHECK_ZERO(j);
k = i / j;

如果 j 0 0 0,会显示出如下形式的信息:

*** Attempt to divide by zero on line 9 of line foo.c ***

类似这样的错误检测的宏非常有用。实际上,C 语言库提供了一个通用的、用于错误检测的宏 —— assert 宏。

__DATE__ 宏和 __TIME__ 宏指明程序编译的时间,可以帮助区分同一个程序的不同版本。

如果编译器符合 C 标准(C89 或 C99),__STDC__ 宏存在且值为 1 1 1。通过让预处理器测试这个宏,程序可以在早于 C89 标准的编译器下编译通过。

3.9 C99 中新增的预定义宏

名字描述
__STDC_HOSTED__如果是托管式实现,值为 1 1 1;如果是独立式实现,值为 0 0 0
__STDC_VERSION__支持的 C 标准版本
__STDC_IEC_559__如果支持 IEC 60559 浮点算术运算,则值为 1 1 1
__STDC_IEC_559_COMPLEX__如果支持 IEC 60559 复数算术运算,则值为 1 1 1
__STDC_ISO_10646__如果 wchar_t 的值与指定年月的 ISO 10646 标准相匹配,则值为 yyyymmL

C 的实现( implementation \text{implementation} implementation)包括编译器和执行 C 程序所需要的其他软件。C99 将实现分为两种:托管式( hosted \text{hosted} hosted)和独立式( freestanding \text{freestanding} freestanding)。托管式实现能够接受任何符合 C99 标准的程序,而独立式实现除了几个最基本的以外,不一定要能够编译使用复数类型或标准头的程序。特别地,独立式实现不需要支持 <stdio.h> 头。如果编译器是托管式实现,__STDC_HOSTED__ 宏代表常数 1 1 1,否则值为 0 0 0

__STDC_VERSION__ 宏为我们提供了一种查看编译器所识别出的 C 标准版本的方法。这个宏第一次出现在 C89 标准的 Amendment 1 \text{Amendment 1} Amendment 1 中,该文档指明宏的值为长整数常量 199409L(代表修订的年月)。如果编译器符合 C99 标准,其值为 199901L。对于标准的每一个后续版本,以及每一次后续修订,宏的值都有所变化。

C99 编译器可能另外定义以下 3 3 3 种宏,也可能没有。仅当编译器满足特定条件时才会定义相应的宏。

  • 如果编译器根据 IEC 60559 标准(IEEE 754 标准的别名)执行浮点算术运算,则定义 __STDC_IEC_559__ 宏,且其值为 1 1 1
  • 如果编译器根据 IEC 60559 标准执行复数算术运算,则定义 __STDC_IEC_559_COMPLEX__ 宏,且其值为 1 1 1
  • __STDC_ISO_10646__ 定义为 yyyymmL 格式(如 199712L)的整数常量,前提是 wchar_t 类型的值由 ISO/IEC 10646 标准中的码值表示(表示格式中指明了修订的年月)。

3.10 空的宏参数

C99 允许宏调用中的任意或所有参数为空,但是需要保持调用时逗号的数量不变。如果替换列表中出现相应的形式参数名,那么只要在替换列表中不出现实际参数即可。例如:

#define ADD(x, y) (x + y)

经过预处理之后,语句 i = ADD(j, k); 变成 i = (j + k);,而赋值语句 i = ADD(, k); 则变为 i = (+ k);

如果空的实际参数被 # 运算符 “字符串化”,则结果为 ""(空字符串):

#define MK_STR(x) #x
...
char empty_string[] = MK_STR();

预处理之后,上面的声明变成:char empty_string[] = "";

如果 ## 运算符之后的一个实际参数为空,它将会被不可见的 “位置标记” 记号代替。把原始的记号与位置标记记号相连接,得到的还是原始的记号,位置标记记号消失了。如果连接两个位置标记记号,得到的是一个位置标记记号。宏扩展完成后,位置标记记号从程序中消失。考虑下面的例子:

#define JOIN(x, y, z) x##y##z
...
int JOIN(a, b, c), JOIN(a, b, ), JOIN(a, , c), JOIN(, , c);

预处理之后,声明变成:int abc, ab, ac, c;。漏掉的参数由位置标记记号代替,这些记号在与非空参数相连接之后消失。JOIN 宏的 3 3 3 个参数可以同时为空,这样得到的结果为空。

3.11 参数个数可变的宏

在 C89 中,如果宏有参数,那么参数的个数是固定的。在 C99 中,这个条件被适当放宽了,允许宏具有可变长度的参数列表。这样的宏可以将参数传递给具有可变参数个数的函数,如 printfscanf。下面给出一个例子:

#define TEST(condition, ...) ((condition) ? 	\
	printf("Passed test: %s\n", #condition) :	\
	printf(__VA_ARGS__))

... 记号(省略号)出现在宏参数列表的最后,前面是普通参数。__VA_ARGS__ 是一个专用的标识符,只能出现在具有可变参数个数的宏的替换列表中,代表所有与省略号相对应的参数。至少有一个与省略号相对应的参数,但该参数可以为空。宏 TEST 至少要有两个参数,第一个参数匹配 condition,剩下的参数匹配省略号。下面这个例子说明了 TEST 的使用方法:

TEST(voltage <= max_voltage, "Voltage %d exceeds %d\n", voltage, max_voltage);

预处理器将会产生如下的输出(重排格式以增强可读性):

((voltage <= max_voltage) ?
  printf("Passed test: %s\n", "voltage <= max_voltage") :
  printf("Voltage %d exceeds %d\n", voltage, max_voltage));

3.12 __func__ 标识符

C99 的另一个新特性是 __func__ 标识符。__func__ 与预处理器无关,但是与许多预处理特性一样,它也有助于调试,所以在这里一并讨论。

每一个函数都可以访问 __func__ 标识符,它的行为很像一个存储当前正在执行的函数的名字的字符串变量。其作用相当于在函数体的一开始包含如下声明:

static const char __func__[] = "function-name";

其中 function-name 是函数名。这个标识符的存在使得我们可以写出如下的调试宏:

#define FUNCTION_CALLED() printf("%s called\n", __func__)
#define FUNCTION_RETURNS() printf("%s returns\n", __func__)

对这些宏的调用可以放在函数体中,以跟踪函数的调用:

void f(void)
{
	FUNCTION_CALLED();		/* displays "f called"  */
	...
	FUNCTION_RETURNS();		/* displays "f returns" */
}

__func__ 的另一个用法:作为参数传递给函数,让函数知道调用它的函数的名字。

4. 条件编译

条件编译是指根据预处理器所执行的测试结果来包含或排除程序的片断。

4.1 #if 指令和 #endif 指令

假如我们正在调试一个程序。我们想要程序显示出特定变量的值,因此将 printf 函数调用添加到程序中重要的部分。一旦找到错误,经常需要保留这些 printf 函数调用,以备以后使用。条件编译允许我们保留这些调用,但是让编译器忽略它们。

首先定义一个宏,并给它一个非零的值:

#define DEBUG 1

接下来,我们要在每组 printf 函数调用的前后加上 #if#endif

#if DEBUG
printf("Value of i : %d\n", i);
printf("Value of j : %d\n", j);
#endif

在预处理过程中,#if 指令会测试 DEBUG 的值。由于 DEBUG 的值不是 0 0 0,因此预处理器会将这两个 printf 函数调用保留在程序中,但 #if#endif 行会消失。如果我们将 DEBUG 的值改为 0 0 0 并重新编译程序,预处理器则会将这 4 4 4 行代码都删除。编译器不会看到这些 printf 函数调用,所以这些调用就不会在目标代码中占用空间,也不会在程序运行时消耗时间。我们可以将 #if-#endif 保留在最终的程序中,这样如果程序在运行时出现问题,可以通过将 DEBUG 改为 1 1 1 并重新编译来继续产生诊断信息。

#if 指令会把没有定义过的标识符当作是值为 0 0 0 的宏对待。因此,如果省略 DEBUG 的定义,测试 #if DEBUG 会失败,但不会产生出错消息,而测试 #if !DEBUG 会成功。

4.2 defined 运算符

defined 应用于标识符时,如果标识符是一个定义过的宏则返回 1 1 1,否则返回 0 0 0defined 运算符通常与 #if 指令结合使用,可以这样写:

#if defined(DEBUG)
...
#endif

仅当 DEBUG 被定义成宏时,#if#endif 之间的代码会被保留在程序中。DEBUG 两侧的括号不是必需的,因此可以简单写成

#if defined DEBUG

由于 defined 运算符仅检测 DEBUG 是否有定义,所以不需要给 DEBUG 赋值:

#define DEBUG

4.3 #ifdef 指令和 #ifndef 指令

#ifdef 指令测试一个标识符是否已经定义为宏,用法与 #if 指令类似:

#ifdef 标识符
当标识符被定义为宏时需要包含的代码
#endif

严格地说,并不需要 #ifdef,因为可以结合 #if 指令和 defined 运算符来得到相同的效果。换言之,指令 #ifdef 标识符 等价于 #if defined(标识符)

#ifndef 指令与 #ifdef 指令类似,但测试的是标识符是否没有被定义为宏。指令 #ifndef 标识符 等价于 #if !defined(标识符)

4.4 #elif 指令和 #else 指令

#if 指令、#ifdef 指令和 #ifndef 指令可以像普通的 if 语句那样嵌套使用。当发生嵌套时,最好随着嵌套层次的增加而增加缩进。一些程序员对每一个 #endif 都加注释,来指明对应的 #if 指令测试哪个条件:

#if DEBUG
...
#endif		/* DEBUG */

这种方法有助于更方便地找到 #if 指令的起始位置。

为了提供更多的便利,预处理器还支持 #elif#else 指令。#elif 指令和 #else 指令可以与 #if 指令、#ifdef 指令和 #ifndef 指令结合使用,来测试一系列条件:

#if 表达式1
当表达式10时需要包含的代码
#elif 表达式2
当表达式10但表达式20时需要包含的代码
#else
其他情况下需要包含的代码
#endif

虽然上面的例子使用了 #if 指令,但 #ifdef 指令或 #ifndef 指令也可以这样使用。在 #if 指令和 #endif 指令之间可以有任意多个 #elif 指令,但最多只能有一个 #else 指令。

4.5 使用条件编译

条件编译对于调试是非常方便的,但它的应用并不仅限于此。下面是其他一些常见的应用:

  • 编写在多台机器或多种操作系统之间可移植的程序。下面的例子中会根据 WIN32、MAC_OS 或 LINUX 是否被定义为宏,而将三组代码之一包含到程序中:
    #if defined(WIN32)
    ...
    #elif defined(MAC_OS)
    ...
    #elif defined(LINUX)
    ...
    #endif
    
    一个程序中可以包含许多这样的 #if 指令。在程序的开头会定义这些宏之一,而且只有一个,由此选择了一个特定的操作系统。
  • 编写可以用不同的编译器编译的程序。不同的编译器可以用于识别不同的 C 语言版本,这些版本之间会有一些差异。一些会接受标准 C,另外一些则不会。一些版本会提供针对特定机器的语言扩展;有些版本则没有,或者提供不同的扩展集。条件编译可以使程序适应于不同的编译器。考虑一下为以前的非标准编译器编写程序的问题。__STDC__ 宏允许预处理器检测编译器是否支持标准 C89 或 C99;如果不支持,我们可能必须修改程序的某些方面,尤其是有可能必须用老式的函数声明替代函数原型。对于每一处函数声明,我们可以使用下面的代码:
    #if __STDC__
    函数原型
    #else
    老式的函数声明
    #endif
    
  • 为宏提供默认定义。条件编译使我们可以检测一个宏当前是否已经被定义了,如果没有,则提供一个默认的定义。例如,如果宏 BUFFER_SIZE 此前没有被定义的话,下面的代码会给出定义:
    #ifndef BUFFER_SIZE
    #define BUFFER_SIZE 256
    #endif
    
  • 临时屏蔽包含注释的代码。我们不能用 /*...*/ 直接 “注释掉” 已经包含 /*...*/ 注释的代码。然而,我们可以使用 #if 指令来实现,将代码以这种方式屏蔽掉经常称为 “条件屏蔽”:
    #if 0
    包含注释的代码行
    #endif
    

5. 其他指令

5.1 #error 指令

#error 指令有如下格式:

#error 消息

其中,消息 是任意的记号序列。如果预处理器遇到 #error 指令,它会显示一条包含 消息 的出错消息。对于不同的编译器,出错消息的具体形式也可能会不一样。格式可能类似 Error directive: 消息 或者 #error 消息。遇到 #error 指令预示着程序中出现了严重的错误,有些编译器会立即终止编译而不再检查其他错误。

#error 指令通常与条件编译指令一起用于检测正常编译过程中不应出现的情况。例如,假定我们需要确保一个程序无法在一台 int 类型不能存储 100000 100000 100000 的机器上编译。最大允许的 int 值用 INT_MAX 宏表示,所以我们需要做的就是当 INT_MAX 宏小于 100000 100000 100000 时调用 #error 指令:

#if INT_MAX < 100000
#error int type is too small
#endif

如果试图在一台以 16 16 16 位存储整数的机器上编译这个程序,将产生一条出错消息:

Error directive: int type is too small

#error 指令通常会出现在 #if-#elif-#else 序列中的 #else 部分:

#if defined(WIN32)
...
#elif defined(MAC_OS)
...
#elif defined(LINUX)
...
#else
#error No operating system specified
#endif

5.2 #line 指令

#line 指令是用来改变程序行编号方式的。我们也可以使用这条指令使编译器认为它正在从一个有不同名字的文件中读取程序。

#line 指令有两种形式。一种形式只指定行号:

#line n

n n n 必须是介于 1 1 1 32767 32767 32767(C99 中是 2147483647 2147483647 2147483647)之间的整数。这条指令导致程序中后续的行被编号为 n n n n + 1 n + 1 n+1 n + 2 n + 2 n+2 等。

#line 指令的第二种形式同时指定行号和文件名:

#line n "文件"

指令后面的行会被认为来自 文件,行号由 n n n 开始。 n n n文件 字符串的值可以用宏指定。

#line 指令的一种作用是改变 __LINE__ 宏的值,可能还有 __FILE__ 宏的值。更重要的是,大多数编译器会使用来自 #line 指令的信息生成出错消息。例如,假设下列指令出现在文件 foo.c 的开头:

#line 10 "bar.c"

现在,假设编译器在 foo.c 的第 5 5 5 行发现一个错误。出错消息会指向 bar.c 的第 13 13 13 行,而不是 foo.c 的第 5 5 5 行。因为指令占据了 foo.c 的第 1 1 1 行,对 foo.c 的重新编号从第 2 2 2 行开始,并将这一行作为 bar.c 的第 10 10 10 行。

实际上,程序员并不经常使用 #line 指令,它主要用于那些产生 C 代码作为输出的程序。在生成的 C 代码文件中插入 #line 指令,就可以将编译该文件时产生的出错消息指向原始文件中的行。最著名的程序之一是 yacc Yet Another Compiler-Compiler \text{Yet Another Compiler-Compiler} Yet Another Compiler-Compiler),它是一个用于自动生成编译器的一部分的 UNIX 工具,yacc 的 GNU 版本称为 bison

5.3 #pragma 指令

#pragma 指令为要求编译器执行某些特殊操作提供了一种方法。这条指令对非常大的程序或需要使用特定编译器的特殊功能的程序非常有用。

#pragma 指令有如下形式:

#pragma 记号

其中,记号 是任意记号。#pragma 指令可以很简单,也可以很复杂:

#pragma data(heap_size => 1000, stack_size => 2000)

#pragma 指令中出现的命令集在不同的编译器上是不一样的。如果 #pragma 指令包含了无法识别的命令,预处理器必须忽略这些 #pragma 指令,不允许给出出错消息。

C89 中没有标准的编译提示( pragma \text{pragma} pragma),它们都是在实现中定义的。C99 有 3 3 3 个标准的编译提示,都使用 STDC 作为 #pragma 之后的第一个记号。这些编译提示是 FP_CONTRACTCX_LIMITED_RANGEFENV_ACCESS

5.4 _Pragma 运算符

C99 引入了与 #pragma 指令一起使用的 _Pragma 运算符。_Pragma 表达式可以具有如下形式:

_Pragma (字符串字面量)

遇到该表达式时,预处理器通过移除字符串两端的双引号并分别用字符 "\ 代替转移序列 \"\\ 来实现对字符串字面量的 “去字符串化”。表达式的结果是一系列的记号,这些记号被视为出现在 pragma 指令中。例如,下面的两个指令是一样的:

_Pragma ("data(heap_size => 1000, stack_size => 2000)")
#pragma data(heap_size => 1000, stack_size => 2000)

_Pragma 运算符使我们摆脱了预处理器的局限性:预处理指令不能产生其他指令。由于 _Pragma 是运算符而不是指令,所以可以出现在宏定义中。这使得我们能够在 #pragma 指令后面进行宏的扩展。

  • 18
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

朔北之忘 Clancy

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

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

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

打赏作者

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

抵扣说明:

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

余额充值