最近打算写一个系列文章详细讨论反射(reflection)这一概念,刚好 C++26 有了新的反射提案,发现知乎上又没有相关的文章,而这个话题又经常被讨论。所以借此机会来聊一聊属于 C++ 的静态反射(static reflection),作为系列预热了。

本文已经过时,静态反射已经正式进入 C++26,请移步 Reflection for C++26!!!

What is Static Reflection?

首先反射是指什么呢?这个词就像计算机科学领域很多其他的惯用词一样,并没有详细而准确的定义。关于这个问题,我不打算在这个文章讨论,后续的文章我会详细的解释。本文的重点是 C++ 的 static reflection,为什么强调 static 呢?主要是因为平常我们谈论到反射的时候几乎总是指 Java,C#,Python 这些语言中的反射,而它们的实现方式无一不是把类型擦除,在运行期进行信息的查询。这种方式当然有不可避免的运行时开销,而这种开销显然是违背了 C++ zero cost abstraction 的原则的。为了和它们的反射区分开来,故加上 static 作为限定词,也指示了 C++ 的反射是在编译期完成的。当然,这种说法仍然缺乏一些严谨性。详细的讨论在后续的文章给出,你只需要知道 C++ 的静态反射和 Java,C#,Python 的反射不同,并且主要是在编译期完成的就行了。

What can static reflection do?

Type as Value

我们都知道随着 C++ 版本的不断更新,编译期计算的功能在不断的增强,通过 constexpr/consteval 函数我们能很大程度上直接复用运行期的代码,方便的进行编译期计算。完全取代了很久之前使用模板元进行编译期计算的方法。不仅写起来更加方便,编译速度也更快。

观察下面几段编译期计算阶乘的代码:

在 C++03/98 的时候,我们只能通过模板递归实例化来实现,而且无法将代码复用到运行期

template <int N>
struct factorial {
    enum { value = N * factorial<N - 1>::value };
};

template <>
struct factorial<0> {
    enum { value = 1 };
};

C++11 中第一次引入了 constexpr 函数的概念,使得我们可以编写编译期和运行期复用的代码。但是限制很多,没有变量和循环,我们只能按照纯函数式的风格来编写代码

constexpr int factorial(int n) {
    return n == 0 ? 1 : n * factorial(n - 1);
}

int main() {
    constexpr std::size_t a = factorial(5);  // 编译期计算
    std::size_t& n = *new std::size_t(6);
    std::size_t b = factorial(n);  // 运行期计算
    std::cout << a << std::endl;
    std::cout << b << std::endl;
}

随着 C++14/17 的到来,constexpr 函数中的限制被进一步放开,现在能在 constexpr 函数中使用局部变量和循环了,就像下面这样

constexpr std::size_t factorial(std::size_t N) {
    std::size_t result = 1;
    for(std::size_t i = 1; i <= N; ++i) {
        result *= i;
    }
    return result;
}

C++20 之后,我们还可以在编译期使用 new/delete,我们可以在编译期代码里面使用 vector。很多运行期的代码可以直接在编译期复用,而不需要任何更改,只需要在函数前面加上一个 constexpr 标记,再也不用为了进行编译期计算而使用晦涩难懂的模板元编程了。但是,上面的示例仅仅适用于 value,在 C++ 里面除了 value 还有 type 和 higher kind type

template <typename... Ts>
struct type_list;

template <typename T, typename U, typename... Ts>
struct find_first_of {
    constexpr static auto value = find_first_of<T, Ts...>::value + 1;
};

template <typename T, typename... Ts>
struct find_first_of<T, T, Ts...> {
    constexpr static std::size_t value = 0;
};

static_assert(find_first_of<int, double, char, int, char>::value == 2);

由于 type 和 higher kind type 只能是 template arguments,所以还是只能通过模板递归匹配处理它们。要是我们能像 value 一样操作它们就好了,这样的话 constexpr 函数也能处理它们了。但是 C++ 又不是像 Zig 那样的语言,type is value。怎么办呢?没关系,我们把 type 映射到 value 不就行了?实现 type as value 的效果。在静态反射加入之前,我们可以通过一些 trick 来实现这个效果。可以在编译期把类型映射到类型名,于是只要对类型名进行计算就好了。关于如何进行这种映射,可以参考 C++ 中如何优雅进行 enum 到 string 的转换

template <typename... Ts>
struct type_list {};

template <typename T, typename... Ts>
constexpr std::size_t find(type_list<Ts...>) {
    // type_name 用于获取编译期类型名
    std::array arr{type_name<Ts>()...};
    for(auto i = 0; i < arr.size(); i++) {
        if(arr[i] == type_name<T>()) {
            return i;
        }
    }
}

非常直观的代码,但是如果我们想把值映射回类型就比较困难了。不过没关系,在即将到来的 static reflection 中,这种类型和值的双向映射已经成为语言特性了,我们不再需要去手动处理了。

使用 ^ 运算符将类型映射到值

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

使用 [: ... :] 将它映射回去,注意这是 symbol 级别的映射

