第十六章 模板与泛型编程
定义模板
1. 函数模板
模板定义以关键字template关键字开始,后面跟着一个模板参数列表(不能为空):
template
int compare(const T &v1, const T &v2)
{
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换:
template T foo(T* p) {
T tmp = *p; // tmp的类型是指针p指向的类型
// ...
return tmp;
}
1.1 非类型模板参数
除了定义类型参数,还可以定义非类型参数nontype parameter
,一个非类型参数表示一个值而非一个类型。当一个模板被实例化时,非类型参数被一个用户提供的或者编译器推断出来的值锁替代,这些纸必须是常量表达式,从而允许编译器在编译时实例化模板。
比如我们编写一个compare
版本处理字符串字面常量(const char
的数组),由于不能拷贝数组,因此我们将自己的参数定义为数组的引用,由于我们希望能够比较不同长度的字符串字面常量,因此为模板定义了两个非类型的参数:第一个模板参数表示第一个数组长度,第二个参数表示第二个数组的长度:
template
int compare(const char (&p1)[N], const char (&p2)[M])
{
return strcmp(p1, p2);
}
// 调用
compare("hi", "mom");
// 编译器相当于实例化如下版本:
int compare(const char (&p1)[3], const char(&p2)[4])
- 非类型参数可以是一个整型,或者是一个指向对象或者函数类型的指针或(左值)引用
- 绑定到非类型参数的实参必须是一个常量表达式
- 绑定到指针或者引用非类型参数的实参必须具有静态的生存期
1.2 编写类型无关的代码
编写泛型代码的两个重要原则:
- 模板中的函数参数是
const
的引用 - 函数体中的条件判断仅使用
<
比较运算
通过将函数参数设定为const
引用,我们保证函数可以用于不能拷贝的类型。大多数类型(除了unique_ptr
和IO
类型外)都是允许拷贝的,但是不允许拷贝的类型也是存在的,而且使用常量引用在处理大对象时也可以使函数运行地更快。
1.3 模板编译
当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。即当我们使用而非定义模板时,编译器才生成代码。
通常当我们调用一个函数时,编译器只需要掌握函数的声明。类似地,当我们使用一个类类型的对象时,类定义必须是可用的,但是成员函数的定义不必已经出现。因此我们通常将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。
为了生成一个实例化版本,编译器需要掌握函数模板或者类模板成员函数的定义。因此与非模板文件不同,模板的头文件通常既包含声明也包含定义。
模板直到实例化时才会生成代码,这一特性影响了我们何时才会直到模板内代码的编译错误:
- 第一个阶段:编译模板本身。编译器只能检查语法错误,比如忘记分号或者变量名拼错。
- 第二个阶段:编译器遇到模板使用时。对于函数模板调用,编译器通常会检查实参数目是否正确,还能检查参数类型是否匹配;对于类模板,编译器可以检查用户是否提供了正确数目的模板实参
- 第三个阶段:模板实例化。只有这个阶段才能发现类型相关的错误,这类错误可能在链接时才报告。
2. 类模板
类模板class template
使用来生成类的蓝图的。
2.1 定义类模板
template class Blob {
public:
typedef T value_type;
typedef typename std::vector::size_type size_type;
// 构造函数
Blob();
Blob(std::initializer_list il);
// Blob中的元素数目
size_type size() const { return data->size(); }
bool empty() const { return data->empty(); }
// 添加和删除元素
void push_back(const T &t) { data->push_back(t); }
// 移动版本
void push_back(T &&t) { data->push_back(std::move(t)); }
void pop_back();
// 元素访问
T& back();
T& operator[](size_type i);
private:
// Q: 为什么使用智能指针?
std::shared_ptr> data;
// 若data[i]无效,则抛出msg
void check(size_type i, const std::string &msg) const;
};
2.2 实例化类模板
当使用一个类模板时,我们必须提供额外信息,即显式模板实参explicit template argument
,编译器使用这些模板实参来实例化出特定的类。
Bolb ia;
Blob ia2 = {0,1,2,3,4};
2.3 在模板作用域内引用模板类型
举个例子,我们的data
成员使用了两个模板:vector
和shared_ptr
,我们将其定义为:
std::shared_ptr> data;
2.4 类模板的成员函数
Blob
的成员函数应该这么定义:
template
ret-type Bolb::member-name(param-list)
check
和元素访问成员:
template
void Bolb::check(size_type i, const std::string &msg) const
{
if (i >= data->size())
throw std::out_of_range(msg);
}
2.5 Bolb构造函数
template
// 构造函数分配一个空vector, 并将指向vector的指针保存在data中
Bolb::Blob() : data(std::make_shared>()) { }
// 接受一个initializer_list参数的构造函数将其类型参数为T作为initializer_list参数的元素类型
template
Blob::Blob(std::initializer_list il) : data(std::make_shared>(il)) { }
// 使用方法: 在这条语句中构造函数的参数类型为initializer_list,列表中每个字符串字面常量会被隐式地转换为一个string
Blob articles = {"a", "an", "the"};
2.6 类模板成员函数的实例化
默认情况下一个类模板的成员函数只有当程序用到它时才进行实例化,成员函数只有被用到时才进行实例化,这一特性使得即使某种类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。
2.7 在类模板的作用域内简化模板类名的使用
// 后置: 递增/递减对象但返回原值
template
BlobPtr BlobPtr::operator++(int)
{
// 此处无须检查, 调用前置递增时会进行检查
BlobPtr ret = *this; // 保存当前值, 这里等价于BlobPtr ret = *this; 在类模板作用域内简化模板类名的使用
++ *this; // 推进一个元素, 前置++检查递增是否合法
return ret; // 返回保存的状态
}
2.8 类模板和友元
类模板和另一个(类或者函数)模板间友好关系最常见的形式是建立对应实例及其友元间的友好关系:
// 首先将Blob、BlobPtr和operator==声明为模板, 这些声明是operator==函数的参数声明及Blob中的友元声明所需要的
template class BlobPtr;
template class Blob; // 运算符==中的参数所需要的
template
bool operator==(const Blob&, const Blob&);
template class Blob {
// 每个Blob实例将访问权限授予相同类型实例化的BlobPtr和相等运算符
friend class BlobPtr;
friend bool operator==
(const Blob&, const Blob&);
}
// 友元的声明用Blob的模板形参作为他们自己的模板形参,因此友好关系被限定在用相同类型实例化的Blob与BlobPtr相等运算符之间
Blob ca; // BlobPtr和operator==都是本对象的友元
Blob ia; // BlobPtr和operator==都是本对象的友元
// BlobPtr的成员可以访问ca(或者任意其他Blob对象)的非public部分, 但对ia或任何其他Blob对象或Blob的其他实例都没有特殊访问权限
一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元:
// 前置声明, 在将模板的一个特定实例声明为友元时将要用到
template class Pal;
class C { // C时一个普通的非模板类
friend class Pal; // 用类C实例化的Pal是C的一个友元
// Pal2的所有实例都是C的友元, 这种情况无须前置声明
template friend class Pal2;
};
template class C2 { // C2本身是一个类模板
// C2的每个实例将相同实例化的Pal声明Pal声明成友元
friend class Pal; // Pal的模板声明必须在作用域之内
// Pal2的所有实例都是C2的每个实例的友元,不需要前置声明
template friend class Pal2;
// Pal3是一个非模板类,它是C2所有实例的友元
friend class Pal3;
}
在新标准中,我们可以将模板类型参数声明为友元,这样对于某个类型名Foo
,Foo
将成为Bar
的友元:
template class Bar {
friend Type; // 将访问权限授予用来实例化Bar的类型
// ...
}
2.9 模板类型别名
我们可以通过定义一个typedef
来引用实例化后的类:
typedef Blob StrBlob;d
由于模板并不是一个类,因此我们不能定义一个typedef
引用Blob
,但是新标准允许我们为类模板定义一个类型别名:
template using twin = pair;
twin authors; // authors是一个pair
// 我们也可以固定一个或者多个模板参数
template using partNo = pair;
partNo books; // books类型是pair
2.10 类模板与static成员
类模板可以声明static成员:
template class Foo {
public:
static std::size_t count() { return ctr; }
// 其他接口成员
private:
static std::size_t ctr;
};
每个Foo
的实例都有其自己的static
成员实例,即给定任意类型X
,都有一个Foo
和Foo
成员,所有的Foo
类型的对象共享相同的ctr
对象和count
函数。由于类的每个实例都有一个独有的static
对象,因此我们可以将它的static
成员也定义成模板:
template
size_t Foo::ctr = 0;
3. 模板参数
3.1 模板声明
一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前,原因我们将在后面讲。
3.2 使用类的类型成员
我们用作用域运算符::
来访问static
成员和类型成员,在普通(非模板)代码中,编译器掌握类的定义,因此它直到通过作用域运算符访问的名字是类型还是static
成员。比如当我们写下string::size_type
,由于编译器有string
的定义,因此它知道size_type
是一个类型。
对于模板代码来说就不是这么简单,假定T
是一个模板类型参数,当编译器遇到T::mem
代码时,它在实例化之前不知道mem
是一个类型成员还是一个static
数据成员。比如编译器遇到如下语句时:
T::size_type *p
// 编译器需要知道我们是正在定义一个名为`p`的变量,还是将一个名为size_type的static数据成员与名为p的变量相乘
如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型,我们通过使用关键字typename
来实现这一点:
template
typename T::value_type top(const T& c)
{
if (!c.empty())
return c.back();
else
return typename T::value_type(); // 使用T的类型
}
3.3 默认模板实参
在新标准中我们可以为函数和类模板提供默认实参:
// compare有一个默认模板实参less和一个默认函数实参F()
template >
int compare(const T &v1, const T &v2, F f = F())
{
if (f(v1, v2)) return -1;
if (f(v2, v1)) return 1;
return 0;
}
与函数默认实参一样,对于一个模板参数,只有它右侧的所有参数都有默认实参时,它才可以有默认实参。
3.4 模板默认实参与类模板
如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名后面加上一个空尖括号对:
template class Numbers { // T 默认为int
public:
Numbers(T v = 0) : val { }
// 其他操作
private:
T val;
};
Number lots_of_precision;
Number<> average_precision; // 空<>表示我们希望使用默认类型
4. 成员模板
一个类可以包含本身是模板的成员函数,这种成员被称为成员模板,成员模板不能是虚函数。
4.1 普通(非模板)类的成员模板
我们定义一个类,类似于unique_ptr
所使用的默认删除器类型。我们的类将包含一个重载的函数调用运算符,它接受一个指针并对此指针执行delete
,由于我们希望删除器适用于任何类型,因此我们将调用运算符定义为一个模板。
// 函数对象类. 对给定指针执行delete
class DebugDelete {
public:
DebugDelete(std::ostream &s = std::cerr) : os(s) { }
// 与任何函数模板相同, T的类型由编译器推断
template void operator()(T *p) const
{ os << "deleting unique_ptr" << std::endl; delete p; }
private:
std::ostream &os;
}
我们可以使用这个类代替delete
函数:
double* p = new double;
DebugDelete d; // 可像delete表达式一样使用的对象
d(p); // 调用DebugDelete::operator()(double*), 释放p
int* ip = new int;
// 在一个临时DebugDelete对象上调用operator()(int*)
DebugDelete()(ip);
我们可以将DebugDelete
用作unique_ptr
的删除器:
// 删除p指向的对象
// 实例化DebugDelete::operator()(int *)
unique_ptr p(new int, DebugDelete());
//销毁sp执行的对象
unique_ptr sp(new string, DebugDelete());
4.2 类模板的成员模板
对于类模板,我们也可以定义它的成员模板,这种情况下,类和成员各自有自己的、独立的模板参数。
template class Blob {
// 构造函数是一个成员模板, 接受两个迭代器, 表示要拷贝的元素范围
template Blob(It b, It e);
// ...
};
// 当我们在类模板外定义一个成员模板时, 必须同时为类模板和成员模板提供模板参数列表, 类模板的参数列表在前, 后跟成员的模板参数列表
template // 类的类型参数
template // 构造函数的类型参数
Blob::Blob(It b, It e) : data(std::make_shared>(b, e)) { }
4.3 实例化与成员模板
为了实例化一个类模板的成员模板,我们必须同时提供类和函数模板的实参。与普通函数模板相同,编译器通常根据传递给成员模板的函数实参来推断它的模板实参:
int ia[] = {0,1,2,3,4,5,6,7,8,9};
vector vi = {0,1,2,3,4,5,6,7,8,9};
list w = {"now", "is", "the", "time"};
// 实例化Blob类及其接受两个int*参数的构造函数
Blob a1(begin(ia), end(ia));
// 实例化Blob类及其接受两个vector::iterator的构造函数
Blob a2(vi.begin(), vi.end());
// 实例化Blob及其接受两个list::iterator参数的构造函数
Blob a3(w.begin(), w.end());
5. 控制实例化
前面我们提到只有当模板被使用时才会进行实例化,这一特性意味着相同的实例可能出现在多个对象文件中。
当多个独立编译的源文件使用了相同的模板,并且提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。在大系统中,如果我们在多个文件中实例化相同模板的额外开销可能非常严重。
新标准允许我们通过显式实例化explicit instantiation
来避免这种开销。
extern template declaration; // 实例化声明
template declaration; // 实例化定义
例子:
// 实例化声明与定义
extern template class Blob; // 声明
template int compare(const int&, const int&); // 定义
当编译器遇到extern
模板声明时,他不会在本文件中生成实例化代码,将一个实例化声明为extern
就表示承诺在程序其他位置有该实例化的一个非extern
声明(定义)。对于一个给定的实例化版本,可能有多个extern
声明,但必须只有一个定义。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。
由于编译器在使用一个模板时自动对齐实例化,因此extern
声明必须出现在任何使用此实例化版本的代码之前:
// Application.cc
// 这些模板类型必须在程序其他位置进行实例化
extern template class Blob;
extern template int compare(const int&, const int&);
Blob sa1, sa2; // 实例化会出现在其他位置
// Blob及其接受initializer_list的构造函数在本文件中实例化
Blob a1 = {0,1,2,3,4,5,6,7,8,9};
Blob a2(a1); // 拷贝构造函数在本文件中实例化
int i = compare(a1[0], a2[0]); // 实例化出现在其他位置
文件Application.o
将包含Blob
的实例及其接受initializer_list
参数的构造喊你书和拷贝构造函数的实例。而compare
函数和Blob
将不在本文件中进行实例化,这些模板的定义必须出现在程序的其他文件中:
// templateBuild.cc
// 实例化文件必须为每个在其他文件中声明为extern的类型和函数提供一个非extern的定义
template int compare(const int&, const int&);
template class Blob; // 实例化类模板的所有成员
当编译器遇到一个实例化定义(与声明相对)时,它为其生成代码。因此,文件templateBuild.cc
将会包含compare
的int
实例化版本的定义和Blob
类的定义。当我们编译此应用程序时,必须将templateBuild.o
和Application.o
链接到一起。
一个类模板的实例化定义会实例化该模板的所有成员,包括内联的成员函数。与处理类模板的普通实例化不同,编译器会实例化该类的所有成员。即使我们不使用某个成员,它也会被实例化。因此在一个类模板的实例化定义中,所用类型必须能用于模板的所有成员函数。
模板实参推断
1. 类型转换与模板类型参数
能在调用中应用于函数模板的包括如下三项:
顶层
const
无论是在形参中还是在实参中都会被忽略const
转换:可以将一个非const
对象的引用(或指针)传递给一个const
的引用(或指针)形参数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组形参可以转换为一个指向其首元素的指针,一个函数实参可以转换为一个该函数类型的实参
将实参传递给待模板类型的函数形参时,能够自动应用的类型转换只有
const
转换及数组或函数到指针的转换。
1.1 使用相同模板参数类型的函数形参
// compare函数接受两个const T&参数, 其实参必须是相同类型
long lng;
compare(lng, 1024); // 错误, 不能实例化compare(long, int)
1.2 正常类型转换应用于普通函数参数
template ostream &print(ostream &os, const T &obj)
{
return os << obj;
}
// 由于低于一个参数的类型不依赖于模板参数, 因此编译器会将f隐式转换为ostream&
ofstream f("output");
print(f, 10);
2. 函数模板显式实参
假设我们定义一个sum
的函数模板,它接收两个不同类型的参数,我们希望允许用户指定结果的类型,这样用户就可以选择合适的精度。我们可以定义表示返回类型的第三个模板参数,从而允许控制返回类型:
// 编译器无法推断T1, 它不会出现在函数参数列表中
template
T1 sum(T2, T3)
每次调用sum
时调用者都必须为T1
提供一个显式模板实参:
auto val3 = sum(i, lng); // long long sum(int, long)
需要注意的是,显式模板实参按由左到右的顺序与对应的模板参数匹配:第一个模板实参与第一个模板参数匹配,第二个实参与第二个参数匹配。只有尾部参数的显示模板实参才可以忽略,而且前提是它们可以从函数参数推断出来。如果我们的sum
函数按照如下形式编写:
// 糟糕的设计, 用户必须指定所有三个模板参数
template
T3 alternative_sum(T1, T2);
// 错误: 不能推断前几个模板参数
auto val3 = alternative_sum(i, lng);
// 正确: 显式指定了所有三个参数
auto val2 = alternative_sum(i, lng);
正常类型转换应用于显式指定的实参:
long lng;
compare(lng, 1024); // 模板参数不匹配
compare(lng, 1024); // 正确: 实例化compare(long, long);
compare(lng, 1024); // 正确: 实例化compare(int, int);
3. 尾置返回类型与类型转换
3.1 尾置返回类型
当我们希望用户确定返回类型时,用显式模板实参表示模板函数的返回类型是比较有效的,但是要求显式指定模板实参会给用户增添额外负担。尾置返回允许我们在参数列表之后声明返回类型:
template
auto fcn(It beg, It end) -> decltype(*beg) // 通知编译器fcn的返回类型与解引用beg参数的结果类型相同, 解引用类型返回一个左值, 因此通过decltype推断的类型为beg表示的元素的类型的引用
{
// 处理负担
return *beg; // 返回序列中一个元素的引用
}
3.2 进行类型转换的标准库模板类
在前面提到的例子中,我们对传递的参数类型一无所知,唯一可以使用的操作是迭代器操作,而所有的迭代器操作都不会生成元素,只能生成元素的引用。
为了获得元素类型,我们可以使用标准库的类型转换
type transformation
模板,这些模板定义在type_traits
中。
在本例中,我们可以使用remove_reference
来获得元素类型。remove_reference
模板有一个模板类型参数和一个名为type
的public
类型成员。如果我们用一个引用类型实例化它,那么type
表示被引用的类型。我们重写一个函数以返回元素值的拷贝而不是引用:
template
auto fcn2(It beg, It end) ->
typename remove_reference::type
{
// 处理序列
return *beg; // 返回序列中一个元素的拷贝
}
对Mod ,其中Mod 为 |
若T 为 |
则Mod 为 |
---|---|---|
remove_reference | X& 或X&& 否则 |
X T |
add_const | X& 、const X 或函数否则 |
T const T |
add_lvalue_reference | X& X&& 否则 |
T X& T& |
add_rvalue_reference | X& 或X&& 否则 |
T T&& |
remove_pointer | X* 否则 |
X T |
add_pointer | X& 或X&& 否则 |
X* T* |
make_signed | unsigned X 否则 |
X T |
make_unsigned | 带符号类型 否则 |
unsigned X T |
remove_extent | X[n] 否则 |
X T |
remove_all_extents | X[n1][n2]... 否则 |
X T |
4.函数指针和实参推断
当我们用一个函数模板初始化一个函数指针或者为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
template int compare(const T&, const T&);
// pf1指向实例int compare(const int&, const int&)
int (*pf1)(const int&, const int&) = compare;
如果不能从函数指针类型确定模板实参,那么会产生错误:
// func的重载版本: 每个版本接受一个不同的函数指针类型
void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));
func(compare); // 错误: 使用compare哪个实例?
// 我们可以通过显式模板实参来消除func调用的歧义
func(compare); // 传递compare(const int&, const int&)
5.模板实参推断和引用
为了理解如何从函数调用进行类型推断,考虑下面例子:
template void f(T &p);
其中函数参数p
是一个模板类型参数T
的引用,需要记住非常重要的两点:
- 编译器会应用正常的引用绑定规则
-
const
是底层的,不是顶层的
5.1 从左值引用函数参数推断类型
当一个函数参数是模板类型参数的一个普通(左值)引用时(即形如T&
),绑定规则告诉我们只能给它一个左值(比如一个变量或者一个返回引用类型的表达式)。实参可以是const
类型也可以不是。如果实参是const
的,那么T
将会被推断为const
:
template void f1(T&); // 实参必须是一个左值
f1(i); // i是一个int; 模板参数类型T是int
f1(ci); // ci是一个const int; 模板参数T是const int
f1(5); // 错误: 传递给一个&参数的实参必须是一个左值
如果一个函数参数的类型是const T&
,正常的绑定规则告诉我们可以传递给它任何类型的实参——一个对象(const
或者非const
)、一个临时对象或是一个字面常量值。当函数参数本身是const
时,T
的类型推断的结果不会是一个const
,const
已经是函数参数类型的一部分,因此它不会也是模板参数类型的一部分:
template void f2(const T&); // 可以接受一个右值
// f2中的参数是const &; 实参中的const是无关的
// 在每个调用中, f2的函数参数都被推断为const int&
f2(i); // i是一个int;模板参数T是int
f2(ci); // ci是一个const int, 但模板参数T是int
f2(5); // 一个const &参数可以绑定到一个右值; T是int
5.2 从右值引用函数参数推断类型
当一个函数参数是一个右值引用(即形如T&&
),正常绑定规则告诉我们可以传递给它一个右值:
template void f3(T&&);
f3(42); // 实参是一个int类型的右值; 模板参数T是int
5.3 引用折叠和右值引用参数
假定i
是一个int
对象,我们可能认为像f3(i)
这样的调用是不合法的。毕竟i
是一个左值,而通常我们不能将一个右值引用绑定到一个左值上。但是C++
在正常绑定规则外定义了两个例外规则,允许这种绑定:
- 第一个例外规则:当我们将一个左值(如
i
)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&
)时,编译器推断模板类型参数为实参的左值引用类型。因此,当我们调用f3(i))
时,编译器推断T
的类型为int&
而非int
通常我们不能(直接)定义一个引用的引用,但是通过类型别名或通过模板类型参数间接定义是可以的
- 第二个例外规则:如果我们间接创建一个引用的引用,则这些引用形成了“折叠”,在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。只有一种情况下回折叠成右值引用:右值引用的右值引用。
对于一个给定类型X
:
-
X& &
、X& &&
和X&& &
都折叠成X&
-
X&& &&
折叠成X&&
这两个规则导致了两个重要结果:
- 如果一个函数参数是一个指向模板类型参数的右值引用(即如
T&&
),则它可以被绑定到一个左值 - 如果实参是一个左值,则推断出模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(
T&
)
这两个规则暗示我们将任意类型的实参传递给
T&&
类型的函数参数,对于这种类型的参数,(显然)可以传递给它右值,也可以传递给它左值。
5.4 编写接收右值引用参数的模板函数
如果一个函数参数是一个指向模板类型参数的右值引用(即如T&&
),模板内的代码就会产生歧义:
template void f3(T&& val)
{
T t = val; // 实参是左值时, 模板参数T是int&, 那么是绑定一个引用; 实参是右值时, 模板参数T是int, 那么是拷贝val值到t
t = fcn(t); // 赋值是只改变t还是既改变t又改变val
if (val == t) { /*...*/ } // 如果T是引用类型, 则一直为true
}
如上所述,当代码中涉及的类型可能是普通(非引用)类型,也可能是引用类型时,编写正确的代码就变得异常困难(虽然remove_reference
这样的类型转换可能有所有帮助)。
在实际中,右值引用通常用于两种情况:模板转发其实参或模板被重载。使用右值引用的函数模板通常要进行重载:
// 右值会调用第一个函数, 排除歧义的问题
template void f(T&&); // 绑定到非const右值
template void f(const T&); // 左值和cosnt右值
6. 理解std::move
虽然不能直接将一个右值引用绑定到一个左值上,但可以用move
获得一个绑定到左值上的右值引用。
6.1 std::move在标准库中的定义
// 在返回类型和类型转换中也要用到typename
template
typename remove_reference::type&& move(T&& t)
{
return static_cast::type&&>(t);
}
move
的函数参数T&&
是一个指向模板类型参数的右值引用,通过引用折叠意味着该参数可以与任何类型的实参匹配。特别是我们既可以给move
传递一个左值,又可以给它传递一个右值:
string s1("hi!"), s2;
s2 = std::move(string("bye!")); // 正确: 从一个右值移动数据
s2 = std::move(s1); // 正确: 但是赋值之后, s1的值是不确定的
6.2 std::move是如何工作的
仍然看上面的例子:
s2 = std::move(string("bye!"));
- 推断出
T
的类型是string
-
remove_reference
用string
实例化 -
remove_reference
的type
成员是string
-
move
返回类型是string &&
-
move
的函数参数t
的类型为string&&
因此这个调用实例化即函数:
string&& move(string &&t)
// 参数t的类型已经是string&&, 因此函数体类型转换什么都不做, 返回它所接受的右值引用
看第二个例子:
s2 = std::move(s1);
- 推断出
T
的类型是string &
-
remove_reference
用string&
实例化 -
remove_reference
的type
成员是string
-
move
返回类型string &&
-
move
的函数参数t
实例化为string& &&
,会折叠成string &
因此这个调用实例化即:
string&& move(string &t)
6.3 从一个左值static_cast
到一个右值引用是允许的
- 虽然不能隐式地将一个左值转换成右值引用,但是我们可以用
static_cast
显式转换 - 使用
static_cast
显式将一个左值转换成右值引用,会截断一个左值,只有当你确保截断后是安全的才这么操作 - 使用
std::move
使我们在程序 中查找潜在的截断左值的代码很容易
7. 转发
某些函数需要将其一个或多个实参联通类型不变地转发给其他参数,这种情况我们需要保持被转发实参的所有性质:
- 实参类型是不是
const
- 实参是左值还是右值
看一下这个例子,我们编写一个函数接受一个可调用表达式和两个额外实参:
// 对"翻转"的参数调用给定的可调用对相关
// flip1是一个不完整的实现: 顶层const和引用丢失了
template
void flip1(F f, T1 t1, T2 t2)
{
f(t2, t1);
}
这个函数一般工作得很好,但当我们希望用它调用一个接受引用参数的函数就会出现问题:
void f(int v1, int &v2) // v2是一个引用
{
cout << v1 << " " << ++v2 << endl;
}
当我们通过flip1
调用f
时就会丢失v2
的引用信息。
我们可以使用forward
的新标准库来传递flip2
的参数,它能保持原始参数的类型。与move
不同的是,forward
必须通过显式模板实参来调用,forward
返回该显式实参类型的右值引用。即forward
的返回类型是T&&
。通过返回类型上的引用折叠,forward
可以保持给定实参的左值/右值属性。
template intermediary(Type &&arg)
{
finalFcn(std::forward(arg));
// ...
}
我们使用Type
作为forward
的显式模板实参类型,它是从arg
推断出来的。由于arg
是一个模板类型参数的右值引用,Type
将表示传递给arg
的实参的所有类型信息:
- 如果实参是一个右值,那么
Type
是一个普通(非引用)类型,forward
将返回Type&&
- 如果实参是一个左值,那么通过引用折叠,
Type
本身是一个左值引用类型,forward
返回类型是一个指向左值引用类型的右值引用,折叠后返回一个左值引用类型
当用于一个指向模板参数类型的右值引用函数参数T&&
时,forward
会保持实参类型的所有细节。使用forward
,我们可以再次重写反转函数:
template
void flip(F f, T1 &&t1, T2 &&t2)
{
f(std::forward(t2), std::forward(t1));
}
重载与模板
函数模板可以被另一个模板或一个普通非模板函数重载。与之前一样,名字相同的函数必须具有不同数量或类型的参数。
1. 编写重载模板
我们构造一组调试函数命名为debug_rep
,每个函数返回一个给定对象的string
表示。我们首先编写此函数的最通用版本,并将它定义为接受一个const
对象引用的模板:
template string debug_rep(const T &t)
{
ostringstream ret;
ret << t; // 使用T的输出运算符打印t的一个表示形式
return ret.str(); // 返回ret绑定的string的一个副本
}
再定义打印指针的版本:
// 注意此函数不能用于char*对象, 因为IO库为char*定义了一个<<版本, 此版本假定指针表示一个空字符结尾的字符数组, 并打印数组的内容而非地址
template string debug_rep(T *p)
{
ostringstream ret;
ret << "pointer: " << p; // 打印地址值
if (p)
ret << " " << debug_rep(*p); // 打印p指向的值
else
ret << " null pointer"; // 指出p为空
return ret.str();
}
使用:
// 只有第一个版本是可行的, 因为编译器无法从一个费指针参数实例化一个期望值真类型参数的函数模板
string s("hi");
cout << debug_rep(s) << endl;
// 如果用指针调用, 则两个版本都是可行的
cout << debug_rep(&s) << endl;
// 第一个版本的T被绑定到string*, 实例化debug_rep(const string*&)
// 第二个版本的T被绑定到string, 实例化debug_rep(string*)
第一个版本的实例需要进行普通指针到
const
指针的转换,正常函数匹配规则告诉我们应该选择第二个版本。
2. 多个可行模板
当多个重载模板对一个调用提供同样好的匹配时,应选择最特例化的版本。
3. 非模板和模板重载
对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。
4. 重载模板与类型转换
考虑一下C
风格字符串指针和字符串字面常量。考虑一下这个调用:
cout << debug_rep("hi world!") << endl; // 调用debug_rep(T*)
有三个debug_rep
版本都是可行的:
-
debug_rep(const T&)
,T
被绑定到char[10]
-
debug_rep(T*)
,T
被绑定到const char
-
debug_rep(const sring&)
,要求从const char*
到string
的类型转换
对给定实参来说,两个模板都提供精确匹配(第二个模板需要进行一次数组到指针的转换,对于函数匹配来说,这种转换被认为是精确匹配)。非模板版本是可行的,但是需要进行一次用户定义的类型转换,因此没有精确匹配那么好,所以两个模板称为可能被调用的函数。由于第二个模板T*
更加特例化,因此编译器会选择它。
5. 缺少声明可能导致程序行为异常
通常如果使用了一个忘记声明的函数,代码将编译失败。但是对于重载函数模板的函数而言,如果编译器可以从模板实例化出与调用匹配的版本,则缺少的声明就不会报错。以前面的例子而言,如果缺少了接收T*
的模板版本,则编译器会默认实例化接受const T&
的模板版本。
在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你需要的版本。
可变参数模板
一个可变参数模板variadic template
就是一个接受可变数组参数的模板函数或模板类。可变数目的参数被称为参数包parameter packet
,参数包包括模板参数包和函数参数包。
// Args是一个模板参数包; rest是一个函数参数包
// Args表示零个或多个模板类型参数
// rest表示零个或多个函数参数
template
void foo(const T &t, const Args& ... rest)
// 调用方式
int i = 0; double d = 3.14; string s = "how now brown cow";
foo(i, s, 42, d); // 包中三个参数
foo(s, 42, "hi"); // 包中两个参数
foo(d, s); // 包中一个参数
foo("hi"); // 空包
// 编译器会分别实例化对应的版本
void foo(const int&, const string&, const int&, const double&);
void foo(const string&, const int&, const char[3]&);
void foo(const double&, const string&);
void foo(const char[3]&);
1. sizeof...运算符
当我们需要知道包中有多少元素时,可以使用sizeof...
运算符:
template void g(Args ... args) {
cout << sizeof...(Args) << endl; // 类型参数的数目
cout << sizeof...(args) << endl; // 函数参数的数目
}
2. 编写可变参数函数模板
// 用于终止递归并打印最后一个元素的函数
// 此函数必须在可变参数版本的print定义之前声明
template
ostream &print(ostream &os, const T &t)
{
return os << t; // 包中最后一个元素之后不打印分隔符
}
// 包中除了最后一个元素之外的其他元素都会调用这个版本的print
template
ostream &print(ostream &os, const T &t, const Args&... rest)
{
os << t << ", "; // 打印第一个实参
return print(os, rset...); // 递归调用, 打印其他实参
}
// 调用:
print(cout, i, s, 42); // 包中有两个参数
// 依次执行:
print(cout, i, s, 42); // t:i, rset:s, 42
print(cout, s, 42); // t:s, rset:42
print(cout, 42); // 调用非可变参数版本的print
- 对于最后一个调用,两个函数提供同样好的匹配,但是非可变参数模板比可变参数模板更加特例化,因此编译器选择非可变参数版本
- 当定义可变参数版本的
print
时,非可变参数版本的声明必须在作用域中,否则可变参数版本会无限递归
3. 包扩展
我们前面提到的print
函数包含两个扩展:
template
ostream &print(ostream &os, const T &t, const Args&... rest) // 扩展Args
{
os << t << ", ";
return print(os, rset...); // 扩展rest
}
C++
还支持更复杂的扩展模式,我们可以编写第二个可变参数函数,对其每个实参调用debug_rep
,然后调用print
打印结果的string
:
// 在print调用中对每个实参调用debug_rep
template
ostream &errorMsg(ostream &os, const Args&... rest)
{
// 等价于print(os, debug_rep(a1), debug_rep(a2), ..., debug_rep(an))
return print(os, debug_rep(rset)...);
}
4. 转发参数包
可变参数函数通常将它们的参数转发给其他函数,这种函数通常与我们的emplace_back
函数具有一样的形式:
// fun有零个或多个参数, 每个参数都是一个模板参数类型的右值引用
template
void fun(Args&&... args) // 将Args扩展为一个右值引用的列表
{
// work的实参既扩展Args又扩展args
work(std::forward(args)...);
}
我们希望将fun
的所有实参转发给另一个名为work
的函数,假定它完成函数的实际工作。类似于emplace_back
中对construct
的调用,work
调用中的扩展既扩展了模板参数包也扩展了函数参数包。由于fun
的参数是右值引用,因此我们可以传递给它任意类型的实参,由于我们使用std::forward
传递这些实参,因此它们的所有信息类型在调用work
时都会得到保持。
模板特例化
继续看我们之前定义的compare
函数:
// 第一个版本: 可以比较任意两个类型
template int compare(const T&, const T&);
// 第二个版本处理字符串字面常量
template
int compare(const char (&)[N], const char (&)[M]);
只有当我们传递给compare
一个字符串字面常量或者一个数组时,编译器才会调用第二个版本,如果我们传递给它字符指针,就会调用第一个版本(我们无法将一个指针转换为一个数组的引用):
const char *p1 = "hi", *p2 = "mom";
compare(p1, p2); // 调用第一个模板版本
compare("hi", "mom"); // 调用第二个版本
1. 定义函数模板特例化
为了处理字符指针(而不是数组),可以为第一个版本的compare
定义一个模板特例化版本。
模板特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。
// compare的特殊版本, 处理字符数组的指针
template<>
int compare(const char* const&p1, const char* const &p2)
{
return strcmp(p1, p2);
}
一个特例化的版本本质上是一个实例,而非函数名的一个重载版本。因此特例化不影响函数匹配。另外需要注意的是:
- 为了特例化一个模板,原模板的声明必须在作用域之中;在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中
- 从前一条得知:模板及其特例化版本应该声明在同一个头文件中,所有同名模板的声明应该放在前面,然后是这些模板的特例化版本
2. 类模板特例化
除了特例化函数模板,我们还可以特例化类模板。唯一需要注意的是,我们必须在原模板定义所在的命名空间中特例化它。
3. 类模板部分特例化
与函数模板不同,类模板的特例化不必为所有模板参数提供实参。我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化本身是一个模板,使用它时用户还必须为那些在特例化版本中未指出的模板参数提供实参:
// 原始的通用的版本
typename struct remove_reference {
typedef T type;
};
// 部分特例化版本, 将用于左值引用和右值引用
template struct remove_reference // 左值引用
{ typedef T type; };
template struct remove_reference // 右值引用
{ typedef T type; };
// 调用
int i;
// decltype(42)为int, 使用通用版本
remove_reference::type a;
// decltype(i)为int&, 使用第一个版本T&
remove_reference::type b;
// decltype(std::move(i))为int&&, 使用第二个版本T&&
remove_reference::type c;
4. 特例化成员而不是类
假定Foo
是一个模板类,包含一个成员Bar
,我们可以只特例化该成员:
template struct Foo {
Foo(const T &t = T()) : mem(t) { }
void Bar() { /*...*/ }
T mem;
// Foo其他成员
};
template<> // 我们正在特例化一个模板
void Foo::Bar() // 我们正在特例化Foo的成员Bar