C 预处理器宏是 C/C++ 中最古老也最具争议的特性之一。它发生在编译之前——纯文本替换,没有类型检查、没有作用域、调试时不可见。尽管现代 C++ 已为绝大多数宏场景提供了更安全的替代方案,宏仍大量存在于系统编程、跨平台代码和第三方库中。理解宏的常见用法,既是阅读遗留代码的必备技能,也是避免在新代码中误用的前提。

常量定义

最古老的用法——用宏定义编译期常量:

1
2
3
4
#define PI        3.14159265
#define MAX_SIZE 1024
#define VERSION "2.1.0"
#define EOF (-1)

问题:宏常量没有类型,不尊重作用域,调试器看不到宏的名字(只看到替换后的字面量),且容易引发重定义冲突。

现代替代constexpr(C++11)提供类型安全、作用域正确、调试可见的编译期常量:

1
2
3
constexpr double pi       = 3.14159265;
constexpr int max_size = 1024;
constexpr auto version = "2.1.0"; // const char[5]

头文件卫士

防止头文件被重复包含的经典模式:

1
2
3
4
5
6
#ifndef MY_HEADER_H
#define MY_HEADER_H

// ... 声明 ...

#endif // MY_HEADER_H

现代替代

  • #pragma once——非标准但所有主流编译器支持,更简洁且编译更快
  • C++20 模块——从根本上消除了头文件包含模型,但迁移成本高
1
2
#pragma once
// ... 声明 ...

条件编译与平台适配

根据平台、编译器或特性可用性选择不同代码路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 平台检测
#ifdef _WIN32
#include <windows.h>
#define PATH_SEP '\\'
#elif defined(__linux__)
#include <unistd.h>
#define PATH_SEP '/'
#endif

// 编译器特性检测
#if __cplusplus >= 202002L
// C++20 路径
constexpr bool has_concepts = true;
#else
constexpr bool has_concepts = false;
#endif

// 调试/发布切换
#ifdef NDEBUG
#define LOG(x) ((void)0)
#else
#define LOG(x) std::cerr << x << '\n'
#endif

现代替代

  • if constexpr(C++17)可在编译期分支,但仍需模板上下文
  • [[maybe_unused]] 消除未使用参数的警告,替代某些条件编译
  • std::is_constant_evaluated()(C++20)/ if consteval(C++23)区分编译期与运行期路径
  • CMake 生成 config.hpp 比手写 #ifdef 更可维护

带参数的宏(类函数宏)

宏可以接受参数,实现简单的"函数"效果:

1
2
3
4
5
6
#define SQUARE(x)      ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))
#define MAKE_PTR(T) std::make_unique<T>()
#define TO_STRING(x) #x // 转字符串
#define CONCAT(a, b) a ## b // 拼接标识符

典型陷阱

1
2
int i = 3;
int val = SQUARE(++i); // 展开为 ((++i) * (++i)),i 自增两次!

括号只能防止优先级问题,无法防止参数的多次求值。

现代替代

1
2
3
4
5
6
7
8
9
10
// 内联函数 / constexpr 函数:类型安全、单次求值
constexpr auto square(auto x) { return x * x; }

// 函数模板
template <typename T>
constexpr T max_val(T a, T b) { return a > b ? a : b; }

// `#` 和 `##` 运算符没有直接替代,但可用:
// - std::to_string / fmt::format 替代 #x
// - 模板特化 / 变参模板替代 ## 拼接

预处理运算符:# 与

#(stringification)和 ##(token pasting)是宏参数专用的两个运算符,无法在普通代码中使用。

# 字符串化运算符

将宏参数原样转为字符串字面量:

1
2
3
4
5
6
7
8
9
10
11
12
#define TO_STR(x)   #x
#define STR(x) TO_STR(x) // 二次间接,允许宏参数先展开

TO_STR(hello); // "hello"
TO_STR(123); // "123"
TO_STR(std::vector); // "std::vector"

// 二次间接的典型用途:把宏的值而非名字转为字符串
#define VERSION_MAJOR 2
#define VERSION_MINOR 1
STR(VERSION_MAJOR); // "2"(先展开宏,再字符串化)
TO_STR(VERSION_MAJOR); // "VERSION_MAJOR"(直接字符串化,不展开)

常见用途

  • 断言消息中嵌入表达式文本
  • 枚举值与字符串映射
  • 日志中自动打印变量名
1
2
3
4
5
// 自动打印变量名与值
#define DBG_VAR(x) std::cerr << #x << " = " << (x) << '\n'

int count = 42;
DBG_VAR(count); // 输出: count = 42

现代替代:C++ 反射(未来)可原生获取标识符名;当前可用 std::to_string() 转值,但无法自动获取变量名。# 运算符在需要"名字即字符串"的场景中仍无可替代。

## 记号拼接运算符

将两个记号(token)拼接为一个新记号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define CONCAT(a, b)    a ## b
#define XCONCAT(a, b) CONCAT(a, b) // 二次间接,允许宏参数先展开

CONCAT(var, 1); // var1
CONCAT(func, _impl); // func_impl

