一、如何使用模板(template)
模板定义:
模板就是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数, 从而实现了真正的代码可重用性。模版可以分为两类,一个是函数模版,另外一个是类模版。
我们可以通过下面的形式
template < parameter-list > declaration
template<typename T>
void func(...) { ... }
template<typename... Args>
class A { ... }
template<int N>
void func(...) { ... }
template<typename T, typename U>
class A { ... }
template<typename T, template<typename U> V>
class A{ ... }
...
typename 也可替换为 class
定义一个函数模板或者类模板。通过这种形式将原本函数或者类中固定的型别参数化,在编译的过程中,编译器将会根据实际的情况的去为我们自动生成对应型别的代码,从而实现的代码复用。
#include <iostream>
template<typename T>
T add(T x, T y)
{
return x + y;
}
int main()
{
int x1 = 0, y1 = 1;
auto result1 = add(x1, y1);
std::cout << result1 << std::endl;
float x2 = 0.0f, y2 = 1.0f;
auto result2 = add(x2, y2);
std::cout << result2 << std::endl;
return 0;
}
// 代码段:1
在代码段:1中定义一个 add 函数用于解决不同数据类型相加的问题, 当调用add计算两个int类型的时,编译器会用int替换模板中的参数 T 即模板实参,当调用add计算两个float 类型时 ,编译器会用float 替换模板实参,像这样通过使用具体的值替换模板实参,从模板中产生普通类,函数或者成员函数的过程被叫做模板的实例化,这个过程最后获得的实体,叫做特化 。这一系列过程的效果等价于在我们不使用template的时候自己定义两个版本的add函数(代码段:2)
#include <iostream>
int add(int x, int y)
{
return x + y;
}
float add(float x, float y)
{
return x + y;
}
int main()
{
int x1 = 0, y1 = 1;
auto result1 = add(x1, y1);
std::cout << result1 << std::endl;
float x2 = 0.0f, y2 = 1.0f;
auto result2 = add(x2, y2);
std::cout << result2 << std::endl;
return 0;
}
// 代码段:2
通过对比代码段:1和代码段:2 可以发现使用 template ,很好解决代码复用的问题,template的引入为C++添加了强大的泛型编程的能力。
对于类模板而言,也与函数模板相同, 在编译环节编译器会用不同型别去实例化模板获得最终的实体。但是有一点与函数模板不同,类模板的在调用的时候必须显示的指出所使用的型别而函数模板可以选择显示指出或者不指出直接让编译器自己推导所需要的数据类型(有的时候也必须显示指出部分型别或者全部,例如代码段:3中的minus函数)。
#include <iostream>
template<typename T>
class A
{
public:
T getV() const { return V; }
private:
T V;
};
template<typename T>
T add(T x, T y)
{
return x + y;
}
template<typename T1, typename T2>
T1 minus(T2 x, T2 y)
{
return x - y;
}
int main()
{
A<int> a;
// A a;
// 报错
// main.cpp:27:7: error: missing template arguments before ‘a’
auto sum = add(4, 3);
auto r = minus<float>(10, 5);
// auto r = minus(10, 5);
// 报错
// main.cpp:31:25: error: no matching function for call to ‘minus(int, int)’
// auto r = minus(10, 5);
// ^
// main.cpp:19:4: note: candidate: template<class T1, class T2> T1 minus(T2, T2)
// T1 minus(T2 x, T2 y)
// ^~~~~
// main.cpp:19:4: note: template argument deduction/substitution failed:
// main.cpp:31:25: note: couldn't deduce template parameter ‘T1’
// auto r = minus(10, 5);
// ^
return 0;
}
// 代码段:3
是否需要显示的指出型别在于编译器是否能准确的推断出你所需要的型别。对于函数模板,当你是对函数传入的参数型别进行参数化的时候,编译器可以很容易通过你传入的参数判断你所需要的型别,而但你对返回值进行参数化,编译器无法得知你想要什么型别,比如代码段:3的 minus 函数,在调用的时候必须显示指出部分型别。而类模板同理。
补充说明一下:
实例化有两种形式:分别为是显示实例化和隐式实例化。其实这两种形式在上文也均有提到只是没有概念化。显示实例化就是我们显示指出我们所需要的型别,让编译器按照我们的意愿去生实例化。而隐式实例化便是有编译器自己推断型别,自己去实例化模板。
二、模板的特化和偏特化
上文中我们提到,实例化的最终过程将获得实体(类,函数,成员函数),也称为特化(这种特化也称为隐式特化)。但这并不是特化的唯一方法,我们可以通过显示的方法去为某一型别或多个型别去定制特化。
template<typename T1, typename T2>
class A
{
T1 v1;
T2 v2;
...
};
//全特化
template<>
class A<int, int>
{
int v1;
int v2;
...
};
// 偏特化
template<typename T>
class A<T, int>
{
T v1,
int v2;
...
};
template<typename T1, typename T2, typename T3>
T1 add(T2 x, T3 y)
{
return x + y;
}
// 全特化
template<>
int add(char x, char y)
{
return x + y - '0';
}
// template<typename T>
// T add<T,int,int>(int x, int y)
// {
// return 0;
// }
// 报错
// main.cpp:43:30: error: non-class, non-variable partial specialization ‘add<T, int, int>’ is not allowed
// T add<T,int,int>(int x, int y)
// 代码段:4
如代码段:4所示,通过引入template<> 的形式对类或者函数等提供定制特化的过程被称为全特化,然而在代码段:4存在一些还有模板参数存在的特化,这种被叫做偏特化。这些也被称为显示特化。相对于特化,称最开始的定义的模板,为基本模板。
在这里需要强调几点:
- 对于函数以及成员函数只能进行全特化,不能进行偏特化
- 对于类的特化,全特化和偏特化都可以进行,单单使用template, 你无法对数据成员进行特化, 你只能对其成员函数进行特化。
在代码段:4中尝试对add函数进行偏特化,在编译期的时候便产生了报错。
当我们需要对一个模板类或模板函数进行了特化后,编译器在编译我的代码时,便会在我们显示特化的版本中挑选最合适的版本,如果没有,便会根据基本模板去实例化和特化一个合适的版本供调用,如果无法匹配调用,将会在编译期产生报错。通过这一机制,我们便可以进行很多有趣的操作。
让我们看下面这段代码:
#include <iostream>
template<typename T>
struct is_const
{
static bool const value = false;
};
template<typename T>
struct is_const<const T>
{
static const bool value = true;
};
int main()
{
int a = 10;
const int b = 10;
std::cout<<"a is const:"<<std::boolalpha<< is_const<decltype(a)>::value << std::endl;
std::cout<<"b is const:"<<std::boolalpha<< is_const<decltype(b)>::value << std::endl;
return 0;
}
// 输出结果
// a is const:false
// b is const:true
// 代码段:6
分析一下代码:
is_const<decltype(a)> 相当于 is_const<int>
is_const<decltype(b)> 相当于 is_const<const int>
如果是我们自己去将调用的模板与特化版本和基本模板匹配,会怎么匹配呢?
显然: int -> T, const int -> const T 是这样进行匹配
编译器也很聪明,他也会是按照这样的匹配,选择特化的方案。
在这里,我并不是想细致讨论编译器匹配的机制,只是想告诉大家,编译器是足够聪明的,一般情况下均可以完成符合大家直觉的匹配方案。
由于编译器的给力,很轻松便可以判断 a 不是被const修饰, b是 被const修饰 。
我们通过特化的机制轻松实现了判断数据是否被const 修饰的 "函数"(虽然它事实上是一个类)。
type_traits实现
C++ 中的type_traits 正是利用模板的特化和偏特化及递归算法,提供了丰富的编译期计算、查询、判断、转换和选择的帮助类。 (详情如下)
我们挑选其中几个类去简单复现一下,熟悉下模板特化和偏特化,继承和递归:
integral_constant 可指定类型的编译期常量
bool_constant
namespace iwtbam {
template<typename T, T v>
struct integral_constant
{
using value_type = T;
using value = v;
};
template<bool v>
struct bool_constant: integral_constant<bool, v>
{};
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;
}
is_const 检查类型是否为 const 限定
namespace iwtbam {
...
template<typename T>
struct is_const:false_type
{};
template<typename T>
struct is_const<const T>:true_type
{};
}
is_same 检查两个类型是否相同
namespace iwtbam {
...
template<typename T, typename U>
struct is_same: false_type
{};
template<typename T>
struct is_same<T, T>:true_type
{};
}
rank 获取数组类型的维数
namespace iwtbam {
...
template<typename T>
struct rank:integral_constant<std::size_t, 0>
{};
template<typename T, std::size_t size>
struct rank<T[size]>:integral_constant<std::size_t, 1 + rank<T>::value>
{};
}
在上面的代码中,复现了
integral_constant, bool_constant, is_const, is_same, rank
5个模板类,实现的基本策略就是特化与偏特化,同时通过模板类的继承,也使代码看着更加的简洁和明了,有条理性。在 is_const, is_same 的复现中有所体现。
rank的实现可能与其他四个的模板类有所不同,核心虽然还是特化和偏特化,还附加了一个通过继承实现了递归的策略。因为rank的目的是计算一个数组的维度,我们不可能为多维数组都定义一个特化的版本。 所以我们利用递归的形式不断的将型别去"降维",直到不能降为止。对于模板递归, 和普通递归一样我们必须给终止条件,否则就是出现编译错误,对于rank来说,这个条件就是:
template<typename T>
struct rank:integral_constant<std::size_t, 0>
{};
当我们对数组不停的"降维"后,降到普通的型别(非数组类型)后,即停下。
对于type_traits的几个类的复现,只是简单复现了一下,和STL相比在细节上,还有一定差距,如果对于C++ Template 很感兴趣可以直接阅读STL的源代码,其实阅读源码是开拓眼界很好的方法,像STL的源码都是经过大师打磨, 既有很好的设计模式和实现策略,也不缺少令人咋舌的奇淫巧技, 侯捷老师翻译的《STL 源码解析》(《The Annotated STL Sources》)。
三、变参数模板
上面我们所使用的模板,模板参数的个数都是固定,可是有的时候,如果不确定模板参数的个数怎么办呢?
在C++11时,便在标准中加入参数包的感念:
模板参数包是接受零或更多模板实参(非类型、类型或模板)的模板形参。函数模板形参包是接受零或更多函数实参的函数形参。至少有一个参数包的模板被称作变参数模板。
语法如下:
template<typename... Args>/template<class... Args>
我们可以像这样去定义带参数包的类模板和函数模板:
template<typename... Args>
struct tuple
{};
template<typename... Args>
void sum(Args... args)
{}
然后我们在调用的时候,就可以传递任意个参数:
tuple<int> t1;
tuple<int, float> t2;
tuple<int, float, char> t3;
sum(1);
sum(1,3,3,45,6, 7.0);
注意
在初等类模板中,模板参数包必须是模板形参列表的最后一个形参。在函数模板中,模板参数包可以在列表中早于所有能从函数实参推导的参数出现,或拥有默认参数
模板参数展开/包展开
参数包直接给我们,是没有什么用处的,我们需要将参数包展开,获取到里面的参数。
模板参数展开/包展开语法:
pattern...
模板参数展开/包展开:后随省略号的模式,其中至少一个参数包名出现至少一次者,被展开成零或更多个逗号分隔的模式实例,其中参数包被按顺序替换成每个来自包的元素。
实例分析一下:
模式中仅有一个参数包
template<typename... Args>
int wapper(Args... args)
{
return 0;
}
template<typename T>
T inc(T t)
{
return t + 1;
}
template<typename... Args>
void test(Args... args)
{
wapper(&args...);
//&args是模式
//wapper(&args...) <=> wapper(&E1, &E2,...,&En)
wapper(inc(args)...);
//inc(args)是模式
//wapper(inc(args)...) <=> wapper(inc(E1), inc(E2),...,inc(En))
}
template<typename... Args>
class wapper_class {};
template<typename... Args>
class A:public wapper_class<Args>...
//wapper_class<Args>是模式
//wapper_class<Args>... <=> wapper_class<E1>, wapper_class<E2>, ..., wapper_class<En>
{};
template<typename... Args>
class B:public wapper_class<Args...>
// Args 是模式
// wapper_class<Args...> <=> wapper_class<E1,E2,...,En>
{};
template<typename...> struct tuple {};
template<typename T1, typename T2> struct pair {};
模式中有多个参数包, 每个参数包数量需一致
template<class... Args1>
struct zip
{
template<class... Args2>
struct with
{
using type = tuple<Pair<Args1, Args2>...>;
//Pair<Args1,Args2>是模式
//Pair<Args1, Args2>... <=> Pair<E11,E21>, Pair<E12, E22>, ..., Pair<E1n, E2n>
};
};
template<typename... Args>
void test2(Args... args)
{
wapper(wappper(args...) + args...)
// wapper(args...) <=> wapper(E1,E2,...,En)
// wapper(E1, E2,..., En) + args... ,
// <=> wapper(E1, E2,..., En) + E1, wapper(E1,E2, ..., En) + E2, ..., wapper(E1, E2, ..., En) + En
}
关于包展开更详细情况,参考:
如何处理参数包里
- 递归
首先定义类似下面的形式
template<typename T, typename... Args>
函数模板或者类模板。
当我们把一个参数包传递给这样一个函数或者类的时候,参数包中的第一个参数,就会被取出来给 T。通过递归的方式便可以不停将参数包中的第一个参数取出,直到参数取完。
要注意:要定义终止条件。
函数模板的例子:
#include <iostream>
//终止条件
template<typename T>
void log(std::ostream& os, T t)
{
os << t << std::endl;
}
template<typename T, typename... Args>
void log(std::ostream& os, T t, Args... args)
{
os << t;
log(os, args...);
}
int main()
{
log(std::cout, 1,2,3,4,5,6, "hello world");
return 0;
}
// 输出结果
// 123456hello world
log 函数不停将参数包 (1,2,3,4,5,6,"hello world") 第一个参数取出来,并打印到终端
当参数包只有一个参数,编译器很聪明匹配到了我们定义的终止条件,完成最后打印工作。
类模板的例子
template<typename... Args> struct tuple;
template<>
struct tuple<>{};
template<typename T, typename... Args>
struct tuple<T, Args...>:
tuple<Args...>
{
T value;
};
int main()
{
tuple<int, int, char> t;
std::cout<< sizeof(t) << std::endl;
return 0;
}
通过继承,不停递归取出参数包中型别,从而实现了一个包裹任意个数,任意型别的容器。
递归的思路大体是在模板匹配的过程中,通过模板的特化,不停的将参数包里的参数拿出来,进行操作的一个过程。
- 折叠表达式(fold expression C++17)
折叠表达式,在某些情况,可以更简单处理参数包,而避免使用递归
语法如下:
一元左折叠:( pack op ... ) <=> (E1 op (... op (EN-1 op EN)))
一元右折叠:( ... op pack ) <=> (((E1 op E2) op ...) op EN)
二元左折叠:( pack op ... op init )
<=> (E1 op (... op (EN−1 op (EN op I))))
二元右折叠:( init op ... op pack )
<=> ((((I op E1) op E2) op ...) op EN)
op:以下32个二元运算符:+ - * / % ^ & | = < > << >> += -= *= /= %= ^= &= |= <<= >>= == != <= >= && || , .* ->*
pack:参数包 EN:参数包中第N个参数 Init:参与运算的表达式或者值
关于折叠表达更详细情况,参考:
根据折叠表达式我可以将原先的函数模板的例子改的更简洁一些:
#include <iostream>
template<typename... Args>
void log(std::ostream& os, Args... args)
{
(os << ... <<args);
os << std::endl;
}
int main()
{
log(std::cout, 1,2,3,4,5,6, "hello world");
return 0;
}
// 输出结果
// 123456hello world
暂且告一段落,真正开始写的时候,才发现还有很多的概念似懂非懂,还需要多多实践。限于水平,文中难免理解错误的地方,欢迎大家指正。其实最开始的目的是想写模板元编程,打算在开始的部分去介绍些模板的基本常识做铺垫,但发现越写越多。关键是感觉写了很多,还有很多没有介绍,语言精炼能力比较差。