喵~ C++26 猫耳运算符详解:静态反射与元编程

6123 字
31 分钟
喵~ C++26 猫耳运算符详解:静态反射与元编程

如果你第一次在 C++ 代码里看到 ^^User,大概率会停一下。

这是什么?两个异或靠太近了?键盘打滑?还是委员会终于决定给 C++ 加一点猫耳朵?

“猫耳运算符”这个外号当然可爱,但如果只把它当梗,就有点亏。^^ 真正戳中的,是 C++ 多年来一个非常别扭的现实:

编译器明明知道你的类型长什么样,程序员却一直不能用标准方式去问。

比如这个结构体:

struct User {
int id;
std::string name;
bool active;
};

编译器当然知道它有三个成员,知道每个成员的名字、类型、顺序,也知道 user.name 该怎么生成代码。可是在 C++26 静态反射之前,标准 C++ 里没有一句像样的话可以表达:

给我 User 的所有成员,我想在编译期遍历它们。

于是我们发明了一整片替代生态:宏、traits、手写注册表、外部代码生成器、侵入式 visit_membersBOOST_HANA_DEFINE_STRUCTmagic_enum 的编译器字符串技巧、各种 tuple adapter。

这些办法都能用,但它们有一个共同味道:编译器已经知道的信息,我们又让程序员手抄了一遍。

C++26 静态反射要改变的,就是这件事。

这篇文章不想把提案接口像字典一样摊开。更重要的是抓住这套机制背后的闭环。你可以先把它记成一条管线:

// 1. 抬起来:语言实体 -> 编译期反射值
constexpr auto r = ^^User;
// 2. 问一问:从反射值里查信息
constexpr auto members = nonstatic_data_members_of(r);
// 3. 展开并用回去:反射值 -> 真实语法
user.[: members[0] :]

一句话:^^ 让你在编译期“看见”代码结构,std::meta::info 承载这份结构,元函数负责查询,[: ... :] 让你把看见的东西重新变回代码,template for 让你对一组反射结果展开真实语句。

这不是给 C++ 加一点语法糖,而是在给 C++ 的元编程换一条路。

TL;DR:30 秒版#

  • ^^T:把类型、成员、枚举项、命名空间等 C++ 实体抬成一个编译期反射值。
  • std::meta::info:反射值的统一载体。它不是运行时对象,也不是字符串,而是常量求值世界里的句柄。
  • nonstatic_data_members_of(^^T)enumerators_of(^^E):向编译器查询“这个类型/枚举里面有什么”。
  • [: r :]:把反射值 splice 回真实语法位置,比如类型位置、成员访问位置、枚举值位置。
  • template for:把一组编译期结果展开成多条真实语句,避免把异质成员硬塞进运行时循环。

所以猫耳运算符的核心不是“更酷的 typeid”,而是:

用标准方式拿到 C++ 代码的结构,并在编译期把它变成普通 C++ 代码。

先说结论:猫耳解决的不是“可爱”,是“重复”#

C++ 元编程最痛的地方,很多时候不是“我不会写模板”,而是这两件事:

第一,我不知道一个类型里面有什么。

第二,就算我绕路知道了,也很难把这个结果优雅地变回普通代码。

举个最朴素的需求:写一个通用调试输出函数。

你想要:

User u{1, "Xenith", true};
debug_dump(u);

输出类似:

id = 1
name = Xenith
active = true

没有静态反射时,你通常要做下面几种选择:

  • 写宏,把字段名和字段访问都列一遍。
  • 要求 User 自己提供 as_tuple()
  • 给每个类型写一份 traits。
  • 用外部脚本扫描源码生成辅助代码。
  • 直接放弃通用性,手写 operator<<

问题不在这些方案不能用,而在它们都把“字段列表”变成了第二份事实。一旦结构体改了,第二份事实就可能忘记同步。

静态反射的理想很直接:字段列表本来就在编译器那里。我要问编译器,而不是问手抄本。

所以 ^^ 的真正价值不是“我能拿到类型名字”,而是它让 C++ 程序可以在编译期基于程序自身结构生成代码。

这才是猫耳开始锋利的地方。

