在昨天刚才结束的 C++26 Sofia 会议上,有关静态反射 (Static Reflection) 的七个提案:

全都通过了 plenary 得以被正式纳入 C++26 标准,这是一个令人激动的时刻。在我看来,静态反射无疑是 20 年来 C++ 最重要的一个新特性。它彻底改变了以前使用模板进行元编程的模式,让元编程 (meta programming) 的代码可以像普通的代码逻辑一样易于阅读、编写、使用,而不再是以前基于模板的 DSL。

在一年多前,P2996R1 的时候我就编写过一篇 文章 来介绍静态反射这个令人激动的提案。过了这么久,静态反射提案本身的内容有了较大的改变,上面文章的内容已经过时了,而且还新增了很多的附属提案。所以我决定编写一篇新文章来介绍静态反射及其附属提案的内容。

如果想体验静态反射,有两种方式,一种是通过 Compiler Explorer 这个在线编辑器,把上面的编译器调成 P2996 clang 就行了。另外一种是自己编译 https://github.com/bloomberg/clang-p2996/tree/p2996 这个 P2996 分支的 clang 和 libc++。然后,参考 use libc++ 这个页面,在编译的时候使用刚编译出的 libc++ 作为标准库,就可以本地使用了,记得要开启 C++26 标准。

What is Static Reflection?

首先反射是指什么呢?这个词就像计算机科学领域很多其他的惯用词一样,并没有详细而准确的定义。关于这个问题,我的反射专栏进行了较多的讨论,感兴趣的读者可以自行阅读,本文的重点是 C++ 的 static reflection。为什么强调 static 呢?主要是因为平常我们谈论到反射的时候几乎总是指 Java,C#,Python 这些语言中的反射,而它们的实现方式无一不是把类型擦除,在运行期进行元信息的查询。这种方式当然有不可避免的运行时开销,而这种开销显然是违背了 C++ zero cost abstraction 的原则的。为了和它们的反射区分开来,故加上 static 作为限定词,也指示了 C++ 的反射是在编译期完成的。

Everything as Value

静态反射引入了两种新的语法,可以用反射运算符 (reflection operator): ^^ 将绝大多数 name entity 映射到 std::meta::info

constexpr std::meta::info rint = ^^int;

std::meta::info 是一种新的、特殊的、consteval only 的 builtin 类型。它只能存在于编译期,你可以把它当成编译器中对这个 name entity 的 handle,后续可以基于这个不透明的 handle 做一些其它的操作。

具体来说 ^^ 支持下面四种 name entity,

  • :::全局命名空间
  • namespace-name:普通命名空间
  • type-id:类型
  • id-expression:绝大多数具有名字的东西,比如变量,静态成员变量,字段,函数,模板,枚举等

那怎么可以这个 handle 还原回去吗?欸,可以的,使用拼接器 (splicer)[: :]std::meta::info 还原回 name entity。

例如

constexpr std::meta::info rint = ^^int;
using int2 = [:rint:];

使用 [:rint:] 就将 rint 映射回了 int 类型,对于其它的 name entity 也是类似的,使用 [:rint:] 可以将它们映射回去。注意在某些可能造成歧义的上下文中需要在 [: :] 前面加上 typename 或者 template 关键字来消除歧义。

需要消歧义的地方基本上还是 dependent name 的情况,也就是说当 r 是模板参数的时候,没法直接确定 [:r:] 是表达式,还是类型,还是模板,所以要手动来消除歧义。

总结一下,静态反射引入了两种新的运算符,^^ 用于获取 name entity 的 handle[: :] 用于把 handle 映射回对应的 name entity。

Meta Function

我们都知道,仅仅获取一个 handle 并没有什么用,关键在其它于基于 handle 的一些操作。例如获取了一个文件的 handle,可以基于这个 handle 读取内容或者关闭文件什么的。在静态反射中,对这些 handle 的操作就是元函数 (meta function)。在 <meta> 头文件中,提供了一组非常广泛的函数用于操作这些 handle。下面对其中一些非常常用的元函数进行介绍

反射目前使用编译期的异常来处理元函数中遇到的错误

members

namespace std::meta {
    consteval vector<info> members_of(info r, access_context ctx);
    consteval vector<info> bases_of(info type, access_context ctx);
    consteval vector<info> static_data_members_of(info type, access_context ctx);
    consteval vector<info> nonstatic_data_members_of(info type, access_context ctx);
    consteval vector<info> enumerators_of(info type_enum);

    consteval bool has_parent(info r);
    consteval info parent_of(info r);
}

