参考资料:
定义一个类时,我们要显式或隐式地指定此类型对象在拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment constructor)、析构函数(destructor)。我们称这些操作为拷贝控制操作(copy control)。
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
};
如果我们没有为一个类定义拷贝构造函数,编译器会提供合成拷贝构造函数(synthesized copy constructor),从给定对象中依次将每个非 static
成员拷贝到正在创建的对象中。每个成员的类型决定了它如何拷贝:
当使用直接初始化时,我们实际上要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数;当使用拷贝初始化时,编译器将右侧运算对象拷贝到正在创建的对象中,必要时还要进行类型转换。
拷贝初始化依靠拷贝构造函数或移动构造函数来完成,发生在:
=
定义变量时;此外,如果我们在初始化标准库容器时调用 push
或 insert
,容器会对元素执行拷贝初始化;调用 emplace
则执行直接初始化。
string dots(10, ','); // 直接初始化
string s1(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-999-9999"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
在函数调用过程中,具有非引用类型的参数要执行拷贝初始化,这也解释了为什么拷贝构造函数的参数必须是引用类型:如果参数不是引用类型,则必须调用拷贝构造函数,而调用拷贝构造函数因为要拷贝实参,又必须调用拷贝构造函数,如此无限循环…
vector<int> v1(10); // 正确
vector<int> v2 = 10; // 错误,因为接受大小参数的构造函数是explicit的
在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象,即允许编译器将 string s = "hello";
改写为 string s("hello");
。
但是,即使编译器绕过了拷贝/移动构造函数,在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
亲测 VS2022 没有这个限制,DEV C++ 有这个限制
重载运算符本质上是函数,其名字有 operator
关键字后接运算符的符号构成。赋值运算符就是名为 operator=
的函数。
重载运算符的参数表示运算符的运算对象,如果一个运算符为成员函数,则其左侧运算对象绑定到隐式 this
参数,其他运算符作为参数显式传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数:
class Foo{
public:
Foo& operator=(const Foo&); // 这里的参数可以不是引用
};
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。需要注意的是,标准库容器要求容器中的元素类型要具有赋值运算符,返回值是左侧对象的引用。
如果类未定义自己的拷贝赋值运算符,编译器会提供合成拷贝赋值运算符(synthesized copy-assignment operator),将右侧运算对象的每个非 static
成员赋值给左侧运算对象的相应成员(通过拷贝赋值运算符完成),对于数组类型,逐个元素赋值。返回值为一个指向左侧运算对象的引用。
析构函数是类的一个成员函数,名字由 ~
接类名构成,没有返回值,也不接受参数:
class Foo{
public:
~Foo();
};
由于析构函数不接受参数,因此它不能被重载,一个类只会有唯一的析构函数。
和构造函数类似,析构函数有一个函数体和一个隐式的析构部分,首先执行函数体,然后按初始化顺序的逆序销毁成员。成员销毁时做什么依赖于对象的类型:类类型执行自己的析构函数,内置类型什么都不做。
隐式销毁一个内置指针类型不会
delete
它所指向的对象。
无论何时一个对象被销毁,就会调用其析构函数:
delete
运算符时被销毁;当一个类未定义自己的析构函数时,编译器会提供一个合成析构函数(synthesized destructor),函数体为空。
认识到析构函数体本身并不直接销毁成员是非常重要的。
前面提到,有五个基本操作可以控制类的拷贝操作,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义其他操作的情况是很少见的。
当我们决定一个类是否需要定义自己版本的拷贝控制成员时,一个基本原则是首先确定该类是否需要一个析构函数。如如果该类需要析构函数,则一定也需要拷贝构造函数和拷贝赋值运算符。
class HasPtr {
public:
HasPtr(const string &s = string()):
ps(new string(s)), i(0){}
~HasPtr() { delete ps; }
private:
string *ps;
string::size_type i;
};
HasPtr
在构造函数中分配动态内存,因此需要定义析构函数释放这片内存。如果我们不自己定义拷贝构造函数和拷贝赋值运算符,将有多个 HasPtr
对象指向同一片动态内存,其中一个对象被析构后,其他对象的指针将失效!
某些类只需要拷贝或赋值操作,而不需要析构函数。如:一个类为每个对象分配一个唯一的编号。这个类的拷贝构造函数从给定对象拷贝所有其他数据成员,并为新创建的对象分配一个新的编号。此时,也应该自己定义拷贝赋值运算符来避免将序号赋予目的对象。
class Person {
private:
static unsigned ID;
public:
Person(int wage = 0):
id(++ID), wage(wage){}
Person(const Person &p):
id(++ID), wage(p.wage){}
Person &operator=(const Person &p) {
wage = p.wage;
}
private:
unsigned id;
double wage;
};
unsigned Person::ID = 0;
=default
(P449)我们可以将拷贝控制成员定义为 =default
来显式要求编译器提供合成版本。
使用 =default
实际上定义了函数,所以在类内用 =default
将隐式声明合成的函数为内联的,在类外使用 =default
则无此效果。
对于某些类,拷贝或赋值没有合理的意义,例如 iostream
不允许拷贝,以避免多个对象写入或读取相同的 IO 缓冲。为了阻止拷贝,看起来不应该定义拷贝控制成员,但是,编译器会生成合成的版本,所以这种策略是无效的。
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function)来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何形式使用它们。在参数列表后面加上 =delete
:
class NoCopy {
NoCopy() = default;
NoCopy(const NoCopy &) = delete;
NoCopy &operator=(const NoCopy &) = delete;
~NoCopy() = default;
};
=delete
必须出现在函数第一次声明的时候,且可以对任何函数指定 =delete
。
如果析构函数被删除,就无法销毁此类型的对象了。对于一个删除了析构函数的类型,编译器不允许定义该类型的变量或创建该类型的临时对象。我们可以动态分配这种类型的对象,但不能释放这些对象:
class NoDtor {
public:
NoDtor() = default;
~NoDtor() = delete;
};
NoDtor nd; // 错误
NoDtor *p = new NoDtor(); // 正确,但不能delete p
前面提到,如果我们没有定义拷贝控制成员,编译器会提供合成的版本;如果我们没有定义构造函数,编译器会提供合成默认构造函数。对于某些类来说,编译器将这些合成的函数定义为删除的函数:
const
成员、引用成员,则类的合成拷贝赋值运算符被定义为删除的。const
成员,则该类的合成默认构造函数被定义为删除的。private
拷贝控制在新标准发布之前,类通过将拷贝构造函数和拷贝赋值运算符声明为 private
来阻止拷贝。