万物容器与 c++ 类型反射

xiwon / 2024-03-02 / 原文

这是一篇组会分享,并且是拖了很长很长时间的那种。这次不会再鸽了


这篇文章可以说是针对某 cpp 佬的公众号的两篇原创内容的笔记

c++反射--包容一切的all容器(上)

c++反射--包容一切的all容器(中)

什么是反射

这个好像没有严格的定义,但是概括的说,「反射」是指在程序运行期对程序本身进行访问修改的能力

比如以下一些例子:

  • 我们希望通过一个字符串 "func",调用名字为 "func" 的函数。

    比如 java 里可以这么写

    Class cls = Class.forName("com.reflection.A");
    Method method = cls.getMethod("introduction");
    

    这个看起来还容易用 cpp 做到类似效果

  • 写出一个函数 print(),它可以传入任意类型的结构体作为参数,然后打印其所有的成员、方法的类型

    这在 cpp 看来就像是天方夜谭了

「通过反射,我们可以在程序运行期动态获取类型信息,从而能够观察、创建以及修改一个对象」


不像 go、java 等语言提供了完善的原生反射支持,cpp 对反射属于是完全绝缘

对于静态反射:「C++无法原生支持任意对象的静态反射,基本无法做到对一个类的成员对象或方法进行遍历」。作为特例,std::tuple 提供了一些编译期工具,包括「编译期获得一个 tuple 的成员数量」,「通过一个编译期的索引值静态访问成员」。但是实际上使用效果不佳

对于动态反射:cpp 给了一个头文件 <typeinfo>,里面的组件仅提供类型的判断,对于所谓 “动态反射” 的涉及非常有限,况且组件本身也很难用...


所以这里我们尝试自己手搓一套类型系统,这种类自己想办法记录了对象的类型信息,在此之上就有机会实现一种近似反射的效果

all 容器

我们希望有一种 all 容器,满足以下性质

  • all 是一个普通类而不是一个模板类。否则不同模板的 all 不能被装进同一个 vector
  • all 能够存储各种类型。包括常规变量、引用、指针,以及他们的派生
  • 此外,我们还希望 all 能提供自动的类型转换。「例如输入的是 float 类型,使用的地方需要的是 double,我们希望容器 all 有能力把 float 转换成 double 提供给使用方。再比如容器 all 里面存储的类型 T*,而使用的地方需要的是 const T&,我们也希望容器能够提供这样的功能」

实现-引入&存储

完整的实现需要很多模板元编程的经验,并且要有对 cpp 类型系统和内存排布的理解,外加一堆细节,这可难以高攀。所以我就贴一些原文代码外加理解到的思路好了


容器 all 作为一个普通类但是需要具备构造各种类型的能力,那么类型信息从什么地方来呢?在 cpp 里操作类型也只能用模板了。我们配备一个模板构造函数来接收传入对象的类型

class all final {
public:
    template <typename T> all(T&& value);
};

然后,我们要考虑如何把这个对象的「值」和「类型」都存下来

因为 all 是一个普通类,其成员就都必须是不依赖 T 类型的普通类成员

这时候我们就要用到一种被称为「类型擦除」的技巧(非正式称呼),意思是把对象的值取出,放到一个通用的容器里,实现 “无类型” 的存下这些数据;同时把它的类型单独用模板传递

这样一来,一个对象就实现了值和类型分别单独处理


具体地说:

我们可以按照下图来存储七种类型

归结为:

  • 左值引用用指针存储
  • 右值引用直接存值
  • const TT 存储
  • 其他的保持不变

这样对于类型 T,只有三种存储类型:

  • T
  • T*
  • const T*

构造如下的通用存储类:

union storage_union {
  using stack_storage_t =
    std::aligned_storage<2 * sizeof(void*),
   	  std::alignment_of<void*>::value>::type;

  void*           dynamic;
  stack_storage_t stack;
};

我们希望如果类型 T 占的空间小于等于两个指针大小,就在栈空间上存储,否则给 void* 指针动态申请空间存储数据

判断是否使用动态分配的元函数如下:

template<typename T>
struct requires_allocation
  : std::integral_constant<bool,
!(std::is_nothrow_move_constructible<T>::value &&
  sizeof(T) <= sizeof(storage_union::stack) &&
  std::alignment_of<T>::value <=
  std::alignment_of<storage_union::stack_storage_t>::value)>
{};

all 的模板构造函数里使用

if constexpr (requires_allocation<T>::value)

