C++现代模板元编程

个人发现很多国外的大佬的演讲或者文章都很不错,但是鲜有人来进行分享,届后本人会时不时拿一些看起来很好的东西来给大家分享,主要也是搬运,不过也省去了大家去读英文的麻烦,同时文章中也会参杂着一些自己的见解。 本文来自于CppCon 2014: Walter E. Brown的一篇演讲 Modern Template Metaprogramming(现代模板元编程),我尽可能对这篇演讲进行还原。演讲通过比较多的例子的挨个讲述使得听众可以充分的理解元编程的奥秘,演讲非常精彩,希望我的传达可以有所体现。 如有需要可以关注微信公众号:0号程序员

元编程

元编程的意思是用来产生新代码的代码,而且新产生的代码实现了我们真正想要的功能,通常名词"元编程"暗示了一种自反的属性。演讲中选取的例子也基本来自于标准库,方便理解和了解标准库。 演讲花了一些篇幅做自我介绍,同时介绍了作者的一些共享,大家感兴趣可以去了解,这里不展开说。 C++的模板元编程是使用模板实例化驱动的编译期编程的方式 ,这样可以提升代码灵活性及运行期的性能。那也就是意味着不存在可变性,不存在虚函数,不存在rtti等特性。 以下讲述中的元函数可以分为值元函数类型元函数两种。

使用以struct为基础封装元函数

c++ 复制代码
template<int N>
struct abs {
    static_assert(N != INT_MIN);
    static constexpr int value = (N < 0) ? -N : N;
};

// call
int const n = 12;
abs<n>::value;

展示了一个编译期求绝对值的元函数的例子,首先接受模版参数是非类型的,要保证N数值能够在编译期确定,然后使用静态断言保护N不为最小值,因为int的最小值无法取得他的相反数,(int的取值范围是-2^31 ~ 2^31-1) 。最后使用value存放到N的绝对值。 使用constexpr函数来改写一个这个元函数:

c++ 复制代码
constexpr int abs(int N) { return (N < 0) ? -N : N; } 

这样做的好处是可以像调用正常函数那样调用一个元函数,更加符合人类的思维,但是使用struct封装的元函数能够提供比较多的工具使用,比如说一些公开成员的类型声明(using/typedef形容),公开的成员数据声明(static const/constexpr形容),这个是constexpr函数做不到的。 这里其实就是类型的萃取(类型元函数 )已经值元函数的封装了,且这两个可以兼顾。除此之外还可以公开成员模板,比如上边例子的static_assert。 以上特性会非常有用,以gcd(最大公约数)为例:

c++ 复制代码
template<unsigned M, unsigned N>
struct gcd {
    static int const value = gcd<N, M % N>::value;
};

template<unsigned M>
struct gcd<M, 0> {
    static_assert(M != 0); // gcd(0,0) undefined
    static int const value = M;
};

使用编译期递归及模板的偏特化来实现,使用static_assert保护M不为0,使用static int const value来存放最终的取值。这里本人来看其实目前c++的constexpr元函数也可以做到,不过上边所说的提供的工具等确实是无法替代的。的确标准库的实现也都是基于此(struct封装)来做到的。

元函数概述

以类型为参数的元函数

之前所比较常见的则是sizeof,sizeof以类型为参数,可以知道该类型所占内存的大小。 但是我们也可以使用类型为参数来写我们的代码,以求一个数组的层级(维度)为例:

c++ 复制代码
template<class T>
struct rank {
    static size_t const value = 0;
};

template<class U, size_t N>
struct rank<U[N]> {
    static size_t const value = 1u + rank<U>::value;
};

// call
using array_t = int[10][20][30];
rank1<array_t>::value // 3

brown这里讲述了一个方法论,一般这里需要两部分,一部分是比较常规的或者递归的结束条件,也就是上边中的主模板;另一部分就是所谓正常处理实现,也就是上边的模板特化部分。 这里计算数组的层级中,将最后一维使用N特化出来,这样U就可以表示前几维,如此就是U的维数加1就可以递归获得维度。

