40 | 如何在编译期遍历数据?

你好,我是吴咏炜。

你考虑过 tuple 和普通的结构体存在哪些区别吗?

显然,一个字段没有名字,一个字段有名字——这是一个非常基本的区别。

其他还有吗?

有。

对于 tuple,标准 C++ 里提供了很多机制,允许你:

  • 知道 tuple 的大小(数据成员数量)
  • 通过一个编译期的索引值,知道某个数据成员的类型
  • 通过一个编译期的索引值,对某个数据成员进行访问

利用这些信息,我们可以额外做很多事情,比如,像第 38 讲描述的那样,用一个函数模板来输出所有 tuple 类型对象的内容。这些功能是普通结构体所没有的!

在 C++ 的静态反射到来之前,我们想在结构体里达到类似的功能,只能自己通过一些编程技巧来实现。本讲我们就会介绍一种手工实现静态反射的方法,能够让结构体在用起来跟原来没感觉有区别的情况下,额外提供类似 tuple 的功能,甚至还更多。毕竟,结构体里的字段是有名字的,可以产生更可读的代码。我们还能进一步利用编译期的字符串模板参数技巧(第 39 讲),使用字段名称这一数据,让下面的代码能通过编译:


DEFINE_STRUCT(
  S1,
  (int)v1,
  (bool)v2,
  (string)msg
);

DEFINE_STRUCT(
  S2,
  (long)v1,
  (bool)v2
);

S1 s1{1, false, "test"};
S2 s2;
copy_same_name_fields(s1, s2);

这段代码做的事情正是你看名字可以想象的,把 s1 中跟 s2 同名的字段复制到 s2 里面去!(注意这不是 memcpy——两个结构体里同名字段的类型可以不同,它们也不需要相邻。)

静态反射的定义

静态反射的基本原理和实现手法,在罗能的博客文章中已经有了较详细的描述 [1]。建议你去读一下。我在这里将不再重复其中的一些技术细节,而是强调基本原理,以及我这边实现得不一样的地方。

宏基础

文件 metamacro.h 中提供了一些基础的宏工具,这边我简单介绍一下其中主要的几个功能,对其实现细节就不进行展开了:

  • GET_ARG_COUNT:获取宏参数的数量,如 GET_ARG_COUNT(a, b, c) 将得到 3
  • STRING:把参数变成字符串,如 STRING(foo) 将得到 “foo”
  • PASTE:把两个参数拼接起来,如 PASTE(Hello, World) 将得到 HelloWorld
  • PAIR:把 (类型)字段名 这样的序列脱去第一层括号,如 PAIR((long)v1) 将得到 long v1
  • STRIP:把 (类型)字段名 这样的序列的类型部分去掉,如 STRIP((long)v1) 将得到 v1
  • REPEAT_ON:这是主要玩重复展开的地方,如 REPEAT_ON(func, a, b, c) 将得到 func(0, a) func(1, b) func(2, c)

我们对 DEFINE_STRUCT 的定义如下所示:


#define DEFINE_STRUCT(st, ...)      \
  struct st {                       \
    template <typename, size_t>     \
    struct _field;                  \
    static constexpr size_t _size = \
      GET_ARG_COUNT(__VA_ARGS__);   \
    REPEAT_ON(FIELD, __VA_ARGS__)   \
  }

可以看到,宏展开后成了 struct st 的定义(st 是结构体名),里面有三部分:

  • 首先,我们声明了一个叫 _field 的嵌套类模板,指定它具有两个参数,一个类型,一个是 size_t;
  • 然后,我们根据参数数量,算出字段数量,赋给静态 constexpr 变量 _size;
  • 最后,我们利用 REPEAT_ON 宏,有多少个字段就重复多少次,逐项产生字段的定义。

以我们上面的 S2 为例,宏展开后的结果大致如下(已重新格式化;另注意分号是宏外面手工添加的):


struct S2 {
  template <typename, size_t>
  struct _field;
  static constexpr size_t _size = 2;
  FIELD(0, (long)v1)
  FIELD(1, (bool)v2)
}