序列化 (serialization)反序列化 (deserialization) 中的一个常见诉求就是获取到某个 struct 的 members,然后递归进行序列化。在静态反射之前,我们只能通过各种 hack 的方式来做到这一点,而且并不完美。例如 reflect-cpp 支持 C++20 下获取聚合类 (aggregate class) 的数据成员 和 magic-enum 支持枚举值在 [-127, 128] 范围内的枚举成员。实现方式非常的 hack 而且对编译器不友好,实例化大量模板导致编译速度降低,而且限制也很多。

现在在静态反射中,我们可以轻松的利用这几个元函数来获取命名空间或者类型的成员,而且不仅限于数据成员成员函数和别名之类的成员也可以轻松获取,还可以获取基类信息,这在之前无论如何也是做不到的。也支持反向操作,通过 parent_of 获取某个成员的 parent,也就是定义这个 entity 的 namespace, class 或者 function。

struct Point {
    int x;
    int y;
};

int main() {
    Point p = {1, 2};
    constexpr auto no_check = meta::access_context::unchecked();
    constexpr auto rx = meta::nonstatic_data_members_of(^^Point, no_check)[0];
    constexpr auto ry = meta::nonstatic_data_members_of(^^Point, no_check)[1];

    p.[:rx:] = 3;
    p.[:ry:] = 4;

    std::println("p: {}, {}", p.x, p.y);
}

输出 p: 3, 4,成功通过反射访问成员!

access_context 参数用于控制访问权限,它决定了我们是否能“看到”私有或保护成员,unchecked() 则代表拥有完全的访问权限,也就是说不进行任何访问检查。除了 unchecked 以外还有 current 表示使用当前作用域的访问权限,以及 unprivileged 只能访问非私有成员。上述获取成员的元函数会根据 access_context 对返回结果进行过滤。

identifiers

namespace std::meta {
    consteval bool has_identifier(info r);

    consteval string_view identifier_of(info r);
    consteval u8string_view u8identifier_of(info r);

    consteval string_view display_string_of(info r);
    consteval u8string_view u8display_string_of(info r);

    consteval source_location source_location_of(info r);
}

这个功能也是 C++ 程序员心心念念已久的功能了,获取变量名,函数名,字段名。

constexpr auto rx = meta::nonstatic_data_members_of(^^Point, no_check)[0];
constexpr auto ry = meta::nonstatic_data_members_of(^^Point, no_check)[1];

static_assert(meta::identifier_of(rx) == "x");
static_assert(meta::identifier_of(ry) == "y");

这样在序列化到 json 这样需要字段名的格式的时候也很简单了。identifier_of 一般只能用于拥有简单名字的 entity,并且直接返回这个 named entity 的不带限定符 (qualifier) 的名字。而 display_string_of 则可能更倾向于返回带全称限定的名字,比如它的命名空间前缀,也可以用于处理 vector<int> 这样的模板特化。source_location_of 则进一步突破了 C++20 加的 std::source_location::current() 只能获取当前源码位置的限制。

offsets

namespace std::meta {
    struct member_offset {
        ptrdiff_t bytes;
        ptrdiff_t bits;

        constexpr ptrdiff_t total_bits() const {
            return CHAR_BIT * bytes + bits;
        }

        auto operator<=>(const member_offset&) const = default;
    };

    consteval member_offset offset_of(info r);
    consteval size_t size_of(info r);
    consteval size_t alignment_of(info r);
    consteval size_t bit_size_of(info r);
}

offset_of 返回给定字段 offset 信息,由两部分构成字节数 bytes 和位数 bits,用 total_bits 就可以获取具体的偏移了。这样设计的主要是考虑到字段可能是位域,偏移量不一定就是字节数。size_ofalignment_of 顾名思义就是获取 size 和 alignment。而 bit_size_of 则是获取位域的大小。

通过这一组元函数,也不再需要通过各种 hack 的手段获取字段偏移量了,比如 bit_cast 成员指针来根据 ABI 细节获取到偏移量。在某些二进制序列化的场景是十分有用的。

type operations

接下来是有关 type 的操作了,这些操作就是简化模板元编程的关键所在。在这之前,由于类型只能作为模板参数,我们不得不基于丑陋的模板 DSL 来对类型做计算。一个纯函数式,没有变量,通过模板特化来表示分支,通过模板递归来表示循环的丑陋的 DSL,这也是模板元编程长期被人所诟病的原因。现在有了静态反射,我们可以把类型映射到值,只需要对值进行操作,普通的编写 consteval 函数就好了,和正常的代码逻辑没什么区别,只是 handle 变成了 std::meta::info

