模板初步
函数模板
写一个最简单的模板函数:
1 | template<typename T1, typename T2> |
比较两个数的大小,然后返回最大值。存在一个问题,返回的类型将会和第一个入参的类型一致。
1 | cout << max(4.2, 7) << endl; |
执行这两个时,返回的都将会是整型,结果都为7,这不是我们期望的。如果是一个整型和浮点型比较,浮点型比较大就返回浮点型,整型比较大就返回整型,这才是我们期待的。
当然,我们可以通过显式指定返回类型的方式来明确我们期待返回什么:
1 | template<typename Ret, typename T1, typename T2> |
在模板参数最前面再加上一个参数,表示我们希望返回的类型,由于返回类型无法直接进行推导,所以在使用的时候需要显式地指定它。后面的参数可以通过函数的入参进行推导,所以不用再显式指定。
1 | cout << max<int>(4.2, 7) << endl; |
简单的模板函数倒还好,如果函数写的再复杂一点,甚至连我们自己都不确定使用的时候需要返回什么类型合适的时候,就显得不是那么“聪明”了。
更好的办法是使用C++标准库中提供的模板技巧,来获取两者的共同类型,使用std::common_type_t
模板类来萃取出两个类型的通用类型。
1 | template<typename T1, typename T2> |
还有更简洁的做法,就是用auto
来代替返回类型,直接交给编译器去推导。
1 | template<typename T1, typename T2> |
这样也可以获得适当的返回类型。
三目运算符可以由编译器来推导出要返回的类型,如果将三目运算符改成if-else的话,编译就会报错,因为不能保证if-else的作用域中的返回值是有公共类型的。
模板函数重载
模板函数是允许重载的,当有多个重载的模板函数时,编译器会选择匹配度更高的那一个。
1 | template<typename T1, typename T2> |
在这里重载了比较最大值的函数,现在要比较max(7, 42)
,那么编译器会选择第二个函数,即一个模板参数的函数。调用这个函数并将结果输出到终端可以看到:
1 | one template params |
重载规则
当调用一个函数时,可以理解为经历了以下的几个步骤:
- 查找函数名称,形成一个最初的重载集;
- 如有必要,修改这个重载集,比如模板函数的实现或者模板参数的推导等;
- 其中与调用不匹配的函数将会从重载集中删除,得到一个可行的候补重载集;
- 执行重载解析寻找最优的候选函数,如果找到唯一的最优候选,就调用它,否则会报二义性的错误;
- 最后还要检查一下被选中的函数,如果它是声明被删除(=delete),或者是不可访问的私有成员函数,编译器会报诊断错误。
这一切都会在编译阶段执行,所以如果是正确的调用往往是无感的,而错误的调用会导致编译失败。
函数的调用也不总是会被重载解析,例如使用函数指针来调用函数,这将完全取决于函数指针指向哪里,这是在运行期才能确定的;还有类似函数的宏,也不会被重载解析。
匹配程度由高到低排列如下:
- 完美匹配,参数和表达式类型完全一致或者是表达式类型的引用(忽略cv限定符)。
- 细节调整,比如数组退化成指针,或者入参添加const限定符。
- 提升的匹配,提升是一种隐式转换。比如将位数较小的整型(bool,char,short,某些枚举类型)提升为int或long long,将float提升为double。
- 标准类型转换,比如将整型转换为浮点型,派生类转换成明确的基类。
- 自定义的类型转换,允许任何用户自定义的类型隐式转换。
- 省略号的匹配。C++中省略号类似于一个参数包,原则上可以接受任何类型,有一种情况除外,类如果具有特殊的拷贝构造,行为将会难以预测。
看起来似乎也还可以,但在使用的时候需要万分小心。
1 | void combine(int, double); |
在这里调用combine()
编译时会报二义性的错误,int类型入参都是完美匹配的,而double和long都可以通过转换得到,尽管直观上long比double更接近我们的预期,但是编译器并不这么认为,C++不会试图去衡量多个调用参数的匹配度,所以当前场景下的两个重载函数都是可用的,所以会报二义性的错误。
类模板
类模板和模板函数类似,在类名前加上template关键字来声明,不过模板类必须在全局中或是命名空间中声明,不能在函数或者块作用域中声明(普通的类可以)。
类名后跟随尖括号包裹的类型来实例化具体的类。在模板类内部可以不指定具体的类型,而在类外部需要明确指定。
1 | template<typename T> |
类似于函数的重载,模板类可以有特化和偏特化版本,并且在匹配的情况下拥有比普通模板类更高的优先级。
1 | template<typename T1, typename T2> |
另外,模板类的模板参数也可以像函数的入参一样拥有一个默认值,如果不显式指定的话,就会使用默认的类型作为模板参数。
非类型模板参数
有时候我们想传入一个值作为模板的参数,这是允许的。
1 | template<typename T, std::size_t sz> |
比如我们定义一个数组类,数组类需要明确的大小,于是可以将希望的数组大小作为模板参数传给模板类。
但这个非类型模板参数不是想穿什么都行的,比如浮点型double就不可以,只能是整型常量、指向对象\函数\成员的指针,或者对象\函数的左值引用,以及nullptr类型。
另外,还可以使用auto
作为占位符,进一步增加灵活性,这样可以传入所有允许的非类型模板参数。
变参模板
变参模板可以接受任意多的参数作为模板的参数。
1 | void print() { |
例如这样一个打印函数,将会依次打印对应模板类型参数。这样定义的args
实际上是一个函数参数包,而Types
是一个类型参数包。
1 | std::string str{ "world" }; |
这里将会顺序将入参打印出来。print
会首先被展开成print<double, const char*, std::string>(7.5, "hello", str)
,然后依次再往下递归调用。
扩展一下,可变模板参数可分为三类:类型模板参数包,非类型模板参数包和模板模板参数包:
1 | // 类型模板参数包 |
大体上可以理解为都是使用省略号来表征为变参参数,类型模板参数包就可以接受任意的能识别的类型;非类型模板参数包就和非类型模板参数一样,只能是固定的类型常量值,但可以接受任意数量;模板模板形参包的类型是模板类型。
其中,类型模板形参包和模板模板形参包是可以作为函数形参包的,但非类型形参包不可以,很好理解,以为前两种是类型,而非类型形参包是具体的常量值。
sizeof… 运算符
C++11以后引入了sizeof...
运算符用来计算参数包中的元素数量。
1 | void print() { } |
这次,将会依次打印参数包中的参数数量2,1,0
。
折叠表达式
C++17后,为参数包引入了更多的特性,其中包括可以对参数包中的所有参数进行二元运算。
折叠表达式 | 计算顺序 |
---|---|
… op pack | ((pack1 op pack2) op pack3) … op packN |
pack op … | pack1 op (… op (packN-1 op packN)) |
init op … op pack | ((init op pack1) op …) op packN |
pack op … op init | pack1 op (… op (packN op init)) |
需要注意参数包可能是空的情况,当参数包是空的时候,二元算法可能会报错,不过有几个特殊情况:&&计算结果为真,||计算结果为假,逗号运算符的空参数包对应值为void()
。
变参基类
1 | class Customer { |
变参参数包有一种很变态的用法,可以作为派生类的基类。正如上面展示的,CustomOP
是一个派生类,将传入的类型参数包中的类型都作为自己的基类,并在自己内部引入了所有基类的小括号运算符的重载函数。这样传入到unordered_set
中时,既可以进行相等比较,又可以计算哈希值。
模板使用“寄”巧
- 使用
typename
关键字来说明标识符是一种类型。
1 | template<typename T> |
使用typename
来说明T::SubType*
是对应的指针类型,如果不加上这个关键字的话,可能会被解析成T中的静态成员SubTy与ptr的乘积,这在某些实例化中可能是对的,但在这里我们希望ptr是个指针,所以加上typename
来明确。
- 使用初始化列表对变量进行初始化。
- 使用
this->
。 - 小心处理原始数组和字符串字面量的模板,在模板参数声明为引用时,实参类型不会退化,例如传递实参"hello"会被推导为
const char[6]
类型,如果是在按值传递的函数中,会退化成const char*
类型。