《C++ Primer 第五版》读书笔记 - 第三部分
类设计者的工具
第13章 拷贝控制
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值, 则此构造函数是拷贝构造函数。虽然我们可以定义一个接受非const
引用的拷贝构造函数,但此参数儿乎总是一个const
的引用。拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit
的。
即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
一般情况下,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static
成员拷贝到正在创建的对象中。
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化不仅在我们用=
定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准容器或是调用其insert
或push
成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace
成员创建的元素都进行直接初始化。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
当传递一个实参或从函数返回一个值时,我们不能隐式使用一个explicit
构造函数。如果我们希望使用一个explicit
构造函数,就必须显式地使用。
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即使编译器略过了拷贝移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
拷贝赋值运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。一般情况下,它会将右侧运算对象的每个非static
成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。
析构函数
析构函数释放对象使用的资源,并销毁对象的非static
数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
析构函数有一个函数体和一个析构部分。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毀,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毀。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用
delete
运算符时被销毁。 - 对于临时对象,当创建它的完整表达式结束时被销毁。
当一个类末定义自己的析构函数时,编译器会为它定义一个合成析构函数。一般情况下,合成析构函数的函数体为空。认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
三/五法则
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝构造函数和自定义拷贝赋值运算符。
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然一一如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
使用=default
我们可以通过将拷贝控制成员定义为=default
来显式地要求编译器生成合成的版本。
阻止拷贝
我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete
来指出我们希望将它定义为删除的。
=delete
必须出现在函数第一次声明的时候。
可以对任何函数指定=delete
。
如果析构函数被删除,就无法销毀此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象,也不能释放指向该类型动态分配对象的指针。
编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个
const
的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。 - 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个
const
成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。
对于具有引用成员或无法默认构造的const
成员的类,编译器不会为其合成默认构造函数,如果一个类有const
成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予个const
对象是不可能的。
虽然我们可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的值,而不是引用本身。如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并不是我们所期望的,因此对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private
的来阻止拷贝。声明但不定义一个成员函数是合法的,试图访问一个未定义的成员将导致一个链接时错误。通过声明(但不定义)private
的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误成员函数或友元函数中的拷贝操作将会导致链接时错误。
拷贝控制和资源管理
类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
行为像值的类
1 | class HasPtr { |
当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
定义行为像指针的类
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
1 | class HasPtr { |
交换操作
可以在我们的类上定义一个自己版本的swap
来重载swap
的默认行为。swap
函数对给定对象的每个数据成员调用swap
,注意调用的swap
不是std::swap
。
定义swap
的类通常用swap
来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。
1 | class HasPtr { |
拷贝控制示例
拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private
的工具函数中完成。
1 | class Message { |
1 | void Message::save(Folder &f) |
动态内存管理类
1 | class SreVec { |
1 | void StrVec::push_back(const string &s) |
移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。而且我们知道标准库保证“移后源”仍然保持一个有效的、可析构的状态。
名为move
的标准库函数,它定义在utility
头文件中。首先,当reallocate
在新内存中构造string
时,它必须调用move
来表示希望使用string
的移动构造函数。如果它漏掉了move
调用,将会使用string
的拷贝构造函数。其次,我们通常不为move
提供一个using
声明。当我们使用move
时,直接调用std::move
。
1 | void StrVec::reallocate() |
对象移动
标准库容器、string
和shared_ptr
类既支持移动也支持拷贝。IO类和unique_ptr
类可以移动但不能拷贝。
右值引用
所谓右值引用就是必须绑定到右值的引用。我们通过&&
而不是&
来获得右值引用。右值引用有一个重要的性质一一只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
对于常规引用(为了与右值引用区分开来,我们可以称之为左值引用), 我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上。
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const
的左值引用或者一个右值引用绑定到这类表达式上。
左值有持久的状态, 而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知:
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move
的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility
中。
move
调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。
我们可以销毁一个移后源对象,也可以赋它新值,但不能使用一个移后源对象的值。
移动构造函数和移动赋值运算符
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样, 任何额外的参数都必须有默认实参。
1 | StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) |
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的StrVec
中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr
。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。
当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
except
是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定except
。在一个构造函数中,except
出现在参数列表和初始化列表开始的冒号之间。
我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定except
。
1 | StrVec &StrVec::operator=(StrVec &&rhs) noexcept |
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。StrVec
的移动操作是通过将移后源对象的指针成员置为nullptr
来实现的。
除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static
数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员。
移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成=default
的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
- 移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的, 则类的移动构造函数或移动赋值运算符被定义为删除的。
- 如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 如果有类成员是
const
的或是引用,则类的移动赋值运算符被定义为删除的。
如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。
如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move
来移动它们时也是如此。用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。
1 | class HasPtr { |
赋值运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数一一左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
1 | void Message::move_Folders(Message *m) |
一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。 我们通过调用标准库的make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。原迭代器的所有其他操作在移动迭代器中都照常工作。由于移动迭代器支持正常的迭代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给uninitialized_copy
:
1 | void StrVec::reallocate() |
标准库不保证哪些算法适用移动迭代器,哪些不适用。
右值引用和成员函数
如一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const
的左值引用,第二个版本接受一个指向非const
的右值引用。
一般来说,我们不需要为函数操作定义接受一个const X&&
或是一个(普通的)X&
参数的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这目的,实参不能是const
的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&
参数的版本。
我们指出this
的左值右值属性的方式是在参数列表后放置一个引用限定符。引用限定符可以是&
或&&
,分别指出this
可以指向一个左值或右值。类似const
限定符,引用限定符只能用于(非static
)成员函数,且必须同时出现在函数的声明和定义中。对于&
限定的函数,我们只能将它用于左值;对于&
限定的函数,只能用于右值。
引用限定符也可以区分重载版本,也可以综合引用限定符和const
来区分一个成员函数的重载版本。
如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加。
第14章 重载运算与类型转换
基本概念
除了重载的函数调用运算符operator()
之外,其他重载运算符不能含有默认实参。
如果一个运算符函数是成员函数,则它的第一个(左)运算对象绑定到隐式的this
指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
对于一个运算符函数来说,它要么是类的成员,要么含有一个类类型的参数。
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
调用运算符函数:
1 | operator+(data1, data2); |
显式地调用成员运算符函数:
1 | data1.operator+=(data2); |
逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来。除此之外,&&
和||
运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。
还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++语言已经定义了这两种运算符用于类类型对象时的特殊含义。
- 赋值(
=
)、下标([]
)、调用(()
)和成员访问箭头(->
)运算符必须是成员。 - 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
输入和输出运算符
重载输出运算符<<
通常情况下,输出运算符的第一个形参是一个非常量ostream
对象的引用。第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。operator<<
一般要返回它的ostream
形参。输出运算符不应该打印空格。
与iostream
标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。
重载输入运算符>>
通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
如果在发生错误前对象已经有一部分被改变,则适时地将对象置为合法状态显得异常重要。
一些输入运算符需要做更多数据验证的工作。即使从技术上来看IO是成功的,输入运算符也应该设置流的条件状态以标示出失败信息,通常情况下,输入运算符只设置failbit
。
算术和关系运算符
通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
算术运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,常常位于一个局部变量之内,操作完成后返回该局部变量的副本作为其结果。如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符。
运算符
- 如果一个类含有判析两个对象是否相等的操作,则它显然应该把函数定义成
operator==
而非一个普通的命名函数。 - 如果类定义了
operator==
,则该运算符应该能判断一组给定的对象中是否含有重复数据。 - 通常情况下,相等运算符应该具有传递性。
- 如果类定义了
operator=
=,则这个类也应该定义operator!=
。 - 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。
关系运算符
通常情况下关系运算符应该
- 定义顺序关系,令其与关联容器中对关键字的要求一致
- 如果类同时也含有
==
运算符的话,则定义一种关系令其与保持一致。特别是, 如果两个对象是!=
的,那么一个对象应该<
另外一个。
赋值运算符
赋值运算符返回其左侧运算对象的引用。和拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间, 再创建一片新空间。
类中的复合赋值运算符也要返回其左侧运算对象的引用。
下标运算符
下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
递增和递减运算符
定义递增和通减运算符的类应该同时定义前置版本和后置版本。
前置运算符应该返回递增或递减后对象的引用。
后置版本接受一个额外的(不被使用)int
类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。尽管从语法上来说后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。
后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
对于后置版本来说,在递增对象之前需要首先记录对象的状态。后置运算符调用各自的前置版本来完成实际的工作。
显式调用后置运算符:
1 | p.operator++(0); // 调用后置版本 |
成员访问运算符
对于形如point->mem
的表达式来说,point
必须是指向类对象的指针或者是个重载了operator->
的类的对象。
- 如果
point
是指针,则我们应用内置的箭头运算符,表达式等价于(*point).mem
。首先解引用该指针,然后从所得的对象中获取指定的成员。 - 如果
point
是定义了operator->
的类的一个对象,则我们使用point.operator->()
的结果来获取mem
。其中,如果该结果是一个指针,则执行第1步;如果该结果本身含有重载的operator->()
,则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。
函数调用运算符
如果类定义了调用运算符,则该类的对象称作函数对象。
函数对象常常作为泛型算法的实参。
lambda是函数对象
当我们编写了一个lambda
后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda
表达式产生的类中含有一个重载的函数调用运算符。
在默认情况下,由lambda
产生的类当中的函数调用运算符是一个const
成员函数。如果lambda
被声明为可变的,则调用运算符就不是const
的了。
当一个lambda
表达式通过引用捕获变量时,将由程序负责确保lambda
执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda
产生的类中将其存储为数据成员。相反,通过值捕获的变量被拷贝到lambda
中。因此,这种lambda
产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
lambda
表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝移动构造函数则通常要视捕获的数据成员类型而定。
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。
定义在functional
头文件中。
表示运算符的函数对象类常用来替换算法中的默认运算符。如我们所知,在默认情况下排序算法使用operator<
将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个greater
类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。
标准库规定其函数对象对于指针同样适用。比较两个无关指针将产生未定义的行为,然而我们可能会希望通过比较指针的内存地址来sort
指针的vector
。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的。
1 | vector<string *> naneTable; |
关联容器使用less<key_type>
对元素排序,因此我们可以定义一个指针的set
或者在map
中使用指针作为关键值而无须直接声明less
。
可调用对象与function
两个不同类型的可调用对象可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:int(int, int)
是一个函数类型,它接受两个int
、返回一个int
。
1 | int add(int i, int j) { return i + j; } |
上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式。
定义一个函数表用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中找该调用的函数。
function
定义在functional
头文件中
1 | map<string, function<int(int, int)>> binops = { |
我们不能(直接)将重载函数的名字存入function
类型的对象中。解决二义性的途径是存储函数指针或者用lambda
调用希望使用的函数。
重载、类型转换与运算符
转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换。
类型转换运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
1 | operator type() const; |
其中type
表示某种类型。类型转换运算符可以面向任意类型(除了void
之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const
成员。
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
不能在类型转换运算符的定义中使用任何形参。每个类型转换函数都会返回一个对应类型的值。
和显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换。
例外:如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
if
、while
及do
语句的条件部分for
语句头的条件表达式- 逻辑非运算符(
!
)、逻辑或运算符(||
)、逻辑与运算符(&&
)的运算对象 - 条件运算符(
? :
)的条件表达式。
向bool
的类型转換通常用在条件部分,因此operator bool
一般定义成explicit
的。
避免有二义性的类型转换
在两种情况下可能产生多重转换路径。第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多个与算术类型有关的转换规则。
我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。
当同一个类中有两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。
1 | struct A { |
- 不要令两个类执行相同的类型转换:如果
Foo
类有一个接受Bar
类对象的构造函数,则不要在Bar
类中再定义转换目标是Foo
类的类型转换运算符。 - 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
- 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
- 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。
如果两个或多个类的类型转换都提供了同一种可行匹配,则这些类型转换一样好。只有当重载函数能通过同一个类的类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换。
1 | struct C { |
在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个类的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。
函数匹配与重载运算符
如果a
是一种类类型,则表达式a sym b
可能是:
1 | a.operatorsym(b); // a有一个operator sym成员函数 |
和普通函数调用不同,我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数。
当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。
当我们调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载, 这是因为我们用来调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。当我们通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。而当我们在表达式中使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数,因此二者都应该在考虑的范围内。
1 | class SmallInt { |
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
第15章 面向对象程序设计
定义基类和派生类
定义基类
在C++语言中,基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不要改变的函数。对于前者,基类通常将其定义为虚函数。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
基类通过在其成员函数的声明语句之前加上关键字virtual
使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual
只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。使用protected
说明派生类有权访问而其他用户禁止访问的成员。
定义派生类
派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public
、protected
或者private
。
如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上。
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象。
因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。这种转换通常称为派生类到基类的类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。
派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。除非我们特別指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化。如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为(派生类)构造函数提供初始值。
派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。如果该成员是private
的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它。
派生类的声明中包含类名但是不包含它的派生列表。
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。一个类不能派生它本身。
在类名后跟一个关键字final
来防止继承。
类型转换与继承
表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在, 也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员。
因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换。
即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换。编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。这些操作不是虚函数。当我们给基类的构造函数传递个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基美部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
虚函数
通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。
同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。
如果我们使用override
标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
1 | struct B { |
我们还能把某个函数指定为final
,如果我们已经把函数定义成final
了,则之后任何尝试覆盖该函数的操作都将引发错误。
final
和override
说明符出现在形参列表(包括任何const
或引用修饰符)以及尾置返回类型之后。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的。通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
如果个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
抽象基类
将函数定义成纯虚函数从而告诉用户当前这个函数是没有实际意义的。和普通的函数不一样,一个纯虚函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)写=0
就可以将一个虚函数说明为纯虚函数。其中,=0
只能出现在类内部的虚函数声明处。
我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0
的函数提供函数体
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。
访问控制与继承
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
假定D
继承自B
:
- 只有当
D
公有地继承B
时,用户代码才能使用派生类向基类的转换;如果D
继承B
的方式是受保护的或者私有的,则用户代码不能使用该转换。 - 不论
D
以什么方式继承B
,D
的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。 - 如果
D
继承B
的方式是公有的或者受保护的,则D
的派生类的成员和友元可以使用D
向B
的类型转换;反之,如果D
继承B
的方式是私有的,则不能使用。
友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。
当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来那个类来说,其友元的基类或者派生类不具有特殊的访问能力。
通过在类的内部使用using
声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。using
声明语句中名字的访问权限由该using
声明语句之前的访问说明符来决定。
默认情况下,使用class
关键字定义的派生类是私有继承的;而使用struct
关键字定义的派生类是公有继承的。
继承中的作用域
每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致,但是我们能使用哪些成员仍然是由静态类型决定的。
派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。我们可以通过作用域运算符来使用一个被隐藏的基类成员。
假定我们调用p->mem()
(或者obj.mem()
),则依次执行以下4个步骤:
- 首先确定
p
(或obj
)的静态类型。因为我们调用的是一个成员,所以该类必然是类类型。 - 在
p
(或obj
)的静态类型对应的类中找mem
。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。 - 一旦找到了
mem
,就进行常规的类型检查以确认对于当前找到的mem
,本次调用是否合法。 - 假设调用合法,则编译器将根据调用的是是虚函数而产生不同的代码:
- 如果
mem
是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。 - 反之,如果
mem
不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
- 如果
定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉。
假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。
1 | class Base { |
成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。
有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。一种好的解决方案是为重载的成员提供一条using
声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using
声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using
声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。
类内using
声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using
声明点的访问。
构造函数与拷贝控制
虚析构函数
基类通常应该定义一个虚析构函数。
合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
无论基类成员是合成的版本还是自定义的版本都没有太大影响。唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销派生类对象的基类部分。
- 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用
=default
请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
如前所述,大多数基类都会定义一个虚析构函数。因此在默认情況下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。 因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。
派生类的拷贝控制成员
派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分。
派生类的赋值运算符也必须显式地为其基类部分赋值。
1 | D &D::operator=(const D &rhs) |
和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源。对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
当我们构建一个对象时,需要把对象的类和构造函数的类看作是同一个;对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看成同一个的要求;对于析构函数也是同样的道理。上述的绑定不但对直接调用函数有效,对间接调用也是有效的,这里的间接调用是指通过构造函数(或析构函数)调用另一个函数。
继承的构造函数
一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数, 则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using
声明语句。
通常情况下,using
声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using
声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。如果派生类含有自己的数据成员,则这些成员将被默认初始化.
和普通成员的using
声明不一样,一个构造函数的using
声明不会改变该构造函数的访问级别。
而且,一个using
声明语句不能指定explicit
或constexpr
。如果基类的构造函数是explicit
或者constexpr
,则继承的构造函数也拥有相同的属性。
当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分別省略掉一个含有默认实参的形参。
如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数。第一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。
第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数。
容器与继承
因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中。
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型。
编写Basket类
1 | class Quote { |
1 | double Basket::total_receipt(ostream &os) const |
第16章 模板与泛型编程
定义模板
函数模板
模板定义以关键字template
开始,后跟一个模板参数列表,这是一个逗号分隔的一个或多个模板参数的列表,用小于号(<
)和大于号(>
)包围起来。在模板定义中,模板参数列表不能为空。
模板参数表示在类或函数定义中用到的类型或值。当使用模板时,我们(隐式地或显式地)指定模板实参,将其绑定到模板参数上。
当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。
编译器用推断出的模板参数来为我们实例化一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板参数来创建出模板的一个新“实例”。
一般来说,我们可以将类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。类型参数前必须使用关键字class
或typename
。在模板参数列表中,这两个关键字的含义相同,可以互换使用。一个模板参数列表中可以同时使用这两个关键字。
除了定义类型参数,还可以在模板中定义非类型参数。一个非类型参数表示一个值而非一个类型。我们通过一个特定的类型名而非关键字class
或typename
来指定非类型参数。当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。 这些值必须是常量表达式,从而允许编译器在编译时实例化模板。
一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期。我们不能用一个普通(非static
)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用nullptr
或一个值为0的常量表达式来实例化。在模板定义内,模板非类型参数是一个常量值。在需要常量表达式的地方,可以使用非类型参数,例如,指定数组大小。
函数模板可以声明为inline
或constexpr
,inline
或constexpr
说明符放在模板参数列表之后,返回类型之前。
当我们使用(而不是定义)模板时,编译器才生成代码。为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。
当使用模板时,所有不依赖于模板参数的名字都必须是可见的,这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。
用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。
类模板
编译器不能为类模板推断模板参数类型。
类模板以关键字template
开始,后跟模板参数列表。在类模板(及其成员)的定义中,我们将模板参数当作替身,代替使用模板时用户需要提供的类型或值。
显式模板实参列表被绑定到模板参数。编译器使用这些模板实参来实例化出特定的类.
一个类模板的每个实例都形成一个独立的类。
一个类模板中的代码如果使用了另外一个模板,通常不将一个实际类型(或值)的名字用作其模板实参。相反的,我们通常将模板自己的参数当作被使用模板的实参。
类模板的成员函数本身是一个普通函数。但是,类模板的每个实例都有其自己版本的成员函数。因此,类模板的成员函数具有和模板相同的模板参数。因而,定义在类模板之外的成员函数就必须以关键字template
开始,后接类模板参数列表。
当我们在类外定义一个成员时,必须说明成员属于哪个类。而且,从一个模板生成的类的名字中必须包含其模板实参。当我们定义一个成员函数时,模板实参与模板形参相同。
默认情况下,一个类模板的成员函数只有当程序用到它时才进行实例化。这一特性使得即使某种类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。
在类模板自己的作用域中,我们可以直接使用模板名而不提供模板实参。
在类模板外定义成员时,直到遇到类名才表示进入类的作用域。由于返回类型位于类的作用域之外,必须在返回类型后提供模板实参。在函数体内,我们已经进入类的作用域,此时如果不提供模板实参,则编译器将假定我们使用的类型与成员实例化所用类型一致。
1 | template <typename> class BlobPtr; |
一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元:
1 | template <typename T> class Pal; |
为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。
我们可以将模板类型参数声明为友元:
1 | template <typename Type> class Bar { |
虽然友元通常来说应该是一个类或是一个函数,但我们完全可以用一个内置类型来实例化Bar
。
由于模板不是一个类型,我们不能定义一个typedef
引用一个模板。但是允许为类模板定义一个类型别名:
1 | template<typename T> using win = pair<T, T>; |
当我们定义一个模板类型别名时,可以固定一个或多个模板参数。
类模板的每个实例都有一个独有的static
对象。
模板参数
类似函数参数的名字,一个模板参数的名字也没有什么内在含义。我们通常将类型参数命名为T
,但实际上我们可以使用任何名字。
一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。与任何其他名字一样,模板参数会隐藏外层作用域中声明的相同名字。但是,与大多数其他上下文不同,在模板内不能重用模板参数名:。
所以一个模板参数名在一个特定模板参数列表中只能出现一次。
与函数参数相同,声明中的模板参数的名字不必与定义中相同。
假定T
是一个模板类型参数,当编译器遇到类似T::mem
这样的代码时,它不会知道mem
是一个类型成员还是一个static
数据成员,直至实例化时才会知道。但是,为了处理模板,编译器必须知道名字是否表示一个类型。
默认情况下,C++语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字typename
来实现这一点。
当我们希望通知编译器一个名字表示类型时,必须使用关键字typename
,不能使用class
。
可以为函数和类模板提供默认实参。对于一个模板参数,只有当它右侧的所有参数都有默认实参时, 它才可以有默认实参。
无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。尖括号指出类必须从一个模板实例化而来。特别是,如果一个类模板为其所有模板参数都提供了默认实参, 且我们希望使用这些默认实参,就必须在模板名之后跟一个空尖括号对。
成员模板
成员模板不能是虚函数。
对于类模板,我们也可以为其定义成员模板。在此情況下,类和成员各自有自己的、独立的模板参数。
与类模板的普通函数成员不同,成员模板是函数模板。当我们在类模板外定义一个成员模板时,必须同时为类模板和成员模板提供模板参数列表。类模板的参数列表在前,后跟成员自己的模板参数列表。
1 | template <typename T> class Blob { |
与普通函数模板相同,编译器通常根据传递给成员模板的函数实参来推断它的模板实参。
控制实例化
在大系统中,在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中我们可以通过显式实例化来避免这种开销。一个显式实例化有如下形式:
1 | extern template declaration; // 实例化声明 |
declaration
是一个类或函数声明,其中所有模板参数已被替换为模板实参。
当编译器遇到extern
模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为extern
就表示承诺在程序其他位置有该实例化的一个非extern
声明(定义)。
extern
声明必须出现在任何使用此实例化版本的代码之前。
一个类模板的实例化定义会实例化该模板的所有成员,包括内联的成员函数。当编译器遇到一个实例化定义时,它不了解程序使用哪些成员函数。因此,与处理类模板的普通实例化不同,编译器会实例化该类的所有成员。
效率与灵活性
通过在编译时绑定删除器,unique_ptr
避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr
使用户重载删除器更为方便。
模板实参推断
类型转换与模板类型参数
与非模板函数一样,我们在一次调用中传递给函数模板的实参被用来初始化函数的形参。如果一个函数形参的类型使用了模板类型参数,那么它采用特殊的初始化规则。只有很有限的几种类型转换会自动地应用于这些实参。编译器通常不是对实参进行类型转换,而是生成一个新的模板实例。
与往常一样,顶层const
无论是在形参中还是在实参中,都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项。
const
转换:可以将一个非const
对象的引用(或指针)传递给一个const
的引用(或指针)形参。- 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。类似的,一个函数实参可以转换为一个该函数类型的指针。
其他类型转换都不能应用于函数模板。
如果形参是一个引用,则数组不会转换为指针。
如果函数参数类型不是模板参数,则对实参进行正常的类型转换。
函数模板显式实参
1 | template <typename T1, typename T2, typename T3> |
在本例中,没有任何函数实参的类型可用来推断T1
的类型。每次调用sum
时调用者都必须为T1
提供一个显式模板实参。
我们提供显式模板实参的方式与定义类模板实例的方式相同。显式模板实参在尖括号中给出,位于函数名之后,实参列表之前。
1 | auto val3 = sum<long long>(i, lng); |
显式模板实参按由左至右的顺序与对应的模板参数匹配:第一个模板实参与第一个模板参数匹配,第二个实参与第二个参数匹配,依此类推。只有尾部(最右)参数的显式模板实参才可以忽略,而且前提是它们可以从函数参数推断出来。
对于模板类型参数已经显式指定了的函数实参,也进行正常的类型转换。
尾置返回类型与类型转换
1 | template <tempname It> auto fcn(It beg, It end) -> decltype(*beg) |
为了获得元素类型,我们可以使用标准库的类型转换模板。这些模板定义在头文件type_traits
中。
remove_reference
模板有一个模板类型参数和一个名为type
的(public
)类型成员。如果我们用一个引用类型实例化remove_reference
,则type
将表示被引用的类型。
1 | template <typename It> auto fcn2(It beg, It end) -> typename remove_reference<decltype(*beg)>::type |
type
是一个类的成员,而该类依赖于一个模板参数。因此,我们必须在返回类型的声明中使用typename
来告知编译器,type
表示一个类型。
函数指针与实参推断
当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。如果不能从函数指针类型确定模板实参,则产生错误。
当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。
1 | void func(int(*)(const string&, const string&)); |
模板实参推断和引用
我们将一个左值传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&
)时,编译器推断模板类型参数为实参的左值引用类型。
如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。对于一个给定类型X:
X& &
、X& &&
和X&& &
都折叠成类型X&
- 类型
X&& &&
折叠成X&&
引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。
- 如果一个函数参数是一个指向模板类型参数的右值引用(如
T&&
),则它可以被绑定到一个左值;且 - 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(
T&
)
在实际中,右值引用通常用于两种情况:模板转发其实参或模板被重载。
使用右值引用的函数模板的重载:
1 | template <typename T> void f(T&&); |
理解std::move
1 | template <typename T> typename remove_reference<T>::type&& move(T&& t) |
虽然不能隐式地将一个左值转为右值引用,但我们可以用static_cast
显式地将一个左值转换为一个右值引用。
转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const
的以及实参是左值还是右值。
通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论是左值还是右值)使得我们可以保持const
属性,因为在引用类型中的const
是底层的。
forward
定义在头文件utility
中。forward
必须通过显式模板实参来调用。forward
返回该显式实参类型的右值引用。即,forward<T>
的返回类型是T&&
。通常情况下,我们使用forward
传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward
可以保持给定实参的左值/右值属性。
1 | template <typename F, typename T1, typename T2> void flip(F f, T1 &&t1, T2 &&t2) |
重载与模板
函数模板可以被另一个模板或一个普通非模板函数重载。与往常一样,名字相同的函数必须具有不同数量或类型的参数。 如果涉及函数模板,则函数匹配规则会在以下几方面受到影响:
- 对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。
- 候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板。
- 与往常一样,可行函数(模板与非模板)按类型转换(如果对此调用需要的话)来排序。当然,可以用于函数模板调用的类型转换是非常有限的。
- 与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。但是,如果有多个函数提供同样好的匹配,则:
- 如果同样好的函数中只有一个是非模板函数,则选择此函数。
- 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板。
- 否则,此调用有歧义。
可变参数模板
一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。存在两种参数包:模板参数包,表示零个或多个模板参数;函数参数包,表示零个或多个函数参数。
我们用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中,class...
或typename...
指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。
与往常一样,编译器从函数的实参推断模板参数类型。对于一个可变参数模板,编译器还会推断包中参数的数目。
当我们需要知道包中有多少元素时,可以使用sizeof...
运算符,sizeof...
返回一个常量表达式,而且不会对其实参求值。
编写可变参数函数模板
可变参数函数通常是递归的。第一步调用处理包中的第一个实参,然后用剩余实参调用自身。
1 | template <typename T> ostream &print(ostream &os, const T &t) |
对于最后一个调用,两个函数提供同样好的匹配。但是,非可变参数模板比可变参数模板更特例化,因此编译器选择非可变参数版本。
当定义可变参数版本的print
时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。
包扩展
对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展它。当扩展一个包时,我们还要提供用于每个扩展元素的模式。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。我们通过在模式右边放一个省略号来触发扩展操作。
第一个扩展操作扩展模板参数包,为print
生成函数参数列表。第二个扩展操作出现在对print
的调用中。此模式为print
调用生成实参列表。 对Args
的扩展中,编译器将模式const Args&
应用到模板参数包Args
中的每个元素。因此,此模式的扩展结果是一个逗号分隔的零个或多个类型的列表,每个类型都形如 const type&
。
第二个扩展发生在对print
的(递归)调用中。在此情况下,模式是函数参数包的名字(即rest
)。此模式扩展出一个由包中元素组成的、逗号分隔的列表。
1 | template <typename... Args> ostream &errorMsg(ostream &os, const Args&... rest) |
这个print
调用使用了模式debug_reg(rest)
。此模式表示我们希望对函数参数包rest
中的每个元素调用debug_rep
。扩展结果将是一个逗号分隔的debug_rep
调用列表。
转发参数包
可变参数函数通常将它们的参数转发给其他函数。
1 | template <typename... Args> void fun(Args&&... args) //将Args扩展为一个右值引用的列表 |
这里我们希望将fun
的所有实参转发给另一个名为work
的函数,假定由它完成函数的实际工作。
由于fun
的参数是右值引用,因此我们可以传递给它任意类型的实参;由于我们使用std::forward
传递这些实参,因此它们的所有类型信息在调用work
时都会得到保持。
模板特例化
一个特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。
当我们特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参。为了指出我们正在实例化一个模板,应使用关键字template
后跟一个空尖括号对(<>
)。空尖括号指出我们将为原模板的所有模板参数提供实参。
当我们定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配。
一个特例化版本本质上是一个实例,而非函数名的一个重载版本。因此,特例化不影响函教匹配。
为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。
一个类模板的部分特例化本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。部分特例化版本的模板参数列表是原始模板的参数列表的一个子集或者是一个特例化版本。
我们可以只特例化特定成员函数而不是特例化整个模板。
《C++ Primer 第五版》读书笔记 - 第三部分