首先这里要谈谈 std::meta::info 的相等性,考虑如下代码

using int1 = int;
constexpr auto rint = ^^int;
constexpr auto rint1 = ^^int1;

这里的 rintrint1 应该相等吗?毫无疑问它们表示相同的类型,但是前面我们说过,std::meta::info 是一个编译器内部表示的 handle,显然编译器会单独的跟踪类型别名的信息,所以 rintrint1 其实是不同的 name entity 的 handle,那么它们不相等。关于判断两个 std::meta::info 是否相等的完整规则这里就略去了,有一些其它的 case 需要考虑,具体的细节可以之后去 cppreference 或者标准草案上查阅。对于本文的例子,理解上述这个别名的例子就足够了。

namespace std::meta {
    consteval auto type_of(info r) -> info;
    consteval auto dealias(info r) -> info;
}

可以使用 type_of 来获取结构体字段等 typed entity 的类型,使用 dealias 获取一个别名的底层 entity,例如类型别名和命名空间别名原本的 entity,这个过程是递归的,会解开所有的别名。

例如

using X = int;
using Y = X;
static_assert(^^int == dealias(^^Y));

原本定义在 <type_traits> 头文件中的模板形式的 trait 现在都在 <meta> 中有了对应的反射版本,命名规则是把后缀从 _v 改成 _type,例如 is_same_v 就变成了 is_same_type_t 后缀则是直接删去后缀

这部分函数太多了,下面列出一些作为示例

namespace std::meta {
    consteval info remove_const(info type);
    consteval info remove_volatile(info type);
    consteval info remove_cv(info type);
    consteval info add_const(info type);
    consteval info add_volatile(info type);
    consteval info add_cv(info type);

    consteval info remove_pointer(info type);
    consteval info add_pointer(info type);

    consteval info remove_cvref(info type);
    consteval info decay(info type);
}

所以现在可以方便的把以前的 type_traits 版本的处理,等价的换成反射的版本了。代码会好理解很多,在文章的最后我会给出几个这样的案例。

template arguments

除了上述对类型的操作以外,现在我们也能方便的对模板进行操作了

namespace std::meta {
    consteval info template_of(info r);
    consteval vector<info> template_arguments_of(info r);

    template <reflection_range R = initializer_list<info>>
    consteval bool can_substitute(info templ, R&& arguments);

    template <reflection_range R = initializer_list<info>>
    consteval info substitute(info templ, R&& arguments);
}

假设 r 是一个模板特化 (template specialization)template_of 返回它的模板,template_arguments_of 返回它的模板参数。substitute 则是根据给定的模板和参数,返回替换结果的模板特化的反射(不触发实例化)。通过这组函数,我们不再需要通过偏特化的方式来萃取模板特化的模板参数,轻而易举就可以拿到参数列表了。

还可以通过它们编写一个 is_specialization_of 用来判断某个类型是不是某个模板的特化,而这在以前无论如何是做不到的

consteval bool is_specialization_of(info templ, info type) {
    return templ == template_of(dealias(type));
}

为什么之前做不到这一点呢?这是因为模板参数可以是类型 (typename),值 (auto),模板模板参数 (template),而你没法穷举出这三种参数的所有组合,这样的话在编写 is_specialization_of 的时候里面待判断的模板签名就是固定的了。假设是 <typename T, template<typename...> HKT> ,这样 HKT 就只能填入类型模板参数,比如 std::array 它就处理不了了。

reflect value

namespace std::meta {
    template<typename T>
    consteval auto reflect_constant(const T& expr) -> info;

    template<typename T>
    consteval auto reflect_object(T& expr) -> info;

    template<typename T>
    consteval auto reflect_function(T& expr) -> info;

    template<typename T>
    consteval auto extract(info) -> T;
}

这些元函数产生一个所提供表达式的求值结果的反射。这类反射最常见的用例之一是作为 std::meta::substitute 的参数,用来构建一个模板特化 (specialization)

reflect_constant(expr) 等价于下面的代码

template <auto P>
struct C {};

那么有

static_assert(reflect_constant(V) == template_arguments_of(^^C<V>)[0]);
constexpr auto rarray5 = substitute(^^std::array, {^^int, std::meta::reflect_constant(5)});
static_assert(rarray5 == ^^std::array<int, 5>);

reflect_object(expr) 产生一个由 expr 所指代的对象的反射。这经常被用来获取一个子对象的反射,然后该反射可以被用作一个引用类型的非类型模板参数。

template <int &> void fn();

