C语言递归宏详解

一、先把地基压实:C 宏并不会"真递归"

C 标准(以 C17 为例)规定了宏展开的单次扫描不立即再次展开相同标识符 的规则。简化地说,这段代码不会像函数那样在运行期一层层压栈,而是在编译前由预处理器做一次(或多次)符号替换。

c 复制代码
#define SELF(X) SELF(X)  // 期待"递归"?不会成功
int x = SELF(1);         // 预处理器直接拒绝/死循环保护

要点 :所谓"递归宏",实际上是通过构造"延迟展开(defer)"和"多轮扫描(rescan)",让预处理器在后续轮次再去展开目标,从而模拟"递归"的效果。

二、故事线索:一份命令表,三处复用

项目里有一张"命令定义清单"。经典解法是 X-Macro:把数据集中放在一个表里,用不同的宏姿势多次"播放"。

c 复制代码
// commands.def ------ 只维护这一份
// 格式: X(标识符, "字符串", 编码)
X(CMD_FOO, "foo", 0x01)
X(CMD_BAR, "bar", 0x02)
X(CMD_BAZ, "baz", 0x03)

然后在不同位置这样用:

c 复制代码
// 1) 枚举
#define X(name, str, code) name = code,
enum Command {
#include "commands.def"
};
#undef X

// 2) 字符串数组
#define X(name, str, code) [code] = str,
const char* kCmdName[0x100] = {
#include "commands.def"
};
#undef X

这已经解决了 "一处维护、多处生成" 的大半问题。可一旦想"遍历变长参数 "或"编出嵌套结构",就会撞到 X-Macro 的边界,于是"递归宏"的戏份来了。

三、递归宏的核心技巧:延迟与阻断

想象成一句"别现在展开,等下一轮再说"。常见做法是准备三个工具宏:

c 复制代码
#define EMPTY()
#define DEFER(id) id EMPTY()            // 把 id 的展开推迟一轮
#define OBSTRUCT(...) __VA_ARGS__ DEFER(EMPTY)() // 阻断+再延期
#define EXPAND(...) __VA_ARGS__        // 有时配合编译器做再展开

它们本身不神秘,重点在让预处理器别一次性把所有层级吃完。这样才能模拟"你先记着,下一口再嚼"的节奏。

例子1:给可变参数"逐个套壳"(MAP)

目标:把 MAP(WRAP, A, B, C) 变成 WRAP(A) WRAP(B) WRAP(C)。一份最小可用(且便于理解)的实现如下:

c 复制代码
// 判空与"还有参数吗"的探针(简化版)
#define EVAL(...)  EVAL1(EVAL1(EVAL1(__VA_ARGS__)))
#define EVAL1(...) EVAL2(EVAL2(EVAL2(__VA_ARGS__)))
#define EVAL2(...) __VA_ARGS__

#define CAT(a,b) a##b
#define PROBE() ~, 1
#define IS_PROBE(...) CAT(IS_PROBE_, __VA_ARGS__)
#define IS_PROBE_~ , 0

#define SECOND(a, b, ...) b
#define NOT(x) IS_PROBE(CAT(PROBE_ , x))
#define BOOL(x) NOT(NOT(x))
#define IF(c) CAT(IF_, c)
#define IF_1(t, ...) t
#define IF_0(t, ...) __VA_ARGS__

// 取第一个参数与余下参数
#define HEAD(x, ...) x
#define TAIL(x, ...) __VA_ARGS__

// 检测是否还有参数(极简近似)
#define HAS_ARGS(...) BOOL(SECOND(__VA_ARGS__ , ~))

// 递归样式的 MAP
#define MAP(f, first, ...)            \
  f(first)                            \
  IF(HAS_ARGS(__VA_ARGS__))(          \
      OBSTRUCT()(DEFER(MAP)()(f, __VA_ARGS__)) \
  , /* empty */)

#define WRAP(x) [x]

/* 使用 */
EVAL(MAP(WRAP, A, B, C))  // 结果: [A] [B] [C]

这里的关键就是 OBSTRUCTDEFER:当 MAP 需要"再处理余下参数"时,它不立刻调用自己,而是故意推迟一个展开节拍,交给下一轮扫描,于是在视觉上形成了"递归"。

例子2:把键值对转成结构初始化

当命令表变复杂,可能出现嵌套:

c 复制代码
// pairs.def
P(kFoo,  1)
P(kBar,  2)
P(kBaz,  3)

想要生成:

c 复制代码
struct Item { const char* name; int code; };
struct Item items[] = {
  {"kFoo", 1}, {"kBar", 2}, {"kBaz", 3}
};

可以在 P 前后套"递归 MAP":

c 复制代码
#define AS_ITEM(pair_name, code) { #pair_name, code },

#define LIST(...) EVAL(MAP(AS_ITEM, __VA_ARGS__))

struct Item items[] = {
  LIST(
    // 让 def 文件只管内容,外面用"展开器"来遍历
    #define P(name, code) name, code
    #include "pairs.def"
    #undef P
  )
};

