Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第十四章 宏

本章要点

在第一章中,我们第一次见到 #include 时提到过“预处理“——在编译之前,预处理器会先对源文件做一遍扫描。#include 把头文件内容复制过来,而本章要介绍的 #define,做的是另一件事:定义宏

宏的核心机制是编译前的文本替换——在代码被真正编译之前,把宏名替换成定义的内容。本章涵盖以下内容:

  • 最简单的宏:用 #define 给常量值起名
  • 带参数的宏(宏函数):像函数一样接受参数,但本质仍是文本替换
  • 宏的本质与陷阱:替换发生在编译前、没有类型检查、运算符优先级问题
  • 宏 vs 函数的取舍

理解宏的关键不在于记语法,而在于时刻意识到:宏不是 C 代码,宏是给预处理器看的文本替换规则。


一、最简单的宏

1.1 语法

#define 最基础的用法,是给一个固定不变的值起一个名字:

#define 宏名 替换内容

两点注意:

  • 末尾没有分号——预处理指令自成一行,不受 C 语句标点规则约束
  • 宏名习惯用全大写加下划线MAX_SIZEBUFFER_LEN)——这不是语法要求,而是社区约定,让读代码的人一眼就能分辨“这是宏“还是“变量“

1.2 一个完整的例子

#include <stdio.h>

#define MAX_SIZE 100
#define PI 3.1415926

int main(void)
{
    int scores[MAX_SIZE];                    // int scores[100];
    for (int i = 0; i < MAX_SIZE; i++)       // i < 100;
        scores[i] = i;

    double radius = 5.0;
    double area = PI * radius * radius;

    printf("数组大小 = %d\n", MAX_SIZE);
    printf("面积 = %.2f\n", area);
    return 0;
}

预处理阶段会把这里的 MAX_SIZE 替换为 100,把 PI 替换为 3.1415926。在需要整数常量表达式的场景(例如文件作用域数组长度、case 标签)中,值合适的宏可以使用,而 C 语言中的块作用域 const int 并不自动成为整数常量表达式。支持 VLA 的实现允许块作用域数组使用运行期长度,但本教程不依赖这一可选特性。

二、带参数的宏

#define 还可以在宏名后面加一对括号,里面写上参数——这种宏看起来像函数调用,大家习惯叫它“宏函数“。但它并不是什么新语法,仍然是 #define,仍然做文本替换,只是替换的时候会把实参文本填到形参的位置。

宏名和左括号之间不能有空格——#define SQUARE (x) 会被理解成无参宏 SQUARE,替换内容是 (x),和带参数的 SQUARE(x) 完全不同。

#include <stdio.h>

#define SQUARE(x)  ((x) * (x))
#define MAX(a, b)  ((a) > (b) ? (a) : (b))

int main(void)
{
    printf("%d\n", SQUARE(5));      // ((5) * (5)) → 25
    printf("%d\n", MAX(3, 7));      // ((3) > (7) ? (3) : (7)) → 7
    printf("%f\n", SQUARE(1.5));    // ((1.5) * (1.5)) → 2.25
    return 0;
}

SQUARE(5) 被替换成 ((5) * (5))——参数 5 填入了宏体中 x 的位置。

看起来和函数调用一样,但它不是函数调用:没有参数压栈、没有跳转、没有返回值传递。预处理器做的只是把实参文本填入宏体中形参的位置。至于这个“文本替换“的本质会带来什么问题,下一节集中讨论。

三、宏的本质:编译前的文本替换

上面两节学了最基本的宏和带参数的宏。现在来回答那个根本问题:宏到底是什么?

答案就一句话——宏是编译前的文本替换。预处理器不解析 C 语法,不检查类型,不做任何语义分析,它只做一件事:找到宏名,贴上宏体

明白了这一点,前面那些“为什么要加括号““为什么 a++ 会出事”“为什么多语句要用 do-while(0)”——全部可以推导出来。

3.1 用 -E 亲眼看看

在讲各种陷阱之前,先掌握一个最有力的工具。写一个程序:

// macro_demo.c
#include <stdio.h>

#define PI 3.14159
#define SQUARE(x)  ((x) * (x))

int main(void)
{
    double r = 2.0;
    double area = PI * SQUARE(r);
    printf("面积 = %f\n", area);
    return 0;
}

在终端中执行:

clang -E macro_demo.c      # Clang 用户
# 或
gcc -E macro_demo.c        # GCC 用户

-E 告诉编译器:只做预处理,不要编译。翻到输出末尾,main 函数变成了:

int main(void)
{
    double r = 2.0;
    double area = 3.14159 * ((r) * (r));
    printf("面积 = %f\n", area);
    return 0;
}

PI 不在了,变成了 3.14159SQUARE(r) 不在了,变成了 ((r) * (r))。编译器根本不知道 PISQUARE 曾经存在过——它只看到替换完毕的纯 C 代码。

