magic_get - A reflection techniques using modern C++

http://purecpp.org/detail?id=2055

magic_get是一个很有趣的C++反射库,它不需要让开发者显式地做额外的事情,但是限制是反射的类型必须是Aggregate Initializable. 在它出现之前,我们碰到需要反射这一类问题都是怎么处理的呢?

1. 传统的反射标记: 侵入式与非侵入式

对于标准输入输出流,我们要重载operator << 和 >>.

namespace client
{
    struct foo 
    {
        std::string     name;
        int             age;
    };

    inline std::ostream& operator<<(std::ostream& os, foo const& f) 
    {
        return os << f.name << " " << f.age;
    }

    inline std::istream& operator>>(std::instream& is, foo& f)
    {
        return os >> f.name >> f.age;
    }
}

重载的<<和>>操作符与struct foo都在同一个命名空间下,并且使用operator<<和>>都会使用非限定名字的查找规则,这里ADL查找会起作用,无论在任何命名空间下都能帮我们找到正确的重载操作符。每个类都要这样手动实现,未免显得太啰嗦。于是,不少类库都各自提供了工具,标记成员,从而让开发者从写死地访问某个确定成员的方式,变成遍历或者通过索引来访问成员的方式。

这里有两个选择,一个是像msgpack的侵入式标记:

namespace client
{
    struct foo
    {
        std::string     name;
        int             age;
        MSGPACK_DEFINE(name, age)
    };
}

还有一种是类似boost.fusion的非侵入式标记:

namespace client
{
    struct foo
    {
        std::string     name;
        int             age;
    };
}

// has to be in the global scope
BOOST_FUSION_ADATP_STRUCT(
    client::foo,
    (std::string, name)
    (int, age)
)

由于boost.fusion是使用类模板特化实现的,所以标记的代码不得不写在全局的namespace中。而iguana的某一个版本,使用函数模板返回local class的trick,可以让开发者把宏写在定义struct相同的命名空间中:

namespace client
{
    struct foo
    {
        std::string     name;
        int             age;
    };

    REFLECTION(foo, name, age);
}

综合来看,侵入式和非侵入式都各有优势。笔者认为侵入式最大的优势就是可以很好的处理私有变量;而非侵入式最大优势是可以处理第三方代码或者开发者无法修改源码的结构化数据。这些技巧,在C++远古时代应该就已经被熟知。

而cppcon2016中的magic_get不需要做任何额外显式地标记工作,就能达到反射成员的目的,非常惊艳。

#include <iostream>
#include <string>
#include "boost/pfr/precise.hpp"

struct foo 
{
    std::string     name;
    int             age;
};

int main() 
{
    foo f{"Madoka", 14};
    std::cout 
        << boost::pfr::get<0>(f)
        << boost::pfr::get<1>(f);
    return 0;
}

magic_get解决问题的方式,是thinking in modern C++教科书式的范例,有很高的价值和意义,下文会详细讨论magic_get中的技术要点。

2. C++目前所拥有的设施

先抛出一个基本的结论,截止目前C++17所拥有的语言基础设施,还不可能反射struct foo而不做任何额外的事情。目前我们拥有:

  1. SFINAE
  2. Meta-programming with
    • templates
    • constexpr
  3. Static dispatch with
    • function overload
    • class template (partial) specialization
    • constexpr if
  4. Unevaluated operators

这些设施能完成的事情仅仅只能被动地test, 例如我们只能被动地给定两个类型,test两个类型的继承关系,但不能获取一个类型的基类:

std::is_base_of_v<foo, bar>;

// ERROR! No way to achieve like the way below
using type = std::base_type_t<foo>; 

面对成员变量,由于expression SFINAE是通过显式推导表达式的返回类型来完成的,所以我们必须要显式地写出表达式:

template <typename T, typename = void>
struct has_member_name : std::false_type {};
template <typename T>
struct has_member_name<T, std::void_t<decltype(std::declval<T>().name)>>
    : std::true_type {};