using Int = typename[:value:];  // 在此语境下,typename 可以省略
typename[:value:] a = 3;        // 相当于 int a = 3;

现在我们就能写出下面这样的代码了。

template <typename... Ts>
struct type_list {
    constexpr static std::array types = {^Ts...};

    template <std::size_t N>
    using at = typename[:types[N]:];
};

using Second = typename type_list<int, double, char>::at<1>;
static_assert(std::is_same_v<Second, double>);

再也不用递归匹配了,我们可以把类型像值一样计算。只要理解了这种映射关系,代码写起来非常简单。用于类型计算的模板元可以退出历史舞台了!

^ 其实不仅能够映射类型,主要有下面这些功能:

  • ^:: —— 代表全局命名空间
  • ^namespace-name—— 命名空间名称
  • ^type-id—— 类型
  • ^cast-expression —— 特殊表达式,目前包括包括:
    • 表示函数或者成员函数的主表达式
  • 表示变量,静态成员变量,结构化绑定的主表达式
  • 表示非静态成员的主表达式
  • 表示模板的主表达式
  • 常量表达式

同样的 [: ... :] 也能还原成对应的东西,注意是还原到对应的符号,所以这个运算符被叫做,拼接器(Splicers)

  • [: r :] —— 还原成对应的实体或者表达式
  • typename[: r :] —— 还原成对应的类型
  • template[: r :] —— 还原成模板参数
  • namespace[: r :] —— 还原成命名空间
  • [:r:]:: —— 还原成对应的命名空间,类,枚举嵌套说明符

看下面的使用示例

int x = 0;

void g() {
    [:^x:] = 42;  // Okay.  Same as: x = 42;
}

如果还原的东西和原本储存的不一样,则会编译错误

typename[:^:::] x = 0;  // Error

metainfo

光是上面一个特性,就足以让人心动了。然而远远不止如此,获取 class 等实体元信息的功能也有了。

最基础的,获取类型名(变量名,字段名都可以用这个函数)

namespace std::meta {
    consteval auto name_of(info r) -> string_view;
    consteval auto display_name_of(info r) -> string_view;
}  // namespace std::meta

比如可以

display_name_of(^std::vector<int>);  //  => std::vector<int>
name_of(^std::vector<int>);          // => std::vector<int, std::allocator<int>>

判断一个模板是不是另一个高阶模板的特化 和 萃取高阶模板里面的参数

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

std::vector<int> v = {1, 2, 3};
static_assert(template_of(type_of(^v)) == ^std::vector);
static_assert(template_arguments_of(type_of(^v))[0] == ^int);

把模板参数填到高阶模板中去

namespace std::meta {
    consteval auto substitute(info templ, span<const info> args) -> info;
}

constexpr auto r = substitute(^std::vector, std::vector{^int});
using T = [:r:];  // Ok, T is std::vector<int>

获取 structclassunionenum 的成员信息

namespace std::meta {
    template <typename... Fs>
    consteval auto members_of(info class_type, Fs... filters) -> vector<info>;

    template <typename... Fs>
    consteval auto nonstatic_data_members_of(info class_type, Fs... filters) -> vector<info> {
        return members_of(class_type, is_nonstatic_data_member, filters...);
    }

    template <typename... Fs>
    consteval auto bases_of(info class_type, Fs... filters) -> vector<info> {
        return members_of(class_type, is_base, filters...);
    }

    template <typename... Fs>
    consteval auto enumerators_of(info class_type, Fs... filters) -> vector<info>;

    template <typename... Fs>
    consteval auto subobjects_of(info class_type, Fs... filters) -> vector<info>;
}  // namespace std::meta

待会用这个我们就可以实现遍历结构体,枚举等功能。进一步就可以实现序列化,反序列化等高级功能。后文会有一些示例。除此之外,还有一些其他的功能的编译期函数,上面只展示了一部分内容,更多的 API 可以参考提案中的内容。由于提供了直接获取高级模板里面参数的函数,再也不用用模板去进行类型萃取了!用于类型萃取的模板元也可以退出历史舞台了。

Better compile facilities

反射的主题部分大致已经介绍完了,现在来聊聊其他的。虽然这部分是其他提案的内容,但是它们可以使代码写起来更加轻松,让代码有更强的表达能力。

template for

在 C++ 里面如何生成大量的代码段是一个非常不好解决的问题,得益于 C++ 独(逆)特(天)的机制,目前的代码片段生成几乎都是基于 lambda 表达式 + 可变参数包展开。看下面的例子

constexpr auto dynamic_tuple_get(std::size_t N, auto& tuple) {
    constexpr auto size = std::tuple_size_v<std::decay_t<decltype(tuple)>>;
    [&]<std::size_t... Is>(std::index_sequence<Is...>) {
        auto f = [&]<std::size_t Index> {
            if(Index == N) {
                std::cout << std::get<Index>(tuple) << std::endl;
            }
        };
        (f.template operator()<Is>(), ...);
    }(std::make_index_sequence<size>{});
}