下面,我们主要就不再关心宏的问题,而是如何在给定索引值的情况下,合适地产生静态反射所需要的全部信息和方法。

不过,先提醒一下,如果你把宏放到一个供别人使用的公共库的话,一般所有的宏名称都应该加上前缀,以免跟其他宏发生冲突——毕竟宏是没有作用域的,会污染全局名空间。比如,Boost.Test 里的测试宏叫 BOOST_CHECK 或类似的名字,而不是简单的 CHECK。我这边出于讲解上的简洁,基本不用名空间和前缀,但不等于你在项目里也应该这样做:像 PASTE、STRING 这样的宏还是非常容易产生冲突的。

如果你使用 MSVC 的话,还有一个额外的问题是 MSVC 的传统预处理方式不符合 C++ 标准,无法正确处理这些宏。你需要启用命令行选项 /Zc:preprocessor 才行 [2]。

字段的定义

给定了字段索引、字段类型和字段名称,我们通常需要生成下面这些信息,来方便通过索引使用它们:

  • 字段名称
  • 字段类型
  • 字段值的访问

我们下面需要考虑的,就是(假设)给定了索引值 0、字段内容 (long)v1,如何来生成字段的定义。

字段名称看起来最好办:


template <typename T>
struct _field<T, 0> {
  static constexpr auto name =
    STRING(STRIP((long)v1));
  …
};

对 (long)v1 进行 STRIP 后,我们得到 v1;再对其 STRING 处理,即得到字符串 “v1”。

字段类型也不复杂。唯一的麻烦是我没找到宏处理的方法从 (long)v1 得到 long。当然,我们并不一定需要采用 (long)v1 这种写法,写成 (long, v1) 这样就没这种麻烦了。只不过,从可读性的角度,字段定义成 (long)v1 更接近 C/C++ 的现有语法,确实比 (long, v1) 看起来要直观得多,也能更自然地处理类型之中带逗号的情况(如 array)。我觉得罗能的这个选择非常棒。

因此,我们这里采取绕弯的方式,定义为:


  using type =
    decltype(decay_t<T>::STRIP((long)v1));

换句话说:


  using type = decltype(S2::v1);

为什么要把 T 作为参数传进来,还要使用 decay_t 呢?因为我们允许 T 是 S2&、const S2&、S2&& 等多种情况(顺便说一句,如果能用 C++20 的话,那 remove_cvref_t 是个更好的选择)。在字段值的访问时我们就需要用上这种灵活性了:


  T&& obj_;
  _field(T&& obj)
    : obj_(forward<T>(obj)) {}
  auto value() -> decltype(auto)
  {
    return (forward<T>(obj_).v1);
  }

这里的另外一个小细节是,返回表达式上的括号是必需的。返回值类型为 decltype(auto),意味着我们使用 decltype(返回表达式) 作为返回值类型:使用 decltype(obj_.v1) 我们会得到 long,而使用 decltype((obj_.v1)) 我们才会得到 long&(或 const long& 之类),后者才是我们想要的。

上一节结尾时的 FIELD 宏,就可以自动帮我们来生成这些定义。它本身被定义为:


#define FIELD(i, arg)                        \
  PAIR(arg);                                 \
  template <typename T>                      \
  struct _field<T, i> {                      \
    _field(T&& obj)                          \
      : obj_(forward<T>(obj)) {}             \
    static constexpr auto name =             \
      CTS_STRING(STRIP(arg));                \
    using type =                             \
      decltype(decay_t<T>::STRIP(arg));      \
    auto value() -> decltype(auto)           \
    {                                        \
      return (forward<T>(obj_).STRIP(arg));  \
    }                                        \
    T&& obj_;                                \
  };

编译期使用字段名称

上面的定义在初步使用时已经够用了,但等到你想在编译期对字段名称进行判断时,你就会发现麻烦来了。当然,利用上一讲我们讲述的编译期传参的技巧,我们可以解决问题。但与其如此,不如我们直接往前走一步,利用字符串模板参数,直接把字段名称从变成类型


  static constexpr auto name =
    CTS_STRING(STRIP((long)v1));