int p[2];
constexpr auto r = substitute(^^fn, {std::meta::reflect_object(p[1])});

reflect_function(expr) 产生一个由 expr 所指代的函数的反射。当只有一个函数的引用可用时,它对于反射该函数的属性非常有用。

consteval bool is_global_with_external_linkage(void(*fn)()) {
    std::meta::info rfn = std::meta::reflect_function(*fn);
    return (has_external_linkage(rfn) && parent_of(rfn) == ^^::);
}

extract 则是上述 reflect_xxx 系列的反向操作,可以用于把一个 value 的反射,还原到对应的 C++ 中的值

  • 如果 r 是一个值的反射,extract<ValueType>(r) 返回该值
  • 如果 r 是一个对象的反射,extract<ObjectType&>(r) 返回该对象的引用
  • 如果 r 是一个函数的反射,extract<FuncPtrType>(r) 返回该函数的指针
  • 如果 r 是一个非静态成员的反射,extract<MemberPtrType>(r) 返回成员指针

define aggregate

namespace std::meta {
    struct data_member_options {
        struct name_type {
            template <typename T> requires constructible_from<u8string, T>
            consteval name_type(T &&);

            template <typename T> requires constructible_from<string, T>
            consteval name_type(T &&);
        };

        optional<name_type> name;
        optional<int> alignment;
        optional<int> bit_width;
        bool no_unique_address = false;
    };

    consteval auto data_member_spec(info type,
                                  data_member_options options) -> info;
    template <reflection_range R = initializer_list<info>>
    consteval auto define_aggregate(info type_class, R&&) -> info;
}

可以用 define_aggregate 给一个不完整的类型生成成员定义,这对于实现 tuple 或者 variant 这样的可变成员数量的类型很有用,例如

union U;
consteval {
    define_aggregate(^^U, {
        data_member_spec(^^int),
        data_member_spec(^^char),
        data_member_spec(^^double),
    });
}

相当于

union U {
    int _0;
    char _1;
    double _2;
};

这样就可以方便的实现一个 variant 类型而无需任何模板递归实例化了。

other functions

除了上面列出的这些函数以外,还有非常多的函数用于查询 r 的某些特性,基本上是见名知义,仅列出

consteval auto is_public(info r) -> bool;
consteval auto is_protected(info r) -> bool;
consteval auto is_private(info r) -> bool;
consteval auto is_virtual(info r) -> bool;
consteval auto is_pure_virtual(info r) -> bool;
consteval auto is_override(info r) -> bool;
consteval auto is_final(info r) -> bool;
consteval auto is_deleted(info r) -> bool;
consteval auto is_defaulted(info r) -> bool;
consteval auto is_explicit(info r) -> bool;
consteval auto is_noexcept(info r) -> bool;
consteval auto is_bit_field(info r) -> bool;
consteval auto is_enumerator(info r) -> bool;
consteval auto is_const(info r) -> bool;
consteval auto is_volatile(info r) -> bool;
consteval auto is_mutable_member(info r) -> bool;
consteval auto is_lvalue_reference_qualified(info r) -> bool;
consteval auto is_rvalue_reference_qualified(info r) -> bool;
consteval auto has_static_storage_duration(info r) -> bool;
consteval auto has_thread_storage_duration(info r) -> bool;
consteval auto has_automatic_storage_duration(info r) -> bool;
consteval auto has_internal_linkage(info r) -> bool;
consteval auto has_module_linkage(info r) -> bool;
consteval auto has_external_linkage(info r) -> bool;
consteval auto has_linkage(info r) -> bool;
consteval auto is_class_member(info r) -> bool;
consteval auto is_namespace_member(info r) -> bool;
consteval auto is_nonstatic_data_member(info r) -> bool;
consteval auto is_static_member(info r) -> bool;
consteval auto is_base(info r) -> bool;
consteval auto is_data_member_spec(info r) -> bool;
consteval auto is_namespace(info r) -> bool;
consteval auto is_function(info r) -> bool;
consteval auto is_variable(info r) -> bool;
consteval auto is_type(info r) -> bool;
consteval auto is_type_alias(info r) -> bool;
consteval auto is_namespace_alias(info r) -> bool;
consteval auto is_complete_type(info r) -> bool;
consteval auto is_enumerable_type(info r) -> bool;
consteval auto is_template(info r) -> bool;
consteval auto is_function_template(info r) -> bool;
consteval auto is_variable_template(info r) -> bool;
consteval auto is_class_template(info r) -> bool;
consteval auto is_alias_template(info r) -> bool;
consteval auto is_conversion_function_template(info r) -> bool;
consteval auto is_operator_function_template(info r) -> bool;
consteval auto is_literal_operator_template(info r) -> bool;
consteval auto is_constructor_template(info r) -> bool;
consteval auto is_concept(info r) -> bool;
consteval auto is_structured_binding(info r) -> bool;
consteval auto is_value(info r) -> bool;
consteval auto is_object(info r) -> bool;
consteval auto has_template_arguments(info r) -> bool;
consteval auto has_default_member_initializer(info r) -> bool;

