rjk::duck: 通过 C++26 反射实现类型擦除
rjk::duck: 通过 C++26 反射实现类型擦除
rjk::duck 库利用 C++26 反射来实现类型擦除,允许开发者只需定义一次 trait 接口,即可自动将其应用于满足该接口的任何类型。这种方法消除了对数百行手动样板代码或像 Boost.TypeErasure 或 Folly.Poly 这样沉重依赖的需求,同时保持了传统 vtable 分发的性能。
使用 C++26 反射实现自动化接口生成
rjk::duck 允许用户使用带有 [[=rjk::trait]] 注解的 struct 来定义接口。随后,该库使用 C++26 反射来检查此 trait 并通过编译时生成必要的类型擦除机制。
Trait 检查与标记
该库通过使用 annotations_of 和反射操作符 ^^ 来检查 [[=rjk::trait]] 注解,从而识别 trait。一旦识别出 trait,members_to_tags 函数会检查 struct 的成员,过滤出带有标识符的用户声明函数。这些函数会被转换为内部 "tags"(例如,has_fn<"name", signature>),它们作为生成的 vtable 的蓝图。
Vtable 代码生成
通过使用 consteval 代码块和 define_aggregate,rjk::duck 会生成一个包含 trait 中每个成员函数指针的 vtable struct。对于给定的类型 T,该库会创建一个静态 vtable,其中每个插槽都由一个包装函数填充。该包装函数执行从 void* 到 T* 的 static_cast 并调用原始成员函数。
高级重载解析
为了避免手动重新实现 C++ 重载解析,rjk::duck 使用了一种涉及 overload_set 和 candidate_wrapper 的技术。
该库并不寻找精确的签名匹配,而是生成一个包含所有匹配特定标识符的成员函数的包装器的可调用 overload_set。当填充 vtable 时,编译器自身的重载解析机制会在 erased_call 函数中使用,从而为底层类型选择最合适的重载。
通过指针可互换性优化内存
类型擦除的朴素实现通常要求每个成员函数包装器都存储一个指向父对象的后向指针,以便访问 vtable 和底层数据。这会导致 duck 对象的大小随 trait 中函数数量的线性增长。
rjk::duck 使用 指针可互换性 (pointer-interconvertibility) 来解决此问题。由于该库使用标准布局类型 (standard layout types),它可以将指向成员函数包装器的指针 reinterpret_cast 回父 duck 对象。这使得 vtable_function 对象可以成为空类 (使用 [[no_unique_address]]),从而确保无论 trait 中定义了多少个函数,duck 实例的大小都保持不变。
性能调优与内联
虽然标准的 vtable 分发是高效的,但 rjk::duck 提供了一种可选的性能优化来减少间接层级。通过定义一个带有 [[=rjk::perf_options]] 的 trait,用户可以指定哪些函数应该被 "inlined"。
内联函数被存储在 duck 对象本身的直接函数指针中,而不是存储在外部 vtable 中。这通过牺牲少量的额外内存(每个内联函数 8 字节)来换取调用时减少一次指针间接层级,如果 vtable 存储在冷内存中,这尤其有益。
技术要求与限制
目前,rjk::duck 是一个处于前沿阶段的实现,具有以下约束:
- 编译器支持:仅适用于带有
-std=c++26 -freflection参数的 GCC。 - 编译时执行:虽然通过 P2738,在
constexpr上下文中进行void*转换正变得可能,但用于指针可互换性的reinterpret_cast目前不允许在编译时使用,这限制了完整的constexpr支持。
社区观点
围绕该库的讨论突出了现代 C++ 反射的两极分化性质:
"我每天都在使用 C++,而这感觉就像是一种完全不同的语言和哲学。"
批评者指出,静态反射可能难以调试,并且如果一个字符放错位置,可能会导致极其冗长的编译器错误信息。其他人则质疑其对编译时间的影响,并指出使用尚在开发中的编译器特性会导致难以进行确定性的基准测试。