预处理器

预处理器是 C 程序编译之前进行文本处理的一种方式。在每个 C 程序实际编译之前,它会先经过一个预处理器。预处理器会扫描程序,尝试找出它能够理解的特定指令,称为预处理指令。所有预处理指令都以 #(井号)符号开头。C++ 编译器使用与 C 相同的预处理器。[1]

预处理器是编译器的一部分,在编译器查看代码之前对代码执行初步操作(有条件地编译代码、包含文件等)。这些转换是词法级别的,意味着预处理器的输出仍然是文本。

注意:从技术上讲,C 的预处理阶段的输出是由一系列标记(tokens)组成,而不是源代码文本,但输出与给定标记序列等价的源代码文本很容易生成,这通常是编译器通过 -E/E 选项支持的——尽管 C 编译器的命令行选项并非完全标准化,许多编译器遵循类似的规则。

指令

指令是发送给预处理器(预处理指令)或编译器(编译指令)的特殊指令,告诉它如何处理源代码的某一部分或全部,或设置最终对象的某些标志。指令用于简化源代码编写(例如,使代码更具可移植性),并使源代码更易于理解。指令由预处理器处理,预处理器可以是由编译器调用的单独程序,也可以是编译器本身的一部分。

#include

C 语言有一些作为语言一部分的特性,还有一些作为标准库的一部分,标准库是与每个符合标准的 C 编译器一起提供的代码库。当 C 编译器编译程序时,它通常还会将其与标准 C 库链接。例如,当遇到 #include <stdio.h> 指令时,它会用 stdio.h 头文件的内容替换该指令。

当你使用库中的特性时,C 要求你声明你将使用的内容。程序中的第一行是一个预处理指令,应该如下所示:

#include <stdio.h>

上述行使得 stdio.h 头文件中的 C 声明被包含到你的程序中。通常,预处理器通过将 stdio.h 头文件的内容插入到程序中来实现这一点,而该头文件通常位于一个系统特定的位置。此类文件的位置可以在你的编译器文档中找到。下面是标准 C 头文件的列表。

stdio.h 头文件包含了用于输入/输出(I/O)的各种声明,通过一个称为流(streams)的 I/O 机制进行抽象。例如,存在一个名为 stdout 的输出流对象,用于将文本输出到标准输出,通常是在计算机屏幕上显示文本。

如果使用如上示例中的尖括号,预处理器指示它沿开发环境路径搜索标准包含文件。

#include "other.h"

如果使用双引号 " ",则预处理器预期在一些附加的、通常是用户定义的位置搜索头文件,如果在这些额外的位置中没有找到文件,则会回退到标准包含路径。这种形式通常用于搜索与包含 #include 指令的文件位于同一目录中的头文件。

注意:你应该查阅你正在使用的开发环境的文档,了解该环境是否对 #include 指令有任何供应商特定的实现。

头文件

C90 标准头文件列表:

  • <assert.h>
  • <ctype.h>
  • <errno.h>
  • <float.h>
  • <limits.h>
  • <locale.h>
  • <math.h>
  • <setjmp.h>
  • <signal.h>
  • <stdarg.h>
  • <stddef.h>
  • <stdio.h>
  • <stdlib.h>
  • <string.h>
  • <time.h>

自 C90 以来添加的头文件:

  • <complex.h>
  • <fenv.h>
  • <inttypes.h>
  • <iso646.h>
  • <stdbool.h>
  • <stdint.h>
  • <tgmath.h>
  • <wchar.h>
  • <wctype.h>

#pragma

#pragma(务实信息)指令是标准的一部分,但任何 #pragma 的意义取决于所使用的标准软件实现。#pragma 指令提供了一种向编译器请求特殊行为的方式。这条指令对那些特别大的程序或需要利用特定编译器功能的程序最为有用。

#pragma 用于源程序中。

#pragma token(s)

#pragma 通常后面跟一个标记,表示编译器应遵循的命令。你应查阅你打算使用的 C 标准软件实现的文档,了解支持的标记。毫不奇怪,#pragma 指令中可以出现的命令对于每个编译器而言都是不同的;你需要查阅编译器的文档,了解允许的命令及其作用。例如,最常见的预处理指令之一是 #pragma once,当它放在头文件的开始位置时,它指示如果该头文件被预处理器多次包含,该文件会被跳过。

