C++14实现编译期反射–剖析magic_get中的magic

pod类型编译期反射

在2016年的cppcon技术大会上,Antony Polukhin做了一个关于C++反射的演讲,他提出了一个实现反射的新思路,即无需使用宏、标记和额外的工具即可实现反射。这看起来似乎是一件不可能完成的任务,因为C++是没有反射机制的,无法直接获取对象的元信息。但是Antony Polukhin发现对pod类型使用modern c++的模版元技巧可以实现这样的编译期反射。他开源了一个pod类型的编译期反射库magic_get,这个库也准备进入boost。我们来看看magic_get的使用示例。

通过这个示例可以看到,magic_get确实实现了非侵入式访问foo对象的字段,不需要写任何宏、额外的代码以及专门的工具,直接在编译期就可以访问pod对象的字段,没有运行期负担,确实有点magic。

本文将通过分析magic_get源码来介绍magic_get实现的关键技术,深入解析实现pod类型反射的原理。

关键技术

实现pod类型反射的思路是这样的:先将pod类型转换为对应的tuple类型,接下来将pod类型的值赋给tuple,然后就可以通过索引去访问tuple中的元素了。所以实现pod反射的关键就是如何将pod类型转换为对应的tuple类型和pod值赋值给tuple。

1. pod类型转换为tuple类型

pod类型对应的tuple类型是什么样的呢?以上面的foo为例,foo对应的tuple应该是tuple<int, char>, 即tuple中的元素类型和顺序和pod类型中的字段完全一一对应。

根据结构体生成一个tuple的基本思路是,按顺序将结构体中每个字段的类型萃取出来并保存起来,后面再取出来生成对应的tuple类型。然而字段的类型是不同的,C++也没有一个能直接保存不同类型的容器,因此需要一个变通的方法,用一个间接的方法来保存萃取出来的字段类型,即将类型转换为一个size_t类型的id,将这个id保存到一个array<size_t, N>中,后面根据这个id来获取实际的type并生成对应的tuple类型。

这里需要解决的一个问题是如何实现类型和id的相互转换。

type和id在编译期相互转换

先借助一个空的模版类用来保存实际的类型,再借助C++14的constexpr特性,在编译期返回某个类型对应的编译期id,就可以实现type转换为id了。具体代码如下:

上面的代码在编译期将类型int和char做了一个编码,将类型转换为一个具体的编译期常量,后面就可以根据这些编译期常量来获取对应的具体类型。
编译期根据id获取type的代码如下:
constexpr auto id_to_type( std::integral_constant<std::size_t, 6> ) noexcept { int res{}; return res; }
constexpr auto id_to_type( std::integral_constant<std::size_t, 9> ) noexcept { char res{}; return res; }
上面的代码中id_to_type返回的是id对应的类型的实例,如果要获取id对应的类型还需要通过decltype推导出来。magic_get通过一个宏将pod基本类型都做了一个编码,以实现type和id在编译期的相互转换。

将类型编码之后,保存在哪里以及如何取出来是接着要解决的问题。magic_get通过定义一个array来保存结构体字段类型id。

array中的定长数组data中保存字段类型对应的id,数组下标就是字段在结构体中的位置索引。

萃取pod结构体字段

前面介绍了如何实现字段类型的保存和获取,那么这个字段类型是如何从pod结构体中萃取出来的呢?具体的做法分为三步:

  1. 定义一个保存字段类型id的array;
  2. 将pod的字段类型转换为对应的id,按顺序保存到array中;
  3. 筛除array中多余的部分;

下面是具体的实现代码:

定义array的时候需要定义一个固定的数组长度,这个数组的长度应该为多少合适呢?按照结构体最多的字段数来确定。因为结构体的字段数最多为sizeof(T),所以array的长度设置为sizeof(T)。array中的元素全部初始化为0。一般情况下,结构体字段数一般不会超过array的长度,那么array中就就会出现多余的元素,所以还需要将array中多余的字段移除,只保存有效的字段类型id。具体的做法是计算出array中非零的元素有多少,接着再把非零的元素赋给一个新的array。下面是计算array非零元素个数,同样是借助constexpr实现编译期计算。

由于字段是按顺序保存到array中的,所以在元素值为0时的count就是有效的元素个数。接下来我们来看看detect_fields_count_and_type_ids的实现,这个constexpr函数将结构体中的字段类型id保存到array的data中。

detect_fields_count_and_type_ids的第一个参数为定长数组array<std::size_t, sizeof(T)>的data,第二个参数是一个std::index_sequence整形序列。detect_fields_count_and_type_ids具体实现代码如下:

上面的代码是为了将index_sequence展开为0,1,2…, sizeof(T)的序列,得到这个序列之后,再调用type_to_array_of_type_ids函数实现结构体中的字段类型id保存到array中。
在讲type_to_array_of_type_ids函数之前我们先看一下辅助结构体ubiq。保存pod字段类型id实际上是由辅助结构体ubiq实现的,它的实现如下:

这个结构体比较特殊,我们先把它简化一下。

这个结构体的特殊之处在于它可以用来构造任意pod类型,比如int, char, double等类型。

