TOP

现代C++与模板元编程(一)
2019-09-04 00:58:28 】 浏览:66
Tags:现代 模板 编程

最近在重温《c++程序设计新思维》这本经典著作,感慨颇多。由于成书较早,书中很多元编程的例子使用c++98实现的。而如今c++20即将带着concept,Ranges等新特性一同到来,不得不说光阴荏苒。在c++11之后,得益于新标准很多元编程的复杂技巧能被简化了,STL也提供了诸如<type_traits>这样的基础设施,c++14更是大幅度扩展了编译期计算的适用面,这些都对元编程产生了不小的影响。今天我将使用书中最简单也就是最基础的元容器TypeList来初步介绍现代c++在元编程领域的魅力。


TypeList顾名思义,是一个存储和操作type的list,你没有看错,存储的是type(类型信息)而不是data。


这些被存储的type也被称为元数据,存储的它们的TypeList也被称为元容器。


那么,我们存储了这些元数据有什么用呢?答案是用处很多,比如tuple,工厂模式,这两个后面会举例;还可用来实现CRTP技巧(一种元编程技巧),线性化继承结构等,这些也在原书中有详细的演示。


不过光看我在上面的解释多半是理解不了什么是TypeList以及它有什么用的,不过没关系,元编程本身就是高度抽象的脑力活动,只有多读代码勤思考才能有所收获。下面我就展示如何使用现代c++实现一个TypeList,以及对c++11以前的古典版本做些简单的对比。


最初的问题是我们要如何存储类型呢?数据可以存变量,单是type和data的不同的东西,怎么办?


聪明的你可能以及想到了,我们可以让模板参数成为type信息的容器。


但是紧接着第二个问题来了,所谓list它的元素数量是固定的,但是直到c++11以前,模板参数的数量都是固定的,那么怎么办?


其实也很简单,参考普通list的链表实现法,我们也可以用相同的思想去构造一个“异质链表”:


这就是最简单的定义,其中,T是一个普通的类型,而U则是一个普通类型或TypeList。创建TypeList是这样的:


可以看到,通过TypeList环环相扣,我们就能把所有的类型都存储在一个模板类组成的链表里了。但是这种实现的弊端有很多:


好在现代c++有变长模板,上述限制大多都不存在了:


通过变长模板,我们可以轻松定义任意长度的list:


同时,我们特化出了空的TypeList,现在我们可以用它作为终止标记,而不用引入新的类型。如果你对变长模板不熟悉,可以搜索相关的资料,cnblogs上就有很多优质教程,介绍这个语法特性已经超过了本文的讨论范畴。


当然,变长模板也不是百利而无一害的,首先变长模板的参数包始终可以解包出空包,这会导致模板的偏特化和主模板发生歧义,因此在处理一些元函数(编译期计算出某些元数据的模板类就叫做元函数,概念来自于boost.mpl)的时候就要格外小心;其次,虽然我们方便了类型定义和部分的处理,但是向list头部添加数据就很困难了,参考下面的例子:


问题出在哪?...运算符只能对参数包进行解包扩展,而TL1是一个类型,不是参数包,但是我们有需要把TL1包含的参数拿出来,于是问题就出现了。


对于这种需求我们只能使用一个元函数来解决,这是现代化方法为数不多的缺憾之一。


定义了TypeList,接下来是定义各种元函数了。


也许你会疑惑为什么不把元函数定义为模板类的内部静态constexpr函数呢?现代c++不是已经具备强大的编译期计算能力了吗?


答案是否定的,编译期函数只能计算数值常量,而我们的元数据还包括了type,这时函数处理不了的。


不过话也不能说死,因为在处理数值常量的地方constexpr的作用还是很大的,后面我也会用constexpr函数辅助元函数。


最常见的需求就是求出TypeList中存放了多少个元素,当然这也是实现起来最简单的需求。


先来看看古典技法,所谓古典技法就是让模板递归特化,依靠偏特化和特化来确定退出条件达到求值的目的。


因为编译期很难存储下迭代需要的中间状态,因此我们不得不依赖这种像递归函数般的处理技巧:


解释一下,static constexpr int value是c++17的新特性,这种变量将会被视为类内的静态inline变量,可以就地初始化(c++11)。否则你可能需要将值定义为匿名的enum,这也是常见的元编程技巧之一。


我们从参数包的第一个参数开始逐个处理,遇到空包就返回0结束递归,然后从底层逐步返回,每一层都让结果+1,因为每一层代表了有一个type。


其实我们可以用c++11的新特性——sizeof...操作符,它可以直接返回参数包中参数的个数:


使用现代c++的代码简单明了,因为参数包总是可以展开为空包,这时候value为0,还可以少写一个特化。


list上第二个常见的操作就是通过index获取对应位置的数据。为了和c++的使用习惯相同,我们规定TypeList的索引也是从0开始。


有了元函数的调用形式,我们可以开始着手实现了:


首先还是声明主模板,具体的实现交给偏特化。


虽然c++已经支持编译期在constexpr函数中进行迭代操作了,但是对于模板参数包我们至今不能实现直接的迭代,即使是c++17提供的折叠表达式也只是实现了参数包在表达式中的就地展开,远远达不到迭代的需要。因此我们不得不用老办法,从第一个参数开始,逐渐减少参数包中参数的数量,在减少了index个后这次偏特化的模板中,index一定是0, 而Head就一定是我们需要的类型,将它设置为type即可,而上层的元函数只需要不断减少index的值,并把Head从参数包中去除,将剩下的参数和index传递给下一层的元函数TypeAt即可。


顺带一提,static_assert不是必须的,因为你传递了不合法的索引,编译器会直接检测出来,但是在我这(g++ 8.3, clang++ 8.0.1, vs2017)编译器对此类问题发出的抱怨实在是难以让人类去阅读,所以我们使用static_assert来明确报错信息,而其余的信息比如不合法的index是多少,编译器会给你提示。


如果你不想越界报错而是返回NullType,那么可以这样写:


因为不想越界后报错,所以我们要提供越界之后参数包为空的退出条件,在参数包处理完后就会立即使用这个新的特化,返回NullType。


聪明的读者也许会问为什么不用SFINAE,没错,在类模板和它的偏特化中我们也可以在模板参数列表或是类名后的参数列表中使用enable_if实现SFINAE,但是这里存在两个问题,一是类名后的参数列表必须要能推演出模板参数列表里的所有项,二是类名后的参数列不能和其他偏特化相同,同时也要符合主模板的调用方式。有了如上限制,利用SFINAE就变得无比困难了。(当然如果你能找到利用SFINAE的实现,也可以通过回复告诉我,大家可以相互学习;不清楚SFINAE是什么的读者,可以参阅cppreference上的简介,非常的通俗易懂)


当然这么做的话静态断言就要被忍痛割爱了,为了接口表现的丰富性,Loki的作者将不报错的TypeAt单独实现为了不同的元函数:


IndexOf的套路和TypeAt差不多,只不过这里的递归不用扫描整个参数包(逐个按顺序处理参数包,是不是和扫描一样呢),只需要匹配到Head和待匹配类型相同,就返回0;如果不匹配就像TypeAt中那样递归调用元函数,对其返回结果+1,因为结果在本层
现代C++与模板元编程(一) https://www.cppentry.com/bencandy.php?fid=54&id=249978

首页 上一页 1 2 3 下一页 尾页 1/3/3
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇C++性能测试工具:计算时间复杂度 下一篇Python高级编程 - 锁的语法