consteval auto is_special_member_function(info r) -> bool;
consteval auto is_conversion_function(info r) -> bool;
consteval auto is_operator_function(info r) -> bool;
consteval auto is_literal_operator(info r) -> bool;
consteval auto is_constructor(info r) -> bool;
consteval auto is_default_constructor(info r) -> bool;
consteval auto is_copy_constructor(info r) -> bool;
consteval auto is_move_constructor(info r) -> bool;
consteval auto is_assignment(info r) -> bool;
consteval auto is_copy_assignment(info r) -> bool;
consteval auto is_move_assignment(info r) -> bool;
consteval auto is_destructor(info r) -> bool;
consteval auto is_user_provided(info r) -> bool;
consteval auto is_user_declared(info r) -> bool;

可以看出可以查询的信息非常多,包括储存期 (storage class)链接 (linkage),乃至 user_declareduser_provided 这样的信息。

Function Reflection

上面介绍了反射主体提案的内容,没有涉及的函数参数反射的部分,也就说你没法获取到注入函数参数名这样的信息。但是这个信息在某些场景比如在 pybind11 将 C++ 函数绑定到 python 中还是非常有用的。P3096R12 允许引入了如下的元函数从而对允许反射函数参数

namespace std::meta {
    consteval vector<info> parameters_of(info r);
    consteval info variable_of(info r);
    consteval info return_type_of(info r);
}

如果 r函数或者函数类型的反射,那么 return_type_of 返回它的返回值类型的反射,parameters_of 返回它的函数参数的反射。例如

void foo(int x, float y);

constexpr auto param0 = meta::parameters_of(^^foo)[0];
static_assert(identifier_of(param0) == "x");
static_assert(type_of(param0) == ^^int);

constexpr auto param1 = meta::parameters_of(^^foo)[1];
static_assert(identifier_of(param1) == "y");
static_assert(type_of(param1) == ^^float);

static_assert(return_type_of(^^foo) == ^^void);

欸,既然这样都已经能获取参数名和参数类型了,variable_of 有什么用呢?variable_of 只能在被反射的函数内部使用,用于获取函数定义中该函数参数对应的变量的反射,例如

void foo(const int x, float y) {
    constexpr auto param0 = meta::parameters_of(^^foo)[0];
    static_assert(type_of(param0) == ^^int);
    static_assert(param0 != ^^x);

    constexpr auto var0 = meta::variable_of(param0);
    static_assert(type_of(var0) == ^^const int);
    static_assert(var0 == ^^x);
}

从这个例子中就可以看出二者的区别了。C++ 会隐式忽略掉类型中函数参数上的 const,例如 decltype(foo) 的结果就是 void(int, float),于是你在函数的外部是永远观察不到这一点的,parameters_of 反射的是函数的接口,用于从函数外部反射观察函数,它的行为和上述行为保持一致。而 variable_of 反射的是函数定义,用于从函数内部观察函数,如果在 foo 内部 decltype(x) 的话,会发现是 const int,没有忽略 const,于是 variable_of 也是这样。

还有其它一些细致的区别,比如同一个函数的多次声明中,某个函数参数的名称不同:

void foo(int x);

void foo(int y);

那么对 identifier_of(parameter) 会求值失败,不知道选择多个结果中的哪一个。但是 identifier_of(variable_of(parameter)) 则不是,它返回函数定义中对应变量声明的参数。

namespace std::meta {
    consteval bool is_function_parameter(info r);
    consteval bool is_explicit_object_parameter(info r);
    consteval bool has_ellipsis_parameter(info r);
    consteval bool has_default_argument(info r);
}

剩下这几个函数则是对函数参数的某些性质进行查询了,见名知义:

  • is_function_parameter:判断某个反射是不是函数参数的反射
  • is_explicit_object_parameter:判断某个函数参数的反射是不是 C++23 新加入的显式对象参数 (explicit this)
  • has_ellipsis_parameter:判断一个函数或函数类型是否包含 ...,即 C 风格的可变参数,例如 C 的 printf(const char*, ...)
  • has_default_argument:检测某个参数是否有默认值