把旧世界和新世界放在一起,差别会更明显:

需求过去常见做法真正麻烦的地方静态反射后的思路
enum 转字符串switch 手写每个枚举项加枚举项容易漏改遍历 enumerators_of(^^E)
结构体调试输出宏或 operator<<字段名和字段访问重复写遍历 nonstatic_data_members_of(^^T)
配置绑定手写 key 到成员的赋值胶水代码多且脆字段名、字段类型由编译器提供
JSON/RPC schematraits、注册表、外部生成器类型结构有第二份描述C++ 类型本身就是 schema 的来源
数据布局变换手写 mirror type原类型变化后镜像类型失配根据成员反射生成对应聚合

这张表不是在说宏、traits、代码生成器从此没价值。它说的是:如果一个工具的主要工作只是重复描述 C++ 已有结构,那这部分工作很适合被语言层面的反射吃掉。

^^:把代码实体抬成反射值#

^^ 在 P2996R13 里是 reflection operator。它做的事情可以叫“抬升”:把一个语言实体变成编译期反射值。

#include <meta>
struct Point {
int x;
int y;
};
constexpr std::meta::info type = ^^Point;

注意:本文里的代码是按 WG21 提案和实验实现的“提案式写法”讲概念。C++26 工具链支持仍在落地中,实际编译器可能需要实验开关,标准库函数名、命名空间或头文件细节也可能随着实现进度调整。读这篇时,先抓语义模型,不要把每一行都当成今天所有编译器都能直接编过的生产代码。

这里 ^^Point 的结果不是一个 Point 对象,不是 typeid(Point),也不是字符串 "Point"

它是一个 std::meta::info

你可以把 std::meta::info 暂时理解成“编译期句柄”。它代表某个东西,但自己不暴露内部结构。你不能把它当运行时对象系统用,也不应该把它塞进业务数据里传来传去。它属于常量求值和元编程的世界。

它可以代表的东西很多:

constexpr auto a = ^^int; // 类型
constexpr auto b = ^^Point; // 类类型
constexpr auto c = ^^Point::x; // 非静态数据成员
constexpr auto d = ^^std; // 命名空间

这种统一设计很重要。C++ 没有给每一种实体发一个完全不同的反射类型,而是让它们先统一落到 std::meta::info,再通过元函数查询:

constexpr auto type = ^^Point;
constexpr auto members = nonstatic_data_members_of(type);
static_assert(identifier_of(members[0]) == "x");
static_assert(type_of(members[0]) == ^^int);

这段代码的读法是:

^^Point:拿到 Point 的反射。

nonstatic_data_members_of:询问它的非静态数据成员。

identifier_of:问成员名字。

type_of:问成员类型。

如果把 C++ 代码看成一座城市,过去程序员只能沿着街道走;现在 ^^ 给了你一张编译期地图。地图不是城市本身,但你可以用它规划路径,甚至生成新的路牌。

静态反射不是 RTTI,更不是动态语言反射#

这里必须把一个误会按住。

C++ 早就有 typeiddynamic_cast 和虚函数 RTTI。那为什么还要静态反射?

因为它们不是一类工具。

RTTI 回答的是运行时问题:

Base* p = new Derived;
std::cout << typeid(*p).name();

它关心“这个对象运行时到底是什么动态类型”。这对多态层次有用,但它不会告诉你 Derived 有哪些字段,也不会帮你生成 Derived 的序列化代码。

静态反射回答的是编译期问题:

constexpr auto members = nonstatic_data_members_of(^^User);

它关心“这个类型在源码语义上由哪些东西组成”。这些信息在编译期使用,最终可以生成普通机器码。运行时不必携带一整套动态元数据。

所以 C++26 反射不是把 C++ 变成 Java 或 C#。它更像是把 C++ 本来就擅长的“零开销抽象”往前推进一步:能在编译期决定的,就在编译期决定;能生成普通代码的,就不要把成本拖到运行时。

这也是它吸引人的地方。

std::meta::info:不是类型,而是“关于类型的值”#

很多人第一次看静态反射时,会把 std::meta::info 当成某种“元类型”。其实更顺手的理解是:

std::meta::info 是一个编译期值,它描述某个 C++ 实体。

这句话里最关键的是“值”。

也就是说,反射结果可以放在 constexpr 变量里,可以传给 consteval 函数,可以组成数组或范围,可以参与编译期计算。

例如:

consteval bool has_member_named(std::meta::info type, std::string_view name) {
for (std::meta::info member : nonstatic_data_members_of(type)) {
if (identifier_of(member) == name) {
return true;
}
}
return false;
}
static_assert(has_member_named(^^User, "name"));

这段代码表达的是一个过去很难标准化表达的动作:在编译期检查某个结构体是否有指定成员。

更重要的是,它不要求 User 配合你。User 不必继承某个基类,不必写宏,不必提供 members()。反射是在语言层面发生的。

这会改变库作者的心态。过去很多库为了拿到类型结构,都要让用户写“注册声明”。将来一部分注册声明可以消失,库可以直接问语言。

当然,这不代表所有库都会变成零配置。命名、格式、权限、字段别名、跳过字段、默认值这些工程语义仍然需要约定。但“字段到底有哪些”这件事,不应该再靠手抄。

[: ... :]:反射值要能回到源码里#

如果 ^^ 只负责把东西拿出来,那还不够。

因为元编程不是为了参观代码结构。我们拿到反射,是为了生成代码。

这就需要 splice:

[: r :]

你可以把它理解成“把反射值插回语法位置”。

如果 r 是类型 int 的反射:

constexpr auto r = ^^int;
using T = [: r :];
static_assert(std::same_as<T, int>);

如果 memberPoint::x 的反射:

Point p{1, 2};
auto value = p.[: member :];

这就相当于:

auto value = p.x;

这一步非常关键。C++ 过去的元编程经常卡在“我算出来了,但我怎么把它用回去”。模板可以算类型,decltype 可以推类型,index_sequence 可以展开下标,但你很难说:

我这里有一个成员的编译期描述,请生成一次真实成员访问。

splice 正是在补这个缺口。

^^[: ... :] 放在一起,语义很漂亮:

constexpr auto r = ^^User; // 源码实体 -> 反射值
using T = [: r :]; // 反射值 -> 源码语法

一个抬起来,一个落回去。

这也是“猫耳”不只是符号可爱的原因。它和 splice 组成了一个来回通道。

template for:为什么普通 for 不够#

拿到成员列表之后,你大概会想写:

for (auto member : nonstatic_data_members_of(^^T)) {
std::println("{} = {}", identifier_of(member), object.[: member :]);
}

看起来很合理,但普通 for 是运行时循环。这里的问题是:每个成员的类型可能不同,生成的成员访问表达式也不同。你真正想要的不是运行时循环,而是编译期展开。

这就是 P1306R5 的 expansion statement,也就是常见写法里的 template for

template <class T>
void debug_dump(T const& object) {
template for (constexpr auto member :
define_static_array(nonstatic_data_members_of(^^T))) {
std::println("{} = {}", identifier_of(member), object.[: member :]);
}
}

这段代码不是在运行时遍历成员。

它更像是编译器帮你展开成:

std::println("id = {}", object.id);
std::println("name = {}", object.name);
std::println("active = {}", object.active);

这里每一行都是普通代码。没有运行时反射表,没有字符串查字段,没有动态派发。

这就是 C++ 风格的反射:不是把一切拖到运行期,而是在编译期把重复工作处理掉。

template for 的重要性经常被 ^^ 抢走风头,但它其实是静态反射真正好用的关键之一。反射给你“列表”,splice 给你“用回去”,template for 给你“逐项生成代码”。

三者缺一个,体验都会断。

第一个真实例子:不再手写 enum to string#

枚举转字符串是最适合看静态反射价值的小例子。

过去我们常写:

enum class Color {
Red,
Green,
Blue,
};
std::string_view to_string(Color color) {
switch (color) {
case Color::Red: return "Red";
case Color::Green: return "Green";
case Color::Blue: return "Blue";
}
return "unknown";
}

这不难,但很烦。

烦点在于 Color 的枚举项已经写过一次,switch 里又写一次。以后加一个 Purple,你要记得改第二处。