从今以后,你写的每一个宏都可以这样亲眼验证。 下面要讲的括号陷阱、副作用陷阱、多语句问题,用 -E 展开一看,真相立即大白。

3.2 括号陷阱

回头看 SQUARE 的定义,括号似乎太多了:((x) * (x))。能不能简化成 x * x?做个实验:

#include <stdio.h>

#define SQUARE_BAD(x)   x * x
#define SQUARE_GOOD(x)  ((x) * (x))

int main(void)
{
    printf("SQUARE_BAD(1+2)   = %d\n", SQUARE_BAD(1 + 2));
    printf("SQUARE_GOOD(1+2)  = %d\n", SQUARE_GOOD(1 + 2));
    printf("100 / SQUARE_BAD(2)  = %d\n", 100 / SQUARE_BAD(2));
    printf("100 / SQUARE_GOOD(2) = %d\n", 100 / SQUARE_GOOD(2));
    return 0;
}

输出:

SQUARE_BAD(1+2)   = 5
SQUARE_GOOD(1+2)  = 9
100 / SQUARE_BAD(2)  = 100
100 / SQUARE_GOOD(2) = 25

-E 展开,真相一目了然:

SQUARE_BAD(1+2)   →   1 + 2 * 1 + 2       // 乘法优先:1+(2×1)+2 = 5
SQUARE_GOOD(1+2)  →   ((1 + 2) * (1 + 2)) // 括号先算:3×3 = 9

100 / SQUARE_BAD(2)  →   100 / 2 * 2       // 左到右:(100/2)×2 = 100
100 / SQUARE_GOOD(2) →   100 / ((2)*(2))   // 括号先算:100/4 = 25

根因就是文本替换:参数表达式被原样粘贴到宏体中。1+2 贴到 x * x 里变成 1+2*1+2,乘法优先于加法,计算顺序全乱了。外层括号同样不可省略——100 / SQUARE_BAD(2) 变成 100 / 2 * 2,除法先于宏体内的乘法执行。

铁律:每个参数加括号,整体再加一层括号。

3.3 副作用陷阱

即便加够了括号,还有另一个根因相同的问题:

#include <stdio.h>

#define SQUARE(x)  ((x) * (x))

int square_func(int x) { return x * x; }

int main(void)
{
    int a = 5;
#if 0
    // 错误示范被禁用:展开后同一完整表达式中出现两次无序的 a++。
    int r1 = SQUARE(a++);
    printf("SQUARE(a++): a=%d, result=%d\n", a, r1);
#endif
    printf("SQUARE(a++) 会产生未定义行为,因此没有执行;a=%d\n", a);

    int b = 5;
    int r2 = square_func(b++);
    printf("square_func(b++): b=%d, result=%d\n", b, r2);
    return 0;
}

若取消 #if 0 保护,SQUARE(a++) 会产生未定义行为,因此不存在可以依赖的标准输出。当前安全版本只解释错误,不执行它;函数调用部分稳定输出 b=6, result=25

SQUARE(a++) 会产生未定义行为,因此没有执行;a=5
square_func(b++): b=6, result=25

-E 展开 SQUARE(a++)

((a++) * (a++))

a++ 被贴了两次,两个对 a 的修改之间没有规定的求值顺序,行为未定义;不能简单概括成“稳定地递增两次”。函数 square_func(b++) 不同——实参表达式只出现一次,求值得到 5 后将值传入函数,因此 b 只递增一次。

核心区别:函数是先求值再传参,宏是先粘贴再求值

防御规则:永远不要在宏参数中放带副作用的表达式——++--、赋值、以及会修改状态的函数调用。需要安全性时,用函数。

3.4 多语句宏

再来看一个更隐蔽的问题。定义一个“交换两个变量“的宏:

#define SWAP(a, b)  \
    int temp = a;    \
    a = b;           \
    b = temp;

反斜杠 \ 是续行符。如果用在 if 后面:

if (x > y)
    SWAP(x, y);
else
    printf("x <= y\n");

-E 展开后:

if (x > y)
    int temp = x;    // 只有这一行归 if 管
    x = y;           // 脱离了 if,不管条件真假都会执行
    y = temp;        // else 也找不到配对的 if 了
else
    printf("x <= y\n");

三行语句被原样粘贴if 后面,但 if 默认只管紧接着的第一条语句——后面两行脱离了控制,else 也断了关联。根因仍然是文本替换:预处理器只管粘贴,不管 C 的语法结构。

标准解决方案——用 do { ... } while(0) 把宏体包成一条整体语句:

#define SWAP(a, b)  \
    do {             \
        int _temp = a; \
        a = b;       \
        b = _temp;   \
    } while(0)

展开后:

if (x > y)
    do { int _temp = x; x = y; y = _temp; } while(0);
else
    printf("x <= y\n");

do { ... } while(0) 是一条完整的语句,if 正好管住它。while(0) 循环体只执行一次,编译器会优化掉循环结构,零运行时开销。这是 C 语言中多语句宏的标准写法。