std::has_member_name<T>::value;

现实的情况是,成员变量的符号有无穷种组合,而我们无法列举出所有这样的组合。test的方式并不能直接处理这样的问题,那magic_get是如何做到的?答案就是SEARCH & TEST

3. Aggregate Initialization

在剖析magic_get之前,先简单介绍一下在magic_get种使用的test的方式:Aggregate Initialization. 具体定义请参阅链接。

还是以struct foo为例,我们可以这样初始化

struct foo
{
    std::string     name;
    int             age;
};

foo f = { "madoka", 14 };

用于初始化struct foo的参数列表的数目可以少于成员变量的数目,但是不能多于:

foo f0 = {};                                     // OK
foo f1 = { "madoka" };                           // OK
foo f2 = { "kakaroto", 50, "super saiyajin" };   // ERROR!

如果我们定义一种类型,它能够cast到任意类型,会发生什么?

struct universe_type
{
    size_t ignored;

    template <typename T>
    constexpr operator T() const noexcept;      // only declaration
};

我们可以利用Aggregate Initialization与universe_type这样组合使用:

using type = decltype(foo{ universe_type{0}, universe_type{1} });

由于universe_type可以cast到任意类型,但是case的操作符只给出了申明而没有给出定义,所以要使用unevaluated operator让aggregate initialization的表达式仅存在于编译期,能够推到出foo类型,就可以推断表达式is well-formed.

以上就是magic_get反射功能的key point,它利用aggregate initialization搜索成员变量的数目,并且在universe_type隐式类型转换为成员变量类型的时候,记录下cast的类型并与传入的index相绑定。magic_get考虑的很多因素,诸如copy和move构造,还有嵌套的struct等,magic_get实际的实现代码与鄙文所列的部分实现还是有出入的。所以在理解完magic_get的实现思路之后,不妨去阅读以下magic_get的源码。源码比较简短,但有着不浅的奥义。

接下来详细展开三个问题:

  • 如何SEARCH成员变量的数目;
  • 如何获取成员变量的类型;
  • 如何访问成员变量。

4. 如何获取成员变量的数目

由于aggregate initialization对于实参列表的数目要求是小于或等于类型成员变量的数目,所以搜索准确的成员变量数目变得麻烦了不少。magic_get的做法是,确定一个成员变量数目的搜索上限,再确定一个搜索下限,然后进行二分查找。

搜索下限很简单,就是ZERO. 而搜索上限magic_get设置的是sizeof(foo) * BITS_PER_BYTE, 也就是sizeof(foo) * 8. 原因是存在如下使用位域的极端情况:

struct fee
{
    int a1 : 1;
    int a2 : 1;
    ...
};

搜索的接口如下:

// helper placehodler for bounds
template<size_t N>
using size_t_ = std::integral_constant<size_t, N>;

// LB is short for LowerBound, while UB for UpperBound
template <typename T, size_t LB, size_t UB>
constexpr size_t detect_fields_count(size_t_<LB> lb, size_t_<UB> ub) noexcept; 

利用第三节所提到的test方法,magic_get中使用了一个模板工具,来测试T是否可以使用N个universe_type来进行aggregate initialization:

template <typename T, typename indics, typename = void>
struct is_aggreate_initializable_impl : std::false_type {};
template <typename T, size_t ... N>
struct is_aggreate_initializable_impl<T,
    std::index_sequence<N...>,
    std::void_t<decltype(T{ universe_type{N}... })>
> : std::true_type {};

template <typename T, size_t N>
struct is_aggreate_initializable : is_aggreate_initializable_impl<
    T, std::make_index_sequence<N>
> {};

这个时候我们把SFINAE的机制用在detect_fields_count接口。test失败的情况,证明UpperBound的数目太大,我们要向下搜索;而test成功的情况也并不能确定成员变量的数目,我们还需向上搜索。