静态反射后的思路是:

template <class E>
requires std::is_enum_v<E>
constexpr std::string_view enum_name(E value) {
template for (constexpr auto enumerator :
define_static_array(enumerators_of(^^E))) {
if (value == [: enumerator :]) {
return identifier_of(enumerator);
}
}
return "unknown";
}

这段代码的重点是:

enumerators_of(^^E) 拿到枚举项反射。

identifier_of(enumerator) 得到枚举项名字。

[: enumerator :] 把枚举项反射变回真实枚举值。

最终你写:

enum class Color { Red, Green, Blue };
static_assert(enum_name(Color::Green) == "Green");

这不是魔法。它只是终于让“枚举项列表”从编译器那里流进了你的程序。

当然,工程里还会有更复杂的问题:别名枚举值、位标志枚举、本地化名称、序列化兼容性。静态反射不会替你做产品决策,但它会帮你拿到最基础、最不该重复的信息。

第二个真实例子:结构体 dump,从“手抄字段”到“问编译器”#

再回到结构体。

我们想写:

User u{1, "Xenith", true};
debug_dump(u);

静态反射风格可以是:

template <class T>
void debug_dump(T const& object) {
std::println("{} {{", identifier_of(^^T));
template for (constexpr auto member :
define_static_array(nonstatic_data_members_of(^^T))) {
std::println(" .{} = {}", identifier_of(member), object.[: member :]);
}
std::println("}}");
}

输出:

User {
.id = 1
.name = Xenith
.active = true
}

这个例子小,但它背后的模式很大。

许多工程工具本质上都是“遍历字段,然后对每个字段做一件事”:

  • 日志 dump
  • JSON 序列化
  • 配置绑定
  • RPC schema
  • 表单生成
  • 数据校验
  • diff / patch
  • hash / compare
  • 命令行参数映射

过去每个库都要设计自己的“字段注册 DSL”。有些 DSL 很优雅,有些非常折磨,但它们解决的是同一个历史缺口:标准 C++ 不能问类型结构。

静态反射让这些库可以把精力从“怎么让用户重复描述字段”转移到“这些字段应该如何映射到业务语义”。

这才是真正的降复杂度。

第三个例子:配置系统可以少写很多胶水#

假设你有一个服务配置:

struct ServerConfig {
std::string host;
int port;
bool enable_tls;
};

你希望从配置文件读入:

host = "127.0.0.1"
port = 8080
enable_tls = true

没有反射时,通常要写映射:

config.host = table["host"].as_string();
config.port = table["port"].as_integer();
config.enable_tls = table["enable_tls"].as_boolean();

字段一多,胶水代码就开始繁殖。更糟的是,这类代码很少有技术含量,却很容易出错。

反射后的伪代码可以是:

template <class T>
T load_config(Table const& table) {
T object{};
template for (constexpr auto member :
define_static_array(nonstatic_data_members_of(^^T))) {
constexpr auto name = identifier_of(member);
using Member = typename[: type_of(member) :];
object.[: member :] = read_value<Member>(table, name);
}
return object;
}

这段代码里最有味道的是:

using Member = typename[: type_of(member) :];

我们先用 type_of(member) 拿到字段类型的反射,再用 splice 把它放回类型位置。

于是 read_value<Member> 可以根据字段类型分发:

template <class T>
T read_value(Table const& table, std::string_view key);

这就是“反射 + 普通泛型代码”的组合拳。

它不会让所有配置问题自动消失。字段默认值、别名、必填项、版本迁移、错误诊断仍然要认真设计。但最机械的字段枚举和成员访问,可以交给编译期。

反射不只是“看”,还可能“造”#

如果静态反射只能观察已有结构,它已经很有用。但 P2996 里还有更进一步的方向:根据反射信息定义新的简单聚合类型,比如 define_aggregate

这类能力一开始听起来危险,但它解决的是非常实际的问题。

比如游戏、图形、数据处理里常见的数据布局转换:

struct Particle {
float x;
float y;
float z;
float velocity;
};

这是 AoS,Array of Structs:

std::vector<Particle> particles;