即可判断。这样,就容易实现在模板构造函数中,将任意类型对象的值都合适的存储到一个通用类 storage_union 中来


接下来,我们考虑到 all 容器中需要实现一系列操作接口用来操作存储的对象

我们希望将 storage_union 作为参数传入,将模板构造函数获取的类型用模板传入,进而接口能同时得知对象的值和类型

又注意到,all 是一个普通类,其成员函数不能用模板

因此我们需要玩帽子戏法。这里的实现是首先在 all 类里面声明一系列的接口函数指针

struct vtable_type
{
  // 获取对象地址
  const void* (*address)(const storage_union&);
  // 销毁对象
  void (*destroy)(storage_union&) noexcept;
  // 复制内容
  void (*copy)(const storage_union& src, storage_union& dest);
  // 移动内容
  void (*move)(storage_union& src, storage_union& dest) noexcept;
  // swap
  void (*swap)(storage_union& lhs, storage_union& rhs) noexcept;
  // operator=(const T& rhs)对应实现
  void (*assign)(all* ptr, storage_union&);
  // operator=(T&& rhs)对应的实现
  void (*moveassign)(all* ptr, storage_union&);
};

然后考虑一个模板类 store_type,专门用来实现上文所述七种类型之一的接口,并且其成员函数都是静态函数

例如,对于类型 T&,我们有下面的模板类

template<typename T>
struct store_type<T&>
{
  using type = T*;

  static const void* address(const storage_union& storage) {
    return &storage.stack;
  }

  static void construct(T& value, storage_union& storage) {
    new (&storage.stack) T*(&value);
  }

  static void assign(all* ptr, storage_union& storage) {
    auto self = static_cast<T**>(const_cast<void*>(address(storage)));
    auto rhs  = ptr->cast_to<const T*>();
    (**self)     = *rhs;
  }

  static void moveassign(all* ptr, storage_union& storage) {
    auto self = static_cast<T*>(const_cast<void*>(address(storage)));
    auto rhs  = ptr->cast_to<T*>();
    *self     = std::move(*rhs);
  }

  static void destroy(storage_union& storage) noexcept {}

  static void copy(const storage_union& src, storage_union& dest) {
    auto src_ptr = (const T**)(&(src.stack));
    new (&dest.stack) T*(*const_cast<T**>(src_ptr));
  }

  static void move(storage_union& src, storage_union& dest) noexcept {
    new (&dest.stack) T*(*reinterpret_cast<T**>(&src.stack));
  }

  static void swap(storage_union& lhs, storage_union& rhs) noexcept {
    T* src = *reinterpret_cast<T**>(&lhs.stack);
    T* dst = *reinterpret_cast<T**>(&rhs.stack);
    auto temp = src;
    src = dst;
    dst = temp;
  }
};

然后,在 all 的模板构造函数中,令 all 容器的接口指针一一指向对应类型的模板类的静态成员函数,就可以了

「到此为止,我们就解决了类型如何『引入』以及『保持』的问题,已经可以把需要存储的三种类型存储到没有类型的空间,并且把必要的类型信息通过模版静态函数的方式进行进行抹除,这样在容器 all 里面看起来是类型无关的」


实现-类型转换 & 跨平台类型 id

我们希望最后达到类似这样的效果:

all a((short)(0));
int b = a.to<int>();

1 转换器生成

构造一个 converter 模板类,里面放一个接收 void* 参数的静态成员函数 convert,例如:

template<typename SRC, typename DST>
struct type_converter {
    static bool convert(const void* src, void* dst) noexcept {
        auto dst_ptr = reinterpret_cast<DST*>(dst);
        auto src_ptr = reinterpret_cast<const SRC*>(src);
        *dst_ptr     = static_cast<DST>(*src_ptr);

        return true;
    }
};

好处和上文的类型擦除是一样的:这样所有的转换器的类型都是 bool (*)(const void*, void*) noexcept,我们方便统筹起来管理,例如这样是可行的:

using F = bool (*)(const void*, void*) noexcept;
std::vector<F> vec;
vec.push_back(type_converter<short, int>::convert)

对于 type_converter 来说,对于以下情况进行特化:

这样以来,对于任何合法的 SRC -> DST 的转化,取 type_converter<SRC, DST>::convert 就自动生成了对应的转换器

2 转换器管理

我们增加一个管理类用来管理所有的转换器

  • 使用单例,无论注册还是获取转换函数都使用同一个实例
  • 可以添加转换函数,也可以查询转换函数