向下搜索的策略,把UpperBound缩小至UpperBound与LowerBound均值:

template <typename T, size_t LB, size_t UB>
constexpr auto detect_fields_count(size_t_<LB>, size_t_<UB>) noexcept
    -> disable_if_t<is_aggreate_initializable<T, UB>::value, size_t>
{
    using next_ub_t = size_t_<(LB + UB) / 2>;
    return detect_fields_count<T>(size_t_<LB>{}, next_ub_t{});
}

向上搜索的时候,可以断定UpperBound可以作为新的LowerBound,而UpperBound更新为相差的一半:

template <typename T, size_t LB, size_t UB>
constexpr auto detect_fields_count(size_t_<LB>, size_t_<UB>) noexcept
    -> std::enable_if_t<is_aggreate_initializable<T, UB>::value, size_t>
{
    using next_ub_t = size_t_<(UB - LB + 1) / 2>;
    return detect_fields_count<T>(size_t_<UB>{}, next_ub_t{});
}

搜索会收敛到UpperBound与LowerBound相等, 因为LowerBound始终都是可以满足test的情况。

template <typename T, size_t N>
constexpr size_t detect_fields_count(size_t_<N>, size_t_<N>) noexcept
{
    return N;
}

以struct foo为例,模拟一下整个计算的过程:

0: detect_fields_count<0, 256>  - Test Failed
1: detect_fields_count<0, 128>  - Test Failed
2: detect_fields_count<0, 64>   - Test Failed
3: detect_fields_count<0, 32>   - Test Failed
4: detect_fields_count<0, 16>   - Test Failed
5: detect_fields_count<0, 8>    - Test Failed
6: detect_fields_count<0, 4>    - Test Failed
7: detect_fields_count<0, 2>    - Test Succeed!
8: detect_fields_count<2, 3>    - Test Failed
9: detect_fields_count<2, 2>    - Terminated! 

至此,我们就通过Search & Test获取到了struct foo的成员变量数目为2.

5. 如何获取成员变量的类型

如何获取成员变量的类型,这个问题到现在来看依然不好解决,应该换一种思维模式。首先我们再次回顾一下is_aggreate_initializable这个boolean模板元函数的实现:

template <typename T, size_t ... I>
struct is_aggreate_initializable_impl<T,
    std::index_sequence<N...>,
    std::void_t<decltype(T{ universe_type{I}... })>
> : std::true_type {};

这里我们使用了index sequence来展开出N个universe_type的构造,并且每个universe_type传递的I的值是从0至N-1,这也是为什么universe_type要添加一个size_t的成员变量的原因。

接着本节最开始的那个话题,如何获取成员变量的类型。这个问题就目前我们所拥有的条件信息可以替换为:如何记录第i个universe_type在隐式类型转换到对应成员变量类型时的信息。也就是我们要构造这样的一个映射:

map: <i, T> => i-th data member type, where i is from [0, N - 1]

目前的C++能够完成这一项工作吗?笔者在阅读magic_get的代码的时候发现了代码注释中的一篇博文: type-loophole. 博文中给出了一段简单的代码:

template<int N> struct tag{};

template<typename T, int N>
struct loophole_t {
  friend auto loophole(tag<N>) { return T{}; };
};

auto loophole(tag<0>);

sizeof(loophole_t<std::string, 0>);

static_assert(std::is_same<std::string, decltype( loophole(tag<0>{}) ) >::value);

需要注意的是类模板中loophole_t中friend函数的定义式,并不是loophole_t的成员函数,而是与loophole_t在同一个命名空间下的函数,只不过该函数是loophole_t的友元,并且该友元函数同loophole_t一同实例化。具体可以参阅《C++ Templates, 2nd》的2.4节,12.5节也详细讨论了模板与友元的很多细节,例如何时可以写同本例一样的友元定义式等。《C++ Template, 1st》也有完整的讨论,可以参阅对应的章节。