有时你更想要 SoA,Struct of Arrays:

struct ParticleColumns {
std::vector<float> x;
std::vector<float> y;
std::vector<float> z;
std::vector<float> velocity;
};

没有反射时,你要手写 ParticleColumns,并保持它和 Particle 同步。

有反射后,编译期可以读取 Particle 的字段,再生成一个对应的列式聚合类型。直觉上是:

using ParticleColumns = columns_of<Particle>;

columns_of<T> 内部读取 T 的成员列表,把每个成员类型 M 转成 std::vector<M>,再定义一个新聚合。

这里的意义很大:元编程不再只是“选择已有类型”,还可以在受限规则下“合成新类型”。

当然,这也是最需要谨慎的地方。生成声明会碰到 ODR、模块可达性、注入位置、常量求值顺序等问题。标准不会允许你在任意角落随手变出任意代码。C++26 的方案是小心地开门,而不是把墙拆掉。

为什么不是宏#

讲到这里,很容易有人说:宏也能做啊。

宏确实能做一部分事情,但宏和静态反射的层级完全不同。

宏发生在预处理阶段。它看到的是 token,不懂 C++ 语义。它不知道一个名字经过重载决议后到底是什么,也不知道模板实例化后的真实类型结构。宏强在文本替换,弱在语义理解。

静态反射发生在语言语义层。^^User 反射的是编译器理解后的实体,不是一段文本。type_of(member) 得到的是成员类型反射,不是字符串拼接。

所以宏更像“复印机”,静态反射更像“询问编译器”。

复印机当然有用,但你不能指望它理解文档。

为什么不是外部代码生成#

外部代码生成也不是坏东西。很多大项目用 protobuf、flatbuffers、OpenAPI、Qt MOC、Unreal Header Tool,都有充分理由。

但外部生成器通常有几个成本:

  • 构建链路更复杂。
  • IDE 跳转和错误诊断更绕。
  • 生成文件和源文件可能不同步。
  • 生成器要自己理解一部分 C++ 或额外 DSL。
  • 库作者和使用者之间多了一层工具约定。

静态反射不会替代所有外部生成器。跨语言 IDL、网络协议、持久化 schema 这些场景仍然适合显式 schema。

但如果你的需求只是“根据当前 C++ 类型生成一些 C++ 代码”,那静态反射就非常有吸引力。因为类型本身就是 schema,不需要再维护一份影子文件。

这套语法为什么长这样#

早期 C++ 反射讨论过 reflexpr(...),后来 P2996 采用了操作符风格,并在较新修订里从单个 ^ 转向 ^^

从可读性上看,这套语法有一个内在方向感:

^^T // lift:抬成反射
[: r :] // splice:放回语法

^^ 很短,适合嵌在元编程表达式里。[: ... :] 则刻意显眼,因为它在做一件很强的事:把编译期反射值重新解释成源码语法的一部分。

刚看当然陌生。C++ 也从不缺陌生符号。

但相比一串宏、traits、偏特化和 index_sequence,我更愿意读:

template for (constexpr auto member :
define_static_array(nonstatic_data_members_of(^^T))) {
use(object.[: member :]);
}

至少它表达的是问题本身,而不是一台为了绕过语言缺口搭出来的机器。

什么时候它会让代码更差#

任何强工具都有滥用空间。

静态反射最危险的用法,是把它当成“我不用设计接口了”的借口。

比如序列化。你可以自动遍历所有字段,但这不代表所有字段都应该被序列化。密码 hash、缓存字段、兼容字段、内部状态、迁移逻辑,都需要显式设计。

再比如日志。自动 dump 很方便,但生产日志里乱打字段可能泄漏敏感信息。

再比如配置。字段名直接等于配置 key 很省事,但长期维护时你可能需要别名、弃用提示、版本迁移。

静态反射降低的是机械重复,不是工程判断。

还有编译期成本。反射让更多计算发生在编译期,库作者如果不节制,可能制造很厚的模板实例化和更大的二进制。过去模板元编程已经证明过:能在编译期做,不代表应该把所有东西都塞到编译期做。

我的判断是:反射最适合用来写基础设施库,而不是让业务代码到处散落 ^^[: :]