Annotations

元编程的目的是为了编写通用的代码,比如自动为某个类型生成序列化的代码逻辑,从而一行代码就能完成序列化,比如

struct Point {
    int x;
    int y;
};

Point p = {1, 2};
auto data = json::serialize(p);

通过静态反射,json::serialize 可以遍历 Point 的字段自动生成序列化逻辑,从而一行代码就能完成序列化。我们不再需要自己去编写重复的、繁琐的的序列化样板代码。通用性是好的,但是有时候我们也想要一些定制的能力。

仍然是上面 json 序列化的例子,假设我们从服务器接收的 json 字段名是 "first-name",但 C++ 的标识符不能包含 -,所以我们可能将成员命名为 first_name。如果能在序列化时特殊处理它,将 first_name 成员重命名为 "first-name" 就好了。

在别的语言中,可以通过 attributeannotation 来附加元数据,然后在代码中读取这些元数据。C++ 也加入了 attribute,语法为 [[...]],比如 [[nodiscard]]。但它主要的设计意图是为编译器提供额外的信息,而不是让用户附加额外的元数据并获取。

为了解决这个问题,P3394R4(Annotations for Reflection) 提案为 C++26 引入了可反射的注解 (annotation)。它的语法非常直观,使用 [[=...]] 为某个 entity 添加注解,任意的可以作为模板参数的常量表达式都可以作为注解的内容。

例如:

struct [[="A simple point struct"]] Point {
    [[=serde::rename("point_x")]]
    int x;

    [[=serde::rename("point_y")]]
    int y;
};

它额外添加了下面这三个函数用于和注解进行交互

namespace std::meta {
    consteval bool is_annotation(info);
    consteval vector<info> annotations_of(info item);
    consteval vector<info> annotations_of_with_type(info item, info type);
}

is_annotation 判断一个反射是不是注解的反射。annotations_of 获取给定 entity 上的所有注解的反射,annotations_of_with_type 则是获取给定 entity 上所有类型为 type 的注解的反射。获取到注解后再使用前面提到的 extract 解开值然后使用就行了。

例如

struct Info {
    int a;
    int b;
};

[[=Info(1, 2)]] int x = 1;
constexpr auto rs = annotations_of(^^x)[0];
constexpr auto info = std::meta::extract<Info>(rs);
static_assert(info.a == 1 && info.b == 2);

这样的话我们就可以在序列化库中预先定义一些类型,比如前文案例中的 serde::rename,然后检测用户的字段上有没有这些 annotation 从而进行一些特殊的处理。这样既保证了整体的通用性,又提供了局部的定制性,两全其美。

Expansion Statement

传统的 range-for 循环遍历运行期的序列,而在元编程中,遍历编译期序列的需求越来越常见。比如遍历 tuple,这种编译期序列和运行期序列的最大区别是元素的类型可能不同

在 C++17 之前我们只能通过模板递归来完成这样的遍历,C++17 加入的折叠表达式稍微缓解了这一情况,但是仍然需要编写大量复杂的模板代码来完成这个目的。鉴于遍历编译期序列的操作如此之常见,P1306R5(Expansion Statements) 引入了新的语法 template for 来解决这个问题

现在你可以轻松直观地遍历一个 tuple 了,在效果上相当于编译期展开循环,并对其中的每个元素实例化一次循环体

void print_all(std::tuple<int, char> xs) {
    template for (auto elem : xs) {
        std::println("{}", elem);
    }
}

精确的的语法定义如下:

template for (init-statement(opt) for-range-declaration : expansion-initializer)
    compound-statement
  • init-statement(opt):前置的初始化语句
  • for-range-declaration: 循环变量的声明
  • expansion-initializer: 用于循环的序列

template for 支持三种不同类型的序列,优先级从高到低:

  • 表达式列表 (Expression List){ expression-list },遍历列表中的每一个元素
template for (auto elem : {1, "hello", true}) { ... }

包展开也是支持的,还能轻松的往参数包中添加内容

void foo(auto&& ...args) {
    template for (auto elem : {args...}) { ... }

    template for (auto elem : {0, args..., 1}) { ... }
}
  • 常量范围 (Constant Range)

要求 range 的长度是编译期确定的

void foo() {
    constexpr static std::array arr = {1, 2, 3};
    constexpr static std::span<const int> view = arr;

    template for (constexpr auto elem : view) { ... }
}
  • 元组式解构 (Tuple-like Destructuring)