all 从管理类获取转换器,方便我们精细控制类型之间的转化规则。同时也方便自定义特定的类型转换

class type_conversion final {
public:
    static type_conversion& get_instance();
    using F = bool (*)(const void*, void*) noexcept;

    bool has_converter(uint32_t src, uint32_t dst) const noexcept;
    F find_converter(uint32_t src, uint32_t dst) const noexcept;
    void add_converter(uint32_t src, uint32_t dst, F f) noexcept;

private:
    type_conversion();
    type_conversion(const type_conversion&) = delete;
    type_conversion(type_conversion&&)      = delete;

    type_conversion& operator=(const type_conversion&) = delete;
    type_conversion& operator=(type_conversion&&)      = delete;

private:
    std::unordered_map<uint64_t, F> m_convert_list;
} conversion;

可以看到,这个管理类将源类型和目标类型都用一个 uint32_t 表示,以此来提高效率

接下来的问题显然是,「怎么由一个类型产生一个唯一 id」

跨平台类型 id

注意到 cpp 原生的组件 type_info::name 并不能产生跨平台的唯一 id,在不同编译环境下用 type_info 得到的东西可能不一样。所以我们需要自己造轮子

如果可以保证不同平台上的类型名字符串是一致的话,直接使用类型名是一个比较不错的选择。比如

int --> "int"
float --> "float"
...

来尝试一下


windows 环境的 __FUNCSIG__ 和类 Unix 系统的 __PRETTY_FUNCTION__,是两个可以打印函数的宏

看一下这段代码

#include <string_view>
#include <iostream>
using namespace std;

template<typename T>
constexpr std::string_view static_type_name() {
    return __PRETTY_FUNCTION__;
}

int main() {
    cout << static_type_name<int>() << endl;
}

输出结果是:

constexpr std::string_view static_type_name() [with T = int; std::string_view = std::basic_string_view<char>]

我们模板里填了一个 int,这个输出结果里也出现了一个 int

所以针对这个特性添油加醋:

(下面的代码根据我的试验结果做了修改)

template<typename T>
constexpr std::string_view static_type_name() {
#if _MSC_VER
    // ...
#else
    constexpr int skip_begin = 56;
    constexpr int skip_end   = 50;
    const char*   sig        = __PRETTY_FUNCTION__;
    return std::string_view(sig + skip_begin, strlen(sig) - skip_begin - skip_end);
#endif
}

然后取 static_type_name<int>() 得到的就是一个静态字符串 "int"

这还不够好,字符串是难以处理的,不妨将其用静态函数 hash 变成一个 uint32_t 整数

比如:

namespace detail {
  static constexpr uint32_t name_to_id_impl
      (const std::string_view& str, uint32_t hash) {
    if (str.size() == 0)
      return hash;

    for (auto i = 0; i < str.size(); ++i) {
      hash = SDBMHash(hash, str[i]);
    }
    
    return hash;
  }

  static constexpr uint32_t name_to_id(const std::string_view& str) {
    return name_to_id_impl(str, 0);
  }
} // namespace detail

template<typename T>
constexpr uint32_t static_type_id() {
    return detail::name_to_id(static_type_name<T>());
}

static_type_id<int>() 就能静态地得到 int 类型的字符串的 hash 结果,我们可以把它看成是跨平台的唯一类型 id 来使用


以上,all 所需的各种东西都准备齐全了

  • all 容易给实现一个 to 函数(方法和 all 的其他接口一致),它调用转换器管理类的接口获取转换器并使用
  • 我们事先给管理类注册转换器
    • 可以是自动生成的:conversion.add_converter(static_type_id<SRC>(), static_type_id<DST>(), type_converter<SRC, DST>::convert);
    • 其中 type_converter<SRC, DST>::convert 也可以换成其他自定义的 bool (*)(const void*, void*) noexcept 类型函数

这样,最初我们设想的

int b = a.to<int>();

就实现了!可喜可贺

all 容器的三个出发点:「是普通类」、「能存各种东西」、「能够类型转换」也都已完成


尾声

现存已有一些 cpp 反射库了,比如 Boost.Mirror 和 Qt

可能大部分时候反射的作用不大,但当遇到「序列化与反序列化」等场景,一个类型反射支持则尤为重要

所以虽然 cpp 对反射的包容性极差,实现一个比较可用的类型反射系统仍然是很有意义的