因为ubiq构造函数所需要的类型由编译器自动推断出来,所以它能构造任意pod类型。通过ubiq结构体获取了需要构造的类型之后,我们还需要将这个类型转换为id按顺序保存到定长数组中。

上面的代码中先将编译器推导出来的类型转换为id,然后保存到数组下标为I的位置。
再回头看type_to_array_of_type_ids函数。

type_to_array_of_type_ids有两个模版参数,第一个T是pod结构体的类型,第二个size_t…为0到sizeof(T)的整形序列,函数的入参为size_t*,它实际上是array<std::size_t, sizeof(T)>的data, 用来保存pod字段类型id。

保存字段类型的关键代码是这一行:T{ ubiq{types}… },这里利用了pod类型的构造函数,通过initializer_list构造,编译器会将T的字段类型推导出来,并借助ubiq将字段类型转换为id保存到数组中。这个就是magic_get中的magic!

将pod结构体字段id保存到数组中之后,接下来就需要将数组中的id列表转换为tuple了。

pod字段id序列转换为tuple

pod字段id序列转换为tuple的具体做法分为两步:

  1. 将array中保存的字段类型id放入整形序列std::index_sequence;
  2. 将index_sequence中的类型id转换为对应的类型组成tuple。

下面是具体的实现代码:

get是返回数组中某个索引位置的元素值,即类型id,返回的id放入std::index_sequence中,接着就是通过index_sequence将index_sequence中的id转换为type,组成一个tuple。

id_to_type返回的是某个id对应的类型实例,所以还需要decltype来推导类型。这样我们就可以根据T来获取一个tuple类型了,接下来是要将T的值赋给tuple,然后就可以根据索引来访问T的字段了。

2. pod赋值给tuple

对于clang编译器,pod结构体是可以直接转换为std::tuple的,所以对于clang编译器来说,到这一步就结束了。

然而,对于其他编译器,如msvc或者gcc, tuple的内存并不是连续的,不能直接将T转换为tuple,所以更通用的做法是先做一个内存连续的tuple,然后就可以将T直接转换为tuple了。

内存连续的tuple

下面是实现内存连续的tuple代码:

base_from_member用来保存tuple元素的索引和值,tuple_base派生于base_from_member,自动生成tuple中每一个类型的base_from_member,tuple派生于tuple_base用来简化tuple_base的定义。再给tuple增加一个根据索引获取元素的辅助方法。

这样就可以通过get就可以获取tuple中的元素了。
到此,magic_get的核心代码分析完了。由于实际的代码会更复杂,为了让读者能更容易看懂,我选取的是简化版的代码,完整的代码可以参考github上的magic_get或者我github上简化版的代码

总结

magic_get实现了对pod类型的反射,可以直接通过索引来访问pod结构体的字段,而不需要任何额外的宏、标记或工具,确实很magic。magic_get主要是通过c++11/14的可变模版参数、constexpr、index_sequence、pod构造函数以及很多模版元技巧实现的。那么magic_get可以用来做些什么呢?根据magic_get无需额外的负担和代码就可以实现编译期反射的特点,很适合做ORM数据库访问引擎和通用的序列化/反序列化库,我相信还有更多潜力和应用等待我们去发掘。
modern c++的一些看似平淡无奇的特性组合在一起就能产生神奇的魔力,让人不禁赞叹modern c++蕴藏了无限的可能性与神奇!

本文发表于2017.3月《程序员》杂志,转载请注明出处。

《C++14实现编译期反射–剖析magic_get中的magic》有18个想法

    1. 本文将会发表在程序员杂志上,程序员杂志要求发表的文章等发表之后一段时间才公开,所以现在还没有放开,等发表之后过一段时间我就会放开。

  1. qicosmos,你好,请教一下,magic_get支持struct中有数组的反射吗?我定义了一个含数组的struct,但在取的时候,却取不出来,在取字段的时候,数组长度都被当成字段个数了
    struct my_struct {
    int i;
    char c[9];
    double d;
    uint16_t e;
    };
    boost::pfr::tuple_size::value 取出来等于12,无法通过auto& r2 = boost::pfr::flat_get(s);来取数组值了

    1. 支持数组的,不过会把数组平铺展开,比如char c[9]会当成9个char字段,所以你看的是value是12.这个在magic_get的redmine里面有。

          1. 怎么知道数组的长度?是遍历整个长度,判断每一个字段是否为char类型,是则+1,直到字段不为char类型,来知道数组的长度么?

            1. magic_get恐怕还不支持直接获取数组长度,magic_get的问题不少,存在几个天然的缺陷,比如只支持pod, 数组展开。可以关注我的反射框架,不会有这些限制。

  2. 取字段值时,为什么不能用变量作为字段位数,auto& v = boost::pfr::flat_get(*default_struct);我要遍历结构体,怎么解决呢?
    编译时报错:
    note: ‘long unsigned int i’ is not const
    core.hpp:856:16: note: candidate: template decltype(auto) boost::pfr::flat_get(const T&)
    decltype(auto) flat_get(const T& val) noexcept {
    ^
    ./include/boost/pfr/core.hpp:856:16: note: template argument deduction/substitution failed:

发表评论