C++ Primer 学习笔记——第十三章
第十三章 拷贝控制
前言
类是如何控制类型对象的拷贝、赋值、移动和销毁的?类通过一些特殊的成员函数控制,包括:拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符以及析构函数。
当定义一个类时,我们显式地或隐式的指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:
拷贝构造函数(copy constructor),拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、*
*移动赋值运算符(move-assignment operator)和析构函数**(destructor)。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作(copy control)。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它补全缺失操作。但是对于某些类来说,以来自动补全地默认操作会导致问题。
在定义任何C++类时,拷贝控制操作都是必要部分。
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数时自身类类型的引用,且任何额外参数都有默认值,则此构造函数时拷贝构造函数。
class Function{
public:
Function(); // 默认构造函数
Function(const Function &); // 拷贝构造函数
}
拷贝构造函数的第一个参数必须是一个引用类型。拷贝在几种情况下都会被隐式使用,因此拷贝构造函数通常不应该是explicit的。
对某些类来说,**合成拷贝构造函数
**(synthesized copy constructor)用来阻止我们拷贝该类类型的对象。一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定其如何拷贝:
- 对类类型成员,使用拷贝构造函数拷贝
- 对内置类型成员,直接拷贝
- 对数组类型,合成拷贝构造函数会逐个元素拷贝一个数组类型的成员
string a(10,","); // 直接初始化
string b(a); // 直接初始化
string c = b; // 拷贝初始化
string d = "123"; // 拷贝初始化
string e = string(1,"a"); // 拷贝初始化
当使用直接初始化,实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
函数匹配(重载确认):指一个过程,在这个过程中将函数调用与一组重载函数中的某一个关联起来。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
当使用拷贝初始化时,编译器将右侧运算符对象拷贝到正在创建的对象中,如果需要进行类型转换。拷贝初始化通常用拷贝构造函数完成。但如果类有移动拷贝函数,那么拷贝初始化有时会使用移动拷贝函数。
拷贝初始化发生情况:
- 用“=”定义变量时
- 将一个对象作为实参传递给一个非引用类型的形参时
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型还会对它们所分配的对象进行拷贝初始化。例如:初始化标准库容器或调用其insert或push成员。注意到的是emplace成员创建的元素都进行直接初始化(emplace成员会直接在容器管理的内存空间中构造元素,而非像push_back般创建一个局部临时对象并压入容器中)。
聚合类:
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual类
如:
struct Data{ int ival; string s; }
初始化可以写成:
Data val={0,"A"};
,初始化顺序必须与声明顺序一致
参数和返回值
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果不是,那么将陷入死循环,调用永远不会成功。🤯
拷贝初始化的限制
拷贝初始化一般要求构造函数不使用explicit,如果使用,那么是否是用拷贝初始化还是直接初始化显得无关紧要:
vector<int> v1(10); // ok
vector<int> v2 = 10; //false 因为构造函数为explicit
void f(vector<int>); // ok
void f(10); // false
void f(vector<int>(10)); // ok
编译器可以绕过拷贝构造函数
拷贝初始化时,编译器可以(不是必须)跳过拷贝/移动构造函数,直接创建对象:
string a = "a";
// 改写为:
string b("a"); // 编译器略过拷贝构造函数
虽然编译器可以略过,但是在此节点的拷贝/移动构造函数必须存在且可访问(即不是private)。
拷贝赋值运算符
类可以通过拷贝赋值运算符控制对象如何赋值,同拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会自动合成。
重载赋值运算符
重载运算符(overloaded operator)本质上是函数,其名字由operator关键字和要定义的运算符符号组成。类似其他函数,运算符函数也有一个返回类型和一个参数列表。
如果一个运算符是一个成员函数,其左侧运算对象绑定到隐式this参数,对于一个二元运算符,其右侧运算对象作为显式参数传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数。
Function& operator=(const Function&);
为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向左侧运算对象的引用。同时标准库通常要求保存在容器中的类型具有赋值运算符,其返回值是左侧的引用。所以,在设计赋值运算符时通常应该返回一个指向其左侧对象的引用。
合成拷贝赋值运算符
合成拷贝赋值运算符(synthesized copy-assignment operator)。与合成拷贝构造函数类似:对于某些类,合成拷贝赋值运算符用来禁止该类型的对象的赋值(因为隐式生成的拷贝构造和赋值可能是delete的)。
拷贝赋值运算符会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作通过成员类型的拷贝赋值运算符来完成。对于数组类型的成员,逐个赋值数组元素。合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
Sales_data&
Sales_data::operator=(const Sales_data &rhs){
bookNo = rhs.bookNo;
units_sold = rhs.units_sold;
revenue = rhs.revenue;
return *this;
}
析构函数
析构函数执行与构造函数相反的操作:
- 构造函数:初始化对象的非static数据成员,做一些其他工作
- 析构函数:释放对象使用的资源,并销毁对象的非static数据成员
析构函数作为类的成员函数,函数名由波浪号“~”和类名组成,不接受返回类型和参数。
class A{
~A(){
//……
};
}
析构函数不接受参数,所以不支持重载。一个给定类仅存在唯一析构函数
在构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员(成员按照初始化顺序的逆序销毁)。
析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数;销毁内置类型什么也不需要做。
隐式销毁一个内置指针类型成员并不会delete它所指向的对象。
但是销毁智能指针,智能指针是类类型,有自己的析构函数。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用delete时被销毁
- 对临时对象,当创建它的完整表达式结束时被销毁
{
Sales_data *p = new Sales_data; // 内置指针
auto p_2 = make_shared<Sales_data>(); // shared_ptr
Sales_data p_3(*p);
vector<Sales_data> vec; // 局部变量
vec.push_back(*p_2);
delete p;
} // 对 p_2、p_3和vec调用析构函数
// 销毁p_2会递减其引用计数
// 销毁vec会销毁其元素
同样,如果类未定义自己的析构函数,编译器会生成合成析构函数。
认识到析构函数的函数体自身并不直接销毁成员非常重要。成员是在析构函数体之后隐式的析构阶段被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。