这里我们只是简单地把 STRING 变成了 CTS_STRING。这个变化看起来很小,但它会导致下面这些用法上的大改变:

  • 相等判断可以直接使用 is_same_v
  • 输出的话需要使用宏 CTS_GET_VALUE
  • 函数传参大大简化,不再需要 CARG、CARG_WRAP 那套东西

这样带来的一个小问题是:如果你使用 MSVC 的话,你必须启用 C++20 才行。

静态反射的使用

识别对静态反射的支持

很多情况下,我们需要对处理的数据对象是否支持静态反射作分别的处理。使用类型特征很容易就能做到(可参考第 14 讲):


template <typename T, typename = void>
struct is_reflected : false_type {};

template <typename T>
struct is_reflected<
  T, void_t<decltype(T::_size)>>
  : true_type {};

template <typename T>
constexpr static bool is_reflected_v =
  is_reflected<T>::value;

结构体的数据遍历:对象打印

对于一个支持静态反射的结构体,我们可以做的一个非常基本的操作,就是可以像 tuple 一样来进行简单的遍历。为了方便这个常见操作以及类似遍历操作的实现,我们定义下面的函数模板:


template <typename T, typename F,
          size_t... Is>
constexpr void
for_each_impl(T&& obj, F&& f,
              index_sequence<Is...>)
{
  using DT = decay_t<T>;
  (void(forward<F>(f)(
     DT::template _field<T, Is>::name,
     typename DT::template _field<T, Is>(
       forward<T>(obj))
       .value())),
   ...);
}

template <typename T, typename F>
constexpr void for_each(T&& obj, F&& f)
{
  using DT = decay_t<T>;
  for_each_impl(
    forward<T>(obj), forward<F>(f),
    make_index_sequence<DT::_size>{});
}

跟之前一样,为了在推导出 T 是 S2& 等情况下访问 S2 的成员,我们需要使用 decay_t。从 for_each 到 for_each_impl 基本只是个转发,但加上了 index_sequence 参数,这也是我们讲编译期编程时一直在使用的技巧了。

主要工作当然就在 for_each_impl 里。它本质上就是一个折叠表达式的展开,基本形式是:


(void(forward<F>(f)(…)), ...)

也就是说,我们逐项调用函数 f(使用了完美转发),并抛弃返回值(使用 void)。

我们再看一下每次调用函数 f 使用的参数:

  • 第一项是 DT::template _field::name,表示字段的名称。这里你可能感到陌生的是对成员模板的 template 消歧义符 [3],其他就应该没啥特别的地方了。从阅读的角度,你基本上可以简单地忽略 :: 后面的 template 关键字。
  • 第二项是 typename DT::template _field(forward(obj)).value()。这个表达式有点长,其中,typename DT::template _field 指定了 _field 成员模板的类型,然后我们把 obj 完美转发到构造函数里,之后你就可以用 value() 来以合适的引用方式来访问字段了。

所以,对于我们提供给 for_each 的函数,对它的要求也是能接受两个参数:第一个是结构体的字段名,第二个是字段的引用。使用这样一个函数,我们就能对一个结构体进行遍历了。

只除了一点——我上面那句话不严格。由于字段名使用强类型,更由于每一个字段的类型都可能不同,一个普通函数无法工作。我们一般会使用泛型 lambda 表达式,本质上,它是一个具有 operator() 成员函数模板的函数对象。

有了这个基本工具之后,我们已经可以像 print_tuple 一样方便地输出任何支持静态反射的结构体了。代码如下所示:


template <typename T>
void dump_obj(const T& obj,
              ostream& os = cout,
              const char* field_name = "",
              int depth = 0)
{
  auto indent = [&os, depth] {
    for (int i = 0; i < depth; ++i) {
      os << "    ";
    }
  };

  if constexpr (is_reflected_v<T>) {
    indent();
    os << field_name
       << (*field_name ? ": {\n" : "{\n");
    for_each(
      obj, [depth, &os](auto field_name,
                        const auto& value) {
        dump_obj(value, os,
                 CTS_GET_VALUE(field_name),
                 depth + 1);
      });
    indent();
    os << "}" << (depth == 0 ? "\n" : ",\n");
  } else {
    indent();
    os << field_name << ": " << obj << ",\n";
  }
}

