前言反射reflection指程序在运行期查询自身的结构类有哪些成员、成员叫什么名字、类型是什么、能按名字读写。C 长期没有标准反射直到 C26 的静态反射提案P2996std::meta进入标准才补上这块而且它需要编译器支持C17 项目用不上。一个流传很广的误解是「C 有 RTTI所以有反射」。RTTIRun-Time Type Information运行期类型信息只提供两样东西typeid拿到的std::type_info以及多态基类上的dynamic_cast。std::type_info不能枚举成员、没有成员名字、甚至它的name()返回什么都是实现定义的。拿 RTTI 当反射最多能做到「按名字映射到一个工厂函数」再往下就走不动了。本文讲一种最常见、也是 Qt moc、Unreal UHT 等工具在原理上共用的做法的手工版用宏在编译期把「成员指针」登记进一张运行期表成员指针负责按名字找到字段的地址表负责给出名字和类型。这条路线在 C17 下完全可用代价是需要为每个类显式写一行注册宏。文章末尾会对比另外两条路线聚合类型推导、代码生成以及 C26 的标准反射。一、typeid 能做什么为什么不够先把 RTTI 的边界划清楚。typeid有两条规则值得记住第一作用于非多态的左值时以及作用于类型时它给出静态类型只有作用于多态类型有虚函数的左值时才会去求运行期的动态类型。第二typeid会丢掉顶层 const 和引用typeid(const int) typeid(int)为真。#include iostream #include typeinfo struct Base { virtual ~Base() default; }; struct Derived : Base {}; int main() { Derived d; Base br d; std::cout (typeid(br) typeid(Derived)) \n; // 1多态左值取动态类型 std::cout (typeid(int) typeid(const int)) \n; // 1顶层 const 被丢掉 std::cout typeid(int).name() \n; // 输出是实现定义的 return 0; }最后那行的name()是实现定义的libstdc 与 libc 走 Itanium C ABI返回修饰名mangled name需要配abi::__cxa_demangle声明在cxxabi.h才能变成可读名字MSVC 则直接返回人类可读的类型名。写跨平台代码时不要依赖这个字符串的内容更不要拿它做序列化格式。所以「按名字读写字段」这件事typeid帮不上忙——名字和地址都不在它的输出里。我们需要自己建立一张表字段名 → 字段类型 → 从对象基址到字段的偏移以类型安全的方式表达。另外要注意typeid依赖 RTTIRun-Time Type Information开关用-fno-rttiMSVC 是/GR-编译时它不可用本文后面那套方案也会跟着失效。二、用成员指针 类型擦除建表「从对象基址找到成员地址」这件事标准里唯一类型安全的手段是成员指针pointer to member。T Class::*这种类型不依赖任何具体对象可以经模板参数传入而解引用时需要给一个对象做法类型安全标准保证结论reinterpret_castchar*(obj) offsetof(...)无offsetof只对标准布局类型有保证❌ 不要用于普通类T Class::*成员指针有完整保证✅ 本文采用存void*绝对地址无对象一移动就失效❌ 不可行成员指针的麻烦在于int Person::*和double Person::*是不同的类型没法塞进同一个容器。解决办法是把「解引用」这一步也擦除成函数指针——模板实例化出若干个静态函数每个函数内部知道自己的类型对外只暴露void*// reflect.hpp —— 需要 C17 #pragma once #include cstddef #include map #include string #include string_view #include typeinfo #include utility #include vector namespace refl { // 一个字段的登记项名字、类型、以及两个把对象基址变成字段地址的函数指针 struct FieldInfo { const char* name; const std::type_info* type; void* (*address)(void* obj); // 非 const 对象访问 const void* (*caddress)(const void* obj); // const 对象访问 }; class ClassInfo { public: // 幂等登记同名同类型重复登记不会产生重复项 bool add(const FieldInfo f) { if (find(f.name) ! nullptr) { return false; } fields_.push_back(f); return true; } const FieldInfo* find(std::string_view n) const { for (const FieldInfo f : fields_) { if (n f.name) { return f; } } return nullptr; } const std::vectorFieldInfo fields() const { return fields_; } private: std::vectorFieldInfo fields_; }; class Registry { public: static Registry instance() { static Registry r; // C11 起函数局部静态的初始化是线程安全的 return r; } ClassInfo get_or_create(std::string_view name) { return classes_[std::string(name)]; } const ClassInfo* find(std::string_view name) const { auto it classes_.find(std::string(name)); return it classes_.end() ? nullptr : it-second; } private: // 用 std::map 而非 unordered_map按名字遍历时顺序稳定便于输出与测试 std::mapstd::string, ClassInfo classes_; }; // 把 成员指针 实例化成一个能擦除类型的取址函数 template class Class, class T, T Class::* Mp struct FieldAccessor { static void* address(void* obj) { return static_castvoid*((static_castClass*(obj)-*Mp)); } static const void* caddress(const void* obj) { return static_castconst void*((static_castconst Class*(obj)-*Mp)); } }; } // namespace refl // 登记一个字段。类名会被字符串化因此命名空间名也会带进去ns::Person #define REFLECT_FIELD(Class, Field) \ ::refl::Registry::instance().get_or_create(#Class).add(::refl::FieldInfo{ \ #Field, \ typeid(decltype(std::declvalClass().Field)), \ ::refl::FieldAccessorClass, decltype(std::declvalClass().Field), \ Class::Field::address, \ ::refl::FieldAccessorClass, decltype(std::declvalClass().Field), \ Class::Field::caddress})三个技术点需要交代清楚decltype(std::declvalClass().Field)里declval只能出现在不求值的上下文decltype正好是不求值的它给出字段的声明类型。typeid(...)得到const std::type_info*。template class Class, class T, T Class::* Mp里的Mp是非类型模板参数它在宏展开时由Class::Field提供T由前一个实参显式给出所以能编译通过。static_castvoid*((...))要求字段是非 const 的对象类型。const 成员、引用成员、位域bit-field都不适用——这是这套方案的明确边界不是缺陷掩盖。三、按名字读写与遍历有了表按名字读写就是把「表查找 类型检查 指针转换」三件事拼起来。类型检查用type_info的比较类型不匹配时返回失败而不是强转这是这套方案能安全使用的关键#include iostream #include string #include string_view // 接上面的 reflect.hpp template class T bool get_field(const void* obj, const refl::ClassInfo ci, std::string_view name, T out) { const refl::FieldInfo* f ci.find(name); if (f nullptr || *f-type ! typeid(T)) { return false; // 名字不存在或类型不匹配 } out *static_castconst T*(f-caddress(obj)); return true; } template class T bool set_field(void* obj, const refl::ClassInfo ci, std::string_view name, const T value) { const refl::FieldInfo* f ci.find(name); if (f nullptr || *f-type ! typeid(T)) { return false; } *static_castT*(f-address(obj)) value; return true; } // 通用遍历这里用 typeid 分派做演示真实项目通常配合一个 visitor 接口 void print_all(const void* obj, const refl::ClassInfo ci) { for (const refl::FieldInfo f : ci.fields()) { std::cout f.name ; if (*f.type typeid(int)) { std::cout *static_castconst int*(f.caddress(obj)); } else if (*f.type typeid(double)) { std::cout *static_castconst double*(f.caddress(obj)); } else if (*f.type typeid(std::string)) { std::cout *static_castconst std::string*(f.caddress(obj)); } else { std::cout (unsupported type); } std::cout \n; } } struct Person { int id; std::string name; double score; }; void register_person() { REFLECT_FIELD(Person, id); REFLECT_FIELD(Person, name); REFLECT_FIELD(Person, score); } int main() { register_person(); // 显式、一次性、无静态初始化顺序问题 refl::ClassInfo ci refl::Registry::instance().get_or_create(Person); Person p{1, Alice, 92.5}; int id 0; if (get_field(p, ci, id, id)) { // T 由 id 推出为 int std::cout id id \n; // id 1 } double wrong 0.0; // T 推出为 double与 int 字段不符 if (!get_field(p, ci, id, wrong)) { // 类型不匹配 std::cout type mismatch rejected\n; } if (set_field(p, ci, score, 99.0)) { std::cout score updated\n; } if (!set_field(p, ci, nickname, std::string(x))) { std::cout unknown field rejected\n; } print_all(p, ci); return 0; }注意get_field(p, ci, id, wrong)这一行模板参数T由最后一个实参推出是double与字段的int不符函数返回false——没有发生任何reinterpret_cast式的胡来这正是用type_info做校验的价值。如果这里换成「按名字取void*再自己强转」类型写错就是未定义行为。输出会依次是id 1、type mismatch rejected、score updated、unknown field rejected最后是三个字段的当前值。四、另外三条路线路线代表需要什么代价宏 注册表本文、RTTR 一类库的思路每个类写注册宏手工维护对模板类不友好聚合类型推导Boost.PFR类型必须是聚合aggregate不能有私有成员、基类、用户提供的构造函数代码生成Qt moc、Unreal UHT构建流程里加一步需要改构建系统名字含Q_OBJECT之类标记标准静态反射C26 的std::metaP2996支持 C26 的编译器C17/20 项目用不上关于 Boost.PFR它利用聚合初始化aggregate initialization的规则在编译期推导成员个数与类型用法上不需要写宏但对类型有硬性要求必须是聚合。具体 API 名称与版本差异请以你所用 Boost 版本的文档为准。关于 C26 的静态反射它把这一步搬到了编译期std::meta提供members_of、identifier_of之类的操作不再需要运行期表和void*。但在它可用之前以及大量仍停留在 C17 的存量项目里宏注册表仍然是唯一能在标准范围内落地的通用方案。常见坑点1. 用 offsetof 代替成员指针// ❌ offsetof 只对标准布局类型有保证对含虚函数/私有成员的类属于条件支持行为不定 std::size_t off offsetof(Person, id); // ✅ int Person::* mp Person::id; // 编译器保证的、类型安全的偏移2. 用全局静态对象自动注册撞上静态初始化顺序问题// ❌ 不同翻译单元里的全局对象初始化顺序不确定注册表可能还没构造好 static bool registered []{ REFLECT_FIELD(Person, id); return true; }(); // ✅ 用函数局部静态C11 起线程安全或显式调用一次注册函数 refl::Registry::instance().get_or_create(Person);3. 拿type_info的地址或字符串做跨模块比较// ❌ 某些构建配置如 -fvisibilityhidden下不同动态库里的同名类型 // 可能对应不同的 type_info 对象跨库比较会失败 bool same (typeid(Foo) other_module_type_info_ptr); // ✅ 只在同一模块内做类型校验跨模块用稳定的字符串 ID 或版本号4. 忘记登记就直接按名字查// ❌ 表里没有这个字段get_field 静默返回 false行为像字段不存在 Person p{1, Alice, 92.5}; // ✅ 注册函数与结构体放在一起并在初始化路径上显式调用 register_person();5. 查找用的类名字符串和注册时的字符串化结果不一致注册宏用#Class把类名字符串化查找时如果手写一个名字就要和它逐字节对上带命名空间的类会得到ns::Person这样的键。// ❌ 注册的键是 Person这里查 personfind 返回 nullptr静默失败 const refl::ClassInfo* bad refl::Registry::instance().find(person); // ✅ 类名只在一处定义注册与查找共用 const refl::ClassInfo* ok refl::Registry::instance().find(Person);6. 反射字段里放 const 成员或引用成员// ❌ 成员指针能取到但 static_castvoid* 编不过 / set 无从谈起 struct Bad { const int c; int r; }; // ✅ 反射用的结构体保持成员都是非 const 对象类型7. 以为反射遍历是零开销// ❌ 下面这行会拷贝一份 std::string遍历大对象时开销被低估 out *static_castconst std::string*(f.caddress(obj)); // ✅ 大字段按引用取出或提供 visitor 接口避免中间拷贝8. 依赖type_info::name()做序列化// ❌ libstdc/libc 返回修饰名Itanium ABIMSVC 返回可读名跨平台不一致 const char* key typeid(Person).name(); // ✅ 用自己的稳定字符串做键比如注册宏里的 #Class const char* key2 Person;总结环节手段关键点成员定位T Class::*成员指针标准保证、类型安全不用offsetof类型擦除模板实例化出静态address/caddress每个字段一份无捕获、可作函数指针类型校验const std::type_info*比较不匹配就返回失败绝不强转生命周期函数局部静态注册表天然规避静态初始化顺序问题遍历遍历fields()大字段按引用取避免隐式拷贝这套方案的核心只有一句话用模板把类型信息「下沉」到几个小小的静态函数里运行期的表只保存函数指针和名字。表是弱类型的但每个入口都是强类型的所以误用会被type_info挡在门外而不是变成一次野指针访问。理解了这一点再看 Qt moc 生成的代码或 RTTR 这类库会发现它们只是把「登记」这一步从手写宏换成了构建期自动生成而已。
阅读完成 · 觉得有帮助?