别的语言里,obj.fields()能拿到所有成员名、类型、值,闭着眼做序列化。C++ 到今天(C++20/23)都没有内置反射:你没法在运行期或编译期遍历一个结构体的成员。于是「打印结构体」「序列化到 JSON」「自动比较两个对象」这些活儿,全都得靠「模拟反射」来做。这篇讲四种常用手法:宏、结构化绑定、聚合推导(boost.pfr 思路)、consteval,以及它们各自能解决什么、解决不了什么。
1. 引子:序列化一个结构体有多烦
看这个需求:给struct Person写一个serialize(),把每个成员打出来。没有反射,你只能逐个手写:
structPerson{intid;doublescore;constchar*city;};voidserialize(constPerson&p){// 每加一个成员,这里都要多写一行,漏了不会报错,只会在运行时打出垃圾std::printf("id=%d, score=%.2f, city=%s\n",p.id,p.score,p.city);}麻烦点有两个:成员名要手写(容易拼错),成员增删要同步改(漏改不报错)。所有「反射模拟」方案,本质上都是在想办法自动生成这段重复代码。下面从最老实的「宏」讲起。
2. 方案一:X-Macro(REFLECT 宏)—— 一份清单,多处展开
X-Macro 的核心思想:把字段清单写成一份宏,用不同的「子宏」去展开它,从而一份清单同时生成「结构体定义」「逐字段打印」「序列化」等多种代码。
#include<cstdio>// X-Macro:一份字段清单,供多个用途反复展开#definePERSON_FIELDS(X)\X(int,id)\X(int,age)\X(double,score)structPerson{#defineDECL(type,name)type name;PERSON_FIELDS(DECL)// 展开成三个成员声明#undefDECL};// 打印单个值:按类型重载voidprint_field(constchar*label,intv){std::printf(" %-6s = %d\n",label,v);}voidprint_field(constchar*label,doublev){std::printf(" %-6s = %.2f\n",label,v);}// 用同一份清单生成「逐字段打印」,#name 把字段名变成字符串voiddump(constPerson&p){#defineDUMP(type,name)print_field(#name,p.name);PERSON_FIELDS(DUMP)#undefDUMP}intmain(){Person p{1,18,92.5};dump(p);}id = 1 age = 18 score = 92.50宏的#name(字符串化)是唯一能拿到「成员名字符串」的手段:编译器知道字段名,但普通代码里拿不到,只有预处理器能用#把它变成字符串字面量。这是 X-Macro 的价值所在。
代价也很明显:宏没有类型检查、调试时看不到展开前的东西、IDE 跳转失灵。而且字段类型一变,print_field的重载就得跟着加。
官方文档:预处理器与字符串化
#(Preprocessor) ——#name展开成字符串字面量的出处。
3. 方案二:结构化绑定 + 聚合推导(boost.pfr 的思路)
X-Macro 需要「人」把字段列一遍。有没有办法让编译器自己数出一个聚合类型有几个成员?有:这就是boost.pfr的核心思路,标准库里std::is_aggregate(C++17)加上 SFINAE 就能做到一个简化版。
技巧是:用一个「万能类型」any_type,它能隐式转成任意类型;然后试探「这个聚合能不能用 N 个any_type做聚合初始化」。当 N 超过成员个数时,聚合初始化会因为「初始化器太多」而失败。通过 SFINAE 检测这个失败点,就能反推出成员个数。
#include<cstdio>#include<cstddef>#include<type_traits>#include<utility>// 万能类型:能隐式转成任意 T,用来「试探」聚合初始化structany_type{template<typenameT>constexproperatorT()constnoexcept{returnT{};}};// 探测:T 能否用 N 个 any_type 做聚合初始化 { any_type{}, any_type{}, ... }template<typenameT,typenameSeq,typename=void>structbrace_ok:std::false_type{};template<typenameT,std::size_t...I>structbrace_ok<T,std::index_sequence<I...>,std::void_t<decltype(T{(void(I),any_type{})...})>>:std::true_type{};// 递归找「第一个构造失败的 N」,成员数 = N - 1(上限 16 截断)template<typenameT,std::size_t N=0>structfield_count{staticconstexprstd::size_t value=brace_ok<T,std::make_index_sequence<N>>::value?field_count<T,N+1>::value// 还能构造,继续加:N-1;// 构造失败,成员数就是 N-1};template<typenameT>structfield_count<T,16>{staticconstexprstd::size_t value=16;};structPoint{intx;inty;};structRect{intx;inty;intw;inth;};structEmpty{};intmain(){static_assert(field_count<Point>::value==2,"Point 有 2 个成员");static_assert(field_count<Rect>::value==4,"Rect 有 4 个成员");std::printf("Point 成员数: %zu\n",field_count<Point>::value);std::printf("Rect 成员数: %zu\n",field_count<Rect>::value);std::printf("Empty 成员数: %zu\n",field_count<Empty>::value);}Point 成员数: 2 Rect 成员数: 4 Empty 成员数: 0注意Rect用 3 个any_type也能构造(第 4 个成员被默认初始化),所以不能直接拿「能构造的最大 N」当成员数,必须找「第一个构造失败的 N」,成员数等于N - 1。这是这类探测最容易踩的逻辑坑。
field_count<Rect> 的探测过程(成员数 = 4) N=0 Rect{} ✓ 能构造(全默认初始化) N=1 Rect{any} ✓ 能构造(剩下 3 个默认初始化) N=2 Rect{any, any} ✓ 能构造 N=3 Rect{any, any, any} ✓ 能构造 N=4 Rect{any, any, any, any} ✓ 能构造 N=5 Rect{any, any, any, any, any} ✗ 失败(初始化器太多) └─► 第一个失败点 N=5,成员数 = 5-1 = 4官方文档:std::is_aggregate 与聚合初始化(aggregate initialization) —— 判断「T 是不是聚合类型」以及「多给初始化器会失败」的规则依据。
不过这个简化版只能数出成员个数,还拿不到成员的类型和名字。完整的boost.pfr会再往前走一步:用结构化绑定把聚合解包成auto& [a, b, c, ...],再靠模板偏特化把每个成员的类型逐个榨出来。原理相同,只是递归更精密。
官方文档:结构化绑定(structured binding) —— pfr 拿成员类型和值的关键机制。
4. 方案三:宏 + 结构化绑定组合 —— 自动序列化的雏形
现实里最实用的组合是:用宏拿「名字」,用结构化绑定拿「值」。下面这个完整例子,宏生成name=value的序列化输出,结构化绑定则展示「没有宏时只剩按位置访问、没有名字」的局限。
#include<cstdio>#include<tuple>#include<utility>// 字段清单:宏是拿到「成员名」的唯一手段#definePERSON_FIELDS(X)\X(int,id)\X(double,score)\X(constchar*,city)structPerson{#defineDECL(type,name)type name;PERSON_FIELDS(DECL)#undefDECL};// 打印单个值:按类型重载voidprint_one(intv){std::printf("%d",v);}voidprint_one(doublev){std::printf("%.2f",v);}voidprint_one(constchar*v){std::printf("%s",v);}// 序列化:用同一份清单展开成 "name=value;",逗号表达式把三段拼成一句voidserialize(constPerson&p){std::printf("{");#defineSER(type,name)std::printf(" %s=",#name),print_one(p.name),std::printf(";");PERSON_FIELDS(SER)#undefSERstd::printf(" }\n");}intmain(){Person p{7,61.5,"Beijing"};serialize(p);// 结构化绑定:拿得到值,但拿不到名字,只能按位置访问auto&[a,b,c]=p;std::printf("结构化绑定(无名字,按位置): %d %.2f %s\n",a,b,c);}{ id=7; score=61.50; city=Beijing; } 结构化绑定(无名字,按位置): 7 61.50 Beijing对比这两行输出就能看清本质:宏能拿到名字,结构化绑定能拿到值,但谁都拿不到「名字 + 值 + 类型」的完整反射信息。这正是 C++ 反射缺失的根源。
5. 方案对比与现状
| 方案 | 能拿到什么 | 优点 | 缺点 |
|---|---|---|---|
| X-Macro(宏) | 名字 + 值(类型靠重载) | 老标准可用、直白 | 没类型检查、难调试、字段要手动列 |
| 结构化绑定 | 值(按位置) | 标准、干净 | 拿不到名字和数量(要手动知道几个) |
| 聚合推导(pfr 思路) | 成员个数 + 类型 + 值 | 全自动,聚合零改动 | 只对聚合类型有效、实现复杂 |
| consteval(C++20) | 编译期字符串 | 编译期算好、零运行时 | 仍拿不到成员名,只能反射枚举/拼接字符串 |
补充一点:C++20 的consteval+ 字符串能做「编译期字符串处理」(比如magic_enum用它反射枚举名),但反射结构体成员名仍然做不到。因为语言层面就没有这个入口。所以现阶段的正确答案就是「宏 + pfr」的混合:宏提供名字,pfr/结构化绑定提供值和类型。
6. 前瞻:真正的反射在路上(P2996)
好消息是,C++26 的 static reflection(静态反射)提案P2996已经在推进:它引入^运算符拿到类型的「反射元信息」,配合[: ... :]展开,能像下面这样遍历任意结构体成员(示意,非当前标准):
// P2996 示意代码,当前编译器尚不支持 —— 展示未来写法,不要真跑#include<string>structPerson{intid;doublescore;std::string city;};template<typenameT>std::stringserialize(constT&obj){std::string out;for(constexprautomember:nonstatic_data_members_of(^T)){out+=name_of(member);// 成员名out+=": ";out+=std::to_string(obj.[:member:]);// 按成员取到值out+="\n";}returnout;}这段是 P2996 提案里的写法示意,当前编译器还不支持,所以故意不写main、不做编译。它只是让你对「反射长什么样」有个预期。真等它落地,前面的宏、pfr 那一堆手工活就能大幅简化。
官方文档:P2996 — Reflection for C++26(WG21 提案) —— 静态反射的标准提案原文,了解
^与[: :]的设计。
7. 延伸阅读
- 结构化绑定(structured binding)— cppreference:pfr 拿成员值和类型的基础
- 聚合初始化(aggregate initialization)— cppreference:聚合成员个数探测依赖的初始化规则
- std::tie / std::tuple — cppreference:把成员打包成 tuple 再遍历,是 pfr 的落地方式
- 预处理器
#字符串化 — cppreference:宏拿到成员名的原理 - P2996 Reflection for C++26 — open-std.org:正在标准化的静态反射,值得跟
本知识库内的相关篇目:
- 《类型列表与编译期算法:手写 typelist 的 map/filter》 —— 编译期想对「一组类型」做运算(取第 N 个、映射成指针、筛选整型)
- 《表达式模板:Eigen 式延迟求值,把 a+b+c 压成一次遍历》 —— 表达式模板(Expression Templates)让 a+b+c 不再生成中间临时对象
- 《tag dispatch 与重载偏序实战:按迭代器类别派发算法》 —— tag dispatch(标签派发)用「空类型」当标签
8. 一句话总结
C++ 没有反射,所以「遍历成员」全靠模拟:宏(X-Macro)能拿到名字但难维护,结构化绑定能拿到值但拿不到名字,boost.pfr 的聚合推导能全自动拿到成员个数和类型但只支持聚合且实现复杂——现阶段用「宏提供名字 + pfr 提供值」的组合最务实,而 C++26 的 P2996 静态反射才是真正的解法。