仔细看一下,你会发现这个函数的实现相当简单。我们根据 T 类型是否支持静态反射,决定是遍历其所有字段,还是直接调用 << 运算符来将其输出。如果是遍历的话,我们就会调用 for_each,并在传递给 for_each 的泛型 lambda 里递归调用 dump_obj,把下面一层的值、字段名等信息传递过去。

对于我们开头的那个 s2,dump_obj 可以给出下面这样的输出:

{

v1: 1,

v2: false,

}

结构体的元数据遍历:对象字段查找

有时候我们并不希望遍历对象的实际数据,而只是遍历对象的数据类型。一种可能的场景就是,通过一个字段的名称,查找字段在结构体里的索引值。

类似于刚才的 for_each,我们可以定义一个 for_each_meta。其实现如下:


template <typename T, typename F,
          size_t... Is>
constexpr void
for_each_meta_impl(F&& f,
                   index_sequence<Is...>)
{
  using DT = decay_t<T>;
  (void(forward<F>(f)(
     Is, DT::template _field<T, Is>::name)),
   ...);
}

template <typename T, typename F>
constexpr void for_each_meta(F&& f)
{
  for_each_meta_impl<T>(
    forward<F>(f),
    make_index_sequence<T::_size>{});
}

使用这个 for_each_meta,我们就可以实现出刚才说的根据字段名称查找索引值的函数了:


template <typename T, typename Name>
constexpr size_t
get_field_index(Name /*name*/)
{
  auto result = SIZE_MAX;
  for_each_meta<T>([&result](size_t index,
                             auto name) {
    if constexpr (is_same_v<decltype(name),
                            Name>) {
      result = index;
    }
  });
  return result;
}

这个函数要求传递一个“编译期字符串”的字段名称,然后就会找出这个字段名称对应的字段索引值。如果这个字段不存在的话,就会返回 SIZE_MAX。

如对于开头的 S1,我们使用 get_field_index(CTS_STRING(msg)) 就能在编译期得到结果 2。

两个结构体的同时遍历:对象拷贝

遍历一个结构体只能满足部分常见需求。对于像比较、复制这样的操作,我们需要同时遍历两个结构体。这个函数,依据一些业界的惯例,我命名为 zip。

下面是 zip 的实现:


template <typename T, typename U, typename F,
          size_t... Is>
constexpr void zip_impl(T&& obj1,
                        U&& obj2,
                        F&& f,
                        index_sequence<Is...>)
{
  using DT = decay_t<T>;
  using DU = decay_t<U>;
  static_assert(DT::_size == DU::_size);
  (void(forward<F>(f)(
     DT::template _field<T, Is>::name,
     DU::template _field<U, Is>::name,
     typename DT::template _field<T, Is>(
       forward<T>(obj1))
       .value(),
     typename DU::template _field<U, Is>(
       forward<U>(obj2))
       .value())),
   ...);
}

template <typename T, typename U, typename F>
constexpr void zip(T&& obj1, U&& obj2, F&& f)
{
  using DT = decay_t<T>;
  using DU = decay_t<U>;
  static_assert(DT::_size == DU::_size);
  zip_impl(forward<T>(obj1), forward<U>(obj2),
           forward<F>(f),
           make_index_sequence<DT::_size>{});
}

它结构上也还是 for_each 的一个翻版,没有大的区别。此处,根据我这边的实际需求,我要求两个被遍历的结构体的字段数量必须完全一致。根据你的实际需要,你当然也可以实现成以较小的结构体为准;但按照我的实际项目经验,这种在动态大小场景下的常见做法,在编译期显得比较鸡肋,实际用处不大。

有了 zip 这样的工具,我们现在可以实现一些较复杂的操作了,比如,可以支持异质同构结构体(成员必须一一对应,但类型可以不同)的逐成员复制。实现代码如下:


template <typename T, typename U>
constexpr void copy(T&& src, U& dest)
{
  if constexpr (is_reflected_v<decay_t<T>> &&
                is_reflected_v<decay_t<U>>) {
    zip(forward<T>(src), dest,
        [](auto /*field_name1*/,
           auto /*field_name1*/,
           auto&& value1,
           auto& value2) {
          copy(
            forward<decltype(value1)>(value1),
            value2);
        });
  } else {
    dest = forward<T>(src);
  }
}

为了处理移动,我们对源对象使用完美转发,允许它是一个右值。为了防止误用和简化代码,目标对象必须是一个左值。当两个对象都支持静态反射时,我们使用 zip 来进行逐字段的遍历,对每一项继续进行 copy 的动作;否则,我们尝试普通的赋值操作(不支持的话,即会导致编译失败)。

对于同一类型结构体的复制,C++ 一般可以默认提供赋值运算符。但如果两个结构体类型不同,那默认的赋值运算符就不工作了。这时候,这个 copy 函数模板就能大显身手了。一种可能的使用场景是,我们可以利用它来实现网络字节序和主机字节序的自动转换。假设我们实现了数据类型 uint32s 和 uint16s,并且这些数据类型支持在跟 uint32_t 和 uint16_t 赋值的时候自动进行转换(这相当容易实现),那我们就可以定义出类似下面的数据结构:


DEFINE_STRUCT(
  msg_host_t,
  (uint16_t)tag,
  (uint16_t)length,
  (uint32_t)value
);

DEFINE_STRUCT(
  msg_net_t,
  (uint16s)tag,
  (uint16s)length,
  (uint32s)value
);

从 msg_host_t 的一个对象 msg_host,转换到一个 msg_net_t 的对象 msg_net,我们现在可以一行代码搞定:


copy(msg_host, msg_net);

不仅如此,这样的结构体可以嵌套。如果我们有下面的结构体定义:


DEFINE_STRUCT(
  data_host_t,
  (msg_host_t)msg,
  (array<byte, 8>)data
);

DEFINE_STRUCT(
  data_net_t,
  (msg_net_t)msg,
  (array<byte, 8>)data
);

这两种类型的对象之间也可以用 copy 来进行复制,编译器会自动产生合适的转换代码。

应用:拷贝同名字段

利用静态反射,我们可以自动化很多原本需要手工编码的操作。除了上面展示的那些,常用的场景还有序列化、反序列化等。由于序列化和反序列化跟实际应用场景关联比较紧密,代码也比较复杂,我最后就讲一下开头所展示的、按字段名复制结构体的实现。

按字段名来复制结构体,我们首先需要回答下面两个问题:

  • 我们按源结构体优先遍历,还是按目标结构体优先遍历
  • 我们如何解决字段缺失的问题

在当前的解决方案里,我做出了下面的选择:

  • 按目标结构体优先遍历(也就是为大结构体往小结构体拷贝而优化;反过来实现也不难、很相似)
  • 严格指定目标结构体里有、源结构体里没有的字段数量(默认为零),不正确的话,直接编译失败

在实现 copy_same_name_fields 之前,我们需要一些辅助的工具函数模板。首先是 count_missing_fields,数一下源结构体里缺失的字段数量:


template <typename T, typename U>
constexpr size_t count_missing_fields()
{
  size_t result = 0;
  for_each_meta<U>([&result](size_t /*index*/,
                             auto name) {
    if constexpr (get_field_index<T>(name) ==
                  SIZE_MAX) {
      ++result;
    }
  });
  return result;
}

这里我们用到了前面描述过的 get_field_index,通过两重循环搜索字段名。所有的这些操作全部发生在编译时,所以我们可以不用太担心这个 \(O(m×n)\) 级别的性能开销。

在实现 copy_same_name_fields 之前,我们还要做一个小小的处理,标记源结构体里缺失了多少个字段。显然,为了编译期检查,我们需要传递一个模板参数,但我们究竟该用什么类型呢?用 int?还是 size_t?

都不是,为了类型上更严格,也为了代码可读性更高,我选择使用强枚举类型:


enum class missing_fields : size_t {};

它的底层是 size_t,但使用者必须显式地给出 missing_fields{1} 这样的方式来表达缺失了一个字段。我认为这样写更加合适、更加可读。