以类型为结果的元函数

可以产出类型作为结果,比如说移除掉类型的const修饰符:

c++ 复制代码
template<class T>
struct remove_const {
    using type = T;
};

template<class U>
struct remove_const<U const> {
    using type = U;
};

// call
remove_const<T>::type t;

remove_const可以做到移除掉类型的const,如上主模板比较通用的,输入任何类型都返回该类型,特化的这个模板实现比较巧妙,仅仅接收const修饰的类型为入参,然后正好返回去掉了const的类型。

C++11标准库元函数惯例1

C++标准库中一般来说如果是以类型为结果的话,都是用type来给它命名,算是一个约定。介于一些历史原因,标准库中的类型结果不都是以type命名,比如说到的iterator_traits中。

type_is

然后brown说到元函数type_is,他说到这是一个比较特殊的元函数:

c++ 复制代码
template<class T>
struct type_is {
    using type = T;
};

我们看起来平平无奇,仅仅是把入参直接作为结果,但这也是他的特殊之处。在其他元函数对其继承时出奇的好用:

c++ 复制代码
template<class T>
struct remove_volatile : type_is<T> {
};

template<class U>
struct remove_volatile<U volatile> : type_is<U> {
};

remove_volatile类似于remove_const实现,不同的是继承了type_is,同时传递类型参数,也就是说可以直接指定结果类型是哪个,并且省掉了写using type = xxx,是不是方便了很多。

决策标记

假设你要设计一个IF/IF_t的元函数,如果满足条件则返回一个类型,不满足返回另外一个类型,这整个发生在编译期。如何做到呢?可以先尝试自己写写,首先要有一个主模板,还有一个特化模板;

c++ 复制代码
template<bool, class T, class F>
struct IF : type_is<T> {};

template<class T, class F>
struct IF<false, T, F> : type_is<F> {};

实现真的够简单优美,假设主模板的bool值是true,这样结果就是T类型,如果特化bool的值为false时,就返回F类型。也就是C++11的conditional。C++14后增加了xxxx_t表示xxxx::type。也就是说conditional_t表示conditional::type,更加方便。 那么再次想象你可能需要这样一种场景,如果传递给你的bool值是true,就返回指定类型,否则什么也不返回(或者说什么也不做)

c++ 复制代码
template<bool, class T>
struct enable_if : type_is<T> {};

template<class T>
struct enable_if<false, T> {};

借助上边的一个一个例子,很容易就实现了。主模板默认bool值是true,就返回T类型。特化模板是false,那么就什么也不做。 enable_if是不是很熟悉,没错的,就是标准库中的enable_if。那么这样的一个元函数,他有什么用呢? 当调用到enable_if<false,...>::type时是个错误吗?这里就可以使用在SFINAE了。通过一些重载机制,使得enable_if做到替换失败不是错误,进行重载决议选择最合适的函数执行。 我的之前文章也讲过的SFINAE了,不过brown花了一些时间对于此讲解,我们也来转述一下。 SFINAE应用于模板的隐式实例化过程中,在模板的实例化过程中分几步进行:

  1. 计算出模板参数
    • 优先使用模板函数调用时显示指定的参数
    • 否则从函数参数推断
    • 否则从默认模板参数使用
  2. 使用相应的参数替换模板参数

如果这些步骤可以产出格式正确的的代码,则实例化成功。 如果产生的代码格式是不正确的,也就是替换失败,则该函数就会从候选集里丢弃。

SFINAE使用:

c++ 复制代码
template<class T>
enable_if<is_integral<T>::value, maxint_t>
f(T val) {}

template<class T>
enable_if<is_floating_point<T>::value, long double>
f(T val) {}

enable_if应用在函数的返回值上,如果调用f传递是int则调用第一个函数,传递float则调用第二个。但是不论是传递int或者float都会有一个函数会实例化失败,但是这并不是错误,仅仅是从候选集中移除从而选择正确的一个。 某种程度上来讲enable_if和concept类似,concept有着坚实的数学基础。brown也仅仅是一个尝鲜,目前concept在C++20引入。