注意:还有其他方法可以实现这种操作,这种方法通常被称为使用包含保护。

#define

每个 #define 预处理指令定义一个宏。例如:

#define PI 3.14159265358979323846 /* pi */

一个宏如果在名字后面直接跟一个空格,称为常量或字面值。如果在名字后面直接跟一个括号,称为类似函数的宏。[2]

警告:虽然预处理器宏很诱人,但如果使用不当,可能会产生意想不到的结果。请始终记住,宏是对源代码的文本替换,发生在编译之前。编译器对宏一无所知,编译器从未看到过宏。这可能会导致一些晦涩的错误和其他负面影响。如果有等效的语言特性,最好使用它们。例如,使用 const intenum 替代 #define 常量。

尽管如此,在某些情况下,宏确实非常有用(例如,下面的调试宏示例)。

#define 指令用于定义宏。宏由预处理器在编译之前用于处理程序源代码。因为预处理器宏定义是在编译器作用于源代码之前进行替换的,所以由 #define 引入的错误很难追踪。

按照约定,使用 #define 定义的宏通常使用大写字母命名。虽然这不是强制要求,但通常认为不这么做是很差的实践。使用大写字母命名宏可以使宏在阅读源代码时更容易识别。(我们将在后面的章节中提到使用 #define 的许多其他常见约定,C 编程/常见实践)。

如今,#define 主要用于处理编译器和平台的差异。例如,某个 #define 可能包含适当的系统调用错误代码。因此,除非绝对必要,否则应限制使用 #definetypedef 语句和常量变量通常可以更安全地执行相同的功能。

带参数的宏

#define 指令的另一个特点是它可以带有参数,使得它非常适合作为伪函数创建者。考虑以下代码:

#define ABSOLUTE_VALUE(x) (((x) < 0) ? -(x) : (x))
...
int x = -1;
while(ABSOLUTE_VALUE(x)) {
  ...
}

在使用复杂宏时,通常最好使用额外的括号。注意,在上述示例中,变量 x 总是被放在自己的括号内。这样,它将在与 0 比较或乘以 -1 之前先完全求值。此外,整个宏都被括起来,避免它被其他代码污染。如果不小心,可能会导致编译器误解你的代码。

由于副作用,像上面描述的宏函数被认为是非常糟糕的做法。

int x = -10;
int y = ABSOLUTE_VALUE(x++);

如果 ABSOLUTE_VALUE() 是一个真实的函数,x 的值现在会是 -9,但是因为它是宏中的一个参数,它会被扩展两次,因此 x 的值是 -8

示例:

为了说明宏的危险,考虑这个简单的宏:

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

以及以下代码:

i = MAX(2, 3) + 5;
j = MAX(3, 2) + 5;

看看这段代码,考虑执行后的值会是什么。上述语句会被转换为:

int i = 2 > 3 ? 2 : 3 + 5;
int j = 3 > 2 ? 3 : 2 + 5;

因此,执行后 i = 8j = 3,而不是预期的 i = j = 8!这就是为什么你被警告要使用额外的括号,但即使这样,仍然充满了风险。警觉的读者可能会很快意识到,如果 ab 包含表达式,那么宏定义必须将每次使用 ab 的地方都加上括号,如下所示:

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

这样做是有效的,只要 ab 没有副作用。事实上,

i = 2;
j = 3;
k = MAX(i++, j++);

会导致 k = 4i = 3j = 5。这对任何期望 MAX() 像函数一样表现的人来说都是非常惊讶的。

正确的解决方案

那么,正确的解决方案是什么呢?解决方案是根本不使用宏。使用全局内联函数,例如:

inline int max(int a, int b) {
  return a > b ? a : b;
}

这样没有上述的陷阱,但不会适用于所有类型。

注意:除非定义在头文件中,否则显式的 inline 声明实际上不是必需的,因为编译器可以为你内联函数(对于 gcc,可以使用 -finline-functions-O3 选项)。编译器通常比程序员更擅长预测哪些函数值得内联。此外,函数调用实际上并不昂贵(以前它们是昂贵的)。

