C++26 静态反射:在编译期把名字拿回来 · znzryb
## 思路:不存进程序,而是在编译期查
Go 的办法是把说明书存进程序、运行时再查。C++ 不愿意付这个代价,但它可以换个时机:**让编译器在编译的时候把类型的信息交给我们的代码**。编译器本来就知道 `P` 的每个字段叫什么 —— 只是以前不肯告诉我们。
这就是 **C++26 的静态反射**(提案编号 P2996,2025 年 6 月投票进了 C++26 标准)。「静态」的意思是:所有查询都在编译期完成,编译完的程序里只剩查出来的结果,**没有说明书、没有查表**。
## 三个新零件
| 写法 | 意思 |
|---|---|
| `^^P` | 把类型 `P` 变成一个「反射值」,类型是 `std::meta::info`。可以把它理解成编译器手里那份关于 `P` 的说明书 |
| `std::meta::nonstatic_data_members_of(^^P, ...)` | 问说明书:`P` 有哪些普通成员变量?返回一串 `info`,每个对应一个字段 |
| `std::meta::identifier_of(m)` | 这个字段叫什么名字(一个字符串) |
| `obj.[:m:]` | 「拼接」(splice):把反射值变回代码。`m` 是字段 `a` 的说明书时,`obj.[:m:]` 就等于写了 `obj.a` |
还要一个配套的新语法 `template for`(提案 P1306,也进了 C++26):它像 `for` 循环,但是**在编译期把循环体复制展开**,每个字段生成一份代码。普通 `for` 做不到这件事,因为每个字段的类型不一样(`int`、`string`、`double`),没法放进同一个循环变量里。
## 一份 `formatter`,管所有结构体
```cpp
#include <format>
#include <meta>
#include <print>
#include <ranges>
#include <string>
#include <type_traits>
// 一份 formatter,管所有聚合结构体(数组和容器除外,它们标准库已经管了)
template <class T>
requires std::is_aggregate_v<T> && (!std::ranges::range<T>)
struct std::formatter<T> {
constexpr auto parse(std::format_parse_context& ctx) { return ctx.begin(); }
auto format(const T& obj, std::format_context& ctx) const {
auto out = ctx.out();
*out++ = '{';
bool first = true;
// 编译期:问编译器 T 有哪些成员,一个一个展开
template for (constexpr auto m : std::define_static_array(
std::meta::nonstatic_data_members_of(^^T, std::meta::access_context::unchecked()))) {
if (!first) *out++ = ' ';
first = false;
// identifier_of(m) 是字段名,obj.[:m:] 是这个字段的值
out = std::format_to(out, "{}:{}", std::meta::identifier_of(m), obj.[:m:]);
}
*out++ = '}';
return out;
}
};
struct Point {
int x, y;
};
struct P {
int a;
std::string b;
double c;
Point where; // 嵌套的结构体也能打
};
int main() {
std::println("{}", P{1, "hi", 2.5, {3, 4}});
std::println("{}", Point{5, 6});
}
```
```text
{a:1 b:hi c:2.5 where:{x:3 y:4}}
{x:5 y:6}
```
**`P` 和 `Point` 自己一行打印代码都没写**,字段名也没在任何地方再列一遍 —— 这就是 Go 的 `%+v`。嵌套的 `where` 也打出来了:格式化 `obj.where` 时,编译器又给 `Point` 套用了同一份 `formatter`,跟上一节 Go 版的递归是一个道理。
几处细节,第一次看可以跳过:
- `requires std::is_aggregate_v<T> && (!std::ranges::range<T>)`:只接管「聚合结构体」(没有自定义构造函数、成员都是公开的那种),并且把数组、容器排除掉,免得跟标准库自带的 `formatter` 撞车。
- `access_context::unchecked()`:不检查访问权限,私有成员也列出来。也可以换成 `current()`,只列当前位置有权访问的成员。
- `std::define_static_array(...)`:成员列表是编译期临时算出来的,`template for` 要求它是一个编译期就固定好的数组,这一步把它「钉」下来。在 GCC 16 上如果先把它存进一个 `constexpr` 局部变量再循环,会报 `'members' is not a constant expression`,所以上面直接写在 `template for` 里。
## 现在能在哪儿跑
截至 2026 年 10 月,实测这两个编译器能跑通上面的代码:
| 编译器 | 编译选项 |
|---|---|
| GCC 16(正式版) | `-std=c++26 -freflection` |
| clang 的 Bloomberg P2996 分支 | `-std=c++26 -freflection-latest -stdlib=libc++` |
手边没有 GCC 16 的话,最省事的是 [Compiler Explorer](https://godbolt.org):编译器选 `x86-64 gcc 16.x`,选项填 `-std=c++26 -freflection`,把代码贴进去,勾上 "Execute the code" 就能看到输出。
## 跟 Go 比一比
两边都做到了「不写打印代码,也能打出字段名」,但做法正好相反:
- **Go**:编译器把说明书**存进程序**,`fmt` 在**运行时**查。一份 `fmt` 代码处理所有类型,每次打印都在现场判断「这个字段是什么类型、该怎么打」。
- **C++26**:编译器在**编译期**把说明书交给 `formatter`,`template for` 给每个类型**各生成一份**专门的打印代码。生成出来的代码跟你手写的第一种老办法差不多,运行时不用查任何东西。
反过来,C++26 的反射**只在编译期有**:程序跑起来以后,你不能拿着一个「运行时才知道是什么类型」的值去问它有哪些字段 —— 这件事 Go 能做,C++ 做不了。