C++11标准库元函数惯例2

当一个元函数返回一个值时(以值为结果),使用value为其命名,大部分使用static constexpr修饰。一个典型值元函数的例子是:

c++ 复制代码
template<class T, T v>
struct integral_constant {
    static constexpr T value = v;
    constexpr operator T() const noexcept {return value;}
    constexpr T operator()() const noexcept {return value;}
};

是一个常数元函数,仅仅是对其入参的值进行了封装并作为结果,这个和type_is类似的地方是,他也可以作为其他值元函数的父类,免去了重新对value进行编码及相关函数的编写。

再次看下rank

c++ 复制代码
template<class T>
struct rank : integral_constant<size_t, 0> {};
		
template<class U, size_t N>
struct rank<U[N]> : integral_constant<size_t, 1u + rank<U>::value> {};

现在有了integral_constant,就可以很方便的对rank进行封装,写法及思路基本上没有变化,仅仅是将vale和类型传给integral_constant来管理,那也就是说我的最终值结果传递integral_constant就做好了一切。

使用integral_constant的一些便利

c++ 复制代码
template<bool b>
using bool_constant = integral_constant<bool, b>;

using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

bool_constant就是类型为bool的常数,然后借此有可以封装了true_type和false_type,也就是说true_type或者false_type就是值结果为true和false的元函数。

c++ 复制代码
is_void<T>::value
bool(is_void<T>{})
is_void<T>{}();
is_void_v<T> // c++14

这是几种获取到value结果的调用方式,is_void返回一个value,可以使用::value调用,或者实例化对象获取,或者调用()操作符调用,这些在integral_constant都有提供。xxx_v的形式在c++14中提供,和xxx_t类似,也即对xxx::value的封装。

使用模板特化及继承

这里继续使用继承及特化一起的形式来展示给大家一些例子:

is_void

假设要实现一个判断类型是不是void的元函数呢?

c++ 复制代码
template<class T> struct is_void : false_type {};

template<> struct is_void<void> : true_type {};
template<> struct is_void<void const> : true_type {};
...

这里假设主模板中传递的类型不是void,所以让他来继承false_type,也就是说这里他的返回值是false。然后就是模板的特化了,这里只是特化了两个形式,一个是void,一个是void const,这两种类型的参数都会被判断成返回值是true。这里也是我们第一次继承true_type和false_type,当需要设定返回值是bool值时,便可以参照如此来继承。

is_same

假设要实现一个判断传递的两个类型参数是否相等的元函数呢?这里大家可以设想一下自己的实现来和下边实现比对:

c++ 复制代码
template<class T, class U> struct is_same : false_type {};
template<class T> struct is_same<T, T> : true_type {};

主模板时传递两个类型的模板参数,默认是false的,然后看特化版本,仅仅是特化出来两个一样的类型的元函数的版本为true,简单且完美。到这里大家可以想象是不是自己也可以写出类似的函数了,虽然和我们平时写的代码不一样,但是原理很像,假设我们写if T == U 则为true,否则则为false,抽象成元函数时,我们只能特化相等的情况,因为这种情况是已知的。

使用is_same实现is_void

那么我们如果使用is_same再次实现is_void呢?

c++ 复制代码
template<class T>
using is_void = is_same<remove_cv_t<T>, void>;

// remove_cv_t
template<class T>
using remove_cv = remove_volatile<remove_const_t<T>>;

template<class T>
using remove_cv_t = typename remove_cv<T>::type ;

实现也比较简单,就是将T与void比较,这里就是多了将类型T的const及volatile修饰去掉再此比较,下边也有remove_cv的实现,前边也讲过,比较容易理解。

元函数中使用参数包

从这里开始我们将模板参数是参数包的情况下的一些例子。

is_one_of

首先是判断一坨类型参数包中是否包含某个类型参数:

c++ 复制代码
template<class T, class... P0toN>
struct is_one_of;

template<class T>
struct is_one_of<T> : false_type {};

template<class T, class... P1toN>
struct is_one_of<T, T, P1toN...> : true_type {};

template<class T, class P0, class... P1toN>
struct is_one_of<T, P0, P1toN...> : is_one_of<T, P1toN...> {};

这个实现稍微有点复杂,但是也是很巧妙。首先仅仅是对is_one_of声明,不做实现,然后第一个特化版本是就一个T类型,也就是说参数包是空的,那当然是返回false了。第二个特化版本是要找类型刚好在参数包的第一个,那自然返回true。第三个版本是表示不在第一个情况下,那就先把第一个分离出来(P0),让T和其他的剩下的类型比较,也即继承is_one_of<T, P1toN...>,这样递归继承就始终判断是不是在第一个就可以了,如果找了半天没有就回到第一个特化版本了。

使用is_one_of实现is_void

c++ 复制代码
template<class T>
using is_void = is_one_of<T,
                        void,
                        void const,
                        void volatile,
                        void const volatile>;

就比较简单,判断下传递过来的T是不是其中的任何一个,完美。

不做求值的操作符

brown谈到一些未求值的操作符,包含sizeof,typeid,decltype,noexcept,也就是说这些操作符要操作的表达式时,是不对表达式求值的。举例说明:decltype(foo()) 中的表达式是foo的函数调用,其实不会真的去调用这个函数,仅仅是对这个函数的签名等做检查,那么这里就是检查这个函数的返回值类型。 那么由于此特性,结合std::declval()可以一起使用。

c++ 复制代码
decltype((std::declval<int>())); // 

**std::declval()**是一个函数模板,他仅仅只有声明,没有实现,你唯一可以使用他的地方是在没有定义的未求值的上下文中。作用是看起来像是一个函数调用,但是不去调用,且返回值为T的右值引用。如果想要返回值是左指的话,传递参数为T&即可。

is_copy_assignable

先来看一个应用,判断某个类型的是否可以拷贝,也即等号操作符是不是可以调用:

c++ 复制代码
template<class T>
struct is_copy_assignable {
private:
    template<class U, class = decltype(declval<U&>() = declval<U const&>())>
    static true_type try_assignment(U&&);

    static false_type try_assignment(...);

public:
    using type = decltype(try_assignment(declval<T>()));
};

看起来有点复杂,我们一步一步解析,首先可以看到有两个重载函数try_assignment,第一个是返回值是true_type,第二个是false_type。那也就是说第一个是表示可以对拷贝操作符调用。他是如何知道的呢, decltype(declval<U&>() = declval<U const&>())这句是关键,这里就是去获取等号操作符函数的返回值,使用declval来调用等号操作符,但是仅仅是声明没有真正调用,这里是将一个U const&的对象赋值给U&的形式,也即等号的签名形式。第二个重载版本就很简单,参数就是三个点,表示最坏的匹配,其他重载函数找不到就找它。 最后看is_copy_assignable的type,也即对外的返回类型,其实就是检查try_assignment的返回值而已,这样如果可以对等号调用就返回true_type,否则就是false_type。

上边是C++11之后的实现,brown这里也提到了C++11之前对此的实现,思路几乎一致,不够有点丑陋。还无法使用true_type和false_type时,使用typedef char (&yes)[1]typedef char (&no)[2]来代替,及使用char[1]数组表示yes,char[2]数组表示no,这里也就是返回的类型,使用sizeof这个返回值类型来区别。即sizeof(try_assignment(...)),不过这里他没说到declval的替代方案是什么。

void_t

接下来就是大名鼎鼎的void_t了,brown花了很大的篇幅来讲述这个,只能用精美绝伦的来形容,当时在场的听众也是极为赞赏。

c++ 复制代码
template<class...>
using void_t = void; 