编译器实际上可以忽略 inline 关键字。它只是一个提示(除了在允许在头文件中定义函数时,inline 是必要的,因为多个翻译单元定义同一个函数时会生成错误信息)。

### 操作符

### 操作符与 #define 宏一起使用。使用 # 会将其后的第一个参数转换为字符串。例如,以下指令:

#define as_string(s) #s

会使编译器将以下命令:

puts(as_string(Hello World!));

转换为:

puts("Hello World!");

使用 ## 会将其前面的内容与后面的内容连接起来。例如,以下指令:

#define concatenate(x, y) x ## y

使用 ## 操作符可以将宏的两个参数连接在一起。例如,下面的宏定义:

#define concatenate(x, y) x ## y

将会把以下代码:

int xy = 10;
printf("%d", concatenate(x, y));

转化为:

int xy = 10;
printf("%d", xy);

这将把 10 输出到标准输出。

宏与常量前缀或后缀的拼接

你也可以将宏参数与常量前缀或后缀连接,以生成有效的标识符。例如:

#define make_function(name) int my_ ## name(int foo) {}
make_function(bar);

这将定义一个名为 my_bar() 的函数。但无法通过拼接操作符将宏参数与常量字符串合并。为了达到类似效果,可以利用 ANSI C 规定的特性:两个或多个连续的字符串常量被视为一个单一的字符串常量。利用这一特性,可以写出如下代码:

#define eat(what) puts("I'm eating " #what " today.")
eat(fruit);

宏处理器将把它转换为:

puts("I'm eating " "fruit" " today.");

然后,C 解析器会将其解释为一个单一的字符串常量。

数字常量转换为字符串字面量

以下技巧可以用来将数字常量转化为字符串字面量:

#define num2str(x) str(x)
#define str(x) #x
#define CONST 23

puts(num2str(CONST));

这有点棘手,因为它是分两步展开的。首先,num2str(CONST) 会被替换为 str(23),然后 str(23) 会被替换为 "23"。这在以下示例中可能会很有用:

#ifdef DEBUG
#define debug(msg) fputs(__FILE__ ":" num2str(__LINE__) " - " msg, stderr)
#else
#define debug(msg)
#endif

这会给你一个漂亮的调试消息,包含文件名和消息所在的行号。如果没有定义 DEBUG,调试消息将完全消失。需要注意的是,避免使用这种构造方式与具有副作用的代码一起,因为这可能会导致依赖于编译参数的错误出现和消失。

宏的特点

宏不会进行类型检查,因此它们不会对参数进行求值。此外,它们不遵循作用域规则,而是直接将传递给它们的字符串替换为宏文本中的实际参数(代码会被字面复制到调用位置)。

使用宏的示例:

#include <stdio.h>

#define SLICES 8
#define ADD(x) ((x) / SLICES)

int main(void) {
    int a = 0, b = 10, c = 6;

    a = ADD(b + c);
    printf("%d\n", a);
    return 0;
}

在上述代码中,a 的结果应该是 2(因为 b + c = 16,然后传递给 ADD,最后 16 / SLICES = 2)。

注意:通常不建议在头文件中定义宏。

宏应该只在无法通过函数或其他机制实现相同功能时定义。有些编译器能够优化代码,将小函数的调用替换为内联代码,从而消除任何可能的性能优势。因此,使用 typedefenuminline(在 C99 中)通常是更好的选择。

使用宏初始化编译时常量

在某些情况下,inline 函数不能正常工作(因此你必须使用类似函数的宏),这通常发生在宏的参数是编译器可以优化为另一个字面量的常量时。例如,静态初始化结构体时,inline 函数无法处理编译时常量初始化,因此在这种情况下必须使用宏。

#error 指令

#error 指令会终止编译。当遇到该指令时,标准规定编译器应该输出一个诊断消息,包含指令中的剩余标记。这个指令主要用于调试目的。

例如:

#error "This is an error message"

这将在编译时输出错误消息并终止编译过程。

#error 指令

程序员通常在条件块中使用 #error 指令,当 #if#ifdef 检测到编译时问题时,立即停止编译过程。通常,编译器会跳过该块(以及其中的 #error 指令),然后继续编译。

