C++ Primer 0x0D 学习笔记
Posted 鱼竿钓鱼干
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ Primer 0x0D 学习笔记相关的知识,希望对你有一定的参考价值。
📔 C++ Primer 0x0D 学习笔记
推荐阅读 《C++ Primer 5th》知识点总结&练习题解
13.1 拷贝、赋值与销毁
- 当定义一个类时,我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么
- 类通过定义物种特殊的成员函数来控制这些操作(即,拷贝控制操作)
- 拷贝构造函数:定义了用同类型的另一个对象初始化本对象时做什么
- 拷贝赋值运算符:定义了将一个对象赋予同类型对象时做什么
- 移动构造函数:定义了用同类型的另一个对象初始化本对象时做什么
- 移动赋值运算符:定义了将一个对象赋予同类型对象时做什么
- 析构函数:定义了当此类型对象在销毁时做什么
- 如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失操作,但是对于一些类来说,依赖这些默认定义的操作会导致灾难
13.1.1 拷贝构造函数
-
如果一个构造函数的第一个参数是自身类类型的引用(且一般是一个
const
的引用),且任何额外参数都有默认值,则此构造函数是拷贝构造函数 -
拷贝函数通常不应该是
explicit
的,经常会被下面几种情况隐式地使用- 合成拷贝构造函数
- 拷贝初始化
- 参数和返回值
合成拷贝构造函数
- 合成拷贝构造函数:如果我们没有为一个类定义拷贝构造函数,编译器会为我们合成一个拷贝构造函数
- 与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数
- 对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象
- 一般情况,合成拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中一次将每个非
static
成员拷贝到正在创建的对象中 - 每个成员的类型决定了它如何拷贝
- 类类型成员:使用拷贝构造函数来拷贝
- 内置类型的成员:直接拷贝
- 数组:不能直接拷贝一个数组,但会逐元素地拷贝一个数组类型的成员;如果元素是类类型那就用元素的拷贝构造函数
拷贝初始化
- 当使用直接初始化是,实际上要求编译器使用普通的函数匹配类选择与我们提供的参数最匹配的构造函数
- 当我们使用拷贝初始化是,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换
- 拷贝初始化通常使用拷贝构造函数来完成,但是如果有一个移动构造函数,有时候会使用移动构造函数而非拷贝构造函数来完成
- 拷贝初始化不仅在我们用
=
定义变量时发生,在下列情况也会发生- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或聚合类中的成员
- 某些类型还会对它们所分配的对象使用拷贝初始化(
insert
、push
会进行拷贝初始化,emplace
会直接初始化)
参数和返回值
- 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化(这解释了为什么拷贝构造函数的参数必须是引用类型,因为如果是非引用类型就又要找拷贝构造函数,死循环了)
- 当一个函数具有非引用的返回类型时,返回值会被用来初始化调用放的结果
拷贝初始化的限制
- 如果我们使用的初始化要求通过一个
explicit
的构造函数来进行类型转换,你们使用拷贝初始化还是直接初始化是无关紧要的 - 当传递一个实参或从函数返回一个值时,我们不能隐式使用一个
explicit
构造函数。我们希望使用,就必须显示地使用。 vector
接受单一大小参数的构造函数是explicit
的
编译器可以绕过拷贝构造函数
- 在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象
- 即使编译器掠过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(
不能是private
)
13.1.2 拷贝赋值运算符
重载赋值运算符
- 重载运算符本质上是函数,需要有一个返回类型和参数列表,参数表示运算符的运算符对象
- 某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的
this
参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显示参数传递 - 拷贝赋值运算符接受一个与其所在类相同类型的参数
- 为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用
- 值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用
- 赋值运算符通常应该返回一个指向其左侧运算对象的引用
合成拷贝赋值运算符
- 如果一个类未定义自己的拷贝赋值运算符,编译器会为它生产一个合成拷贝赋值运算符
- 对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值
- 一般的,拷贝赋值运算符会将右侧运算对象的每个非
static
成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。对于数组类型的成员,逐个赋值数组元素 - 合成拷贝赋值运算符返回一个指向左侧运算对象的引用
13.1.3 析构函数
- 构造函数:初始化对象的非
static
数据成员 - 析构函数:释放对象使用的资源,并销毁对象的非
static
数据成员 - 析构函数是类的一个成员函数,名字波浪号接类名,没有返回值,也不接受参数,不能被重载,对一个给定类唯一
析构函数完成什么工作
- 在析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁
- 不像构造函数有初始化列表,析构函数的析构部分是隐式的,成员销毁时发生什么完全依赖成员的类型
- 销毁类类型的成员执行类类型的析构函数
- 内置类型没有析构函数,销毁内置类型成员什么也不需要做
- 隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象 - 与普通指针不同,智能指针是类类型,所以具有析构函数。智能指针成员在析构阶段会被自动销毁
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准容器库还是数组)被销毁时,其元素被销毁
- 对于动态分配对象,对指向它的指针引用
delete
运算符时被销毁 - 对于临时对象,当创建它的完整表达式结束是被销毁
当一个指向对象的引用或指针离开作用域时,析构函数不会执行
合成析构函数
- 当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数
- 对于某些类,合成析构函数被用来阻止该类型的对象被销毁
- 一般,合成析构函数的函数体为空
- **认识到析构函数体自身并不直接销毁成员很重要。成员是在析构函数体之后隐含的析构阶段被销毁的。**在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的
13.1.4 三/五法则
- 有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数,新标准还可以定义一个移动构造函数和一个移动赋值运算符。
C++
不要求定义所有这些操作,但是这些操作通常应该被看作一个整体。通常只需要其中一个而不需要定义所有操作的情况很少见- 当我们决定一个类是否要定义他自己版本的拷贝控制成员时,一个基本原则就是确定这个类是否需要析构函数,需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
13.1.5 使用 =default
- 我们可以通过将拷贝控制成员定义为
=default
来显式地要求编译器生成合成的版本 - 当在类内使用
=default
修饰成员的声明时,合成的函数将隐式地声明为内联的;如果不希望是内联的,应该只对成员的类外定义使用=default
- 我们只能对具有合成版本的成员函数使用
=default
(即,默认构造函数或拷贝控制成员)
13.1.6 阻止拷贝
- 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地
- 对于某些类来说,拷贝赋值没有合理的意义,因此要采用某种机制阻止拷贝。例如
iostream
类组织了拷贝,避免多个对象写入或读取相同的IO
缓冲,不定义拷贝控制成员行不通,因为编译器会生成合成的版本。
定义删除的函数
-
我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来组织拷贝
-
删除的函数:虽然声明了它们,但不能以任何方式使用它们,在参数列表后加上
=delete
来指出我们希望将它定义为删除的 -
与
=default
不同,=delete
必须出现在函数第一次声明的地方,并且我们可以对任何函数指定=delete
-
析构函数不能是删除的成员
-
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针
-
合成的拷贝控制成员可能是删除的
- 如果类的某个成员的析构函数是删除的或不可访问的(如是
private
的),则类的合成析构函数被定义为删除的 - 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的
- 如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个
const
的或引用成员,则类的合成拷贝赋值运算被定义为删除的 - 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有个
const
成员,它没有类内初始化器且其类型未显示定义默认构造函数,则该类的默认构造函数被定义为删除的
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除
- 如果类的某个成员的析构函数是删除的或不可访问的(如是
private 拷贝控制
- 在新标准发布前,类通过将其拷贝构造函数和拷贝赋值运算符声明为
private
来阻止拷贝(现在应该使用delete
) private
阻止用户代码拷贝这个类型的对象,但是友元和成员函数仍然可以拷贝,为了阻止,我们将这些拷贝控制成员声明为private
但不定义它们
13.2 拷贝控制和资源管理
- 通常,管理类外资源的类必须定义拷贝控制成员,为了定义这些成员,我们必须确定此类型对象的拷贝语义。一般来说有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或像一个指针
- 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的(标准库容器,
string
类) - 行为像指针的类则共享状态,当我们拷贝一个这种类的对象时,副本和原对象共享相同的底层数据(
shared_ptr
) IO
类型和unique_ptr
不允许拷贝或赋值,因此它们的行为既不像值也不像指针
13.2.1 行为像值的类
- 为了提供类值的行为,对于类管理的资源,每个对象都应有自己的一份拷贝
类值拷贝赋值运算符
- 赋值运算符通常组合了析构函数和构造函数的操作(销毁左侧运算对象的资源,从右侧对象被拷贝数据)
- 重要的是这些操作以正确的顺序执行的,即使将一个对象赋予自身也保证正确,而且如果可能赋值运算符还应该是异常安全的。当异常发生时能将左侧运算对象置于一个有意义的状态
- 当编写赋值运算符时,有两点需要注意
- 如果将一个对象赋予自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝函数的工作
- 编写赋值运算符,一个好的模式:先将右侧运算对象拷贝到一个临时对象中,拷贝完成后销毁左侧对象的现有成员,然后将临时对象拷贝到左侧运算符(即,销毁左侧运算对象之前拷贝右侧运算对象)
13.2.2 定义行为像指针的类
- 对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的对象。还需要析构函数来释放对象内存
- 我们可以用
shared_ptr
来管理类中的资源,如果我们希望直接管理资源,我们可以设计自己的引用计数(可能会考你手写一个shared_ptr
)
引用计数
引用计数的工作方式如下
-
除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态
-
拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器指出给定对象的状态又被一个新用户所共享
-
析构函数递减计数器,如果计数器变0,析构函数释放状态
-
拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如果左侧运算器的计数器为0,意味着它的共享状态没用户了,拷贝赋值运算符就必须销毁状态
-
我们一般把计数器保存在动态内存中而不是直接作为成员。创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象是,我们拷贝指向计数器的指针。使用这种方法副本和原对象都会指向相同计数器
类指针的拷贝成员篡改引用计数
- 当拷贝或赋值
HasPtr
对象时,希望副本和原对象都指向相同的string
,即拷贝指针本身而不是指向的对象。同时递增和string
关联的计数器 HasPtr
的拷贝构造函数会拷贝所有三个数据成员并递增计数器- 析构函数不能无条件
delete
,要先递减计数器,计数器为0才delete
- 拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作:必须递增右侧运算对象的引用计数,然后析构等效的代码,然后拷贝构造函数等效的代码,然后返回本对象。记住要能处理自赋值
13.3 交换操作
- 除了定义拷贝控制成员,管理资源的类通常还定义一个名为
swap
的函数。对于那些与重盘元素顺序的算法一起使用的类,定义swap
非常重要 - 如果一个类自定义了自己的
swap
,那么算法将使用类自定义版本,否则使用标准库版本 - 实现
swap
可以通过创建临时变量的方法(即一次拷贝和两次赋值),也可以通过交换指针而不是分配内存
编写我们自己的swap
函数
- 与拷贝控制成员不同,
swap
不是必要的。但是对于分配了资源的类,定义swap
可能是一种重要的优化手段 - 既然为了优化,那就加个
inline
swap
函数应该调用自己写的swap
而不是std::swap
,如果没有自己写的swap
优先匹配标准库的
在赋值运算中使用swap
- 看书上代码
- 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值
13.4 拷贝控制实例
- 写代码
- 拷贝赋值运算符通常执行拷贝构造函数和析构函数也要做的工作。这种情况下,公共的工作应该放在
private
的工具函数中完成 - 对于动态分配内存的例子来说,拷贝交换方式是一种简洁的设计。如果 类并不需要动态分配内存,用拷贝交换方式只会增加实现的复杂度。
13.5 动态内存管理类
- 某些类需要在运行时分配可变大小的内存空间,一般我们可以用标准库来保存它们的数据,但是这个并不是对每个类都适用。某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存
- 在重新分配内存的过程中移动而不是拷贝元素,使用移动构造函数和
std::move
可以避免string
拷贝 - 看书,写代码
13.6 对象移动
- 如果对象拷贝后就立即被销毁了,那么移动而非拷贝对象会大幅度提升性能
- 使用移动而不是拷贝的另一个原因源于
IO
类或unique_ptr
这样的类都包含不能被共享的资源(如指针或IO
缓冲)。因此,这些类型的对象不能拷贝但是可以移动 - 在旧版本标准库容器保存的类必须是可拷贝的,新标准中只要能被移动就可以了
- 标准库容器、
string
、shared_ptr
类既支持移动也支持拷贝;IO
类和unique_ptr
类可以移动但不能拷贝
13.6.1 右值引用
- 为了支持移动操作,新标准加入了新的引用类型:右值引用
- 右值引用必须绑定在右值(要求转换的表达式,字面常量,返回右值的表达式),不能直接绑定到一个左值上,通过
&&
而不是&
来获得- 返回左值引用的函数,连通赋值、下标、解引用和前置递增/递减运算符,都是返回左值表达式的例子
- 返回非引用类型的函数,连通算术、关系、位以及后值递增/递减运算符,都生成右值。不能将左值引用绑定到这类表达式上,但可以将一个
cosnt
的左值引用或者一个右值引用绑定到这类表达式上
- 只能绑定到一个将要销毁的对象上(就好比打游戏,对面有武器,对面要死了你才可以去捡武器)
- 左值持久:对象的身份,具有持久的状态
- 右值短暂:对象的值,要么是字面常量,要么是表达式求值过程中创建的临时对象
- 右值引用的代码可以自由地接管所引用的对象的资源
- 变量是左值,即使这个变量是右值引用(右值引用不等于右值)。
- 我们不能将一个右值引用绑定到一个变量上,即使是右值引用类型的变量也不行
- 可以通过
move
来获得绑定到左值上的右值引用 - 调用
move
意味着承偌:除了对r11
赋值或销毁它外,我们将不再使用它。在调用move
之后,我们不呢个对移后源对象的值做任何假设 - 使用
move
的代码应该使用std::move
而不是move
,这样可以避免潜在的名字冲突
13.6.2 移动构造函数和移动赋值运算符
- 如果我们自己的类和
string
一样同时支持拷贝和移动,那么也能从中受益。为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符,这两个成员类似对应的拷贝操作,但他们从给定对象窃取而不是拷贝资源 - 不同于拷贝构造函数,移动构造函数第一个参数是一个右值引用。和拷贝构造函数一样,任何额外参数都必须有默认实参
- 除了完成资源的移动,移动构造函数还必须确保移动后的源对象处于销毁的状态
- 一旦资源完成移动,源对象必须不再指向被移动的资源,所有权已经归属新建的对象
- 移动构造函数不分配任何新内存,它接管给定的对象的内存。在接管内存之后,将给定对象中的指针都置位
nullptr
,这样就完成了给定对象的移动操作,此对象继续存在,移后源对象运行析构函数被销毁
移动操作、标准库容器和异常
- 由于移动操作窃取资源,通常不分配任何资源,因此移动操作通常不抛出任何异常。
- 当我们编写不抛出异常的移动操作时,我们要用
noexcept
通知标准库 - 不抛出异常的移动构造函数和移动赋值运算符必须标记为
noexcept
移动赋值运算符
- 移动赋值运算符执行与析构函数和移动构造函数相同的工作,如果我们的移动赋值运算符不抛出任何异常,我们应该标记为
noexcept
。类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值
移后源对象必须可析构
- 从一个对象移动数据并不会销毁此对象,但是有时移动操作完成后,源对象会被销毁,因此编写一个移动操作时,必须确保移后源对象进入一个可析构的状态
- 除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍是有效的。一般,对象有效就是指可以安全得为其赋新值或者可以安全的使用而不依赖当前值。
- 移动对象对移后源对象留下的值没有任何要求,因此我们的程序不应该依赖于移后源对象中的数据
- 移动操作之后,移后源对象必须保证有效的、可析构的状态,但是用户不能对其值进行任何假设
合成的移动操作
- 只有当一个类没有任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符
- 与拷贝操作不同,移动操作永远不会被隐式定义为删除的函数。但如果显示地要求编译器生成
=default
移动操作,且编译器不能移动所有成员时,则编译器会将合成的移动操作定义为删除的函数(有一个例外) - 移动操作和合成的拷贝控制成员还有最后一个相互作用关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的
- 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则这些成员默认地被定义为删除
移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝
- 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数配规则来确定使用哪个构造函数,赋值的情况类似。
- 如果一个类有拷贝构造函数但未定义移动构造函数,编译器不会合成移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用
move
来移动它们时也是如此 - 值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象置于有效状态。实际上,拷贝构造函数甚至不会改变原对象的值。
- 如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来移动的。拷贝赋值运算符和移动赋值运算符的情况类似
拷贝并交换赋值运算符和移动操作
- 拷贝并交换版本的赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
- 拷贝并交换版本的赋值运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数(左值被拷贝,右值被移动)因此,单一的赋值运算符就实现了拷贝赋值和移动赋值两种运算符功能
- 建议:更新三/五法则。所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作
移动迭代器
- 新标准库定义了一种移动迭代器适配器,一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器
- 一般来说,一个迭代器的运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器解引用运算符生产一个右值引用
- 可以使用
make_move_iterator
函数将一个普通迭代器转为移动迭代器 - 原迭代器的所有操作在移动迭代器中都照常工作,所以可以传递一些算法给移动迭代器。
- 标准库并不保证哪些算法使用移动迭代器,哪些不适用。由于移动一个对象可能销毁原对象,所以只有确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法
- 由于一个移后源对象具有不确定状态,对其调用
std::move
是危险的。当我们调用move
时,必须绝对确认移后源对象没有其他用户 - 通过在类代码中小心地使用
move
,可以大幅度提升性能。如果随意在普通用户代码中使用移动操作,很可能导致莫名奇妙的、难以查找的错误、而难以提升应用程序性能 - 在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信需要进行移动操作且移动操作是安全的,才可以使用
std::move
13.6.3 右值引用和成员函数
- 如果一个成员函数同时提供拷贝和移动版本,它也能从中收益。这种允许移动移动的成员函数通常与拷贝/移动构造函数和赋值运算符有相同的参数模式:一个版本接受指向
const
的左值引用,第二个版本接受一个指向非const
的右值引用 - 如果希望强制左值运算对象是一个左值,可以通过在成员函数的参数列表后放置一个引用限定符
- 引用限定符可以和
const
一样用来区分重载版本 - 当我们定义
const
成员函数时,可以定义两个版本,唯一的差别是一个版本有const
限定另一个没有;引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和参数列表的成员函数,就必须对所有函数都加上引用限定,或者所有都不加 - 如果一个成员函数有引用限定,则具有相同参数列表的所有版本都必须有引用限定符
以上是关于C++ Primer 0x0D 学习笔记的主要内容,如果未能解决你的问题,请参考以下文章