四、工程可落地的三板斧

1)把"算法"封到独立头文件 :例如 pp_map.h 专管 MAP/CHAIN/REPEAT 等。业务侧只 include,不反复复制宏魔法。

2)给每条"魔法"写一次"快照单测" :对编译后产物用 static_assert 或数组大小校验,确保未来改动不把生成结果弄坏。

3)设置最大深度 :递归宏最终还是有限步"吃完"。为 MAP 等提供"最大次数保险丝",避免极端输入卡在预处理阶段。

c 复制代码
// 例如硬性限制最多处理 64 项
#define LIMIT_64(...) __VA_ARGS__
#define MAP_LIMITED(f, ...) LIMIT_64(EVAL(MAP(f, __VA_ARGS__)))

五、与现成库牵手:Boost.Preprocessor 的启发

C++ 项目常用的 Boost.Preprocessor 在 C 代码里也能借鉴思想(宏只是预处理期产物)。它提供 BOOST_PP_REPEATBOOST_PP_SEQ_FOR_EACH 等成熟原语,足以覆盖多数"列表扫描""计数""拼接"需求。
Boost.Preprocessor 官网
https://www.boost.org/doc/libs/release/libs/preprocessor/

六、再回到故事:三处生成,一处真相

命令清单只维护在一处;

通过 X-Macro 完成"结构性多次播放 ";

在需要"变长/嵌套遍历"的地方,用**递归宏(延迟展开)**把"列表加工"补齐。

落地后,增删命令就只改一行,枚举、字符串、解析器保持同步。

七、边界与避坑清单(工程经验浓缩)

  • 可读性 :宏层层交错非常晦涩。务必写注释,尤其解释 DEFER/OBSTRUCT 的意图与"多轮扫描"的必要性。
  • 编译器差异 :不同编译器对"再次扫描"的策略略有差别;遇到怪异行为,优先用 EVAL/EVAL1/EVAL2... 逼出足够的展开轮次。
  • 调试手段-E 导出预处理结果;或在 IDE 里开启"预处理产物"查看。
  • 边做边收敛:一旦逻辑成长为"编程语言中的编程语言",要审视是否引入 Python/生成脚本更合适。

九、再给两个"像真递归"的小玩具

计数:编译期统计参数个数

c 复制代码
#define ARG_N( \
 _1,_2,_3,_4,_5,_6,_7,_8,_9,_10, \
 _11,_12,_13,_14,_15,_16,_17,_18,_19,_20, \
 N, ...) N
#define RSEQ() \
  20,19,18,17,16,15,14,13,12,11,10, \
   9, 8, 7, 6, 5, 4, 3, 2, 1, 0

#define NARGS_(...) ARG_N(__VA_ARGS__)
#define NARGS(...) NARGS_(__VA_ARGS__, RSEQ())

// 用法
static int a[NARGS(A,B,C) == 3 ? 1 : -1]; // 通过即为 3

在宏里"折叠"一棵小表达式树

c 复制代码
#define PLUS(a,b) ((a)+(b))
#define TIMES(a,b) ((a)*(b))

// FOLD(PLUS, 0, 1,2,3) => (((0+1)+2)+3)
#define FOLD(op, init, first, ...) \
  op(init, first) \
  IF(HAS_ARGS(__VA_ARGS__))( \
    OBSTRUCT()(DEFER(FOLD)()(op, /* init= */ op(init, first), __VA_ARGS__)) \
  , /* empty */)

十、参考资料(中途穿插,便于继续深挖)

GCC 预处理器手册:
https://gcc.gnu.org/onlinedocs/cpp/

Clang -E 预处理输出说明:
https://clang.llvm.org/docs/CommandGuide/clang.html#preprocessing-options

Boost.Preprocessor(宏元编程原语):
https://www.boost.org/doc/libs/release/libs/preprocessor/

相关推荐
csbysj20202 小时前
C 标准库 - `<ctype.h>`
开发语言
郝学胜-神的一滴2 小时前
计算机图形中的法线矩阵:深入理解与应用
开发语言·程序人生·线性代数·算法·机器学习·矩阵·个人开发
百锦再2 小时前
第8章 模块系统
android·java·开发语言·python·ai·rust·go
ue星空2 小时前
全局描述符表GDT (Global Descriptor Table)
c++
芯联智造2 小时前
【stm32简单外设篇】- HC-SR501 / 人体红外被动红外传感器
c语言·stm32·单片机·嵌入式硬件
m0_591338912 小时前
day8鹏哥C语言--函数
c语言·开发语言·算法
oplp2 小时前
回过头来重新对C语言进行深度学习(一)
c语言·开发语言
oioihoii3 小时前
C++中的多态:动态多态与静态多态详解
java·开发语言·c++
毕设源码-朱学姐3 小时前
【开题答辩全过程】以 基于Java的医务室病历管理小程序为例,包含答辩的问题和答案
java·开发语言·小程序