文章目录
拷贝、赋值与销毁
当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。拷贝和移动构造函数定义了当同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作。
一个类没定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。但某些类并不能依赖于这些默认版本的操作。
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
};
通常,拷贝构造函数第一个参数都是 const 的引用。拷贝构造函数在几种情况下都会被隐式使用,所以拷贝构造函数通常不是 explicit 的。
合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个,即使是我们定义了其他构造函数。
对某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。
合成拷贝构造函数中,成员的类型决定了它如何拷贝:如内置类型直接拷贝;类类型成员则使用该类类型的拷贝构造函数;对于数组,则逐个拷贝数组类型的成员;
拷贝初始化
拷贝初始化通常使用拷贝构造函数来完成。但如果一个类有一个移动构造函数,拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。
拷贝初始化不仅仅发生在用 = 的时候。如对象作为实参传递给一个非引用类型的形参;返回类型为非引用类型的函数返回一个对象;用花括号列表初始化一个数组中的元素或一个聚合类中的成员;以及标准库容器的 insert 或是 push 成员(emplace 是调用构造函数完成,是直接初始化)。
参数和返回值
上面一段说了拷贝初始化发生的情况。
这里解释为什么拷贝构造函数自己的参数是引用类型。如果其参数不是引用,当我们调用拷贝构造函数时,为了拷贝实参,又会再次调用拷贝构造函数,如此会无限循环。
拷贝初始化的限制
如果一个构造函数是 explicit 的,拷贝初始化就有一定的限制了。如:
vector<int> v(10); // 正确,直接初始化
vector<int> v = 10; // 错误,接受大小参数的构造函数是 explicit
void f(vector<int>);
f(10); // 错误
f(vector<int>(10)); // 正确
编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象。如:
string null_book = "99999-999-9"; // 拷贝初始化
string null_bool("99999-999-9"); // 直接初始化
但是,即使编译器可以略过拷贝/移动构造函数,但在程序点上,拷贝/移动构造函数必须是存在且可访问的。
拷贝赋值运算符
与类控制其对象如何初始化一样,类也可以控制其对象如何赋值:
Sales_data trans, accum;
trans = accum; // 调用 Sales_data 的拷贝赋值运算符
与拷贝构造函数一样,如果类没有定义自己的拷贝赋值运算符,编译器会为它合成一个。
重载赋值运算符
这里介绍一点重载运算符。重载运算符本质上是函数,名字有 operator 关键字后接表示要定义的运算符的符号组成。赋值运算符就是一个名为 operator= 的函数。运算符函数同样有一个返回类型和参数列表。
重载运算符的参数表示运算符的运算对象。某些运算符,如赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。对与一个二元运算,其右侧运算对象作为显示参数传递。
所以我们可以这样重载赋值运算符:
class Foo {
public:
Foo& operator= (const Foo&);
};
通常,赋值运算符支持右结合律(即 a = b = 0,从右往左赋值)。为了满足这个条件,所以我们返回类型的是引用。
合成拷贝赋值运算符
同样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。如果拷贝赋值运算符并非出于此目的,它会将右侧对象的每个非 static 成员赋予左侧运算对象的相应成员。合成拷贝运算符返回一个指向其左侧运算对象的引用。
析构函数
析构函数执行与构造函数相反的操作:释放对象使用的资源,并销毁对象的非 static 成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:
class Foo {
public:
~Foo(); // 析构函数
// ...
};
对于一个给定类,只会有唯一一个析构函数。
析构函数完成什么工作
析构函数包含一个函数体和析构部分。在一个析构函数中,首先执行函数体,然后销毁成员。成员按照初始化顺序的逆序销毁。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时会发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象
智能指针是类类型,具有析构函数。因此,智能指针成员在析构阶段会自动被销毁。
什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁
- 对于临时对象,当创建它的完整表达式结束是被销毁
析构函数自动执行,我们的程序通常不需要担心何时释放资源。
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数就为空。
认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
三/五法则
如上,有三个操作可以控制类的拷贝控制操作:拷贝构造函数、拷贝赋值运算符和析构函数。在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
C++不要求我们定义所有这些操作。但是,这些操作通常被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要析构函数。通常,对析构函数的需求比对拷贝构造函数或赋值运算符的需求更为明显。如果一个类需要析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
这里将用一个类来解释原因,假设有以下类:
class HasPtr {
public:
HasPtr(const std::string& s = std::string()): ps(new std::string(s)), i(0) { }
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
我们知道,这个类将会使用合成拷贝构造函数和合成拷贝控制运算符。这些操作都是简单拷贝指针成员,这意味着多个 HasPtr 对象可能指向相同的内存:
HasPtr f(HasPtr hp) { // 这里将会执行一次拷贝初始化
HasPtr ret = hp; // 这里会调用拷贝控制运算符
// 处理 ret
return ret;
}
当 f 返回的时候,hp 与 ret 都被销毁,都会调用 HasPtr 的析构函数。很显然,ret 与 hp 指向的是同一个动态内存,那么在这里将会被 delete 两次,这显然是一个错误。
需要拷贝操作的类也需要赋值操作,反之亦然
作为一个例子,考虑一个类为每个对象分配一个独有的的需要。这个类一个拷贝构造函数为每个新创建的对象生成新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝其他所有数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。
很显然,上面例子需要自定义拷贝构造函数与拷贝控制运算符。但是不需要析构函数。
所以这里有第二个原则:需要拷贝操作的类也需要赋值操作,反之亦然。
使用 =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 修饰成员的声明时,合成的函数将隐式地声明为内联函数(定义在类内部的成员函数隐式内联)。如果我们不希望合成的成员是内联函数,应该值对成员的类外定义使用 =default。
我们只能对具有合成版本的成员函数使用 =default (即,默认构造函数或拷贝控制成员)
阻止拷贝
虽然大多数类应该定义(而且通常也的确定义了)拷贝构造函数和拷贝赋值运算符,但对于某些类(如 iostream 类)来说,这些操作没有意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。
为了阻止拷贝,看起来可能是不应该定义拷贝控制成员。但实际上,这种策略是无效的,如果我们的类未定义这些操作,编译器会为它生成合成的版本。
定义删除函数(阻止拷贝、赋值)
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数意思是:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后加上 =delete 来指明我们希望将它定义为删除的。
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator= (const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default;
};
=delete 必须出现在函数第一次声明的时候,而 =default 影响这个默认成员生成的代码,所以 =default 直到编译器生成代码时才需要。
我们也可以对大多数函数指定 = delete,这一点与 =default 有很大的不同。
析构函数不能是删除的成员
我们不能删除析构函数,这是显然的。如果析构函数被删除,就无法销毁此类型的对象了。
当然,这里的**“不能”不是指编译器会为我们 将析构函数指明为 =delete 的代码指出错误**。指的是,对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类型的临时对象,但可以动态分配这种类型的对象,但又不能释放对象 (不能 delete)。
struct NoDtor {
NoDtor() = default;
~NoDtor() = delete;
};
NoDtor no; // 错误:不能定义 析构函数是删除的类型 的对象
NoDtor *p = new Dtor(); // 正确:可以动态分配析构函数是删除的 类型的对象
delete p; // 错误:不能释放 析构函数是删除的类型 的动态分配对象
合成的拷贝控制成员可能是删除的
对于某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数将被定义为删除的,类的合成拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成析构函数将被定义为删除的。
。。。情况有点多,见 (p450)
private 拷贝控制
从上面可以发现,将拷贝构造函数和拷贝赋值运算符声明为 private 的同样可以阻止拷贝,这一点还是很好理解的。对于析构函数也是同理。
当然,声明为 private 对于友元来说,仍然可以拷贝或者赋值。所以如果想阻止拷贝赋值,应该使用 =delete 来实现。