这就是实现,及其简单优雅,表述的意义是接收多个参数,表示的类型就是void。也就是无论你传给我啥类型,我就给你返回一个确定的类型。 那大家可能会想,这会有啥用呀,我自己都可以实现,继续往下看。 假设需要实现一个检查一个类里是否有某个type的成员:

c++ 复制代码
template<class, class = void>
struct has_type_member : false_type{};

template<class T>
struct has_type_member<T, void_t<typename T::type>> : true_type{};

// call
has_type_member<T>::value

主模板默认是没有的,没什么好说。关键在与特化版本这里,使用void_t包装里T::type,重点是T::type是否可以正常调用,如果是则就是返回true_type,否则就会回到第一个主模板那里了(SFINAE)。 那也就是说void_t反而成了会检查传递给他的类型是不是合法的功能了,如果合法那就返回void,不然就行不通。666

再次实现is_copy_assignable

有了void_t了,brown再次实现is_copy_assignable,来看:

c++ 复制代码
template<class T>
using copy_assignment_t = 
	decltype(declval<T&>() = declval<T const&>());

template<class T, class = void>
struct is_copy_assignable : false_type {};

template<class T>
struct is_copy_assignable<T, void_t<copy_assignment_t<T>>>
        : is_same<copy_assignment_t<T>, T&> {};

首先需要一个表达格式让void_t去检查,这里是单独拿出来的copy_assignment_t,实现我们上边见过,就是对等号操作符的调用声明,切copy_assignment_t还会获取到等号操作符的返回值。 然后开始is_copy_assignable主模板是默认false,特化模板同上边实现基本一致,就是void_t中会去检查copy_assignment_t是否合法,这里合法后还会再次校验返回值是不是T&,如此便是true_type,否则就会到主模板那里false_type。

相信到这里大家也明白了void_t的用法,也就是它可以做到去校验传递给他的调用得出类型或者表达式等等是否是合法的。如果需要的是is_move_assignable,仅仅把copy_assignment_t那里的T const&换成T&&。

演讲到这里,有一个clang标准库维护的嘉宾说他也需要一个这样的东西,实现了一个is_well_form,但是他的实现相比较起来有限的多,回家要把它撕了。哈哈哈。

技巧及工具总结

这里就是做了一下总结,brown讲到前边说了类型的元函数,使用static const/constexpr的值元函数,及元函数的使用继承特化等等技术,以及SFINAE,未求值操作符,参数包的元函数以及void_t等等。 brown最后用一段话结束演讲,我把它贴到这里:

vbnet 复制代码
Although we're professionals now, 
we all started out as humble students - .... 
Back then, everything was new, and we had no real way of knowing 
whether what we were looking at was wizardry or WTF

到此就结束了,希望我的搬运能给大家带来知识,腰酸背痛,点个赞吧

ref

相关推荐
家有狸花2 小时前
VSCODE驯服日记(三):配置C++环境
c++·ide·vscode
dengqingrui1233 小时前
【树形DP】AT_dp_p Independent Set 题解
c++·学习·算法·深度优先·图论·dp
C++忠实粉丝3 小时前
前缀和(8)_矩阵区域和
数据结构·c++·线性代数·算法·矩阵
ZZZ_O^O3 小时前
二分查找算法——寻找旋转排序数组中的最小值&点名
数据结构·c++·学习·算法·二叉树
小飞猪Jay6 小时前
C++面试速通宝典——13
jvm·c++·面试
rjszcb7 小时前
一文说完c++全部基础知识,IO流(二)
c++
小字节,大梦想7 小时前
【C++】二叉搜索树
数据结构·c++
吾名招财7 小时前
yolov5-7.0模型DNN加载函数及参数详解(重要)
c++·人工智能·yolo·dnn
我是哈哈hh8 小时前
专题十_穷举vs暴搜vs深搜vs回溯vs剪枝_二叉树的深度优先搜索_算法专题详细总结
服务器·数据结构·c++·算法·机器学习·深度优先·剪枝
憧憬成为原神糕手8 小时前
c++_ 多态
开发语言·c++