例如:

#error "This is an error message"

当编译器遇到 #error 时,它会显示一个错误消息并终止编译。

#warning 指令

许多编译器支持 #warning 指令。当遇到此指令时,编译器会输出包含指令中剩余标记的诊断信息。

例如:

#warning "This is a warning message"

#undef 指令

#undef 指令用于取消定义一个宏。该标识符无需事先定义。

例如:

#undef PI

此指令会取消宏 PI 的定义。

条件编译指令 (#if, #else, #elif, #endif)

  • #if: #if 指令检查控制条件表达式是否为零或非零,并根据结果包含或排除代码块。例如:
#if 1
    /* 这个块将被包含 */
#endif
#if 0
    /* 这个块不会被包含 */
#endif

条件表达式可以包含除了赋值运算符、增减运算符、取地址运算符和 sizeof 运算符外的任何 C 运算符。

  • defined 运算符:是一个在预处理阶段使用的唯一运算符,它检查宏名称是否已定义,如果已定义则返回 1,否则返回 0

例如:

#if defined MY_MACRO
    /* 如果 MY_MACRO 已定义,包含这个代码块 */
#endif
  • #endif: 结束由 #if#ifdef#ifndef 开始的条件块。

  • #elif: 与 #if 类似,但它用于从一系列代码块中提取一个。例如:

#if /* some expression */
   /* Block 1 */
#elif /* another expression */
   /* Block 2 */
#else
   /* Block 3, executed if none of the above conditions are true */
#endif
  • #else: 如果没有选择前面的 #if#elif 块,则选择可选的 #else 块。

#ifdef#ifndef 指令

  • #ifdef: 与 #if 类似,但它检查宏是否已定义。如果宏定义了,则选择该代码块。例如:
#ifdef NAME
   /* 如果宏 NAME 已定义,选择这个代码块 */
#endif

等同于:

#if defined NAME
  • #ifndef: 与 #ifdef 类似,但它测试的是宏是否没有定义。如果宏没有定义,则选择该代码块。例如:
#ifndef NAME
   /* 如果宏 NAME 没有定义,选择这个代码块 */
#endif

等同于:

#if !defined NAME

#line 指令

#line 指令用于设置当前文件名和行号。它用于设置 __FILE____LINE__ 宏。

例如:

#line 100 "newfile.c"

这将使得后续代码的文件名变为 "newfile.c",行号变为 100

调试时有用的预处理宏

ANSI C 定义了一些有用的预处理宏和变量,也称为“魔法常量”,包括:

  • __FILE__: 当前文件名,作为字符串字面量。
  • __LINE__: 当前源文件的行号,作为数字字面量。
  • __DATE__: 当前系统日期,作为字符串。
  • __TIME__: 当前系统时间,作为字符串。
  • __TIMESTAMP__: 日期和时间(非标准)。
  • __cplusplus: 当你的 C 代码由 C 编译器编译时未定义;当 C++ 代码由符合 1998 C++ 标准的 C++ 编译器编译时值为 199711L
  • __func__: 当前函数名,作为字符串(C99 部分)。
  • __PRETTY_FUNCTION__: "装饰过的" 当前函数名,作为字符串(GCC 中,非标准)。

编译时断言

编译时断言可以帮助你更快地调试,因为它们在编译时进行测试,而运行时的 assert() 语句可能在测试过程中未被触发。

在 C11 标准之前,一些人[6][7][8]定义了一个预处理宏来实现编译时断言,类似于:

#define COMPILE_TIME_ASSERT(pred) switch(0){case 0:case pred:;}

使用方式:

COMPILE_TIME_ASSERT( BOOLEAN CONDITION );

Boost 库中的 static_assert.hpp 也定义了一个类似的宏。[9]

自 C11 起,这类宏已不再使用,因为 _Static_assert 及其宏等效形式 static_assert 已被标准化并内置到语言中。

X-Macros

C 预处理器的一种鲜为人知的使用模式被称为“X-Macros”[10][11][12][13]。X-Macro 是一个头文件或宏。通常这些文件使用扩展名 ".def" 代替传统的 ".h"。这个文件包含一系列相似的宏调用,称为“组件宏”。然后,包含文件以以下模式被重复引用。在这里,包含文件是 "xmacro.def",它包含一个样式为 "foo(x, y, z)" 的组件宏列表。

#define foo(x, y, z) doSomethingWith(x, y, z);
#include "xmacro.def"
#undef foo

#define foo(x, y, z) doSomethingElseWith(x, y, z);
#include "xmacro.def"
#undef foo

(依此类推...)

X-Macro 最常见的用途是建立一组 C 对象,并自动为它们生成代码。一些实现还会在 X-Macro 内部执行任何必要的 #undef,而不是期望调用者去 undefine 它们。

常见的对象集合包括:一组全局配置设置、一组结构体成员、用于将 XML 文件转换为可快速遍历树的 XML 标签列表,或者枚举声明的主体;也可以是其他类型的列表。

一旦 X-Macro 被处理并创建了对象列表,可以重新定义组件宏来生成,例如,访问器和/或修改器函数。结构体的序列化和反序列化也是常见的用途。

以下是一个 X-Macro 的示例,它建立了一个结构体并自动创建序列化/反序列化函数。为了简化示例,本例没有考虑字节序或缓冲区溢出问题。

文件 star.def:

EXPAND_EXPAND_STAR_MEMBER(x, int)
EXPAND_EXPAND_STAR_MEMBER(y, int)
EXPAND_EXPAND_STAR_MEMBER(z, int)
EXPAND_EXPAND_STAR_MEMBER(radius, double)
#undef EXPAND_EXPAND_STAR_MEMBER

文件 star_table.c:

typedef struct {
  #define EXPAND_EXPAND_STAR_MEMBER(member, type) type member;
  #include "star.def"
} starStruct;

void serialize_star(const starStruct *const star, unsigned char *buffer) {
  #define EXPAND_EXPAND_STAR_MEMBER(member, type) \
    memcpy(buffer, &(star->member), sizeof(star->member)); \
    buffer += sizeof(star->member);
  #include "star.def"
}

void deserialize_star(starStruct *const star, const unsigned char *buffer) {
  #define EXPAND_EXPAND_STAR_MEMBER(member, type) \
    memcpy(&(star->member), buffer, sizeof(star->member)); \
    buffer += sizeof(star->member);
  #include "star.def"
}

针对不同数据类型的处理程序可以通过令牌连接 ("##") 和字符串化 ("#") 运算符来创建和访问。例如,可以向上面的代码添加以下内容:

#define print_int(val)    printf("%d", val)
#define print_double(val) printf("%g", val)

void print_star(const starStruct *const star) {
  /* print_##type 会被替换成 print_int 或 print_double */
  #define EXPAND_EXPAND_STAR_MEMBER(member, type) \
    printf("%s: ", #member); \
    print_##type(star->member); \
    printf("\n");
  #include "star.def"
}

这个示例展示了如何利用 X-Macros 来避免重复代码,并自动化生成多个结构体成员的序列化、反序列化及其他操作。

X-Macros 进阶

在这个示例中,您还可以通过为每种支持的数据类型定义打印格式,来避免为每种数据类型创建单独的处理函数,此外,还能减少由头文件生成的扩展代码:

#define FORMAT_(type) FORMAT_##type
#define FORMAT_int    "%d"
#define FORMAT_double "%g"

void print_star(const starStruct *const star) {
  /* FORMAT_(type) 会被替换成 FORMAT_int 或 FORMAT_double */
  #define EXPAND_EXPAND_STAR_MEMBER(member, type) \
    printf("%s: " FORMAT_(type) "\n", #member, star->member);
  #include "star.def"
}

通过创建一个包含原文件内容的单一宏,可以避免创建单独的头文件。例如,上面的 "star.def" 文件可以通过以下宏来替代:

文件 star_table.c:

#define EXPAND_STAR \
  EXPAND_STAR_MEMBER(x, int) \
  EXPAND_STAR_MEMBER(y, int) \
  EXPAND_STAR_MEMBER(z, int) \
  EXPAND_STAR_MEMBER(radius, double)

然后所有的 #include "star.def" 调用都可以被简单的 EXPAND_STAR 语句替代。剩下的文件内容将变成:

typedef struct {
  #define EXPAND_STAR_MEMBER(member, type) type member;
  EXPAND_STAR
  #undef  EXPAND_STAR_MEMBER
} starStruct;

void serialize_star(const starStruct *const star, unsigned char *buffer) {
  #define EXPAND_STAR_MEMBER(member, type) \
    memcpy(buffer, &(star->member), sizeof(star->member)); \
    buffer += sizeof(star->member);
  EXPAND_STAR
  #undef  EXPAND_STAR_MEMBER
}

void deserialize_star(starStruct *const star, const unsigned char *buffer) {
  #define EXPAND_STAR_MEMBER(member, type) \
    memcpy(&(star->member), buffer, sizeof(star->member)); \
    buffer += sizeof(star->member);
  EXPAND_STAR
  #undef  EXPAND_STAR_MEMBER
}

打印处理程序也可以按如下方式添加:

#define print_int(val)    printf("%d", val)
#define print_double(val) printf("%g", val)

void print_star(const starStruct *const star) {
  /* print_##type 会被替换成 print_int 或 print_double */
  #define EXPAND_STAR_MEMBER(member, type) \
    printf("%s: ", #member); \
    print_##type(star->member); \
    printf("\n");
  EXPAND_STAR
  #undef EXPAND_STAR_MEMBER
}

或者:

#define FORMAT_(type) FORMAT_##type
#define FORMAT_int    "%d"
#define FORMAT_double "%g"

void print_star(const starStruct *const star) {
  /* FORMAT_(type) 会被替换成 FORMAT_int 或 FORMAT_double */
  #define EXPAND_STAR_MEMBER(member, type) \
    printf("%s: " FORMAT_(type) "\n", #member, star->member);
  EXPAND_STAR
  #undef EXPAND_STAR_MEMBER
}

另一种变体是避免在展开的子宏中需要知道成员,通过将操作符作为参数传递给列表宏:

文件 star_table.c:

/*
 通用宏
 */
#define STRUCT_MEMBER(member, type, dummy) type member;

#define SERIALIZE_MEMBER(member, type, obj, buffer) \
  memcpy(buffer, &(obj->member), sizeof(obj->member)); \
  buffer += sizeof(obj->member);

#define DESERIALIZE_MEMBER(member, type, obj, buffer) \
  memcpy(&(obj->member), buffer, sizeof(obj->member)); \
  buffer += sizeof(obj->member);

#define FORMAT_(type) FORMAT_##type
#define FORMAT_int    "%d"
#define FORMAT_double "%g"

/* FORMAT_(type) 会被替换成 FORMAT_int 或 FORMAT_double */
#define PRINT_MEMBER(member, type, obj) \
  printf("%s: " FORMAT_(type) "\n", #member, obj->member);

/*
 starStruct
 */

#define EXPAND_STAR(_, ...) \
  _(x, int, __VA_ARGS__) \
  _(y, int, __VA_ARGS__) \
  _(z, int, __VA_ARGS__) \
  _(radius, double, __VA_ARGS__)

typedef struct {
  EXPAND_STAR(STRUCT_MEMBER, )
} starStruct;

void serialize_star(const starStruct *const star, unsigned char *buffer) {
  EXPAND_STAR(SERIALIZE_MEMBER, star, buffer)
}

void deserialize_star(starStruct *const star, const unsigned char *buffer) {
  EXPAND_STAR(DESERIALIZE_MEMBER, star, buffer)
}

void print_star(const starStruct *const star) {
  EXPAND_STAR(PRINT_MEMBER, star)
}

这种方法可能存在一些风险,因为整个宏集始终被解释为单一源代码行,这可能会在复杂的组件宏和/或长成员列表时遇到编译器的限制。

这种技术最早由 Lars Wirzenius[14] 在 2000 年 1 月 17 日的网页中报告,他在其中对 Kenneth Oksanen 给予了“完善和发展”该技术的赞誉,早在 1997 年之前。其他参考文献则描述它作为一种早在世纪之交就存在的方法。

最后修改: 2025年01月12日 星期日 13:30