C++ Templates(1):函数模版和类模版
前面都是一些基础概念,快速过一下。
1. 函数模版
函数模版提供了一种可以被不同类型调用的函数行为。换句话说,函数模版表示一组类似的函数。它的表现类似于普通函数,除了一些元素类型未指定。
template<typename T>
T max(T a, T b) {
return b < a ? a : b;
}
std::cout << max(1, 2) << std::endl; // 2
关键字typename
声明了一个类型参数 $T$ ,由于历史原因,你也可以使用class
关键字声明。$T$ 表示一个任意类型,会根据不同的调用而改变。在上面的例子中,类型 $T$ 必须支持 $operator$< 函数并且可拷贝。C++17
也允许在 $T$ 既不可拷贝也不可移动的情况下传入一个右值。在上面的例子中,$T$ 的类型是 $int$ 。将模版参数用具体类型代替的过程称为实例化 ( $instantiation$ ),实例化会产生一个模版实例。
注意到 $void$ 也可以作为模版参数:
template<typename T>
T foo(T*) {}
void *vp = nullptr;
foo(vp);
模版会进行两阶段编译:
- 定义阶段 ( $definition$ $time$ ),即没有实例化的阶段,编译器会检查模版代码是否正确,包括语法检查、调用检查、$static_-assert$ 等;
- 实例化阶段 ( $instantiation$ $time$ ),模版代码会被再次检查,这次包括了所有依赖于模版参数的部分。
有些编译器并不会在定义阶段进行完全检查,这意味着有些问题只有在实例化的时候才能发现。
在类型推导时,如果存在隐式类型转换,那么会应用以下规则:
- 当参数以引用传递时,即使是一些普通的转换也不能进行参数推导,使用相同模版参数类型 $T$ 的参数类型必须一致;
- 当参数以值传递时,只支持一些普通的类型退化 ( $decay$ ),包括:忽略 $const$ 或 $volatile$ 、引用转化为非引用以及数组或函数转为裸指针。在这种情况下,使用相同模版参数类型 $T$ 的参数类型必须在进行参数退化后保持一致。
函数模版类型推导也不支持默认参数:
template<typename T>
void f(T = "");
f(); // 错误
正确的使用方式如下:
template<typename T = std::string>
void f(T = "");
f();
当我们使用两个模版参数时:
template<typename T1, typename T2>
T1 max(T1 a, T2 b) {
return b < a ? a : b;
}
看上去一切正常,但是考虑一下返回值,如果我们传入了一个浮点数和一个整型,那么返回值就永远会是浮点数,因为我们先传入了浮点数。C++
提供了几种方式解决这个问题。
第一种解决方案是再加入一个模版参数,作为返回值类型:
template<typename T1, typename T2, typename RT>
RT max(T1, T2);
但是这样声明,编译器因为不知道返回类型,所以我们必须在调用时显式指定类型:
::max<int, double, double>(4, 7.2);
也可以通过调换声明顺序从而只显式指定第一个参数类型:
template<typename RT, typenamet T1, typename T2>
RT max(T1, T2);
::max<double>(4, 7.2);
这种多一个类型参数的方式其实并没有什么优势,因为就算是之前的单类型参数的版本,我们也可以通过类型转换来显式指定返回值。相比之下,这种写法就显得很啰嗦了。
第二种解决方案是让编译器自动推导。从C++14
开始,函数返回值允许使用 $auto$ 关键字。
template<typename T1, typename T2>
auto max(T1, T2);
函数返回值推导让编译器根据函数的返回语句推导参数的类型。当然,前提是可以推导,如果函数不可用或者具有多条返回不同类型的 $return$ 语句,那么编译器也无能为力。在C++11
,我们也可以通过尾置返回类型解决这个问题:
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(b < a ? a : b);
这里的比较并不那么必要,我们可以直接使用 $true$ 代替:
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(true ? a : b);
这种方案也存在一个缺点:返回类型可能是一个引用类型,因为 $decltype$ 关键字是精确的推导。为了避免这种情况,我们可以使用 $traits$ :
#include <type_traits>
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> typename std::decay<decltype(true ? a : b)>::type;
当然,如果你使用C++14
的自动返回类型推导,那么 $traits$ 的使用也是不必要的。
第三种解决方案是使用 $std::common_-type$ :
#include <type_traits>
template<typename T1, typename T2>
std::common_type_t<T1, T2> max(T1, T2);
从C++11
开始,标准库就提供了一种选择最通用类型的机制 $std::common_-type$ ,它接收两个或多个不同的参数,并且会返回退化之后的类型。
我们也可以结合默认模版参数来解决这个问题:
#include <type_traits>
template<typename T1, typename T2,
typename RT = std::decay_t<decltype(true ? T1() : T2())>>
RT max(T1, T2);
不过这种实现要求我们可以调用 $T1$ 和 $T2$ 的默认构造函数。
#include <type_traits>
template<typename T1, typename T2,
typename RT = std::common_type_t<T1, T2>>
RT max(T1, T2);
默认模版参数里也可以使用 $common_-type$ ,这样就可以避开默认构造函数。通过默认模版参数,我们既可以让编译器自动推导,也可以显式指定:
auto a = ::max(4, 7.2);
auto b = ::max<double, int, long double>(7.2, 4);
考虑模版函数和普通函数同时存在的情况:
int max(int, int);
template<typename T>
T max(T, T);
int main() {
::max(7, 42); // call the nontemplate version
::max(7.0, 42.0); // call max<double>
::max<>(7, 42); // call max<int>
::max<double>(7, 42); // call max<double>
::max('a', 42.7); // call the nontemplate version
}
你可能会疑惑,为什么我们通常使用传值而不是传引用。通常来讲,传引用更适合一些类类型而不是简单值类型。而且,在很多情况下,传值也是更好的选择:
- 语法简单;
- 编译器可以进行更好的优化措施;
- 移动操作通常很快;
- 有时候甚至既不会发生拷贝也不会进行移动。
此外,对于模版,也需要从一些特定角度考虑:
- 模版必须既能适用于简单类型也能适用于复杂类型,有时候适合复杂类型的方式并不适合普通类型;
- 如果你还是需要以引用方式调用的话,可以使用 $std::ref$ 或者 $std::cref$ ;
- 虽然以值方式传递字符串常量和原始数组存在者问题,但是可以肯定的是,以引用方式传递它们只会导致更麻烦的问题。
通常,函数模版没有必要声明为 $inline$ 。与普通函数不同,我们可以在头文件内定义非内联函数模版。唯一的例外就是如果你要为某个特定类型进行完全特例化,这时特例化的代码就不再是泛型代码了。
2. 类模版
类似于函数,类也可以被参数化。
#include <vector>
#include <cassert>
template<typename T>
class Stack {
private:
std::vector<T> elems;
public:
void push(T const& elem);
void pop();
T const& top() const;
bool empty() const { return elems.empty(); }
};
template<typename T>
void Stack<T>::push(T const &elem) { elems.push_back(elem); }
template<typename T>
void Stack<T>::pop() {
assert(!elems.empty());
elems.pop_back();
}
template<typename T>
T const &Stack<T>::top() const {
assert(!elems.empty());
return elems.back();
}
在一个类内使用不带模版参数类型的类名与带模版参数的类名等价,但是在类外使用的时候必须声明模版参数类型。与普通类型不同,模版类的定义只能在模版类内部或者相同块作用域内。
一个类模版通常会被用于多种操作,这意味着类必须提供多种重载函数,但是这些函数只有在被调用时才会实例化。如果实例化的类型不支持对应操作,那么就会产生编译错误。这带来了一个问题,我们怎么知道一个实例化之后的模版能进行哪些操作呢?概念 ( $concept$ ) 是C++20
引入的,用于表示一组模版库内经常会被使用的约束,通过概念可以约束只有实现了特定操作的类型才能实例化模版。但是,C++11
没有概念,我们只能通过 $static_-assert$ 以及 $traits$ 进行一些基本的约束。
我们可以通过友元函数重载 $Stack$ 的 << 运算符:
template<typename T>
class Stack {
// ...
void printOn(std::ostream& strm) const;
friend std::ostream& operator<<(std::ostream& strm,
Stack<T> const& s) {
s.printOn(strm);
return strm;
}
};
上面这种方式声明的 $operator$<< 函数是一个非模版函数,但是会在必要的时候实例化。然而,如果我们不在类内定义这个友元函数,那么我们就要指定模版。有两种方式可以指定函数模版。
template<typename T>
class Stack {
// ...
template<typename U>
friend std::ostream& operator<<(std::ostream&, Stack<U> const&);
};
第一种方式使用了一个新的模版参数 $U$ 。要注意,在这里是不能再次使用 $T$ 的。
template<typename T>
class Stack;
template<typename T>
std::ostream& operator<<(std::ostream&, Stack<T> const&);
template<typename T>
class Stack {
// ...
friend std::ostream& operator<< <T>(std::ostream&, Stack<T> const&);
};
第二种方式先将 $operator$<< 声明为一个模版函数,并在模版类内指定以 $T$ 为模版参数实例化的 $operator$<< 函数作为友元函数。
模版类允许特例化,类似于函数模版重载。要注意,如果你需要特例化一个模版类,那么你也必须特例化类内的所有函数。虽然你也可以只特例化模版类的某个函数,但是一旦你这么做了,你就不能再使用这个类型特例化模版类了。
#include "stack1.hpp"
#include <deque>
#include <string>
#include <cassert>
template<>
class Stack<std::string> {
private:
std::deque<std::string> elems;
public:
void push(std::string const&);
void pop();
std::string const& top() const;
bool empty() const { return elems.empty(); }
};
void Stack<std::string>::push(std::string const& elem) {
elems.push_back(elem);
}
void Stack<std::string>::pop() {
assert(!elems.empty());
elems.pop_back();
}
std::string const& Stack<std::string>::top() const {
assert(!elems.empty());
return elems.back();
}
除了像上面这样特例化之外,模版类还允许部分特例化,即提供特定条件下的实现。
#include "stack1.hpp"
template<typename T>
class Stack<T*> {
private:
std::vector<T*> elems;
public:
void push(T*);
T* pop();
T* top() const;
bool empty() const { return elems.empty(); }
};
template<typename T>
void Stack<T*>::push(T* elem) { elems.push_back(elem); }
template<typename T>
T* Stack<T*>::pop() {
assert(!elems.empty());
T* p = elems.back();
elems.pop_back();
return p;
}
template<typename T>
T* Stack<T*>::top() const {
assert(!elems.empty());
return elems.back();
}
与模版函数一样,模版类也可以指定一个默认模版参数:
#include <vector>
#include <cassert>
template<typename T, typename Cont = std::vector<T>>
class Stack {
private:
Cont elems;
// ...
};
从C++17
开始,如果编译器可以推导出类型的话,你可以跳过模版类类型显式声明语句
Stack<int> intStack1;
Stack intStack2 = intStack1; // Stack<int>
也可以结合构造函数使用:
template<typename T>
class Stack {
public:
Stack() = default;
Stack(T const& elem) : elems({elem}) {}
// ...
};
Stack intStack = 0;
理论上,你也可以这样使用:
Stack stringStack = "bottom"; // Stack<char const[7]>
但是,这会带来一大堆问题。因为这种方式推导出来的类型是未退化的类型,这意味着我们无法再添加其他长度的字符串。出于这个原因,在一些时候我们建议使用传值方式的构造函数。
template<typename T>
class Stack {
public:
Stack(T elem) : elems({std::move(elem)}) {}
// ...
};
Stack stringStack = "bottom"; // Stack<char const*>
有时候,与其让编译器自己推导,不如我们自己指定一种类型。
Stack(char const*) -> Stack<std::string>;
Stack stringStack{"bottom"}; // Stack<std::string>
这种技巧称为推导指引,在C++17
被引入。它定义了在当前作用域内,所有以字符串常量作为参数构造的 $Stack$ ,其模版参数类型都为 $std::string$ 。
聚合类 ( 没有显式或者继承的构造器,没有私有或受保护的非静态成员,没有虚函数,非 $private$ 或者 $protected$ 继承的类 ) 也可以使用模版。
template<typename T>
struct ValueWithComment {
T value;
std::string comment;
};
ValueWithComment<int> vc;
vc.value = 42;
vc.comment = "initial value";
从C++17
开始,你也可以为聚合类定义推导指引。
ValueWithComment(char const*, char const*)
-> ValueWithComment<std::string>;
标准库容器 $std::array$ 就是一个聚合类。