业务代码应该看到的是:

auto json = to_json(user);
auto options = parse_options<Config>(argc, argv);
debug_dump(object);

库内部再用反射把重复劳动吃掉。

它暂时不是什么#

为了避免把期待拉太满,也要说清楚 C++26 静态反射不是什么。

它不是完整 AST 反射。你不能把任意函数体当语法树拿出来遍历,也不能像编译器插件那样随意改写每个表达式。它关心的是标准暴露出来的语言实体和查询能力,而不是把整个前端内部结构开放给用户。

它不是运行时元对象系统。std::meta::info 主要活在常量求值和编译期生成代码的路径里。你不应该期待它像 Java/C# 反射那样,在运行时拿着一个对象随便列字段、按字符串访问成员。

它也不是“自动工程正确性”。反射可以告诉你字段叫 password_hash,但它不会知道这个字段能不能进日志;可以告诉你 versionint,但它不会替你设计迁移策略;可以帮你生成配置读取,但不会替你判断错误信息要怎么对用户友好。

换句话说,反射给的是“结构事实”,不是“业务语义”。这两件事分清楚,代码才不会从手写胶水变成自动化事故。

现在能不能直接用#

要冷静一点。

C++26 静态反射已经是 C++26 里最受关注的核心语言方向之一,但编译器和标准库支持会逐步跟进。你在文章、提案、Godbolt 示例里看到的代码,可能需要特定实验分支、特定编译参数,或者会因为提案接口修订而改名。

所以现在最值得做的不是立刻把生产项目改成反射,而是先建立正确模型:

^^ 拿到反射。

std::meta::info 承载反射。

元函数查询结构。

[: ... :] 把反射用回代码。

template for 展开一组编译期结果。

等工具链稳定后,再看哪些库最先受益。

我最期待的方向有这些:

  • 标准化的 enum string / enum parse
  • 结构体日志和调试视图
  • JSON/TOML/YAML 绑定
  • RPC 和消息 schema
  • CLI 参数解析
  • 测试框架里的对象比较与断言输出
  • 数据布局转换
  • 反射驱动的 UI inspector

这些都不是“炫技”。它们是 C++ 工程里每天都会遇到的胶水代码。

最后怎么记#

我会用一个小剧场记住它。

编译器坐在那里,手里有全项目的结构图。过去你问它:

User 有哪些字段?

它不理你。你只能自己在旁边抄一份。

C++26 之后,你终于可以问:

constexpr auto type = ^^User;
constexpr auto members = nonstatic_data_members_of(type);

然后你还能说:

template for (constexpr auto member : define_static_array(members)) {
std::println("{} = {}", identifier_of(member), user.[: member :]);
}

这就是猫耳运算符真正打开的东西:不是让语法多一个萌点,而是让 C++ 程序第一次能以标准方式看见自己的一部分结构,并把这份结构重新变成普通代码。

以前我们靠宏、手抄、外部生成器和模板技巧在墙边绕路。

^^ 像是在墙上开了一个小门。

门不大,也不是通向一切的任意门。它有规则,有边界,有还没完全稳定的实现现实。

但只要这扇门站稳,C++ 元编程的很多老毛病都会开始松动。

这才是 C++26 静态反射值得认真看的原因。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
喵~ C++26 猫耳运算符详解:静态反射与元编程
https://xustalis.site/blog/posts/cpp26-cat-ears-operator-static-reflection-metaprogramming/
作者
Xenith
发布于
2026-06-23
许可协议
CC BY-NC-SA 4.0
文章互动

点赞和收藏保存在当前浏览器;复制链接可用于分享。

评论区

评论使用站点内置账号系统,内容会在服务端做校验与纯文本渲染。
正在加载评论...
作者头像
Xenith
记录开发、设计与日常思考。
此刻天光
下午
此时

这个钟点最适合发一会儿呆,再把手边的事接回去

去别处看看

一些经常会回来的入口

站点统计
文章
7
分类
1
标签
31
总字数
39,189
运行时长
0
最后活动
0 天前
实时站内资料 熊猫助手 会优先读取博客文章、项目和页面资料

目录