在使用sizeof这个unevaluated operator的时候,代码促使loophole_t<std::string, 0>发生实例化,一同实例化的还有auto loophole(tag<0>)函数,最后使得该函数的返回值可以推导为std::string. 是的,你没有看错,这个是带状态的元编程方法, stateful metaprogramming.

C++之父在之前的访谈中提到过,templates的设计初衷是基于lambda演算的子系统,但是实现以后却变成了一个完备的图灵机。也就是说,模板元编程是可以有状态的。笔者在知乎上看到过的一篇,使用stateful metaprogramming来实现的编译期的计数器,请参阅这里

把这个trick应用到magic_get中需要调整一下。tag除了绑定index N之外,还需要绑定我们需要反射的类型。其次,肯定不能手动写sizeof(loophole_t<std::string, 0>)这样的显式类型的代码,需要调整为配合universe_type隐式类型转换的代码。所以,笔者提取了要点功能把代码抽出,具体代码可以参阅这里。最后装配实施到类库中的详细方法,请参阅magic_get的具体实现。

该通过友元函数来达到带状态的模板元编程方法的机制,被标准委员会一致地认为是不应该支持的,但目前还没有给出一个确切的方案来禁止它,所以在未来的某个时间点,这个机制会被禁止。我们的msvc表现出色,并不支持type-loophole ^_^. 那在msvc中如何解决这个问题呢?幸好C++17有structured bindings.

template <typename T>
constexpr auto tie_as_tuple(T& val, size_t_<2>)
{
    auto& [a, b] = val;
    // ....
}

接下来,我们就可以通过a和b来推导成员变量的类型了。这里又充分体现出了表达式的局限性,也就是绑定identifiers是显式的。不过我们的大神不仅聪明,而且勤奋,他自动生成了size从1到100的实现。

6. 如何访问成员变量

成员变量的访问,在解决了成员变量的数目和各个成员变量的类型两个问题后,变得简单了不少。magic_get中针对type-loophole和structured bindings两种方案的实现也是略有不同。成员访问的思路很简单,但是实现的代码比较啰嗦。因篇幅所限,鄙文就仅在此简述实现的思路,详细的事情可以参阅magic_get的具体实现。

type-loophole的方案较为复杂,在获取了成员变量的数目和类型后,需要构造一个tuple,将成员变量的类型按照顺序平铺进来。然后利用metaprogramming计算出每个成员在反射类型中的内存对齐的信息。这样就构造出了一个大小和内存对齐属性与反射类型一模一样的tuple.访问成员变量就可以通过tuple查询成员变量相对于结构体头部的偏移量。

这里需要提及的是,在搜索成员变量数目的时候,作者考虑的位域影响成员变量数目的情况,但是在访问成员的变量的实现中只使用了每个成员变量类型各自的对齐属性。也就是说,magic_get至此每个成员变量的类型指定各自的align属性,但是不支持使用了位域的结构体。

而structured bindings的方案就特别简单了。既然我们已经通过表达式获取的每个成员的左值引用,我们就可以直接把引用forward成一个引用的tuple,通过正常的tuple访问接口就能够访问到反射类型的成员了。

7. 结论

  1. magic_get可以完成不少简单的反射需求,例如序列化等;
  2. 当前的C++语言基础设施,还无法提供一个完备的反射方案,反射的功能需要我们写额外的标记代码,或者在有限的条件下使用;
  3. magic_get只能使用在aggregate initializable的类型上,这个限制对于复杂生产环境中C++的使用是无法忽略的;
  4. magic_get在C++14标准下使用的type-loophole机制将在未来的某个C++版本中禁止;
  5. magic_get使用的Search & Test的方法和思路对我们使用modern C++解决实际问题时有不小的启发;
  6. 期待C++ Reflection标准的正式到来。
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值