int main() {
    std::tuple tuple = {1, "Hello", 3.14, 42};
    auto n1 = 0;
    dynamic_tuple_get(n1, tuple);  // 1
    auto n2 = 3;
    dynamic_tuple_get(n2, tuple);  // 42
}

一个很经典的例子,原理是通过多个分支判断,将运行期变量分发到编译期常量。实现根据运行期的 index 来访问 tuple 里面的元素。注:这里效率更高的办法是,编译期生成一个函数指针数组,然后直接根据 index 进行跳转,不过这里只是做个展示,不用纠结太多。

上面的代码展开后相当于

constexpr auto dynamic_tuple_get(std::size_t N, auto& tuple) {
    if(N == 0) {
        std::cout << std::get<0>(tuple) << std::endl;
    }
    // ...
    if(N == 3) {
        std::cout << std::get<3>(tuple) << std::endl;
    }
}

可以发现,我们用了极其别扭的写法只是为了实现极其简单的效果。而且由于 lambda 其实是个函数,其实没法直接从 lambda 里面直接返回到上一级函数。导致我们多做了很多多余的 if 判断。

换成 template for 则代码看起来清爽很多

constexpr void dynamic_tuple_get(std::size_t N, auto& tuple) {
    constexpr auto size = std::tuple_size_v<std::decay_t<decltype(tuple)>>;
    template for(constexpr auto num: std::views::iota(0, size)) {
        if(num == N) {
            std::cout << std::get<num>(tuple) << std::endl;
            return;
        }
    }
}

可以认为 template for 是 lambda 展开的语法糖加强版,反正非常好用就是了。如果这个加入了,利用模板元生成函数(代码)就可以退休了。

non-transient constexpr allocation

这个提案主要是将两个问题联合起来讨论了。

  • C++ 可以通过控制模板实例化 static 成员在数据段预留位置,可以看作编译期内存分配
template <auto... items>
struct make_array {
    using type = std::common_type_t<decltype(items)...>;
    inline static type value[sizeof...(items)] = {items...};
};

template <auto... items>
constexpr auto make_array_v = make_array<items...>::value;

int main() {
    constexpr auto arr = make_array_v<1, 2, 3, 4, 5>;
    std::cout << arr[0] << std::endl;
    std::cout << arr[1] << std::endl;  // 成功在数据段预留位置,存放的是 1 2 3 4 5
}
  • C++20 允许了 constexpr 中进行 new,但是编译期 new 的内存必须在编译期 delete
constexpr auto size(auto... Is) {
    std::vector<int> v = {Is...};
    return v.size();
}

那就不能在编译期 new 之后,不 delete?实际数据放在数据段?这就是这个提案要解决的问题,它希望我们能使用

constexpr std::vector<int> v = {1, 2, 3, 4, 5};  // 全局的

主要难点是,在数据段分配的内存不像在堆上的内存一样有所有权,不需要 delete。只要解决了这个问题,就能使用编译期的 std::mapstd::vector 并且保留到运行期。这个作者的做法是进行标记。具体的细节这里就不说了。如果这个加入了,利用模板元打常量表也可以退出了。

Some examples

好了,上面说了那么多,让我们看看用反射我们都能干些什么

template <typename T>
constexpr auto print(const T& t) {
    template for(constexpr auto member: nonstatic_data_members_of(type_of(^t))) {
        if constexpr(is_class(type_of(member))) {
            // 如果是 class 就递归遍历成员
            println("{}= ", name_of(member));
            print(t.[:member:]);
        } else {
            // 非类类型可以直接打印
            std::println("{}= {}", name_of(member), t.[:member:]);
        }
    }
}

enum to string

template <typename E>
    requires std::is_enum_v<E>
constexpr std::string enum_to_string(E value) {
    template for(constexpr auto e: std::meta::members_of(^E)) {
        if(value == [:e:]) {
            return std::string(std::meta::name_of(e));
        }
    }
    return "<unnamed>";
}

conclusion

花费了很长的篇幅介绍 C++ 的 static reflection。其实我非常喜欢 C++ 的编译期计算,对它的发展史也非常感兴趣。C++ 的编译期计算是一步步摸索出来的,有很多富有智慧的大师提出他们的独特想法,让不可能的事情变成现实。从 C++03 的变态模板元,到 C++11 的 constexpr 变量,到 C++14 ~23 对 constexpr 函数中的限制逐渐放开,把越来越多的操作移到编译期。再到如今的 static reflection,C++ 正在逐步脱离模板元的魔爪。之前那些老旧的模板元写法全都可以淘汰掉了!!!如果你没写过以前的老式模板元代码,大概是体会不到它有多可怕的。

为了让静态反射能早点进入标准,作者团队特地选了原本提案的一部分核心子集。希望如作者所愿,静态反射能在 C++26 进入标准!当然,核心部分先进入,之后再补充更多更加有用的功能,所以这绝不是反射的全部内容。

实验编译器:

反射系列文章:写给 C++ 程序员的反射教程