如果上述两种情况都不满足,编译器会尝试将 expansion-initializer 视为一个元组式 (tuple-like) 的实体进行解构(就像结构化绑定 auto [a, b] = ... 那样)

std::tuple t(1, "hello", true);
template for (auto elem : t) { ... }

循环变量声明上有可选的 constexpr,如果标记则要求循环中的每个元素都是 constexpr 的

template for 还支持 continuebreak 语句,可以跳过剩余部分未实例化的代码

define static array

好的,你现在已经学会 template for 了,于是想要兴致冲冲的编写一个能打印任何结构体的函数,用于调试

void print_struct(auto&& value) {
    constexpr auto info = meta::remove_cvref(^^decltype(value));
    constexpr auto no_check = meta::access_context::unchecked();
    template for (constexpr auto e : meta::nonstatic_data_members_of(info, no_check)) {
        constexpr auto type = type_of(e);
        auto&& member = value.[:e:];
        if constexpr (is_class_type(type)) {
            print_struct(member);
        } else {
            std::println("{} {}", identifier_of(e), member);
        }
    }
}

发现报错了,说 template for 的初始化表达式不是常量表达式,这是为什么呢?这个事情就说来话长了。你会发现 nonstatic_data_members_of 的返回值竟然是一个 vector。我们前面说过 C++ 的反射是在编译期完成的,编译期还有 vector 用吗?还真有,C++20 允许了编译期的动态内存分配,于是你可以在 constexpr/consteval 函数中使用 vector 来处理中间状态了。但限制是编译期分配的内存必须在同一段编译期求值上下文中释放,如果在一次编译期求值中,有未释放的内存,则会导致编译错误。这个也可以理解,毕竟编译期分配的内存保留到运行期没任何含义了对吧。而每个 top level 的 constexpr 变量,模板参数等,包括 template for 的初始化表达式都视为一次单独的常量求值

所以上面的错误就很好理解了,template for 的初始化表达式被视为一次单独的常量求值,但是返回 vector 导致还有未释放的编译期内存,于是报错了。那怎么解决呢?P3491R3(definestatic{string,object,array}) 引入了一组函数作为这个问题的临时解决方案:

namespace std {
    template <ranges::input_range R>
    consteval const ranges::range_value_t<R>* define_static_string(R&& r);

    template <ranges::input_range R>
    consteval span<const ranges::range_value_t<R>> define_static_array(R&& r);

    template <class T>
    consteval const remove_cvref_t<T>* define_static_object(T&& r);
}

它们可以将编译期分配的内存提升静态储存期,也就是说和全局变量的储存期相同,并返回该静态储存期的指针或者引用,从而解决这个问题,所以上面的代码只需要额外在获取 members 的时候使用 std::define_static_arrayvector 转成 span 就行了

void print_struct(auto&& value) {
    constexpr auto info = meta::remove_cvref(^^decltype(value));
    constexpr auto no_check = meta::access_context::unchecked();
    constexpr auto members =
        std::define_static_array(meta::nonstatic_data_members_of(info, no_check));
    template for (constexpr auto e : members) {
        constexpr auto type = type_of(e);
        auto&& member = value.[:e:];
        if constexpr (is_class_type(type)) {
            print_struct(member);
        } else {
            std::println("{} {}", identifier_of(e), member);
        }
    }
}

每个 vector 和 template for 的地方都需要这样交互,看起来有些冗余。不过也没办法,这其实只是一个临时的 workaround。真正完善的解决方案是 persistent constexpr allocation,它可以自动把编译期未释放的内容提升到静态储存,但是由于种种原因没有推进。关于它又可以写一篇文章来介绍了,这里就不继续展开了,感兴趣的读者可以阅读:The History of constexpr in C++! (Part Two)

Example

最后来编写一个简单的 to_string 函数作为结尾吧:

#include <meta>
#include <print>
#include <string>
#include <vector>

namespace meta = std::meta;

namespace print_utility {

struct skip_t {};

constexpr inline static skip_t skip;

struct rename_t {
    const char* name;
};

consteval rename_t rename(std::string_view name) {
    return rename_t(std::define_static_string(name));
}

}  // namespace print_utility

/// annotations_of => annotations_of_with_type
consteval std::optional<std::meta::info> get_annotation(std::meta::info entity,
                                                        std::meta::info type) {
    auto annotations = meta::annotations_of_with_type(entity, type);
    if (annotations.empty()) {
        return {};
    } else if (annotations.size() == 1) {
        return annotations.front();
    } else {
        throw "too many annotations!";
    }
}