// 生成类名
#define MAKE_CLASS(name) \
class name ## _handler { \
public: \
void process(); \
};

MAKE_CLASS(click); // 生成 class click_handler { ... };

常见用途

  • 生成相关标识符(var_1, var_2, …)
  • 函数重载/变体的命名约定(func_i32, func_f64
  • __LINE__ 配合生成唯一标识符,避免重定义
1
2
3
4
5
6
7
8
9
10
// 利用 __LINE__ 生成唯一变量名
#define UNIQUE_NAME(prefix) CONCAT(prefix, __LINE__)
#define UNIQUE_VAR UNIQUE_NAME(var_)

// 更健壮的写法(两次间接确保 __LINE__ 先展开)
#define _CONCAT(a, b) a ## b
#define _UNIQUE_NAME(prefix) _CONCAT(prefix, __LINE__)
#define UNIQUE_VAR _UNIQUE_NAME(var_)

int UNIQUE_VAR = 1; // 在第 42 行展开为 int var_42 = 1;

现代替代

  • 模板特化 / 变参模板可替代部分拼接场景
  • 代码生成脚本(构建期产出 .hpp)可替代批量拼接
  • ## 在需要"编译期合成标识符"时仍无可替代——C++ 类型系统无法在编译期拼出新的标识符

VA_ARGS 与变参宏

C99 引入 __VA_ARGS__,表示宏的剩余参数,广泛用于日志/断言宏:

1
2
3
4
5
6
7
8
9
// 基本变参宏
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)
LOG("value = %d, name = %s", val, name);

// GCC/Clang 扩展:##__VA_ARGS__ 消除末尾逗号
// 当 __VA_ARGS__ 为空时,标准写法会留下多余逗号
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
LOG("hello"); // 无额外参数,## 消除末尾逗号
LOG("val = %d", 42); // 正常展开

常见用途

  • 日志宏(LOG_INFO, LOG_DEBUG)——自动注入文件名、行号
  • 断言宏——自定义断言失败消息
  • 包装函数调用——注入前置/后置逻辑
