在C++里聊反射,十有八九会被人当成想做天方夜谭。我最初被“C++编译期反射”逼着研究这个问题,是因为做自研引擎的存档系统:结构体加一个字段,序列化函数就要跟着改一个,漏改一个就是存档损坏。后来做编辑器Inspector、配置文件自动解析、日志面板自动KV展示,发现同一个问题又重复了一遍。这才下定决心认真把编译期反射这套东西捞出来,做一个真正能用在业务里的框架。
这篇文章我打算把路线选型、核心实现、实战案例和踩坑记录都展开讲。所有代码都是在C++17标准下能直接跑的,不依赖任何第三方库,也不依赖Clang的AST工具链。适合自研引擎、轻量ORM、接口调试工具,或者对模板元编程和宏魔法有点兴趣的C++开发。如果你只关心“怎么快速给结构体加个名称到字段的映射”,这里有直接能抄的作业。
1. 项目背景:C++反射为何要从编译期切入
1.1 没有反射时,日常C++开发有多痛
先说说没用反射之前我们是怎么写序列化的。常规做法是给每个结构体手写一对序列化和反序列化函数,或者用宏在结构体声明旁边再补一份注册代码。痛点在于:字段一旦增多,业务代码和序列化代码会形成两份维护源。比如这个结构体:
cpp复制struct PlayerSave {
std::string name;
int level;
int hp;
int mp;
float posX;
float posY;
};
手写序列化时,你得写六个字段的赋值,再写六个字段的读取。这还只是单层结构,如果结构体里嵌套背包列表、任务进度Map、技能快捷栏,代码量会爆炸。更可怕的是我常犯的错:给PlayerSave加了一个gold字段,忘了在序列化函数里补,编译不报错,运行期存档静默丢数据。这比编译期报错难查一百倍。
这类需求在编辑器Inspector里也一样。要做属性面板,就得把结构体字段映射到UI控件上,一个字段一个字段手动写绑定。要写ORM,就得把成员指针转成SQL列名。要做RPC,就得生成参数信息。这些统统需要一个东西:类型元数据。C++原生没给这个能力,那就只能自己找方案。
1.2 编译期反射的定义边界
先明确一下这套系统做什么、不做什么。所谓编译期反射,我理解的是:在编译阶段建立一份“类型 -> 字段列表”的元数据表,字段列表里包含字段名、成员指针、字段类型。因为所有信息都是编译期常量,运行时没有动态查找、没有字符串哈希匹配、没有运行时类型识别开销,生成的代码和手写代码差不多。
这套系统不打算做运行时反射那套活儿。比如不提供“用字符串动态实例化一个类”,也不提供“运行时往类型上挂载额外属性”。C++要做这些,本质上要靠代码生成和编译器前端解析,不在编译期模板元编程的能力范畴内。我们要解决的是字段级自省:能拿到结构体有哪些字段、字段叫什么名字、字段类型是什么、能不能统一遍历它们。
1.3 为什么值得在编译期搞定而不是运行时
这就要说C++和Java/Python在反射上的本质差异了。Java的反射走的是JVM运行时元数据,动态、通用,但性能和类型安全都有代价。C++的typeid和dynamic_cast也能拿到一点运行时信息,但非常有限:typeid只能拿到类型名,dynamic_cast只能处理多态类型转换,字段布局、字段名、字段类型这类信息完全拿不到。
编译期反射的价值在于“把元数据沉淀成普通静态数据”。模板在编译期展开时,字段遍历就变成了内联的、直接成员访问的代码。一个obj.*memberPtr的写法,在模板实例化之后和手写obj.name没有区别。这就实现了反射能力和手写性能的兼得。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:三条路线如何取舍
2.1 路线一:宏注册加模板元编程,最好上手
这是目前自研项目最常用的路线。核心思路是让用户用宏把需要反射的字段显式注册出来,宏内部生成静态元数据,再配合模板函数做编译期遍历。
cpp复制struct Person {
std::string name;
int age;
bool male;
REFLECT(Person, name, age, male)
};
宏展开后的代码大致等价于在类里塞了一堆static constexpr变量和一个std::tuple成员指针表。这种方法最大的优点是:适用面广,任何结构体都能注册,不需要满足聚合体约束,字段可以有默认值、可以在类内声明;可移植性好,GCC、Clang、MSVC通吃;运行时开销为零,注册表本身就是constexpr常量。
代价也不是没有:需要手动维护注册列表,字段多了容易漏;宏在处理带逗号的复杂类型时有坑,这个后面详细说。
