极客时间已完结课程限时免费阅读

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

40 | 如何在编译期遍历数据?-极客时间

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

讲述:吴咏炜

时长22:55大小21.00M

你好,我是吴咏炜。
你考虑过 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。我这边出于讲解上的简洁,基本不用名空间和前缀,但不等于你在项目里也应该这样做:像 PASTESTRING 这样的宏还是非常容易产生冲突的。
如果你使用 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<int, 4>)。我觉得罗能的这个选择非常棒。
因此,我们这里采取绕弯的方式,定义为:
using type =
decltype(decay_t<T>::STRIP((long)v1));
换句话说:
using type = decltype(S2::v1);
为什么要把 T 作为参数传进来,还要使用 decay_t 呢?因为我们允许 TS2&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
函数传参大大简化,不再需要 CARGCARG_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>{});
}
跟之前一样,为了在推导出 TS2& 等情况下访问 S2 的成员,我们需要使用 decay_t。从 for_eachfor_each_impl 基本只是个转发,但加上了 index_sequence 参数,这也是我们讲编译期编程时一直在使用的技巧了。
主要工作当然就在 for_each_impl 里。它本质上就是一个折叠表达式的展开,基本形式是:
(void(forward<F>(f)(…)), ...)
也就是说,我们逐项调用函数 f(使用了完美转发),并抛弃返回值(使用 void)。
我们再看一下每次调用函数 f 使用的参数:
第一项是 DT::template _field<T, Is>::name,表示字段的名称。这里你可能感到陌生的是对成员模板的 template 消歧义符 [3],其他就应该没啥特别的地方了。从阅读的角度,你基本上可以简单地忽略 :: 后面的 template 关键字。
第二项是 typename DT::template _field<T, Is>(forward<T>(obj)).value()。这个表达式有点长,其中,typename DT::template _field<T, Is> 指定了 _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,把下面一层的值、字段名等信息传递过去。
对于我们开头的那个 s2dump_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<S1>(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 函数模板就能大显身手了。一种可能的使用场景是,我们可以利用它来实现网络字节序和主机字节序的自动转换。假设我们实现了数据类型 uint32suint16s,并且这些数据类型支持在跟 uint32_tuint16_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,通过两重循环搜索字段名。所有的这些操作全部发生在编译时,所以我们可以不用太担心这个 级别的性能开销。
在实现 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);
}
});
}
我在这个函数里做了以下的事情:
检查指定的缺失字段数量是否和实际的缺失字段数量一致,不一致则静态断言失败
对目标对象进行逐字段遍历
对每个字段,根据字段名在源对象中查找是否存在对应的字段,存在的话,则 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
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 2

提建议

上一篇
39 | 如何在编译期玩转字符串?
下一篇
41|对象传参和返回的最佳实践
 写留言

精选留言(2)

  • 当初莫相识
    2022-10-19 来自江苏
    这些选学的文章,难度太高,我只能草草阅过,段位不够😂

    作者回复: 这一讲属于高级技巧了,难度是高点。我主要是想分享一点可能用得上的技术。 另外,即使没看懂,你也可以试试看使用这种方式。毕竟,使用 STL 并不要求你能够写出 STL。

  • 钱虎
    2022-07-18