C++primer小结13-14章

第十三章 拷贝控制

当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数定义了当此类型对象销毁时做什么。
我们称这些操作为拷贝控制操作
如果一个类没有定义所有这些拷贝控制成员,编译器会自动地为它定义缺失的操作。因此,很多类会忽略这些拷贝控制操作。

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
拷贝构造函数的第一个参数必须是一个引用类型。
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。

直接初始化和拷贝初始化之间的差异:
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。
拷贝初始化不仅在我们用=定义变量时会发生,在下面情况也会发生

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

某些类类型还会对它们所分配的对象使用拷贝初始化。

参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数。如果其参数不是引用类型,则调用永远也不会成功。

如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了。

在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。

13.1.2 拷贝赋值运算符

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。
如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

重载赋值运算符
重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数,类似于其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。类似拷贝构造函数,对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。

13.1.3 析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象使用的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。
由于析构函数不接受参数,因此它不能被重载。对于一个给定类,只会有唯一一个析构函数。
在对象最后一次使用之后,析构函数的函数体执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。

什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器被销毁时,其元素被销毁
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁
    由于析构函数自动运行,我们的程序可以按需要分配资源,而通常无须担心何时释放这些资源。
    当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。对于某些类,合成析构函数用于阻止该类型的独享被销毁。如果不是这种情况,合成析构函数的函数体就为空。
在(空)析构函数体执行完毕后,成员会被自动销毁。特别地,string的析构函数会被调用,它将释放bookNo成员所用的内存。
认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.4 三/五法则

有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
需要析构函数的类也需要拷贝和赋值操作
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。

13.1.5 使用=default

我们可以通过将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本。
但我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的。
我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。

13.1.6 阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的。
=delete通知编译器,我们不希望定义这些成员。
与=default不同,=delete必须出现在函数第一次声明的时候。
与=default的另一个不同之处是,我们可以对任何函数指定=delete。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
析构函数不能是删除的成员
对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。如果一个成员的析构函数是删除的,则该成员无法被销毁。
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
如果一个类有const成员,则它不能使用合成的拷贝赋值运算符。
对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。
本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
private拷贝控制
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。
拷贝控制成员是private的,因此普通用户代码无法访问。
声明但不定义一个成员函数是合法的。
希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。

13.2 拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。

13.2.1 行为像值的类

关键概念:赋值运算符
当你编写赋值运算符时,有两点要记住:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

13.2.2 定义行为像指针的类

13.3 交换操作

与拷贝控制成员不同,swap不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
在赋值运算符中使用swap
使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

13.4 拷贝控制实例

13.5 动态内存管理类

13.6 对象移动

新标准一个最主要的特性是可以移动而非拷贝对象的能力。
标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

13.6.1 右值引用

为了支持移动操作,新标准引入了一种新的引用类型——右值引用。我们通过&&而不是&来获得右值引用。右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
右值引用有着与常规引用完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上。
左值持久;右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。

变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上。

我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

13.6.2 移动构造函数和移动赋值运算符

类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一种状态——销毁它是无害的。

不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
移后源对象必须可析构
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认的被定义为删除的。
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
建议:不要随意使用移动操作。
由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。但我们调用move时,必须绝对确认移后源对象没有其他用户。
通过在类代码中小心地使用move,可以大幅度提升性能。

13.6.3 右值引用和成员函数

区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
左值和右值引用成员函数
我们指出this的左值/右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符
就像一个成员函数可以根据是否有const来区分其重载版本一样,引用限定符也可以区分重载版本。

第十四章 重载运算与类型转换

当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。

14.1 基本概念

重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。
当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。这一约定意味着当运算符作用域内置类型的运算对象时,我们无法改变该运算符的含义。

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。

选择作为成员或者非成员

14.2 输入和输出运算符

14.2.1 重载输出运算符<<

通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用。第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。
为了与其他输出运算符保持一致,operator<<一般要返回它的ostream形参。
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。

与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象。

14.2.2 重载输入运算符>>

通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。

输入运算符必须处理输入可能失败的情况,而输出运算符不需要。

当读取操作发生错误时,输入运算符应该负责从错误中恢复。

14.3 算术和关系运算符

通常情况下,我们把算术和关系运算符定义为非成员函数以允许对左侧或右侧的运算对象进行转换。
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。

14.3.1 相等运算符

如果某个类在逻辑上有相等性的含义,则该类应该定义operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。

14.3.2 关系运算符

定义了相等运算符的类也常常(但不总是)包含关系运算符。特别地,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。

如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。

14.4 赋值运算符

和拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间,再创建一片新空间。
我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。

赋值运算符必须定义为类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。

14.5 下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。

下标运算符必须是成员函数。

下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

14.6 递增和递减运算符

在迭代器类中通常会实现递增运算符(++)和递减运算符(–),这两种运算符使得类可以在元素的序列中前后移动。
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。

为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。

为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。

可以显式地调用一个重载的运算符,其效果与在表达式中以运算符号的形式使用它完全一样。如果我们想通过函数调用的方式调用后置版本,则必须为它的整型参数传递一个值。

14.7 成员访问运算符

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。

重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

14.8 函数调用运算符

如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

如果类定义了调用运算符,则该类的对象称作函数对象

含有状态的函数对象类
和其它类一样,函数对象类除了operator()之外也可以包含其他成员。
函数对象常常作为泛型算法的实参。

14.8.1 lambda是函数对象

当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用表达式。
默认情况下lambda不能改变它捕获的变量。因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员函数。如果lambda被声明为可变的,则调用运算符就不是const的了。

当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。
相反,通过值捕获的变量被拷贝都lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

14.8.2 标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。
例如,如果svec是一个vector< string>,下面的语句将按照降序对svec进行排序:

sort(svec.begin(), svec.end(), greater<string>());

关联容器使用less<key_type>对元素排序,因此我们可以定义一个指针的set或在map中使用指针作为关键值而无须直接声明less。

14.8.3 可调用对象与function

C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。然而,两个不同类型的可调用对象却可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。
我们可以使用一个名为function的新的标准库类型解决函数表问题。
function是一个模板,和我们使用过的其他模板一样,当创建一个具体的function类型时我们必须提供额外的信息。
我们不能(直接)将重载函数的名字存入function类型的对象中。

14.9 重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换

14.9.1 类型转换运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:operator type() const;其中type表示某种类型。类型转换运算符可以面向任意类型进行定义,只要该类型能作为函数的返回类型。
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常是const的。
避免过度使用类型转换函数。

显示的类型转换运算符(explicit conversion operator):

class SmallInt{
public:
	explicit operator int() const {return val;}
};

和显式的构造函数一样,编译器通常也不会将一个显式地类型转换运算符用于隐式类型转换。

向bool类型的转换通常用在条件部分,因此operator bool一般定义成explicit的。

14.9.2 避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。
如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。
在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。

14.9.3 函数匹配与重载运算符

表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值