前言
反射(reflection)指程序在运行时检查自身的类型、成员、函数,并据此创建对象或调用方法。Java、C# 有完整的反射;标准 C++ 至今没有。C++ 只有一套很有限的运行期类型信息(run-time type information, RTTI),能做类型识别和安全的向下转换,但不能枚举一个类有哪些成员、不能按名字调用任意函数、不能根据字符串创建任意类型。
常见的误解有三个。第一,把typeid当成反射,以为typeid(obj).name()能拿到可用的类名并据此创建对象——它返回的往往是编译器修饰过的名字,且标准不保证内容。第二,以为dynamic_cast能替代反射,实际上它只能在已知目标类型的前提下做转换,不能从字符串反推类型。第三,以为 C++26 的反射提案已经是可用标准,实际上截至本文写作时它尚未成为已发布的正式标准,各编译器的支持情况需要单独确认。
工程上所谓的「C++ 反射」,通常指手动注册 + 查表:在程序启动时把「名字 → 创建函数」「名字 → 字段访问器」登记到一张表里,运行时按名字查表。本文给出这套做法的完整示例,并说明它的边界。
一、RTTI 能提供什么
RTTI 的两个主要工具是typeid和dynamic_cast。typeid作用于多态类型的左值时,返回动态类型的std::type_info;作用于非多态表达式时,返回静态类型。type_info::name()返回的字符串由实现决定,常见的是经过名称修饰(name mangling)的形式,不能直接当类名用。type_info支持==比较,也可以用来做类型索引。
// 适用于 C++17
#include <iostream>
#include <typeinfo>
struct Base {
virtual ~Base() = default;
};
struct Derived : Base {};
int main() {
Derived d;
Base& b = d;
std::cout << typeid(b).name() << '\n'; // 动态类型 Derived
std::cout << typeid(Base).name() << '\n'; // 静态类型 Base
if (dynamic_cast<Derived*>(&b) != nullptr)
std::cout << "b really refers to Derived\n";
}dynamic_cast<Derived*>(p)在p为空时返回空指针;转换失败也返回空指针。转换成引用形式dynamic_cast<Derived&>(r)失败时抛std::bad_cast。它要求源类型是多态类型(至少有一个虚函数),否则编译不通过。
RTTI 可以被编译器选项关闭(例如 GCC / Clang 的-fno-rtti),关闭后typeid和dynamic_cast对多态类型不可用。一些性能敏感或嵌入式项目会这样做,这也是「不能依赖 RTTI 做核心架构」的原因之一。
二、注册表:用字符串创建对象
最常用的「反射」是工厂注册:每个类在启动时把自己的名字和一个创建函数登记到全局表,调用方只需提供名字。
// 适用于 C++17
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
class Base {
public:
virtual ~Base() = default;
virtual std::string describe() const = 0;
};
class Registry {
public:
using Factory = std::function<std::unique_ptr<Base>()>;
static Registry& instance() {
static Registry registry;
return registry;
}
void add(const std::string& name, Factory factory) {
factories_[name] = std::move(factory);
}
std::unique_ptr<Base> create(const std::string& name) const {
const auto it = factories_.find(name);
if (it == factories_.end()) return nullptr;
return it->second();
}
private:
std::unordered_map<std::string, Factory> factories_;
};
template <class T>
struct Registrar {
explicit Registrar(const std::string& name) {
Registry::instance().add(name, [] { return std::make_unique<T>(); });
}
};
#define REGISTER_CLASS(T) \
[[maybe_unused]] static Registrar<T> g_reg_##T(#T)使用时,在每个具体类的实现文件里写一行REGISTER_CLASS(Circle);。宏展开成一个静态对象,它的构造函数在main之前执行,把Circle和创建函数登记进去。#T把标识符转成字符串,g_reg_##T用记号连接生成唯一变量名。
// 适用于 C++17
class Circle : public Base {
public:
std::string describe() const override { return "circle"; }
};
REGISTER_CLASS(Circle);调用方:
// 适用于 C++17
std::unique_ptr<Base> make(const std::string& name) {
return Registry::instance().create(name);
}这套机制有三个必须注意的工程点。第一,静态对象的初始化顺序跨翻译单元未指定,但注册只依赖Registry::instance()里的函数内静态对象,首次调用时构造,因此不受顺序影响。第二,静态库链接器可能把「没有被任何符号引用」的目标文件整个丢掉,注册代码也就不会执行;需要显式引用、使用 whole-archive 链接选项,或把注册放进一定会被链接的源文件。第三,注册表在main之前构建,多线程环境下如果运行期还要动态注册,必须加锁。
三、属性反射:手动登记字段访问器
比「按名字创建」更进一步的是「按名字读写字段」。标准 C++ 无法自动枚举成员,只能为每个字段手工登记一个 getter 和一个 setter,通常存成std::function。
// 适用于 C++17
#include <functional>
#include <stdexcept>
#include <string>
#include <unordered_map>
class Object {
public:
virtual ~Object() = default;
};
class Meta {
public:
using Getter = std::function<std::string(const Object&)>;
using Setter = std::function<void(Object&, const std::string&)>;
void add(const std::string& name, Getter getter, Setter setter) {
fields_[name] = Field{std::move(getter), std::move(setter)};
}
std::string get(const Object& obj, const std::string& name) const {
const auto it = fields_.find(name);
if (it == fields_.end())
throw std::out_of_range("unknown field: " + name);
return it->second.getter(obj);
}
void set(Object& obj, const std::string& name, const std::string& value) const {
const auto it = fields_.find(name);
if (it == fields_.end())
throw std::out_of_range("unknown field: " + name);
it->second.setter(obj, value);
}
private:
struct Field {
Getter getter;
Setter setter;
};
std::unordered_map<std::string, Field> fields_;
};为某个类登记字段时,getter 和 setter 里必须把Object&转回具体类型。这个转换的正确性由登记代码保证:只有该类型的对象才会用这套字段表。如果把它做成「所有类型共用一张表」,就必须在表里记录类型标识并在访问前校验,否则static_cast到错误类型是 UB。
属性表常见的用途是序列化、配置绑定、编辑器检视面板。它的优点是类型安全边界清晰(每个字段的转换逻辑由写登记代码的人负责),缺点是完全手动:新增字段必须记得登记,漏了不会编译报错,只会在运行时表现为「字段不见了」。代码生成工具(解析头文件或 IDL 生成注册代码)能缓解这个问题,但引入了构建步骤。
四、边界与替代方案
| 能力 | 标准 C++ | 手动注册方案 |
|---|
| 按名字创建对象 | 不支持 | 支持(工厂表) |
| 枚举成员 | 不支持 | 需手动登记 |
| 按名字读写字段 | 不支持 | 支持(访问器表) |
| 按名字调用任意函数 | 不支持 | 需为每个函数写包装 |
| 获取可读类名 | typeid().name()为实现定义 | 注册时自己给名字 |
| 跨语言/跨进程 | 不支持 | 需自定义协议 |
如果项目能接受额外工具,可以用 IDL、注解式宏或外部代码生成器来减少样板;如果只是少量类型需要按名字创建,手写注册表最直接。不要把注册表当成「完整反射」——它只覆盖你显式登记过的东西。
常见坑点
- ❌ 用
typeid(obj).name()的结果当类名去查表或显示给用户。
✅ 注册时自己指定稳定名字;name()是实现定义的修饰名。
- ❌ 把
REGISTER_CLASS写在头文件里,被多个源文件包含。
✅ 放在具体类的实现文件中,避免重复注册与无谓的目标文件膨胀。
- ❌ 注册代码放在静态库里,链接器没有拉取该目标文件,注册未执行。
✅ 显式引用该目标文件,或使用 whole-archive 链接选项。
- ❌ 认为
dynamic_cast能按字符串创建对象。
✅ 它只能在已知目标类型时做转换;按名字创建需要工厂表。
- ❌ 在运行期多线程动态注册,却不加锁。
✅ 注册集中在启动阶段,或给注册表加互斥量。
- ❌ 属性访问器里
static_cast到错误的具体类型。
✅ 每个类型独立字段表,或访问前校验动态类型。
- ❌ 以为 C++26 反射已经可以直接使用。
✅ 截至本文写作时标准尚未发布该能力,按项目工具链实际情况确认。
总结
| 层面 | 能做什么 | 不能做什么 |
|---|
| RTTI | 识别动态类型、安全向下转换 | 枚举成员、按名创建 |
| 工厂注册表 | 按名字创建对象 | 自动发现未注册类型 |
| 属性表 | 按名字读写字段 | 自动生成访问器 |
| 代码生成 | 减少手写登记 | 需要额外构建步骤 |
C++ 没有内建反射,这不是缺陷,而是「零开销」取向的取舍:运行期类型信息只保留到能支持虚调用和安全转换的程度。工程上的反射都是显式注册出来的,它的可靠性取决于登记是否完整。把注册集中在启动阶段、把名字与创建函数绑定、把字段访问器的类型转换封装在类型内部,就能用很小的代价获得「够用」的反射能力。