C++标准库 --拷贝控制 (Primer C++ 第五版 · 阅读笔记)
——(持续更新!!!)
第13章 拷贝控制 —— 第Ⅲ部分(类设计者的工具)
当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数( copy constructor
)、拷贝赋值运算符(copy-assignment operator
)、移动构造函数(move constructor
)、移动赋值运算符(move-assignment operator
)和 析构函数(destructor
)。
- 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
- 析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作(copy control)。
如果一个类没有定义所有这些 拷贝控制成员(上述5种),编译器会自动为它定义缺失的操作。因此,很多类会忽略这些拷贝控制操作。但是,对一些类来说,依赖这些操作的默认定义会导致灾难。通常,实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。
在定义任何C++类时,拷贝控制操作都是必要部分。对初学C++的程序员来说,必须定义对象拷贝、移动、赋值或销毁时做什么,这常常令他们感到困惑。这种困扰很复杂,因为如果我们不显式定义这些操作,编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想。
13.1、拷贝、赋值与销毁
我们将以最基本的操作 ---- 拷贝构造函数、拷贝赋值运算符 和 析构函数作为开始。我们在13.6节中将介绍移动操作(新标准所引入的操作)。
13.1.1、拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
- 拷贝构造函数的第一个参数必须是一个引用类型,原因我们稍后解释。
- 虽然我们可以定义一个接受
非 const
引用的拷贝构造函数,但此参数几乎总是一个const
的引用。 - 拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是
explicit
的。
class Foo {
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
// ...
};
⭐️ 合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
- 如我们将在13.1.6节中所见,对某些类来说, 合成拷贝构造函数(
synthesized copy constructor
)用来阻止我们拷贝该类类型的对象。 - 而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非
static成员
拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:
- 对类类型的成员,会使用其拷贝构造函数来拷贝;
- 内置类型的成员则直接拷贝。
虽然我们不能直接拷贝一个数组 ,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。
作为一个例子,我们的 Sales_data
类的合成拷贝构造函数等价于:
class Sales_data{
public:
//其他成员和构造函数的定义,如前;
//与合成的拷贝构造函数等价的拷贝构造函数的声明
Sales_data (const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
//与Sales_data的合成的铂贝构造函数等价
Sales_data::Sales_data (const sales_data &orig):
bookNo(orig.bookNo), //使用string 的铂贝构造函数
units_sold(orig.units_sold), //拷贝orig.units_sold
revenue(orig.revenue) //拷贝orig.revenue
{
}//空函数体
⭐️ 拷贝初始化
现在,我们可以完全理解直接初始化和拷贝初始化之间的差异了:
- 如果使用等号(
=
)初始化一个变量,实际上执行的是拷贝初始化(copy initialization
),编译器把等号右侧的初始值 拷贝 到新创建的对象中去。 - 与之相反,如果不使用等号,则执行的是直接初始化(
direct initialization
)。
string dots(10, '.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string null_book = "9-999-99999-9"; //拷贝初始化
string nines = string(100, '9'); //拷贝初始化
- 当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
- 当我们使用拷贝初始化(
copy initialization
)时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。但是,如我们将在13.6.2节所见,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。但现在,我们只需了解拷贝初始化何时发生,以及拷贝初始化是依靠拷贝构造函数或移动构造函数来完成的就可以了。
拷贝初始化不仅在我们用 =
定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个 非引用类型 的形参;
- 从一个返回类型为 非引用类型 的函数返回一个对象;
- 用 花括号列表 初始化一个数组中的元素或一个 聚合类 中的成员;
某些类类型还会对它们所分配的对象使用拷贝初始化。
- 例如,当我们初始化标准库容器或是调用其
insert
或push
成员时,容器会对其元素进行拷贝初始化。 - 与之相对,用
emplace
成员创建的元素都进行直接初始化。
⭐️ 参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
⭐️ 拷贝初始化的限制
如前所述,如果我们使用的初始化值要求通过一个explicit
的构造函数来进行 类型转换 ,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
vector<int> v1(10); //正确:直接初始化
vector<int> v2 = 10; //错误:接受大小参数的构造函数是explicit的
void f(vector<int>); //f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector
直接初始化 v1
是合法的,但看起来与之等价的拷贝初始化 v2
则是错误的,因为
vector
的接受单一大小参数的构造函数是 explicit
的。出于同样的原因,当传递一个实参或从函数返回一个值时,我们不能隐式使用一个 explicit
构造函数。如果我们希望使用一个 explicit
构造函数,就必须显式地使用,像此代码中最后一行那样。
⭐️ 编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码
string null_book = "9-999-99999-9"; //拷贝初始化
//改写为
string null_book("9-999-99999-9"); //编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是
private
的)。
13.1.2、拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum; //使用Sales_data的拷贝赋值运算符
与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它 合成 一个。
⭐️ 重载赋值运算符
在介绍合成赋值运算符之前,我们需要了解一点儿有关重载运算符(overloaded operator
)的知识,详细内容将在第14章中进行介绍。
重载运算符本质上是函数,其名字由operator
关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator=
的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为 成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this
参数。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。
拷贝赋值运算符 接受一个与其 所在类相同类型的参数 :
class Foo {
public:
Foo& operator=(const Foo&); //赋值运算符
// ...
};
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的 引用 。另外值得注意的是,标准库通常要求保存在容器中的类型要具有赋值运算符,且其返回值是左侧运算对象的引用。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
⭐️ 合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符(synthesized copy-assignment operator
)。
- 类似拷贝构造函数,对于某些类, 合成拷贝赋值运算符用来 禁止 该类型对象的赋值。
- 如果拷贝赋值运算符并非出于此目的,它会将右侧运算对象的每个非
static
成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。 - 对于数组类型的成员,逐个赋值数组元素。
- 合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
作为一个例子,下面的代码等价于 Sales_data
的合成拷贝赋值运算符:
//等价于合成铂贝赋值运算符
sales_data&
Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; //调用string::operator=
units_sold = rhs.units_sold; //使用内置的int赋值
revenue = rhs.revenue; //使用内置的double赋值
return *this; //返回一个此对象的引用
}
13.1.3、析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的 非static
数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的 非static
数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:
class Foo{
public:
~Foo(); //析构函数
//...
};
由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一 一个析构函数。
⭐️ 析构函数完成什么工作
如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。
- 在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。
- 在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。 成员销毁时发生什么完全依赖于成员的类型。
- 销毁类类型的成员需要执行成员自己的析构函数。
- 内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象。
与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁。
⭐️ 什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用
delete
运算符时被销毁。 - 对于临时对象,当创建它的完整表达式结束时被销毁。
由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源。
例如,下面代码片段定义了四个 Sales_data
对象:
{
//新作用域
// p和p2指向动态分配的对象
Sales_data *p = new Sales_data; // p是一个内置指针
auto p2 = make_shared<Sales_data>(); // p2是一个shared_ptr
Sales_data item(*p); //拷贝构造函数将*p拷贝到item中
vector<Sales_data> vec; //局部对象
vec.push_back(*p2); //拷贝p2指向的对象
delete p; //对p指向的对象执行析构函数
}//退出局部作用域;对item、 p2和vec调用析构函数
//销毁p2会递减其引用计数;如果引用计数变为0,对象被释放
//销毁vec会销毁它的元素
每个 Sales_data
对象都包含一个 string
成员,它分配动态内存来保存 bookNo
成员中的字符。但是,我们的代码唯一需要直接管理的内存就是我们直接分配的 Sales_data
对象。我们的代码只需直接释放绑定到 p
的动态分配对象。
其他 Sales_data
对象会在离开作用域时被自动销毁。当程序块结束时,vec
、p2
和 item
都离开了作用域,意味着在这些对象上分别会执行 vector
、shared_ptr
和 Sales_data
的析构函数。
vector
的析构函数会销毁我们添加到vec
的元素。shared_ptr
的析构函数会递减p2
指向的对象的引用计数。在本例中,引用计数会变为0
,因此shared_ptr
的析构函数会delete p2
分配的Sales_data
对象。
在所有情况下, Sales_data
的析构函数都会隐式地销毁 bookNo
成员。销毁bookNo
会调用 string
的析构函数,它会释放用来保存ISBN的内存。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
⭐️ 合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数(synthesized destructor
)。
- 类似 拷贝构造函数 和 拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数 的函数体就为空。
例如,下面的代码片段等价于 Sales_data
的合成析构函数:
class sales_data {
public:
//成员会被自动销毁,除此之外不需要做其他事情
~Sales_data(){
}
//其他成员的定义,如前
};
在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string
的析构函数会被调用,它将释放 bookNo
成员所用的内存。
认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
13.1.4、三/五法则
如前所述,有三个基本操作可以控制类的拷贝操作; 拷贝构造函数、拷贝赋值运算符 和 析构函数。而且,在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符,我们将在13.6节中介绍这些内容。
C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
⭐️ 需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。
- 通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。
- 如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
如果我们为 HasPtr
定义一个析构函数,但使用合成版本的拷贝构造函数和拷贝赋值运算符,考虑会发生什么:
class HasPtr{
public:
HasPtr (const std::string &s = std::string()):
ps(new std::string(s)), i(0){
}
~HasPtr() {
delete ps; }
//错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
//其他成员的定义,如前
};
在这个版本的类定义中,构造函数中分配的内存将在 HasPtr
对象销毁时被释放。但不幸的是,我们引入了一个严重的错误! 这个版本的类使用了合成的拷贝构造函数和拷贝赋值运算符。 这些函数简单拷贝指针成员,这意味着多个 HasPtr
对象可能指向相同的内存:
HasPtr f(HasPtr hp)// HasPtr是传值参数,所以将被拷贝
{
HasPtr ret = hp; //拷贝给定的HasPtr
//处理ret
return ret; // ret和hp被销毁
}
当 f
返回时,hp
和 ret
都被销毁,在两个对象上都会调用 HasPtr
的析构函数。此析构函数会 delete
ret
和 hp
中的指针成员。但这两个对象包含相同的指针值。此代码会导致此指针被 delete
两次,这显然是一个错误。将要发生什么是未定义的。
此外,f
的调用者还会使用传递给f的对象:
HasPtr p("some values");
f(p); //当f结束时,p.ps指向的内存被释放
HasPtr q(p); //现在p和q都指向无效内存!
p
(以及q
)指向的内存不再有效,在hp
(或 ret!
)销毁时它就被归还给系统了。
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符 和 拷贝构造函数。
⭐️ 需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。
作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。
这个例子引出了第二个基本原则: 如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个考贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
13.1.5、使用=default
我们可以通过将拷贝控制成员定义为 =default
来显式地要求编译器生成合成的版本
class Sales_data{
public:
//拷贝控制成员;使用default
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator= (const Sales_data &);
~Sales_data() = default;
//其他成员的定义,如前
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;
- 当我们在类内用
=default
修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。 - 如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用
=default
,就像对拷贝赋值运算符所做的那样。
我们只能对具有合成版本的成员函数使用
=default
(即, 默认构造函数或拷贝控制成员)。
13.1.6、阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
虽然大多数类应该定义(而且通常也的确定义了)拷贝构造函数 和 拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义。在此情况下,定义类时必须采用某种机制 阻止 拷贝或赋值。
- 例如,
iostream
类阻止了拷贝,以避免多个对象写入或读取相同的 IO缓冲。 - 为了阻止拷贝,看起来可能应该不定义拷贝控制成员。但是,这种策略是无效的: 如果我们的类未定义这些操作,编译器为它生成合成的版本。
⭐️ 定义删除的函数
在新标准下,我们可以通过将 拷贝构造函数 和 拷贝赋值运算符 定义为 删除的函数( deleted function
)来阻止拷贝。删除的函数是这样一种函数:
- 我们虽然声明了它们,但不能以任何方式使用它们。
- 在函数的参数列表后面加上
=delete
来指出我们希望将它定义为删除的:
struct NoCopy {
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const Nocopy&) = delete; // 阻止赋值
~NoCopy() = default;
//使用合成的析构函数
//其他成员
};
=delete
通知编译器(以及我们代码的读者),我们不希望定义这些成员。
- 与
=default
不同,=delete
必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default
直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。 - 与
=default
的另一个不同之处是,我们可以对任何函数指定 =delete
(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default
)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。
⭐️ 析构函数不能是删除的成员
值得注意的是,我们不能删除析构函数。
- 如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且,如果一个类有某个成员的类型 删除了析构函数,我们也不能定义该类的变量或临时对象。因为如果一个成员的析构函数是删除的,则该成员无法被销毁。而如果一个成员无法被销毁,则对象整体也就无法被销毁了。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象:
struct NoDtor {
NoDtor() = default; //使用合成默认构造函数
~NoDtor () = delete; //我们不能销毁NoDtor类型的对象
};
NoDtor nd; //错误:NoDtor的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能delete p
delete p; //错误:NoDtor的析构函数是删除的
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
⭐️ 合成的拷贝控制成员可能是删除的
如前所述,如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的 析构函数是 删除的或不可访问的(例如,是
private
的),则类的合成析构函数被定义为删除的。 - 如果类的某个成员的拷贝构造函数是 删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个
const
的或 引用成员,则类的合成拷贝赋值运算符被定义为删除的。 - 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个
const
成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上,这些规则的含义是: 如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
一个成员有删除的或不可访问的 析构函数 会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。
对于具有引用成员或无法默认构造的const成员
的类,编译器不会为其合成默认构造函数,这应该不奇怪。同样不出人意料的规则是: 如果一个类有 const成员
,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个const
对象是不可能的。
- 虽然我们可以将一个新值赋予一个引用成员,但这样做改变的是引用指向的对象的
值,而不是引用本身。 - 如果为这样的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象。由于这种行为看起来并不是我们所期望的,因此对于有引用成员的类,合成拷贝赋值运算符被定义为删除的。
本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
⭐️ private 拷贝控制
在新标准发布之前,类是通过将其 拷贝构造函数和 拷贝赋值运算符声明为private
的来阻止拷贝:
class PrivateCopy {
//无访问说明符;接下来的成员默认为private 的;
//拷贝控制成员是private的,因此普通用户代码无法访问
PrivateCopy(const Privatecopy&);
Privatecopy &operator= (const PrivateCopy&);//其他成员
public:
PrivateCopy() = default; //使用合成的默认构造函数
~PrivateCopy();//用户可以定义此类型的对象,但无法拷贝它们
};
由于析构函数是 public
的,用户可以定义 PrivateCopy
类型的对象。但是,由于拷贝构造函数和拷贝赋值运算符是 private
的,用户代码将不能拷贝这个类型的对象
但是,友元和成员函数仍旧可以拷贝对象。为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为 private
的,但并不定义它们。
声明但不定义一个成员函数是合法的,对此只有一个例外,我们将在15.2.1节=中介绍。
- 试图访问一个未定义的成员将导致一个链接时错误。
- 通过声明(但不定义)
private
的拷贝构造函数,我们可以预先阻止任何拷贝该类
型对象的企图:- 试图拷贝对象的用户代码将在编译阶段被标记为错误;
- 成员函数或友元函数中的拷贝操作将会导致链接时错误。
希望阻止拷贝的类应该使用
=delete
来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private
的。
13.2、拷贝控制和资源管理
通常,管理 类外资源 的类必须定义拷贝控制成员。如我们在13.1.4节中所见,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择: 可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
- 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
- 行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
在我们使用过的标准库类中,标准库容器 和 string类
的行为像一个值。而不出意外的,shared_ptr
类提供类似指针的行为,就像我们的 StrBlob类
(参见12.1.1节)一样,IO类型
和 unique_ptr
不允许拷贝或赋值,因此它们的行为既不像值也不像指针。
为了说明这两种方式,我们定义 HasPtr
类有两个成员,一个 int
和一个string
指针。通常,类直接拷贝内置类型(不包括指针)成员;这些成员本身就是值,因此通常应该让它们的行为像值一样。我们如何拷贝指针成员决定了像 HasPtr
这样的类是具有类值行为还是类指针行为。
13.2.1、行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。这意味着对于 ps
指向的 string
,每个 HasPtr
对象都必须有自己的拷贝。为了实现类值行为,HasPtr
需要
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放
string
- 定义一个拷贝赋值运算符来释放对象当前的
string
,并从右侧运算对象拷贝string
类值版本的 HasPtr
如下所示:
class HasPtr {
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0){
}
// 对ps指向的string,每个HasPtr对象都有自己的拷贝
HasPtr(const. HasPtr &p):
ps(new std::string(*p.ps)), i(p.i) {
}
HasPtr& operator=(const HasPtr &);
~HasPtr() {
delete ps; }
private:
std::string *ps;
int i;
};
我们的类足够简单,在类内就已定义了除赋值运算符之外的所有成员函数。第一个构造函数接受一个(可选的)string
参数。这个构造函数 动态分配它自己的 string
副本,并将指向 string
的指针保存在 ps
中。拷贝构造函数也分配它自己的 string
副本。析构函数对指针成员ps
执行delete
,释放构造函数中分配的内存。
⭐️ 类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。
- 类似析构函数,赋值操作会销毁左侧运算对象的资源。
- 类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
但是,非常重要的一点是,这些操作是以正确的顺序执行的,即使将一个对象赋予它自身,也保证正确。而且,如果可能,我们编写的赋值运算符还应该是异常安全的——当异常发生时能将左侧运算对象置于一个有意义的状态。
在本例中,通过先拷贝右侧运算对象,我们可以处理自赋值情况,并能保证在异常发生时代码也是安全的。在完成拷贝后,我们释放左侧运算对象的资源,并更新指针指向新分配的string
:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; //返回本对象
}
在这个赋值运算符中,非常清楚:
- 我们首先进行了构造函数的工作:
newp
的初始化器等价于HasPtr
的拷贝构造函数中ps
的初始化器。 - 接下来与析构函数一样,我们delete当前
ps
指向的string
。 - 然后就只剩下拷贝指向新分配的
string
的指针,以及从rhs
拷贝int
值到本对象了。
关键概念:赋值运算符
当你编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
为了说明防范自赋值操作的重要性,考虑如果赋值运算符如下编写将会发生什么
//这样编写赋值运算符是错误的!HasPtr&
HasPtr::operator= (const HasPtr &rhs)
{
delete ps; //释放对象指向的string
//如果rhs和*this是同一个对象,我们就将从已释放的内存中拷贝数据!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}
如果 rhs
和本对象是同一个对象,delete ps
会释放*this
和 rhs
指向的 string
。接下来,当我们在 new
表达式中试图拷贝 *(rhs.ps)
时,就会访问一个指向无效内存的指针,其行为和结果是未定义的。
对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作。一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。
13.2.2、定义行为像指针的类
对于行为类似指针的类,我们需要为其定义 拷贝构造函数 和 拷贝赋值运算符,来 拷贝指针成员本身而不是它指向的 string
。我们的类仍然需要自己的析构函数来释放接受 string
参数的构造函数分配的内存。但是,在本例中,析构函数不能单方面地释放关联的 string
。只有当最后一个指向 string
的 HasPtr
销毁时,它才可以释放 string
。
令一个类展现类似指针的行为的最好方法是使用 shared_ptr
来管理类中的资源。拷贝〈或赋值)一个 shared_ptr
会拷贝(赋值) shared_ptr
所指向的指针。 shared_ptr
类自己记录有多少用户共享它所指向的对象。当没有用户使用对象时, shared_ptr
类负责释放资源。
但是,有时我们希望直接管理资源。在这种情况下,使用引用计数(reference count
) 就很有用了。为了说明引用计数如何工作,我们将重新定义 HasPtr
,令其行为像指针一样,但我们不使用shared_ptr
,而是设计自己的引用计数。
⭐️ 引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为
0
,则析构函数释放状态。 - 拷贝赋值运算符 递增右侧运算对象的计数器 , 递减左侧运算对象的计数器 。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
唯一的难题是确定在哪里存放引用计数。计数器不能直接作为 HasPtr
对象的成员。下面的例子说明了原因:
HasPtr p1("Hiya!");
HasPtr p2(p1); // p1和p2指向相同的string
HasPtr p3(p1); // p1、p2和p3都指向相同的string
如果引用计数保存在每个对象中,当创建 p3
时我们应该如何正确更新它呢? 可以递增 p1
中的计数器并将其拷贝到 p3
中,但如何更新 p2
中的计数器呢?
解决此问题的一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。
⭐️ 定义一个使用引用计数的类
通过使用引用计数,我们就可以编写类指针的 HasPtr
版本了:
class HasPtr{
public:
//构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)) , i(0), use(new std::size_t(1)){
}
//拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr &p):
ps(p.ps), i(p.i), use(p.use){
++*use; }
HasPtr& operator= (const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; //用来记录有多少个对象共享*ps 的成员
};
在此,我们添加了一个名为 use
的数据成员,它记录有多少对象共享相同的 string
。受 string
参数的构造函数分配新的计数器,并将其初始化为1
,指出当前有一个用户用本对象的string
成员。
⭐️ 类指针的拷贝成员“篡改”引用计数
当拷贝或赋值一个 HasPtr
对象时,我们希望副本和原对象都指向相同的 string
。即,当拷贝一个 HasPtr
时,我们将拷贝 ps
本身,而不是 ps
指向的 string
. 当我们进行拷贝时,还会递增该 string
关联的计数器。
(我们在类内定义的)拷贝构造函数 拷贝给定 HasPtr
的所有三个数据成员。这个构造函数还递增 use
成员,指出 ps
和 p.ps
指向的 string
又有了一个新的用户。
析构函数不能无条件地 delete ps
—— 可能还有其他对象指向这块内存。析构函数应该递减引用计数,指出共享 string
的对象少了一个。如果计数器变为 0
,则析构函数释放 ps
和 use
指向的内存:
HasPtr::~HasPtr(){
if(--*use == 0){
//如果引用计数变为0
delete ps; //释放string内存
delete use; //释放计数器内存
}
}
拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作。即,它必须递增右侧运算对象的引用计数(即,拷贝构造函数的工作),并递减左侧运算对象的引用计数,在必要时释放使用的内存(即,析构函数的工作)。
而且与往常一样,赋值运算符必须处理自赋值。我们通过先递增 rhs
中的计数然后再递减 左侧运算对象中的计数来实现这一点。通过这种方法,当两个对象相同时,在我们检查 ps
(及use
)是否应该释放之前,计数器就已经被 递增 过了:
HasPtr& HasPtr::operator=(const HasPtr &rhs){
{
++*rhs.use; //递增右侧运算对象的引用计数
if (--*use == 0){
//然后递减本对象的引用计数
delete ps; //如果没有其他用户
delete use; //释放本对象分配的成员
}
ps = rhs.ps; //将数据从rhs拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; //返回本对象
}
13.3、交换操作
需要交换两个元素时会调用 swap
。如果一个类定义了自己的 swap
,那么算法将使用类自定义版本。否则,算法将使用标准库定义的 swap
。虽然与往常一样我们不知道swap
是如何实现的,但理论上很容易理解,为了交换两个对象我们需要进行一次拷贝和两次赋值。例如,交换两个类值HasPtr
对象的代码可能像下面这样:
HasPtr temp = vl; //创建v1的值的一个临时副本
v1 = v2; //将v2的值赋予v1
v2 = temp; //将保存的v1的值赋予v2
理论上,这些内存分配都是不必要的。我们更希望 swap
交换指针,而不是分配string
的新副本。即,我们希望这样交换两个 HasPtr
:
string *temp = v1.ps; //为v1.ps中的指针创建一个副本
v1.ps = v2.ps; //将v2.ps中的指针赋予v1.ps
v2.ps = temp; //将保存的v1.ps中原来的指针赋予v2.ps
⭐️ 编写我们自己的swap函数
可以在我们的类上定义一个自己版本的 swap
来重载 swap
的默认行为。swap
典型实现如下:
class HasPtr {
friend void swap(HasPtr&, HasPtr&);
//其他成员定义,与13.2.1节中一样
};
inline
void swap(HasPtr &lhs, HasPtr &rhs){
using std::swap;
swap(lhs.ps, rhs.ps); //交换指针,而不是string数据
swap(lhs.i, rhs.i); //交换int成员
}
我们首先将 swap
定义为 friend
,以便能访问 HasPtr
的( private
的)数据成员。由于 swap
的存在就是为了优化代码,我们将其声明为 inline
函数(参见6.5.2节)。swap
的函数体对给定对象的每个数据成员调用 swap
。我们首先 swap
绑定到 rhs
和 lhs
的对象的指针成员,然后是 int
成员。
与拷贝控制成员不同,
swap
并不是必要的。但是,对于 分配了资源的类 , 定义swap
可能是一种很重要的优化手段。
⭐️ swap函数应该调用swap,而不是std::swap
此代码中有一个很重要的微妙之处: 虽然这一点在这个特殊的例子中并不重要,但在一般情况下它非常重要 ——
swap
函数中调用的swap
不是std::swap
。在本例中,数据成员是内置类型的,而内置类型是没有特定版本的swap
的,所以在本例中,对swap
的调用会调用标准库std::swap
。
但是,如果一个类的成员有自己类型特定的 swap
函数,调用 std::swap
就是错误的了。
- 例如,假定我们有另一个命名为
Foo
的类,它有一个类型为HasPtr
的成员h
。如果我们未定义Foo
版本的swap
,那么就会使用标准库版本的swap
。如我们所见,标准库swap
对HasPtr
管理的string
进行了不必要的拷贝。
我们可以为 Foo
编写一个 swap
函数,来避免这些拷贝。但是,如果这样编写 Foo
版本的 swap
:
void swap (Foo &lhs, Foo &rhs){
//错误:这个函数使用了标准库版本的swap,而不是HasPtr版本
std::swap(lhs.h, rhs.h);
//交换类型Foo的其他成员
}
此编码会编译通过,且正常运行。但是,使用此版本与简单使用默认版本的 swap
并没有任何性能差异。问题在于我们显式地调用了标准库版本的 swap
。但是,我们不希望使用 std
中的版本,我们希望调用为 HasPtr
对象定义的版本。
正确的 swap
函数如下所示:
void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap(lhs.h, rhs.h); //使用HasPtr版本的swap
//交换类型Foo的其他成员
}
每个 swap
调用应该都是未加限定的。即,每个调用都应该是 swap
,而不是 std::swap
。
- 如果存在类型特定的
swap
版本,其匹配程度会优于std
中定义的版本,原因我们将在16.3节中进行解释。 - 因此,如果存在类型特定的
swap
版本,swap
调用会与之匹配。如果不存在类型特定的版本,则会使用std
中的版本(假定作用域中有using
声明)。
非常仔细的读者可能会奇怪为什么
swap
函数中的using
声明没有隐藏HasPtr
版本swap
的声明(参见6.4.1节)。我们将在18.2.3节中解释为什么这段代码能正常工作。
⭐️ 在赋值运算符中使用swap
定义 swap
的类通常用 swap
来定义它们的赋值运算符。这些运算符使用了
一种名为拷贝并交换(copy and swap
)的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:
//注意rhs是按值传递的,意味着 HasPtr 的拷贝构造函数
//将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
//交换左侧运算对象和局部变量rhs的内容
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而delete了 rhs中的指针
}
在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。因此,rhs
是右侧运算对象的一个副本。参数传递时拷贝 HasPtr
的操作会分配该对象的 string
的一个新副本。
在赋值运算符的函数体中,我们调用 swap
来交换 rhs
和 *this
中的数据成员。这个调用将左侧运算对象中原来保存的指针存入 rhs
中,并将 rhs
中原来的指针存入*this
中。因此,在 swap
调用之后,*this
中的指针成员将指向新分配的 string
——右侧运算对象中 string
的一个副本。
当赋值运算符结束时, rhs
被销毁,HasPtr
的析构函数将执行。此析构函数delete rhs
现在指向的内存,即,释放掉左侧运算对象中原来的内存。
这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。
- 它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确,这与我们在原来的赋值运算符中使用的方法是一致的(参见13.2.1节)。
- 它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的
new
表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。
使用 拷贝和交换的赋值运算符 自动就是异常安全的,且能正确处理自赋值。