C++ Primer, 5th Edition 笔记3
类设计者的工具
拷贝控制
当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。
拷贝、赋值与销毁
拷贝构造函数的参数必须是引用类型。如Sales_data(const Sales_data&);
。
重载赋值运算符,赋值运算符通常应该返回一个指向其左侧运算对象的引用。Foo& operator=(const Foo&);
。
析构函数没有参数,不能被重载。析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的。
需要析构函数的类也需要拷贝和赋值操作。需要拷贝操作的类也需要赋值操作,反之亦然。
可以通过将拷贝控制成员定义为=default
来显式地要求编译器生成合成的版本。定义在类内的是内联函数,在类外的就不是了。
1 | class Sales_data { |
阻止拷贝,之前是通过将其拷贝构造函数和拷贝赋值运算符声明为private。现在不推荐用了,应该用C++11的方法。
1 | // Before |
在C++11里,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete
来指出我们希望将它定义为删除的:
1 | // C++11 |
=delete
必须出现在函数第一次声明的时候,=default
不是。可以对任何函数指定=delete
,但只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default
。
注意析构函数不能是删除的成员。
如果一个类有const成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个const对象是不可能的。
拷贝控制和资源管理
拷贝语义有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
像一个值,意味它有自己的状态。拷贝出的副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
像一个指针,意味共享状态。拷贝出的副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
举例,在标准库中,标准库容器和string类的行为像一个值,shared_ptr类提供类似指针的行为,IO类型和unique_ptr不允许拷贝或赋值,行为既不像值也不像指针。
行为像值的类
示例:
1 | class HasPtr { |
编写赋值运算符时要注意:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。销毁左侧运算对象资源之前拷贝右侧运算对象。
行为像指针的类
需要定义拷贝构造函数和拷贝赋值运算符,最好使用shared_ptr来管理类中的资源。如果希望自己直接管理资源,使用引用计数。
使用引用计数的示例(副本和原对象都指向相同的string):
1 | class HasPtr { |
交换操作
对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。如果没有类自定义版本,算法会使用标准库定义的swap。理论上交换两个对象需要进行一次拷贝和两次赋值。如果是交换两个类值的HasPtr对象,会涉及到多次内存分配,没有必要,我们更希望swap交换指针,而不是分配string的新副本。
1 | class HasPtr { |
与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
swap函数应该调用swap,而不是std::swap。防止该类有自定义swap却调用了std的。
可以在赋值运算符中使用swap。拷贝并交换,这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。
1 | // 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数 |
动态内存管理类
示例,实现标准库vector类的一个简化版本。没使用模板,我们的类只用于string,命名为StrVec。
StrVec类的设计
vector类将其元素保存在连续内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
在StrVec中使用类似策略。使用allocator来获得原始内存,添加新元素的时候用construct创建对象,删除的时候用destroy。
每个StrVec有三个指针成员指向其元素所使用的内存:
- elements,指向分配的内存中的首元素。
- first_free,指向最后一个实际元素之后的位置。
- cap,指向分配的内存末尾之后的位置。
还有一个名为alloc的静态成员,类型为allocator
还有4个工具函数:
- alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
- free会销毁构造的元素并释放内存。
- chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
- reallocate在内存用完时为StrVec分配新内存。
StrVec类定义
1 | class StrVec { |
使用construct
1 | void StrVec::push_back(const string& s) |
alloc_n_copy成员
拷贝或赋值StrVec,必须分配独立的内存,并从原StrVec对象拷贝元素至新对象。
alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。
此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后位置。
1 | pair<string*, string*> |
free成员
首先destroy元素,然后释放StrVec自己分配的内存空间。
1 | void strVec::free() |
拷贝控制成员
拷贝构造函数调用alloc_n_copy:
1 | StrVec::StrVec(const StrVec &s) |
析构函数调用free:
1 | StrVec::~StrVec() {free();} |
拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了:
1 | StrVec &StrVec::operator=(const StrVec &rhs) |
reallocate成员
在重新分配内存的过程中移动而不是拷贝元素
reallocate函数应该:为一个新的、更大的string数组分配内存;在内存空间的前一部分构造对象,保存现有元素;销毁原内存空间中的元素,并释放这块内存。
应尽量避免分配和释放string的额外开销。
使用移动构造函数和std::move。
1 | void StrVec::reallocate() |
对象移动
从C++11开始。标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
右值引用是必须绑定到右值的引用,通过&&获得,只能绑定到一个将要销毁的对象。因此可以自由地将一个右值引用的资源“移动”到另一个对象中。
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。
1 | int i = 42; |
左值持久,右值短暂。右值引用只能绑定到临时对象,即该引用的对象将要被销毁,该对象没有其他用户。所以使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
标准库move函数。可以显式地将一个左值转换为对应的右值引用类型。定义在头文件<utility>
中。
int &&rr3 = std::move(rr1);
注意使用std::move
而不是move,避免潜在的名字冲突。
移动构造函数和移动赋值运算符
接着前文的例子,让StrVec类支持移动和拷贝。
1 | // 在声明时 |
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。因为为了避免潜在的一半在原地一半被移走的问题,除非容器知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类也必须定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝。
对于行为像指针的类HasPtr定义拷贝并交换赋值运算符
1 | class HasPtr { |
用移动迭代器重写reallocate
C++11中提供了一种移动迭代器。移动迭代器的解引用运算符生成一个右值引用。使用make_move_iterator
将一个普通迭代器转换为一个移动迭代器。
1 | void StrVec::reallocate() |
**建议不要随意使用移动操作。**由于一个移动源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。小心的使用move可以大幅提升性能,随意使用就等着哭吧。
重载版本的push_back
如果一个成员函数同时提供拷贝和移动版本,一般成员函数也会有重载,一个接受指向const的左值引用,一个接受指向非const的右值引用。
1 | class StrVec { |
引用限定符暂略。
重载运算与类型转换
基本概念
当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。这也意味着当运算符用于内置类型的运算对象时,我们无法改变运算符的含义。
并不是所有的运算符都可以重载。
可以直接调用一个重载的运算符函数。
1 | operator+(data1, data2); // 对非成员运算符函数的调用 |
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
将运算符定义为成员函数还是普通的非成员函数,做决定的准则:
输入和输出运算符
输入输出运算符必须是非成员函数。否则,它们的左侧运算对象将是我们的类的一个对象。所以IO运算符一般被声明为友元。
重载输出运算符<<
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印运算符。
1 | ostream &operator<<(ostream &os, const Sales_data &item) |
重载输入运算符>>
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
可能发生的错误:
- 当流含有错误类型的数据时读取操作可能失败。
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
1 | istream &operator>>(istream &is, Sales_data &item) |
算术和关系运算符
一般定义为非成员函数,以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
1 | // 假设两个对象指向同一本书 |
如果某个类在逻辑上有相等性的含义,应该定义operator==
。
1 | bool operator==(const Sales_data &lhs, const Sales_data &rhs) |
如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。
赋值运算符
可以重载赋值运算符,不论形参的类型是什么,赋值运算符都必须定义为成员函数。复合赋值运算符也通常如此。它们都应该返回左侧运算对象的引用。
StrVec中的赋值运算符重载
1 | class StrVec { |
1 | // 假定两个对象表示的是同一本书 |
下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。
下标运算符必须是成员函数。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
StrVec的下标运算符
1 | class StrVec { |
递增和递减运算符
建议把递增和递减运算符设定为成员函数。
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
为了区分前置和后置运算符,后置版本接受一个额外的(不被使用)int类型的形参。
1 | class StrBlobPtr { |
成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符*
和箭头计算符->
。
箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
1 | class StrBlobPtr { |
函数调用运算符
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
函数对象。
1 | struct absInt { |
lambda是函数对象。标准库定义了很多函数对象。
C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
不同类型的可调用对象可以共享同一种调用形式。如int(int, int)
。
可以使用标准库的function类型将它们统一为一种格式。定义在functional
头文件中。
1 | // 普通函数 |
重载、类型转换与运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
operator type() const;
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
避免过度使用类型转换函数,为防止意外的隐式转换,可以定义为显式的。
1 | class SmallInt { |
1 | SmallInt si = 3; // SmallInt的构造函数不是显式的 |
为了避免有二义性的类型转换。通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
如果在调用函数重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,这通常意味着程序的设计存在不足。
重载的运算符也是重载的函数。表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则会遇到重载运算符与内置运算符的二义性问题。
面向对象程序设计
OOP:概述
OOP的核心是数据抽象、继承和动态绑定。通过使用数据抽象,可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
小结:
继承使得我们可以编写一些新的类,这些新类既能共享其基类的行为,又能根据需要覆盖或添加行为。动态绑定使得我们可以忽略类型之间的差异,其机理是在运行时根据对象的动态类型来选择运行函数的哪个版本。继承和动态绑定的结合使得我们能够编写具有特定类型行为但又独立于类型的程序。
在C++语言中,动态绑定只作用于虚函数,并且需要通过指针或引用调用。
在派生类对象中包含有与它的每个基类对应的子对象。因为所有派生类对象都含有基类部分,所以我们能将派生类的引用或指针转换为一个可访问的基类引用或指针。
当执行派生类的构造、拷贝、移动和赋值操作时,首先构造、拷贝、移动和赋值其中的基类部分,然后才轮到派生类部分。析构函数的执行顺序则正好相反,首先销毁派生类,接下来执行基类子对象的析构函数。基类通常都应该定义一个虚析构函数,即使基类根本不需要析构函数也最好这么做。将基类的析构函数定义成虚函数的原因是为了确保当我们删除一个基类指针,而该指针实际指向一个派生类对象时,程序也能正确运行。
派生类为它的每个基类提供一个保护级别。public基类的成员也是派生类接口的一部分;private基类的成员是不可访问的;protected基类的成员对于派生类的派生类是可访问的,但是对于派生类的用户不可访问。
定义基类和派生类
基类应该定义虚析构函数。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
每个类控制它自己的成员初始化过程。首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
在C++11中如果不希望其他类继承某个类,可以将该类设置为final
。
1 | class NoDerived final {}; // NoDerived不能作为基类 |
从派生类到基类的类型转换只对指针或引用类型有效。
虚函数
基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。
派生类中虚函数的返回类型也必须与基类函数匹配。唯一的例外是,类的虚函数返回类型是类本身的指针或引用时,不过要求从派生类到基类的类型转换是可访问的。
为了防止派生类覆盖基类虚函数时不匹配,C++11中建议显式写上override
,有问题会报错。void f(int) const override;
。
将某个函数指定为final
则之后该函数不能再被覆盖。void f(int) const final;
。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
抽象基类
含有纯虚函数的类是抽象基类。不能创建抽象基类的对象。
访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问。
类的protected说明符:
- 和私有成员类似,受保护的成员对于类的用户是不可访问的。
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
公有、私有和受保护继承
1 | class Base { |
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:
1 | Pub_Derv d1; // 继承自Base的成员是public的 |
派生访问说明符还可以控制继承自派生类的新类的访问权限:
1 | struct Derived_from_Public : public Pub_Derv { |
基类应该将接口成员声明为公有的;将属于实现的部分分为两组:一组可供派生类访问,声明为受保护的;一组只能由基类及基类的友元访问,声明为私有的。
可以用using
声明改变个别成员的可访问性。
1 | class Base { |
派生类只能为那些它可以访问的名字提供using声明。
继承中的类作用域
当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。所以派生类才能像使用自己的成员一样使用基类的成员。
派生类的成员将隐藏同名的基类成员。可以通过作用域运算符来使用隐藏的成员。除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
名字查找先于类型查找。如果内层作用域的成员与外层作用域的某个成员同名,即使形参列表不一致,外层作用域该成员也会被隐藏掉。
构造函数与拷贝控制
基类应该定义一个虚析构函数。否则delete一个指向派生类对象的基类指针将产生未定义的行为。
(略过部分内容)
容器与继承
当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。一般在容器中放置(智能)指针而非对象。
模板与泛型编程
定义模板
函数模板
例如:
1 | template <typename T> T foo(T* p) |
也可以有非类型模板参数,一个非类型参数表示一个值而非一个类型。当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替,这些值必须是常量表达式。例如:
1 | template<unsigned N, unsigned M> |
模板程序应该尽量减少对实参类型的要求。
当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。
函数模板和类模板成员函数的定义通常放在头文件中。
保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。
类模板
与函数模板的不同之处是,编译器不能为类模板推断模板参数类型,必须提供模板实参。
例如:
1 | template <typename T> class Blob { |
一个类模板的每个实例都形成一个独立的类。类型Blob
在模板作用域中引用模板类型,例如:
std::shared_ptr<std::vector<T>> data;
可以在类模板外部定义成员函数。而定义在类模板内的成员函数被隐式声明为内联函数。
1 | template <typename T> |
以构造函数为例。
1 | template <typename T> |
默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。
在一个类模板的作用域内,可以直接使用模板名而不必指定模板实参。例如:
1 | template <typename T> |
当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。
例如,一对一友好关系,对应实例及其友元间的友好关系。
1 | // 前置声明,在Blob中声明友元所需要的 |
或者,设置通用或特定的模板友好关系。注意为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。
1 | // 前置声明,在将模板的一个特定实例声明为友元时要用到 |
模板自己的类型参数也可以成为友元。
1 | template <typename Type> class Bar { |
可以为类模板定义一个类型别名:
1 | template<typename T> using twin = pair<T, T>; |
定义一个模板类型别名时,可以固定一个或多个模板参数。
1 | template <typename T> using partNo = pair<T, unsigned>; |
类模板可以有static成员,类模板的每个实例都有一个独有的static对象,一个static成员函数也只有在使用时才会实例化。例如:
1 | template <typename T> class Foo { |
模板参数
模板参数遵循普通的作用域规则。在模板内不能重用模板参数名。
一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。
默认情况下,C++语言假定通过使用作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型,用typename。例如:
1 | template <typename T> |
可以提供默认模板实参,从C++11起,可以为函数和类模板提供默认实参,之前只能为类模板提供默认实参。
函数模板提供默认实参,例如:
1 | // compare有一个默认模板实参less<T>和一个默认函数实参F() |
类模板提供默认实参,例如:
1 | template <class T = int> class Numbers { // T默认为int |
成员模板
一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员就是成员模板。成员模板不能是虚函数。
普通(非模板)类的成员模板,例如:
1 | // 函数对象类,对给定指针执行delete |
类模板的成员函数,在此情况下,类和成员各自有自己的、独立的模板参数。
1 | template <typename T> class Blob { |
控制实例化
当模板被使用时才会进行实例化,这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。
C++11中,通过显式实例化来避免这种开销,以这种形式:
1 | extern template declaration; // 实例化声明 |
例如:
1 | extern template class Blob<string>; // 声明 |
对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。由于编译器在使用一个模板时自动对其实例化,因此extern声明必须出现在任何使用此实例化版本的代码之前。实例化定义会实例化所有成员。
效率与灵活性
通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便。
模板实参推断
从函数实参来确定模板实参的过程称为模板实参推断。
类型转换与模板类型参数
将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。
例子:
1 | template <typename T> T fobj(T, T); // 实参被拷贝 |
如果函数参数类型不是模板参数,则对实参进行正常的类型转换。
函数模板显式实参
显示模板实参按从左到右的顺序与对应的模板参数匹配,所以要显式写的定义在最左边。
例如,允许用户指定函数的返回类型。
1 | // 编译器无法推断T1,它未出现在函数参数列表中 |
尾置返回类型与类型转换
1 | // 尾置返回允许我们在参数列表之后声明返回类型 |
有时我们无法直接获得所需要的类型,比如上例中我们希望直接返回一个元素的值而非引用。此时可以用标准库的类型转换模板,在头文件type_traits
中。
上例可以改写为:
1 | // 为了使用模板参数的成员,必须用typename,告知编译器type是一个类型 |
函数指针和实参推断
用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
1 | template <typename T> int compare(const T&, const T&); |
1 | // func的重载版本;每个版本接受一个不同的函数指针类型 |
模板实参推断和引用
编译器会应用正常的引用绑定规则;const是底层的,不是顶层的。
从左值引用函数参数推断类型。
1 | template <typename T> void f1(T&); // 实参必须是一个左值 |
1 | template <typename T> void f2(const T&); // 可以接受一个右值 |
从右值引用函数参数推断类型。
1 | template <typename T> void f3(T&&); |
引用折叠和右值引用参数。
一般来说不能将一个右值引用绑定到一个左值上。但是C++有两个例外规则。
一,当我们将一个左值(如i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型。
二,如果我们间接创建一个引用的引用,则这些引用形成了折叠,饮用会折叠成一个普通的左值引用类型。
例如X& &、X& &&和X&& &都折叠成类型X&。类型X&& &&折叠成X&&。
引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。
1 | f3(i); // 实参是一个左值;模板参数T是int& |
这两个规则导致了两个重要结果:
- 如果一个函数参数是一个指向模板类型参数的右值引用(如T&&),则它可以被绑定到一个左值;且
- 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&)。
这两个规则暗示,可以将任意类型的实参传递给T&&类型的函数参数。即,
如果一个函数参数是指向模板参数类型的右值引用(如T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(如T&)。
编写接受右值引用参数的模板函数。
1 | template <typename T> void f3(T&& val) |
如果对一个右值(如字面常量42)调用f3,T为int,此时局部变量t的类型为int,且通过拷贝参数val的值被初始化。当我们对t赋值时,参数val保持不变。
如果对一个左值(如i)调用f3,T为int&,此时t的类型也为int&,初始化被绑定到val。当我们对t赋值时,也同时改变了val的值。
此时代码自然容易出错。
使用右值引用的函数模板通常使用这种方式重载:
1 | template <typename T> void f(T&&); // 绑定到非const右值 |
理解std::move
标准库move函数是使用右值引用的模板的一个很好的例子。
1 | // 在返回类型和类型转换中也要用到typename |
使用示例
1 | string s1("hi"), s2; |
从一个左值static_cast到一个右值引用是允许的。
转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。
1 | // 接受一个可调用对象和另外两个参数的模板 |
如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的const属性和左值/右值属性将得到保持。
当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward会保持实参类型的所有细节,定义在头文件utility中。
与std::move相同,对std::forward不使用using声明是一个好主意。
重载与模板
函数模板可以被另一个模板或一个普通非模板函数重载。
1 | // 打印任何我们不能处理的类型 |
使用:
1 | string s("hi"); |
当有多个重载模板对一个调用提供同样好的匹配时,应选择最特例化的版本。
对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。
1 | // 针对char*,将字符指针转换为string,并调用string版本的debug_reg |
在定义任何函数之前,记得声明所有重载的函数版本。例如:
1 | template <typename T> string debug_rep(const T& t); |
可变参数模板
可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包,模板参数包或函数参数包。
1 | // Args是一个模板参数包;rest是一个函数参数包 |
可以用sizeof...
计算包中有多少元素:
1 | template <typename ... Args> void g(Args ... args) |
编写可变参数函数模板
可变参数函数通常是递归的,需要写一个终止递归的函数。
1 | // 用来终止递归并打印最后一个元素的函数 |
包扩展
扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。通过在模式右边放一个省略号…来触发扩展操作。
扩展中的模式会独立地应用于包中的每个元素。
1 | // 在print调用中对每个实参调用debug_rep |
转发参数包
组合使用可变参数模板与forward机制来编写函数,实现将其实参不变地传递给其他函数。
1 | class StrVec { |
模板特例化
当不能或不希望使用模板版本时,可以定义类或函数模板的一个特例化版本。
函数模板特例化
1 | // 第一个版本;可以比较任意两个类型 |
定义函数模板特例化,为字符数组的指针定义一个特例化的版本。
为了指出我们正在实例化一个模板,应使用关键字template后跟一个空尖括号对(<>)。空尖括号指出我们将为原模板的所有模板参数提供实参。
定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配。
1 | // compare的特殊版本,处理字符数组的指针 |
特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。
模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。
类模板特例化
例子:为标准库hash模板定义一个特例化版本,用来将Sales_data对象保存在无序容器中。
必须在原模板定义所在的命名空间中特例化它。
1 | // 打开std命名空间,以便特例化std::hash |
为了让Sales_data的用户能使用hash的特例化版本,应该在Sales_data的头文件中定义该特例化版本。
类模板部分特例化
只能部分特例化类模板,不能部分特例化函数模板。
1 | // 原始的、最通用的版本 |
特例化类成员而不是整个类
1 | template <typename T> struct Foo { |
跨时6月才完成,饱含泪花(˘̩̩̩ε˘̩ƪ)