X
即变化
X-Macro
早在C语言被创造之前,就已在带预处理器的汇编器中得到运用。也就是说,今天介绍的宏魔法X
并不依赖C语言的任何Hack特性,它只是一类纯粹的代码文本处理技巧。所以,这期的案例会更聚焦于X-Macro
的应用场景而非C语言的各种技巧。
| 基础X-Macro
X-Macro
的核心思想是将一组定义放在一个宏列表中,然后通过多次展开这个宏列表,生成重复性代码(例如函数声明、结构体初始化等),从而减少代码重复和维护成本。
可以说,X-Macro
技巧是根据不变
的表项生成不同的代码,X
正是那变化
的部分。
大家可以先看看这个简单的MP3程序示例中实现的X-Macro
:
#define MP3_TABLE X(M_PLAY, play)
X(M_VOLUME, volume)
X(M_SONG, cur_song_id)
// 1. 定义MP3操作枚举
#define X(a, b) a,
enum mp3_option_e {
MP3_TABLE
MUSIC_OP_MAX
};
#undef X
// 2. 定义MP3状态结构体
#define X(a, b) int b;
struct mp3_status_s {
MP3_TABLE
};
#undef X
// 3. 定义MP3操作索引表和操作接口函数
#define X(a, b) {#b, mp3_set_##a},
struct {
char *op_name;
int (*handle)(struct mp3_status_s *status);
} g_mp3_op_table[] = {
MP3_TABLE
};
int mp3_option_handle(enum mp3_option_e op, struct mp3_status_s *status)
{
int ret = g_mp3_op_table[op].handle(status);
if (ret != 0) {
printf("option[%s] failedn");
return ret;
}
return 0;
}
#undef X
// 4. 定义MP3状态打印函数
#define X(a, b) printf("%s: %d", #b, status->b);
void mp3_status_print(struct mp3_status_s *status)
{
MP3_TABLE
}
#undef X
以上示例展示了X-Macro
的2项能力——表项定义、代码生成。
让我们来解析这段代码:
-
MP3_TABLE
:作为X
宏的主宏
,利用X
宏定义3组MP3相关表项信息 -
X(a, b) a,
:定义MP3操作枚举。在使用完毕后#undef X
-
X(a, b) int b;
:定义MP3状态结构体 -
X(a, b) {#b, mp3_set_##a},
:定义MP3操作索引表和操作接口函数 -
X(a, b) printf("%s: %d", #b, status->b);
:定义MP3状态打印函数
我们根据应用场景中那些不变
的约束,筛选出了MP3操作表项信息中的有效信息,并通过X
宏将这些有效信息生成出程序中所需要生成的各种信息,做到了以不变,应万变
。
这么做的好处是使代码更聚焦和规范,当表项中需要新增操作时不容易遗漏相关联处代码更改,同时通过代码生成,减少了代码行数。
当看到这里后,如果你想用
X
宏重构你的代码,请十分注意以下几点:
1、使用X
宏的程序中不变
的约束能够满足需求吗?
例如:当MP3新增操作功能时,能否满足当前的约束?
① 当新增播放模式
功能时,仍可以满足约束,只需新增X(M_MODE, mode)
② 但新增快进/快退
功能时,由于MP3不存在快进/快退状态,新增此功能会破坏原来新增表项即为MP3状态结构体字段
这一约束,所以如果需求中有此功能,MP3状态结构体应手动定义而非由X
宏生成
2、使用X
宏带来的收益
是否超过其带来的成本
?X
宏带来的好处是有成本的:
① 规范和约束了代码框架,同时降低了框架的灵活性
② 宏间接生成文本,使编辑器全局搜索变量和函数更困难,程序调试成本也会上升
| X
作为参数
上一节基础X-Macro
的案例中,为了避免宏命名冲突,我们在使用完X
宏后都需要有#undef X
的步骤,这里仍有优化空间。
以下程序通过将MP3程序示例中的X
宏作为参数传入,可以避免命名冲突,提高代码可读性:
#define MP3_TABLE(X) X(M_PLAY, play)
X(M_VOLUME, volume)
X(M_SONG, cur_song_id)
// 1. 定义MP3操作枚举
#define MP3_ENUM(a, b) a,
enum mp3_option_e {
// 后续MP3_TABLE替换同理,节约篇幅考虑仅列第1个
MP3_TABLE(MP3_ENUM)
MUSIC_OP_MAX
};
// 2. 定义MP3状态结构体
#define MP3_STRUCT_FIELD(a, b) int b;
// 3. 定义MP3操作索引表和操作接口函数
#define MP3_OP_ELEMENT(a, b) {#b, mp3_set_##a},
// 4. 定义MP3状态打印函数
#define MP3_STATUS_PRINT(a, b) printf("%s: %d", #b, status->b);
实际应用
实际项目中,X-Macro
一般在表行数较多的场景使用,有时为了与一般头文件做区分,会将表放在.def
或.tbl
文件中。为了实现X-Macro
效果,该文件不应加入一般头文件的仅单次包含保护,而应在X
宏定义后,在需要的地方引用.def
或.tbl
文件。
| LLVM
LLVM
是C++
项目,其大量运用了X-Macro
,以LLVM IR
所定义的指令为例,以下列出核心部分:
拓展知识:
前端: 源代码分析,主要是Clang
,为LLVM IR
屏蔽了高级语言差异。
后端: 目标代码生成,主要是LLVM CodeGen
和LLVM Target
,为LLVM IR
屏蔽了硬件平台差异。
LLVM IR:LLVM
编译器框架的核心部分,其被设计为与平台无关,起到连接前端与后端之间的桥梁作用,不同前端与后端可以使用相同的LLVM IR
。
llvm/include/llvm/IR/Instruction.def
:HANDLE_TERM_INST
或HANDLE_INST
为X
#ifndef HANDLE_TERM_INST
#ifndef HANDLE_INST
#define HANDLE_TERM_INST(num, opcode, Class)
#else
#define HANDLE_TERM_INST(num, opcode, Class) HANDLE_INST(num, opcode, Class)
#endif
#endif
FIRST_TERM_INST ( 1)
HANDLE_TERM_INST ( 1, Ret , ReturnInst)
...
HANDLE_TERM_INST (11, CallBr , CallBrInst) // A call-site terminator
LAST_TERM_INST (11)
以下为2个典型的X-Macro
应用:
llvm/include/llvm/IR/Instruction.h
enum TermOps { // These terminate basic blocks
#define FIRST_TERM_INST(N) TermOpsBegin = N,
#define HANDLE_TERM_INST(N, OPC, CLASS) OPC = N,
#define LAST_TERM_INST(N) TermOpsEnd = N+1
#include "llvm/IR/Instruction.def"
};
llvm/lib/IR/Core.cpp
static LLVMOpcode map_to_llvmopcode(int opcode)
{
switch (opcode) {
default: llvm_unreachable("Unhandled Opcode.");
#define HANDLE_INST(num, opc, clas) case num: return LLVM##opc;
#include "llvm/IR/Instruction.def"
#undef HANDLE_INST
}
}
| Linux
Linux
内核代码中并未使用X-Macro
技术,而是通过.tbl文件+生成脚本
实现了代码生成功能。
例如,Linux
的系统调用表(syscall.tbl
)的定义就采用了此方式,这里仅以其中一个为例:
拓展知识:
Linux
内核支持多种硬件架构(x86、arm、risc-v、mips、powerpc等),通过为不同硬件平台编写一套syscall.tbl
屏蔽不同硬件平台差异。事实上,Linux
内核主干代码中有十来种架构对应的syscall.tbl
,不同硬件平台间差异很大。
arch/x86/entry/syscalls/syscall_64.tbl
# The format is:
# <number> <abi> <name> <entry point> [<compat entry point> [noreturn]]
0 common read sys_read
1 common write sys_write
2 common open sys_open
...
该文件经过
scripts/syscalltbl.sh
的解析后,会生成类似以下内容:
asmlinkage long (*sys_call_table[])(const struct pt_regs *) = {
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
[3] = sys_close,
...
};
更进一步
读者可以思考下,为什么同样是大型的C/C++
项目,面对代码生成的需求时,LLVM IR
对指令表使用了X-Macro
技巧,而Linux
内核中系统调用表却选择使用脚本+文本
生成呢?
以下是笔者的理解:
LLVM IR
指令表与Linux
内核系统调用表的处境差异很大:
1、LLVM IR
指令表不需要考虑不同平台差异,仅需维护一份指令表即可;而Linux
内核系统调用表需要考虑不同平台差异,其.tbl文件+生成脚本
的代码生成方式能够很好地维护不同平台系统调用表差异
2、X-Macro
本质上是集成在代码中的文本操作,使用的核心是找到1套
不变的表项信息,并由X
来应对编写表项信息在代码不同处的使用方式;而Linux
内核需要面对多套
表项信息,无法使用X-Macro