第24.1章:为什么需要反射
1. 编译器回答不了的问题
假设你有这个类型:
struct person
{
std::string name;
int age;
double height;
};
你想把它打印成 JSON:
{"name":"Ada","age":36,"height":1.68}
在大多数语言里,这是一个库调用。在 C++26 之前,这是手工劳动。编译器知道成员名字、成员类型、 顺序和布局。程序却问不到其中任何一项。信息在翻译期间存在,却从语言的表达式文法里消失了。
常见的变通办法都把知识搬到别处:
| 变通办法 | 知识住在哪里 | 哪里会出错 |
|---|---|---|
| 手写序列化器 | 类型旁边的第二个函数 | 会漂移。加一个字段忘了改序列化器,bug 是静默的。 |
| 宏列表 | 展开进结构体和访问器的文本列表 | 仍然是第二份声明。它看不到类型、基类或注解。 |
| 外部代码生成器 | 解析头文件的构建步骤 | 又一个解析器、又一个构建依赖、又一份会过期的输出。 |
| 编译器专用内建 | __builtin_... 或插件 |
不可移植、不标准,而且不是每个目标都有。 |
| 运行时类型信息 | RTTI、调试信息、注册宏 | 运行时成本,没有成员名字,而且只适用于多态类型。 |
反射就是缺失的那个操作:程序向编译器询问它正在编译的程序。它不是运行时库, 也不是调试器。它是一个编译期查询,而查询的答案可以再变回代码。
2. 编译期,不是运行时
Java 和 C# 有运行时反射:对象携带类描述符,程序可以在运行时枚举字段、调用方法、创建实例。 C++26 反射不同。它发生在编译期间,在常量求值器里。结果要么是一个常量值,要么是生成的代码。 程序运行时,反射已经消失,除非你显式生成了表。
| 属性 | 运行时反射(Java/C#) | C++26 反射 |
|---|---|---|
| 何时运行 | 运行时 | 翻译期间,在 consteval 里 |
| 能看到什么 | 已加载的类型和元数据 | 这个翻译单元里编译器能命名的任何东西 |
| 运行时成本 | 元数据、查找、分配 | 如果生成的代码是直接访问,则没有额外成本 |
| 能创建新类型吗 | 通常不能 | 能,通过 define_aggregate 创建简单聚合体 |
| 能看到私有成员吗 | 有足够权限时可以 | 只能通过显式的访问上下文 |
编译期限制是特性,不是缺陷。编译器已经拥有完整的类型信息;在编译期询问意味着答案可以被类型检查、 内联和优化。运行时没有字符串查找,二进制里也没有元数据块。
3. 反射与生成是同一个特性
C++26 反射实际上是两种能力绑在一起:
-
反射:把程序实体变成
std::meta::info类型的值。这个值可以保存、 比较、放进 vector,并被consteval函数查询。 - 拼接与生成:把反射变回文法元素——类型、名字、表达式、成员访问——或者从一组 反射成员定义一个聚合体。
只有第一种能力,就是一个没有使用答案方式的编译期查询语言。只有第二种,就是一个类型更好的宏系统。 两者结合,JSON 例子才可能:反射成员,查询名字和类型,然后生成字段访问。
grammar ──^^──► reflection value ──std::meta::...──► new reflection value
▲ │
└────────────────────── [: ... :] ───────────────────────────┘
上面的箭头是反射运算符 ^^。下面的箭头是拼接(splice):
[: r :] 把 r 表示的实体插回程序。中间是普通的
consteval 代码:循环、if、std::vector、函数。
4. 第一眼:枚举转字符串
最经典的动机例子是把枚举值打印成名字。反射之前,每个项目都有一个 switch、一张宏表, 或者一个解析枚举声明的第三方库。
enum class color { red, green, blue };
template <typename E>
requires std::is_enum_v<E>
constexpr std::string_view enum_to_string(E value)
{
template for (constexpr auto e :
std::define_static_array(std::meta::enumerators_of(^^E)))
{
if (value == [:e:])
{
return std::meta::identifier_of(e);
}
}
return "<unnamed>";
}
static_assert(enum_to_string(color::green) == "green");
先不要担心语法。看这三个步骤:
-
^^E反射枚举类型。std::meta::enumerators_of返回一组反射, 每个枚举值一个。 -
template for在编译期展开循环。每个e是一个constexpr反射值。 -
[:e:]把枚举值拼回表达式,所以value == [:e:]是普通比较。identifier_of(e)给出拼写。
编译器为每个枚举值生成一个比较。没有表查找、没有字符串解析、没有注册。如果你加一个枚举值, 函数自动看到它。
5. 为什么模板和类型萃取不够
C++ 已经有编译期计算。模板是图灵完备的,<type_traits> 回答数百个问题。
为什么那不算反射?
类型萃取回答的是库作者预先想到的问题:is_integral_v<T>、
is_base_of_v<A, B>、tuple_size_v<T>。它回答不了
“T 的成员名字按声明顺序是什么?”,因为语言里没有表达这个答案的语法形式。
这个萃取必须返回一组字符串和类型,而语言没有一种类型能容纳异构的程序实体列表。
模板也不能从一个看起来像运行时的列表生成新类型。你可以用参数包特化模板,但不能写一个循环,
把 data_member_spec 推进 vector,然后从它定义一个结构体。这正是
std::meta::info 和 define_aggregate 增加的东西。
宏是文本的,不是语义的。它们看到 token,看不到类型。宏不能问一个成员是不是位域、基类是不是虚的、 枚举的底层类型是什么。它也不能遍历一个不是自己声明的类型的成员。
6. 反射适合做什么
动机用例都很无聊,而且到处都是:
- 序列化。JSON、YAML、二进制格式、数据库行。反射一次成员,生成字段访问。
- 枚举名字。日志、命令行解析、配置文件、协议转储。
- 绑定。C++ 到 Python、Lua、JavaScript、C ABI。从 C++ 声明生成包装, 而不是手工维护。
- 测试与 mock。从成员生成结构相等、哈希、调试打印器或 builder。
- 布局与 ABI 工具。在编译期检查偏移、大小和对齐;生成静态断言或二进制布局描述。
- 命令行与配置解析器。从成员名推导选项名,从成员类型推导类型。
共同点是一份已经包含信息的声明,和一份重复它的第二声明。反射删除第二份声明。 这就是全部价值主张。
7. 反射不是什么
-
不是运行时自省。你不能反射运行时从共享库加载的类型,也不能从
void*问出一个成员名字。 - 不替代模板。模板按类型实例化代码;反射检查和生成声明。它们组合使用: 反射可以为模板实例化产生参数。
-
不让私有成员变公开。访问控制仍然有效。成员查询接受
access_context;从错误的上下文询问是错误,不是漏洞。 - 不消除 API 设计。生成的序列化器仍然需要可选字段、版本、未知键和错误的策略。 反射删除样板,不删除设计。
- 不是没有编译期成本。每次查询和每个生成的声明都是编译器的工作。反射密集的头文件 会拖慢构建。成本在编译期,不在运行期。
8. 标准的状态
反射于 2025 年 6 月作为 P2996 进入 C++26,同时还有注解、consteval 块、展开语句和
<meta> 库的支撑论文。语言特性包括:
- 反射运算符
^^; - 不透明类型
std::meta::info和std::meta命名空间; - 拼接符
[: ... :]、typename[: ... :]、template[: ... :]; template for展开语句;consteval { ... }块和注入声明;std::meta::define_aggregate和data_member_spec。
编译器支持仍然不均衡。GCC 16 有一个实现;Bloomberg 的 clang-p2996 分支是参考
Clang 实现;EDG 有一个;写作时 MSVC 没有公开实现。这一章的代码是 C++26,不是库模拟。
在生产构建里使用之前,先查编译器支持表。
本章其余部分先讲模型,再讲模式。语法一开始看起来奇怪,因为它是新的。心智模型很小:
用 ^^ 反射,用 std::meta 查询,用 [: :] 拼接,
用 define_aggregate 生成。