40 | 如何在编译期遍历数据?
下载APP
关闭
渠道合作
推荐作者
40 | 如何在编译期遍历数据?
2022-06-27 吴咏炜 来自北京
《现代C++编程实战》
课程介绍
讲述:吴咏炜
时长22:55大小21.00M
你好,我是吴咏炜。
你考虑过 tuple 和普通的结构体存在哪些区别吗?
显然,一个字段没有名字,一个字段有名字——这是一个非常基本的区别。
其他还有吗?
有。
对于 tuple,标准 C++ 里提供了很多机制,允许你:
知道 tuple 的大小(数据成员数量)
通过一个编译期的索引值,知道某个数据成员的类型
通过一个编译期的索引值,对某个数据成员进行访问
在 C++ 的静态反射到来之前,我们想在结构体里达到类似的功能,只能自己通过一些编程技巧来实现。本讲我们就会介绍一种手工实现静态反射的方法,能够让结构体在用起来跟原来没感觉有区别的情况下,额外提供类似 tuple 的功能,甚至还更多。毕竟,结构体里的字段是有名字的,可以产生更可读的代码。我们还能进一步利用编译期的字符串模板参数技巧(第 39 讲),使用字段名称这一数据,让下面的代码能通过编译:
这段代码做的事情正是你看名字可以想象的,把 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 的定义如下所示:
可以看到,宏展开后成了 struct st 的定义(st 是结构体名),里面有三部分:
首先,我们声明了一个叫 _field 的嵌套类模板,指定它具有两个参数,一个类型,一个是 size_t;
然后,我们根据参数数量,算出字段数量,赋给静态 constexpr 变量 _size;
最后,我们利用 REPEAT_ON 宏,有多少个字段就重复多少次,逐项产生字段的定义。
以我们上面的 S2 为例,宏展开后的结果大致如下(已重新格式化;另注意分号是宏外面手工添加的):
下面,我们主要就不再关心宏的问题,而是如何在给定索引值的情况下,合适地产生静态反射所需要的全部信息和方法。
不过,先提醒一下,如果你把宏放到一个供别人使用的公共库的话,一般所有的宏名称都应该加上前缀,以免跟其他宏发生冲突——毕竟宏是没有作用域的,会污染全局名空间。比如,Boost.Test 里的测试宏叫 BOOST_CHECK 或类似的名字,而不是简单的 CHECK。我这边出于讲解上的简洁,基本不用名空间和前缀,但不等于你在项目里也应该这样做:像 PASTE、STRING 这样的宏还是非常容易产生冲突的。
如果你使用 MSVC 的话,还有一个额外的问题是 MSVC 的传统预处理方式不符合 C++ 标准,无法正确处理这些宏。你需要启用命令行选项 /Zc:preprocessor 才行 [2]。
字段的定义
给定了字段索引、字段类型和字段名称,我们通常需要生成下面这些信息,来方便通过索引使用它们:
字段名称
字段类型
字段值的访问
我们下面需要考虑的,就是(假设)给定了索引值 0、字段内容 (long)v1,如何来生成字段的定义。
字段名称看起来最好办:
对 (long)v1 进行 STRIP 后,我们得到 v1;再对其 STRING 处理,即得到字符串 "v1"。
字段类型也不复杂。唯一的麻烦是我没找到宏处理的方法从 (long)v1 得到 long。当然,我们并不一定需要采用 (long)v1 这种写法,写成 (long, v1) 这样就没这种麻烦了。只不过,从可读性的角度,字段定义成 (long)v1 更接近 C/C++ 的现有语法,确实比 (long, v1) 看起来要直观得多,也能更自然地处理类型之中带逗号的情况(如 array<int, 4>)。我觉得罗能的这个选择非常棒。
因此,我们这里采取绕弯的方式,定义为:
换句话说:
为什么要把 T 作为参数传进来,还要使用 decay_t 呢?因为我们允许 T 是 S2&、const S2&、S2&& 等多种情况(顺便说一句,如果能用 C++20 的话,那 remove_cvref_t 是个更好的选择)。在字段值的访问时我们就需要用上这种灵活性了:
这里的另外一个小细节是,返回表达式上的括号是必需的。返回值类型为 decltype(auto),意味着我们使用 decltype(返回表达式) 作为返回值类型:使用 decltype(obj_.v1) 我们会得到 long,而使用 decltype((obj_.v1)) 我们才会得到 long&(或 const long& 之类),后者才是我们想要的。
上一节结尾时的 FIELD 宏,就可以自动帮我们来生成这些定义。它本身被定义为:
编译期使用字段名称
上面的定义在初步使用时已经够用了,但等到你想在编译期对字段名称进行判断时,你就会发现麻烦来了。当然,利用上一讲我们讲述的编译期传参的技巧,我们可以解决问题。但与其如此,不如我们直接往前走一步,利用字符串模板参数,直接把字段名称从值变成类型:
这里我们只是简单地把 STRING 变成了 CTS_STRING。这个变化看起来很小,但它会导致下面这些用法上的大改变:
相等判断可以直接使用 is_same_v
输出的话需要使用宏 CTS_GET_VALUE
函数传参大大简化,不再需要 CARG、CARG_WRAP 那套东西
这样带来的一个小问题是:如果你使用 MSVC 的话,你必须启用 C++20 才行。
静态反射的使用
识别对静态反射的支持
结构体的数据遍历:对象打印
对于一个支持静态反射的结构体,我们可以做的一个非常基本的操作,就是可以像 tuple 一样来进行简单的遍历。为了方便这个常见操作以及类似遍历操作的实现,我们定义下面的函数模板:
跟之前一样,为了在推导出 T 是 S2& 等情况下访问 S2 的成员,我们需要使用 decay_t。从 for_each 到 for_each_impl 基本只是个转发,但加上了 index_sequence 参数,这也是我们讲编译期编程时一直在使用的技巧了。
主要工作当然就在 for_each_impl 里。它本质上就是一个折叠表达式的展开,基本形式是:
也就是说,我们逐项调用函数 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 一样方便地输出任何支持静态反射的结构体了。代码如下所示:
仔细看一下,你会发现这个函数的实现相当简单。我们根据 T 类型是否支持静态反射,决定是遍历其所有字段,还是直接调用 << 运算符来将其输出。如果是遍历的话,我们就会调用 for_each,并在传递给 for_each 的泛型 lambda 里递归调用 dump_obj,把下面一层的值、字段名等信息传递过去。
对于我们开头的那个 s2,dump_obj 可以给出下面这样的输出:
{
v1: 1,
v2: false,
}
结构体的元数据遍历:对象字段查找
有时候我们并不希望遍历对象的实际数据,而只是遍历对象的数据类型。一种可能的场景就是,通过一个字段的名称,查找字段在结构体里的索引值。
类似于刚才的 for_each,我们可以定义一个 for_each_meta。其实现如下:
使用这个 for_each_meta,我们就可以实现出刚才说的根据字段名称查找索引值的函数了:
这个函数要求传递一个“编译期字符串”的字段名称,然后就会找出这个字段名称对应的字段索引值。如果这个字段不存在的话,就会返回 SIZE_MAX。
如对于开头的 S1,我们使用 get_field_index<S1>(CTS_STRING(msg)) 就能在编译期得到结果 2。
两个结构体的同时遍历:对象拷贝
遍历一个结构体只能满足部分常见需求。对于像比较、复制这样的操作,我们需要同时遍历两个结构体。这个函数,依据一些业界的惯例,我命名为 zip。
下面是 zip 的实现:
它结构上也还是 for_each 的一个翻版,没有大的区别。此处,根据我这边的实际需求,我要求两个被遍历的结构体的字段数量必须完全一致。根据你的实际需要,你当然也可以实现成以较小的结构体为准;但按照我的实际项目经验,这种在动态大小场景下的常见做法,在编译期显得比较鸡肋,实际用处不大。
有了 zip 这样的工具,我们现在可以实现一些较复杂的操作了,比如,可以支持异质同构结构体(成员必须一一对应,但类型可以不同)的逐成员复制。实现代码如下:
为了处理移动,我们对源对象使用完美转发,允许它是一个右值。为了防止误用和简化代码,目标对象必须是一个左值。当两个对象都支持静态反射时,我们使用 zip 来进行逐字段的遍历,对每一项继续进行 copy 的动作;否则,我们尝试普通的赋值操作(不支持的话,即会导致编译失败)。
对于同一类型结构体的复制,C++ 一般可以默认提供赋值运算符。但如果两个结构体类型不同,那默认的赋值运算符就不工作了。这时候,这个 copy 函数模板就能大显身手了。一种可能的使用场景是,我们可以利用它来实现网络字节序和主机字节序的自动转换。假设我们实现了数据类型 uint32s 和 uint16s,并且这些数据类型支持在跟 uint32_t 和 uint16_t 赋值的时候自动进行转换(这相当容易实现),那我们就可以定义出类似下面的数据结构:
从 msg_host_t 的一个对象 msg_host,转换到一个 msg_net_t 的对象 msg_net,我们现在可以一行代码搞定:
不仅如此,这样的结构体可以嵌套。如果我们有下面的结构体定义:
这两种类型的对象之间也可以用 copy 来进行复制,编译器会自动产生合适的转换代码。
应用:拷贝同名字段
利用静态反射,我们可以自动化很多原本需要手工编码的操作。除了上面展示的那些,常用的场景还有序列化、反序列化等。由于序列化和反序列化跟实际应用场景关联比较紧密,代码也比较复杂,我最后就讲一下开头所展示的、按字段名复制结构体的实现。
按字段名来复制结构体,我们首先需要回答下面两个问题:
我们按源结构体优先遍历,还是按目标结构体优先遍历
我们如何解决字段缺失的问题
在当前的解决方案里,我做出了下面的选择:
按目标结构体优先遍历(也就是为大结构体往小结构体拷贝而优化;反过来实现也不难、很相似)
严格指定目标结构体里有、源结构体里没有的字段数量(默认为零),不正确的话,直接编译失败
在实现 copy_same_name_fields 之前,我们需要一些辅助的工具函数模板。首先是 count_missing_fields,数一下源结构体里缺失的字段数量:
这里我们用到了前面描述过的 get_field_index,通过两重循环搜索字段名。所有的这些操作全部发生在编译时,所以我们可以不用太担心这个 级别的性能开销。
在实现 copy_same_name_fields 之前,我们还要做一个小小的处理,标记源结构体里缺失了多少个字段。显然,为了编译期检查,我们需要传递一个模板参数,但我们究竟该用什么类型呢?用 int?还是 size_t?
都不是,为了类型上更严格,也为了代码可读性更高,我选择使用强枚举类型:
它的底层是 size_t,但使用者必须显式地给出 missing_fields{1} 这样的方式来表达缺失了一个字段。我认为这样写更加合适、更加可读。
现在我们可以最终实现 copy_same_name_fields 了:
我在这个函数里做了以下的事情:
检查指定的缺失字段数量是否和实际的缺失字段数量一致,不一致则静态断言失败
对目标对象进行逐字段遍历
对每个字段,根据字段名在源对象中查找是否存在对应的字段,存在的话,则 copy 过去
很简单吧?这里面的关键,跟上一讲的编译期字符串处理一样,是需要处处保持 constexpr 性。我现在应该已经提供了足够多的例子,让你看到该如何写出这种代码。
对于开头给出的 copy_same_name_fields(s1, s2),编译器实际生成的 x86-64 的汇编代码是这样子:
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
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 2
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
39 | 如何在编译期玩转字符串?
下一篇
41|对象传参和返回的最佳实践
精选留言(2)
- 当初莫相识2022-10-19 来自江苏这些选学的文章,难度太高,我只能草草阅过,段位不够😂
作者回复: 这一讲属于高级技巧了,难度是高点。我主要是想分享一点可能用得上的技术。 另外,即使没看懂,你也可以试试看使用这种方式。毕竟,使用 STL 并不要求你能够写出 STL。
- 钱虎2022-07-18好