现在我们可以最终实现 copy_same_name_fields 了:


template <missing_fields MissingFields =
            missing_fields{0},
          typename T, typename U>
constexpr void
copy_same_name_fields(T&& src, U& dest)
{
  constexpr size_t actual_missing_fields =
    count_missing_fields<decay_t<T>, U>();
  static_assert(size_t(MissingFields) ==
                actual_missing_fields);
  for_each(dest, [&src](auto field_name,
                        auto& value) {
    using DT = decay_t<T>;
    constexpr auto field_index =
      get_field_index<DT>(field_name);
    if constexpr (field_index != SIZE_MAX) {
      copy(typename DT::template _field<
             T, field_index>(
             forward<T>(src))
             .value(),
           value);
    }
  });
}

我在这个函数里做了以下的事情:

  1. 检查指定的缺失字段数量是否和实际的缺失字段数量一致,不一致则静态断言失败

  2. 对目标对象进行逐字段遍历

  3. 对每个字段,根据字段名在源对象中查找是否存在对应的字段,存在的话,则 copy 过去

很简单吧?这里面的关键,跟上一讲的编译期字符串处理一样,是需要处处保持 constexpr 性。我现在应该已经提供了足够多的例子,让你看到该如何写出这种代码。

对于开头给出的 copy_same_name_fields(s1, s2),编译器实际生成的 x86-64 的汇编代码是这样子:


movsx   rax, DWORD PTR s1[rip]
mov     QWORD PTR s2[rip], rax
movzx   eax, BYTE PTR s1[rip+4]
mov     BYTE PTR s2[rip+8], al

Java 之类的语言虽然有着更为强大的反射能力,但它们的反射机制就完全没有生成这样的高效代码的可能性!

一些实现细节

这一讲的代码有点小复杂,里面有很多容易搞错的小细节,因此我把一个完整的实现和测试放到了 GitHub 上的代码库里。同时,为了方便讲解,我上面给出的代码有一定的简化;而代码库中的实际代码更偏近工程化一些,相比文中的示例有如下的不同:

  • 静态反射的工具函数模板放在了名空间 sr 中,跟标准库的同名函数模板可以清晰地区分
  • 为了防止冲突,for_each 等函数加上了 enable_if 进行限制,要求操作的对象必须满足 is_reflected
  • for_each_meta 在调用函数时增加了一个目前没有用到的参数——字段类型
  • dump_obj 的实现方式有所变化
  • 测试里添加了对 tuple 行为的模拟,允许某种程度上把结构体当成 tuple 来用

根据你的实际使用场景,这些代码可能还有进一步的优化空间。不过,作为一个基本的实现参考,我想它们已经很有用了。

内容小结

本讲通过实现复制两个结构体中的同名字段,讲解了编译期数据遍历和一个重要的实际使用案例。在 C++ 标准中尚未引入正式的静态反射之际,这些技巧会是你的百宝箱中的重要工具。

课后思考

请阅读示例代码,并考虑一下,如何可以使用这些技巧来扩充更多的数据处理能力,如实现数据结构的序列化?

期待你的思考,有任何疑问,欢迎在留言区与我讨论!

特别致谢

罗能阅读了本讲的手稿,并提出了很好的改进意见。

参考资料

[1] 罗能, “如何优雅的实现 C++ 编译期静态反射”. https://netcan.github.io/2020/08/01/ 如何优雅的实现 C- 编译期静态反射 /

[2] Microsoft, “/Zc:preprocessor (Enable preprocessor conformance mode)”. https://docs.microsoft.com/en-us/cpp/build/reference/zc-preprocessor

[3] cppreference.com, “Dependent names > The template disambiguator for dependent names”. https://en.cppreference.com/w/cpp/language/dependent_name#The_template_disambiguator_for_dependent_names

[3a] cppreference.com, “待决名 > 待决名的 template 消歧义符”. https://zh.cppreference.com/w/cpp/language/dependent_name#.E5.BE.85.E5.86.B3.E5.90.8D.E7.9A.84_template_.E6.B6.88.E6.AD.A7.E4.B9.89.E7.AC.A6