1
2
3
4
5
6
7
8
9
// 带文件名和行号的日志宏
#define LOG_INFO(fmt, ...) \
do { \
std::printf("[INFO %s:%d] " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)

LOG_INFO("started"); // [INFO main.cpp:42] started
LOG_INFO("count = %d", count); // [INFO main.cpp:43] count = 42

现代替代

  • std::format / std::print(C++20/23)替代 printf 风格格式化
  • std::source_location(C++20)替代 __FILE__/__LINE__ 宏,获取调用点信息无需宏包装
1
2
3
4
5
6
7
// C++20:无需宏即可获取调用位置
void log_info(std::string_view msg,
std::source_location loc = std::source_location::current()) {
std::println("[INFO {}:{}] {}", loc.file_name(), loc.line(), msg);
}

log_info("started"); // 自动捕获文件名和行号

其他常见预处理指令

除了 #define / #ifdef 系列,预处理器还提供以下指令:

#include 变体

1
2
3
#include <header>      // 系统头文件,搜索标准路径
#include "header" // 用户头文件,先搜索当前目录,再搜标准路径
#include HEADER_MACRO // 宏展开后包含——极少使用,但构建系统偶有需求

#error 与 #warning

编译期报错/警告,用于强制约束编译条件:

1
2
3
4
5
6
7
#if !defined(__cplusplus) || __cplusplus < 202002L
#error "This library requires C++20 or later"
#endif

#if defined(_WIN32) && defined(__GNUC__)
#warning "MinGW on Windows has limited support for some features"
#endif

C23 / C++23 引入 #warning 为标准指令(此前是 GCC/Clang 扩展)。

#pragma

编译器专属指令,不可移植但广泛使用:

1
2
3
4
5
6
7
8
9
#pragma once                    // 头文件卫士(替代 #ifndef guard)
#pragma pack(push, 1) // 结构体 1 字节对齐(协议/文件格式)
struct Packet { ... };
#pragma pack(pop) // 恢复默认对齐

#pragma GCC diagnostic push // GCC/Clang:临时关闭警告
#pragma GCC diagnostic ignored "-Wold-style-cast"
// ... 不得不使用旧式转换的代码 ...
#pragma GCC diagnostic pop

#line

修改编译器报告的行号和文件名,用于代码生成器或测试框架:

1
#line 100 "generated_code.hpp"  // 让编译器认为下一行是 generated_code.hpp 的第 100 行

预定义宏

编译器内置的标识宏,提供编译环境信息:

含义示例值
__FILE__当前源文件名"main.cpp"
__LINE__当前行号42
__func__当前函数名(C++11 标准)"main"
__cplusplusC++ 标准版本202302L (C++23)
__DATE__编译日期"Jul 7 2026"
__TIME__编译时间"09:28:02"

现代替代std::source_location(C++20)替代 __FILE__/__LINE__/__func__,类型安全且可隐式传递。__cplusplus 和特性测试宏(__cpp_lib_*)仍需宏——语言本身无法查询自身版本。

代码生成与 X-Macro 惯用法

X-Macro 是一种用宏实现"数据驱动代码生成"的经典技巧——维护一张表,通过不同"展开器"生成枚举、字符串、switch 分支等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// color.def —— 数据源(唯一真相)
// X(枚举值, 显示名, RGB)
#define COLOR_LIST \
X(Red, "Red", 0xFF0000) \
X(Green, "Green", 0x00FF00) \
X(Blue, "Blue", 0x0000FF)

// 生成枚举
enum class Color {
#define X(id, name, rgb) id,
COLOR_LIST
#undef X
};

// 生成 to_string
const char* to_string(Color c) {
switch (c) {
#define X(id, name, rgb) case Color::id: return name;
COLOR_LIST
#undef X
}
return "Unknown";
}

// 生成 RGB 查找
int to_rgb(Color c) {
switch (c) {
#define X(id, name, rgb) case Color::id: return rgb;
COLOR_LIST
#undef X
}
return 0;
}

新增颜色只需改 COLOR_LIST 一处,所有生成代码自动同步。

现代替代

  • C++14 聚合初始化 + std::array 查找表
  • C++20 std::format + 反射库(未来)
  • 代码生成脚本(Python/Json)在构建期产出 .hpp
  • 简单场景下手写枚举 + std::to_underlying(C++23)往往更清晰

编译期断言

在 C++11 之前,static_assert 不存在,宏是唯一的编译期断言手段:

1
2
3
4
5
// C99 风格(负数组大小触发编译错误)
#define STATIC_ASSERT(cond) \
typedef char static_assert_##__LINE__[(cond) ? 1 : -1]

STATIC_ASSERT(sizeof(int) == 4);

现代替代static_assert(C++11),C++17 起甚至可以省略消息字符串:

1
2
static_assert(sizeof(int) == 4);
static_assert(sizeof(int) == 4, "int must be 4 bytes"); // 带消息

特性查询与特性测试宏

C++20 引入特性测试宏(__has_cpp_attribute__cpp_lib_*__cpp_feature_*),让条件编译基于语言特性而非标准版本:

1
2
3
4
5
6
7
8
9
10
11
12
#if __has_cpp_attribute([[likely]])
#define LIKELY [[likely]]
#else
#define LIKELY
#endif

#if __cpp_lib_span >= 202002L
#include <span>
using View = std::span<const int>;
#else
using View = const std::vector<int>&;
#endif

这类宏是条件编译的正当用途——在没有模块/反射的 C++ 中,这是适配不同编译器与标准库版本的唯一可靠方式。

常见陷阱汇总

陷阱示例后果
多次求值SQUARE(++i)副作用重复执行
优先级泄漏#define DOUBLE(x) x * 2DOUBLE(1+3) 展开为 1+3 * 2运算结果错误
分号吞没#define FOO() do_something()if (c) FOO(); else ...else 悬空
无类型检查#define MAX(a,b) ((a)>(b)?(a):(b)) 传不同类型隐式转换、比较无意义
调试不可见断点无法停在宏内无法单步调试
命名冲突宏无视作用域,污染全局重定义、意外替换

防御性写法——如果必须写宏:

1
2
3
4
5
6
7
8
9
// 1. 参数加括号,整体加括号
#define MAX(a, b) ((a) > (b) ? (a) : (b))

// 2. 多语句用 do-while(0) 包裹,保证分号安全
#define SWAP(a, b) do { auto _tmp = (a); (a) = (b); (b) = _tmp; } while(0)

// 3. 避免参数多次求值:用内联函数替代
// 4. 命名全大写 + 前缀,减少冲突
#define MYLIB_MAX(a, b) ...

宏 vs 现代替代速查

宏用法现代替代标准
#define PI 3.14constexpr double pi = 3.14;C++11
#define MAX(a,b)constexpr auto max_val = [](auto a, auto b) { return a > b ? a : b; };C++20
#define ASSERT(cond)static_assert(cond)C++11
#ifndef GUARD#pragma once非标准/广泛支持
#define LOG(x)if constexpr / [[maybe_unused]]C++17
#define TO_STR(x)std::to_string() / fmt::format()C++11/外部
X-Macro聚合初始化 + 查找表 / 代码生成C++14
__cpp_lib_*(正当用途,暂无替代)C++20

何时仍需用宏

宏并非一无是处,以下场景宏仍是合理选择:

  • 特性检测__has_cpp_attribute__cpp_lib_* 等编译器提供的特性宏
  • 跨平台适配:不同操作系统/编译器的 #ifdef 分支(CMake 可减轻但无法完全消除)
  • 构建配置NDEBUGDEBUG 等由构建系统注入的开关
  • 第三方库接口:许多 C 库(SQLite、Lua、zlib)的 API 本身就是宏
  • #include 路径拼接## 运算符在构建动态包含路径时仍无可替代

原则:能用 constexpr/inline/template/static_assert 解决的,就不要用宏;宏只在"编译器需要看到但语言无法表达"的层面发挥作用。