- 定义一个类时,可显式或隐式的指定在此类型对象上
拷贝
、移动
、赋值
、销毁
时做什么。通过5种成员函数
实现拷贝控制
操作:拷贝构造函数
:用同类型的另一个对象初始化本对象时做什么(拷贝初始化)拷贝赋值算符
:将同类型的另一个对象赋值给本对象时做什么(拷贝赋值)移动构造函数
:用同类型的另一个对象初始化本对象时做什么(移动初始化)移动赋值算符
:将同类型的另一个对象赋值给本对象时做什么(移动赋值)析构函数
:本对象销毁时做什么(析构)
- 如果类未定义这些拷贝控制成员,编译器会自动合成一部分缺失的操作,因此很多类不需要自定义拷贝控制。最困难的经常是认识到什么时候需要自定义拷贝控制:编译器合成版本的行为可能并非预期。
拷贝、赋值与销毁
拷贝构造函数
拷贝构造函数
:这种构造函数的第一个参数是自身类类型的引用
,且任何额外参数都有默认值。- 拷贝构造函数的第一个参数必须是引用类型(否则传参时需要拷贝,循环调用)。虽然也可定义为非const,但几乎总是用
const引用
(不会改变被拷贝对象,且const引用能接受更多类型的参数)。 - 拷贝构造函数经常会被隐式使用(例如函数的传参和返回值),故不应该是
explicit
- 若未自定义拷贝构造函数,即使定义了其他构造函数,编译器也会合成一个拷贝构造函数(这一点与默认构造函数不同)
合成的拷贝构造函数
若非删除,则会将其参数的非static成员逐个拷贝到正在构造的对象中:- 内置类型:直接拷贝
- 类类型:用拷贝构造函数来拷贝
- 数组:逐元素拷贝(若元素是类类型,也用拷贝构造函数)
- 例子:合成的拷贝构造函数
|
|
- 直接初始化和拷贝初始化的差别:
直接初始化
:要求编译器使用普通函数匹配来选择最匹配的构造函数拷贝初始化
:要求编译器将=
右侧运算对象拷贝到正在创建的对象中,需要时可进行隐式转换
- 例子:直接初始化和拷贝初始化
|
|
- 拷贝初始化的工具:
拷贝构造函数
:通常使用拷贝构造函数移动构造函数
:如果该类有移动构造函数且用右值
调用时
- 拷贝初始化发生的情况:
- 用
=
定义变量 - 将对象作为实参传递给非引用类型的形参
- 从返回类型非引用的函数返回对象
- 用花括号列表初始化一个数组的元素或一个聚合类的成员
- 某些类类型会对其分配的对象使用拷贝初始化。例如初始化标准库容器或是调用
insert
/push
时容器对元素进行拷贝初始化,而用emplace
是对元素直接初始化
- 用
- 使用explicit的构造函数时,只能直接构造,不能拷贝构造(即,explicit的构造函数不可用于拷贝构造)
- 传递实参或返回值时,不能隐式使用explicit构造函数,必须显式使用
- 例子:explicit的构造函数不可用于拷贝构造
|
|
- 拷贝初始化时,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。但即使编译器跳过拷贝/移动构造函数,他们仍必须是存在且可访问的(例如不能是private)
- 例子:跳过拷贝/移动构造函数,直接创建
|
|
拷贝赋值运算符
- 类可通过拷贝构造函数来控制初始化,也可通过
拷贝赋值算符
来控制对象赋值 重载算符
本质上是函数,其名字是operator
关键字后接要定义的算符的符号。- 赋值算符是名为
operator=
的函数,它也有返回类型和参数列表 - 某些算符,包括赋值算符,必须定义为成员函数。若算符是成员函数,则其
左侧对象
隐式绑定到this指针。对于二元算符,例如赋值算符,其右侧对象
作为显式参数传递。 - 拷贝赋值算符接受一个与其所在类型相同的参数,返回左侧运算对象的引用(为与内置类型的赋值保持一致)。
- 标准库通常要求容器元素的类型有赋值算符,且返回值是左侧对象的引用,因为很多操作会拷贝元素。
- 若未自定义拷贝赋值算符,编译器会合成一个
合成的拷贝赋值算符
若非删除,则会将右侧对象的每个非static成员赋予左侧对象的对应成员(类类型成员调用拷贝赋值算符,数组成员逐个赋值)。它返回一个指向左侧对象的引用- 例子:合成拷贝赋值算符
|
|
析构函数
- 析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员并执行函数体,析构函数执行函数体并销毁对象的非static数据成员
析构函数
的名字是波浪线~
后接类名,它没有返回值也不接受参数,故不可重载,一个类只能有一个析构函数- 构造函数和析构函数的共性和差异:
- 构造函数有一个显式的初值列表和一个函数体;析构函数有一个函数体和一个隐式的析构部分。
- 构造函数先做成员初始化再执行函数体,成员按照类中出现的顺序初始化;析构函数先执行函数体再销毁成员,成员按照初始化的逆序销毁。
- 析构函数的析构部分是隐式的,成员如何销毁完全取决于类型:类类型成员析构时调用析构函数,内置成员析构时什么都不做,特别是,析构内置指针成员不会delete它指向的对象。
- 对象被销毁时调用析构函数:
- 变量离开作用域被销毁
- 类对象被销毁时成员被销毁
- 标准库容器/数组被销毁时,元素被销毁
- 动态对象的指针被delete时对象被销毁
- 临时对象,创建它的完整表达式结束时被销毁
- 析构函数自动运行,故程序可按需分配资源,无需担心何时释放(前提是析构函数良好定义)
- 例子:析构函数自动运行
|
|
- 若未自定义析构函数,编译器会合成一个
合成析构函数
若非删除,则其函数体为空,只有隐式的析构部分。- 析构函数体并不直接销毁成员,成员是在析构函数体执行之后的隐式析构阶段被销毁。
三/五法则
- 三个基本操作可控制类的拷贝:
拷贝构造函数
、拷贝赋值算符
、析构函数
。C++11中还定义了移动构造函数
和移动赋值算符
- 这5个操作不必全部定义,可以只定义一两个。但这些操作经常是一个整体,需要同时定义。
需要自定义析构函数的类也需要自定义拷贝和赋值
- 若一个类需要自定义析构函数(如管理动态对象),几乎肯定它也需要自定义拷贝和赋值操作
- 例子:需要析构函数时使用默认的拷贝和赋值(反例)
|
|
需要自定义拷贝操作的类也需要自定义赋值操作,反之亦然
- 需要自定义拷贝和赋值时,不一定需要自定义析构函数。例如,为类的每个对象生成编号
使用=default
- 将拷贝控制成员定义为
=default
可显式要求编译器生成合成的版本。只能对具有合成版本的成员函数使用(构造函数和拷贝控制成员) - 例子:使用=default
|
|
阻止拷贝
- 大多数类应该定义默认构造函数、拷贝构造函数、拷贝赋值算符,无论显式还是隐式。但对某些类,这些操作并无意义。例如iostream不可拷贝。
- 若要阻止一个操作(例如禁止拷贝),不定义是无效的,因为编译器会合成。正确做法是将其定义为删除函数
删除函数
是这样一种函数:虽然定义了,但不可以任何形式使用。C++11可将拷贝构造函数和拷贝赋值算符定义为删除来阻止拷贝。- 定义删除函数的方法是在参数列表后将其定义为
=delete
,它通知编译器,我们不希望定义这些成员。 =delete
和=default
的区别:- =delete必须出现在第一次声明时,而=default只需在定义时给出。因为编译器在函数声明时就必须知道它是否为删除的,以便禁止使用它
- =delete可对任意函数使用,而=default只能对编译器能合成的函数使用
- 不能删除析构函数。若删除析构函数则无法销毁此类型对象。
- 删除析构函数的情形:
- 对于删除了析构函数的类,编译器不允许对该类型定义变量/临时对象
- 若类的类类型成员删除了析构函数,则该类也不能定义变量/临时对象。
- 对于删除了析构函数的类,可以分配该类型的动态对象,只是不能释放
- 例子:删除析构函数的类可分配动态对象
|
|
- 对某些类的某些拷贝控制成员,编译器将合成的成员定义为删除函数。本质上,这些规则的含义是:若类有数据成员不可默认构造、拷贝、赋值、销毁,则该类对应的成员函数被定义为删除:
- 删除合成析构函数:
- 类某成员的析构函数是删除的或不可访问
- 删除合成拷贝构造函数:
- 类某成员的拷贝构造函数是删除的或不可访问
- 类某成员的析构函数是删除的或不可访问(可能创建无法销毁的对象)
- 删除合成拷贝赋值算符:
- 类某成员的拷贝赋值算符是删除的或不可访问
- 类有const成员或引用成员(可给引用成员赋值,但改变的是底层共享对象,不是期望结果)
- 删除默认构造函数:
- 类某成员的析构函数是删除的或不可访问
- 类的引用成员没有类内初始值
- 类的const成员没有类内初始值且类型未显式定义默认构造函数
- 删除合成析构函数:
- C++11之前,阻止拷贝控制的方法是将拷贝构造函数和拷贝赋值算符
声明为private且不定义
- 声明为private,保证类外拷贝时在编译期报错
- 声明但不定义,保证友元和成员函数拷贝时在链接期报错
- 例子:通过声明为
private
且不定义来阻止拷贝
|
|
- 希望阻止拷贝的正经操作是定义拷贝构造函数和拷贝赋值算符为=delete
拷贝控制和资源管理
- 管理类外资源的类通常需要定义拷贝控制成员,因为它需要析构函数来释放资源,几乎肯定也需要拷贝构造函数和拷贝赋值算符
拷贝语义
:有2种选择,可定义拷贝操作使类的行为看起来像值或指针行为像值的类
:每个对象有自己的资源。拷贝一个像值的对象时,副本和原对象完全独立。改变副本不会改变原对象,反之亦然。行为像指针的类
:所有对象共享资源。拷贝一个像指针的对象时,副本和原对象使用相同的底层数据。改变副本会改变原对象,反之亦然。
- 标准库容器和string的行为像值,shared_ptr的行为像指针,IO类型和unique_ptr不允许拷贝/赋值故不像值也不像指针
行为像值的类
- 为了提供类值的行为,对于类管理的资源,每个对象都应有一份拷贝。
赋值算符通常组合了析构函数和构造函数的操作
(销毁左侧对象类似析构,从右侧对象拷贝/移动类似构造),且应保证执行顺序正确,可处理自赋值。如果可能,还要保证异常安全。自赋值安全
:先拷贝右侧对象资源,再销毁左侧对象资源异常安全
:先分配空间存放临时资源,再改变左侧对象状态
- 最佳实践:定义赋值算符的步骤:
- 将右侧对象的资源拷贝出来
- 释放左侧对象的资源
- 让左侧对象接管从右侧对象拷出的资源
- 例子:类值版本的HasPtr
|
|
定义行为像指针的类
- 对于行为像指针的类,需为其定义拷贝构造函数和拷贝赋值算符,来拷贝指针成员本身而不是它管理的底层资源
- 令一个类行为像指针的最好办法是用
shared_ptr
来管理类内的资源 引用计数
的工作方式:- 每个
构造函数
(除拷贝构造函数外)都会创建独立的计数器(经常放在动态内存中),记录有多少对象与正在创建的对象共享数据。每个计数器初始化时都为1。 拷贝构造函数
不创建新的计数器,而是拷贝数据成员(包括指向计数器的指针),并将共享的计数器递增。析构函数
递减计数器,若计数为0则销毁底层数据。拷贝赋值算符
递增右侧对象的计数,递减左侧对象的计数。若左侧对象计数为0则销毁其底层数据
- 每个
- 计数器被保存在动态内存中,每次调用构造函数(除拷贝构造函数外)创建一个对象时都分配新的计数器。拷贝或赋值对象时,拷贝指向计数器的指针,使得副本和原对象共享相同的计数器。
- 赋值运算必须处理
自赋值
。实现方式是先递增右侧对象的计数再递减左侧对象的计数(检查是否为0之前递增) - 例子:类指针版本的HasPtr,手动实现引用计数
|
|
交换操作
- 除定义拷贝控制成员外,管理资源的类通常还需定义
swap函数
(非成员)。对于与重排元素顺序的算法一起使用的类而言,swap函数很重要。这类算法需要在交换两元素时调用其类型的swap函数。若未自定义swap,则算法调用标准库定义的swap - 交换两个对象,需要
一次拷贝和两次赋值
,即swap建立在拷贝控制之上 swap时尽量交换指针
,而不是分配新的副本- 例子:交换对象时需要一次拷贝和两次赋值
|
|
- 在类中将swap声明为友元函数,即可重载出该类型的swap
- 自定义swap不是必须的,但对于管理资源的类,自定义swap可能是很重要的优化手段
- swap经常被声明为
inline
以提高性能 - 例子:自定义swap友元函数
|
|
- 不同版本的swap:
- 对于内置类型,没有特定版本的swap,使用std::swap即可
- 对于类类型,应使用重载的swap,即使用swap而不是std::swap
- 最佳实践:
using std::swap;
允许使用标准库的swap,使用时用swap
尽量选择最匹配的自定义swap - 定义了swap的类可用
swap实现赋值算符
,使用拷贝并交换
技术,传参时拷贝右侧对象,将左侧对象与右侧对象的形参副本交换。 - 例子:用swap实现拷贝赋值算符
|
|
- “拷贝并交换”技术是
自赋值安全
且是异常安全
的:- 改变左侧对象之前就拷贝右侧对象,保证自赋值安全
- 唯一可能抛出异常的是拷贝右侧对象时的new,但这发生在改变左侧对象之前
拷贝控制示例
- 例子:使用拷贝控制进行簿记操作。每个Message可出现在多个Folder中,每个Folder也可容纳多个Message,但每个Message的内容只有一个副本。如图13.1
- 具体实现:
- 每个Message保存一条内容和一个它所在的Folder指针的
set
,每个Folder保存一个它容纳的Message指针的set
- Message类提供
save
和remove
操作,用于给定Folder添加/删除这个Message - Folder类提供
addMsg
和remMsg
操作,用于在该Folder中添加/删除一个Message 拷贝
Message时,除拷贝消息内容和Folder指针集合外,还要在容纳它的所有Folder中添加指向新Message的指针析构
Message时,除析构Message对象外,还要在容纳它的所有Folder中删除指向它的指针- 将一个Message
赋值
给另一个Message时,除更新左侧对象的内容外,还要从容纳左侧对象的Folder中删除左侧对象,并在容纳右侧对象的Folder中添加左侧对象
- 每个Message保存一条内容和一个它所在的Folder指针的
- 拷贝赋值算符通常执行拷贝构造函数和析构函数的工作,此时可定义公共的操作放在private中
- 代码:
|
|