consteval auto fields_of(std::meta::info type) {
    return std::define_static_array(
        meta::nonstatic_data_members_of(type, meta::access_context::unchecked()));
}

template <typename T>
auto to_string(const T& value) -> std::string {
    constexpr auto type = meta::remove_cvref(^^T);
    if constexpr (!meta::is_class_type(type)) {
        return std::format("{}", value);
    } else if constexpr (meta::is_same_type(type, ^^std::string)) {
        return value;
    } else {
        std::string result;

        result += meta::identifier_of(type);
        result += " { ";

        bool first = true;

        template for (constexpr auto member : fields_of(type)) {
            if constexpr (get_annotation(member, ^^print_utility::skip_t)){
                continue;
            }

            if (!first) {
                result += ", ";
            }
            first = false;

            std::string_view field_name = meta::identifier_of(member);
            constexpr auto rename = get_annotation(member, ^^print_utility::rename_t);
            if constexpr (rename) {
                constexpr auto annotation = *rename;
                field_name = meta::extract<print_utility::rename_t>(annotation).name;
            }

            result += std::format("{}: {}", field_name, to_string(value.[:member:]));
        }

        result += " }";
        return result;
    }
}

我们这个简单的 to_string 函数支持两种 annotation,一种是 skip 跳过输出某个字段,一种是 rename 用于对这个字段进行重命名。get_annotation 用于判断给定的 entity 是否只有一个给定类型的 annotation,如果有就返回那个 annotation 返回空或者报错。在 to_string 函数中的处理逻辑也很直接,如果 value 是基本类型或者 string,简单的调用 format 返回结果。否则递归的转换它的字段,先检查字段有没有 skip 这个 annotation,有就跳过。如果没有的话,就检查它有没有 rename,如果有就使用 rename 的名字否则使用字段名。

尝试使用

struct User {
    int id;
    std::string username;

    [[= print_utility::skip]]
    std::string password_hash;
};

struct Order {
    int order_id;

    [[= print_utility::rename("buyer")]]
    User user_info;
};

int main() {
    User u = {101, "Alice", "abcdefg"};
    Order o = {20240621, u};

    std::println("{}", to_string(u));
    std::println("{}", to_string(o));
}

输出

User { id: 101, username: Alice }
Order { order_id: 20240621, buyer: User { id: 101, username: Alice } }

符合预期!代码放在 Compiler Explorer 上了。

Conclusion

到这里这篇关于静态反射的介绍文章就结束了,我尽量涵盖了反射中一些较为重要的核心特性,并附上合适的案例。静态反射是简洁的,强大的和易于理解的。这也象征着 C++ 数十年来 constexpr 演进取得了阶段性的里程碑。在文章的最后,让我引用 Herb Sutter 的 一段话 来结束这篇文章:

在今天之前,C++ 历史上最重要的单项特性投票或许是 2007 年 7 月在多伦多举行的那次,该投票决定将 Bjarne Stroustrup 和 Gabriel Dos Reis 的第一份 “constexpr” 提案纳入 C++11 草案。如今回首,我们可以看到那为 C++ 带来了多么巨大的结构性转变。

我坚信,在未来的许多年里,当我们回望今天,这个反射特性首次被采纳为标准 C++ 的日子,会视其为该语言历史上的一个关键日期。反射将从根本上改善我们编写 C++ 代码的方式,其对语言表达能力的扩展将超过我们至少 20 年来所见的任何特性,并将极大地简化现实世界中的 C++ 工具链和环境。即使仅凭我们今天拥有的部分反射能力,我们已经能够反射 C++ 类型,并利用这些信息加上普通的 std::cout 来生成任意额外的 C++ 源代码,这些代码基于反射信息,并且可以在程序构建时被编译并链接到同一程序中(未来我们还将获得 token injection 功能,从而可以在同一源文件中直接生成 C++ 源码)。但我们实际上可以生成任何东西:任意的二进制元数据,例如 .WINMD 文件;任意的其他语言代码,例如自动生成用于封装 C++ 类型的 Python 或 JS 绑定。所有这一切都可以通过可移植的标准 C++ 实现。

这是一件非常了不起的大事。听着,大家都知道我偏爱说 C++ 的好话,但我不喜欢夸大其词,也从未说过这样的话。今天确实是独一无二的:反射带来的变革性,比我们以往投票纳入标准的所有其他 10 个主要特性的总和还要大。在未来的十年(甚至更久)里,它将主导 C++ 的发展,我们将通过增加更多功能来完善这一特性(就像我们随着时间推移为 constexpr 添加功能以使其完备一样),并学习如何在我们的程序和构建环境中使用它。