拷贝控制
一个类通过五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。 析构函数定义了当此类型对象销毁时做什么。 称这些操作为拷贝控制操作。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo();
Foo(const Foo&);
};
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,编译器会为我们定义一个。 与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
合成拷贝构造函数用来阻止我们拷贝该类类型的对象。 一般情况,合成的构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。
拷贝初始化
string dots(10, '.');
string s(dots);
string s2 = dots;
string null_book = "9-999-99999-9";
string nines = string(100, '9');
当使用直接初始化时,我们时间上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。 当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化通常使用拷贝构造函数来完成。
拷贝初始化不仅在我们用 = 定义变量时会发生,在下列情况下也会发生
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。 当一个函数局具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝初始化的限制
如果使用的初始化值要求通过一个 explicit 的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
vector<int> v1(10);
vector<int> v2 = 10;
void f(vector<int>);
f(10);
f(vector<int>(10));
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。 即,编译器被允许将下面的代码
string null_book = "9-999-99999-9";
改写为
string null_book("9-999-99999-9");
拷贝赋值运算符
类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum;
重新赋值运算符
重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为 operator= 的函数。
重载运算符的参数表示运算符的运算对象。 某些运算符,包括赋值运算符,必须定义为成员函数。 如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。
拷贝赋值运算符接受一个与其所在类相同类型的参数:
class Foo {
public:
Foo& operator=(const Foo&);
};
合成拷贝赋值运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。
析构函数
析构函执行与构造函数相反的操作:构造函数初始化对象的非 static 数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。 它没有返回值,也不接受参数:
class Foo {
public:
~Foo();
};
由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
析构函数完成什么工作
析构函数也有一个函数体和一个析构部分。 在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
通常,析构函数释放对象在生存期分配的所有资源。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
合成析构函数 当一个类未定义自己的性函数时,编译器会为它定义一个合成析构函数。
对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。
例:
class Sales_data {
public:
~Sales_data() { }
};
在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string 的性函数会被调用,它将释放 bookNo 成员所用的内存。
三/五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。但是,这些操作通常应该被看作一个整体。 通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是受限确定这个类是否需要一个析构函数。
如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。
第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。 反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。 然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
使用 =default
可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成的版本:
class Sales_data {
public:
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 修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。
阻止拷贝
虽然大多数类应该定义(而且通常也的确定义了)拷贝构造函数和拷贝赋值运算符,但对某些类来说,这些操作没有合理的意义。 在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。
定义删除的函数
在新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。
删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。
在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的:
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy &operator=(const NoCopy&) = delete;
~NoCopy() = default;
};
与 =default 不同, =delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。
与 =default 的另一个不同之处是,我们可以对任何函数指定 =delete (我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default )。
析构函数不能是删除的成员
如果析构函数被删除,就无法销毁此类型的对象了。 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。 如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。
如果一个成员的析构函数是删除的,则该成员无法被销毁。而如果一个成员无法被销毁,则对象整体也就无法被销毁了。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分哦这种类型的对象。但是,不能释放这些对象:
struct NoDtor {
NoDtor() = default;
~NoDtor() = delete;
};
NoDtor nd;
NoDtor *p = new NoDtor();
delete p;
合成的拷贝控制成员可能是删除的
对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的(例如,是 private 的),则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或者类有一个引用成员,它没有类内初始化其,或者类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,则对应的成员函数将被定义为删除的。
private 拷贝控制
在新标准前,类是通过将其拷贝构造函数的拷贝赋值运算符声明为 private 的阻止拷贝。
通过声明(但不定义)private 的拷贝构造函数,我们可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段被标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误。
拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。
为了定义这些成员,我们首先必须确定此类型对象的拷贝语义。一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。 这意味着对于 ps 指向的 string ,每个 HasPtr 对象都必须有自己的拷贝。 为了实现类值行为,HasPtr 需要
- 定义一个拷贝构造函数,完成 string 的开吧,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝 string
类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。 类似析构函数,赋值操作会销毁左侧运算对象的资源。 类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的 string 。
令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源。 拷贝(或赋值)一个 shared_ptr 会拷贝(赋值)shared_ptr 所指向的指针。 shared_ptr 类自己记录由多少用户共享它所指向的对象。当没有用户使用对象时,shared_ptr 类负责释放资源。
有时希望直接管理资源。这种情况,使用引用计数。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。
当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。 - 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。
拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。 - 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。
如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
学习参考资料:
C++ 中文版 Primer (第5版)
|