本章主要内容,类定义构造函数,用来控制在创建此类型对象时做什么。学习类如何控制该类型对象拷贝、赋值、移动或销毁时做什么。
主要函数:拷贝构造函数、移动构造函数、拷贝赋值运算、移动赋值运算符以及析构函数。
拷贝控制操作 --
拷贝和移动构造函数,定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值运算符,定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数,定义了当此类型对象销毁时做什么。
13.1 拷贝、赋值与销毁
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数,通常不应该是explicit的,即允许隐式转化
// ...
};
合成拷贝构造函数
如果没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
对某些类来说,合成拷贝构造函数用来阻止拷贝该类类型的对象。
一般情况,合成拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对类类型的成员,使用其拷贝构造函数来拷贝;内置类型的成员,直接拷贝。
问题:如果类的成员是数组,那么如何拷贝?
不能直接拷贝一个数组,但合成的拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组是类类型(如array,vector),则用元素的拷贝构造函数来进行拷贝。
例子,Sales_data类的合成拷贝构造函数等价于
class Sales_data{
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revene = 0.0;
};
// 与Sales_data的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data& orig) :
bookNo(orig.bookNo), // 使用string的拷贝构造函数
units_sold(orig.units_sold), // 直接值拷贝
revenue(orig.revene) // 直接值拷贝
{ // 空函数体
}
拷贝初始化
直接初始化 vs 拷贝初始化
使用自己初始化时,实际上要求编译器使用普通的函数匹配,来选择与我们提供的参数最匹配的构造函数。 -- 相当于从一组重载函数中,选择一个参数最匹配的版本(不一定是拷贝构造函数)
使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话,还要进行类型转换。 -- 调用拷贝构造函数进行构造,有时是移动构造函数
拷贝初始化应用场景:
- 用= 定义变量;
- 将一个对象作为实参传递给一个非引用类型的形参;
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
- 初始化标准库容器,或调用其insert/push函数时,容器会对其元素进行拷贝初始化;
参数和返回值
函数调用过程中,具有非引用类型的参数要进行拷贝初始化。返回非引用类型时,返回值会被用来初始化调用方的结果。
拷贝初始化的限制
拷贝构造函数一般不能是explicit类型的。如果要求使用的初始化值用一个explicit的构造函数来进行类型转换,那么要小心使用直接初始化和拷贝初始化。
// vector接受单一大小参数的构造函数是explicit的
vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10; // 错误:接受容器大小参数的构造函数是explicit的
// 对于函数f,接收类型为vector<int>的参数
void f(vector<int>);
f(10); // 调用错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); // 正确:从一个int直接构造一个临时vector
假设vector构造函数不是explicit,那么下面语句是正确的
vector<int> v2 = 10;
// 等价于
vector<int> tmp = vecotr<int>(10);
vector<int> v2 = tmp;
编译器可以绕过拷贝构造函数
拷贝、移动构造函数必须是存在且可访问的(非private)
string null_book = "9-999-99999-9"; // 拷贝初始化
string null_book("9-999-99999-9"); // 编译器绕过拷贝构造函数,直接创建对象
13.1.2 拷贝赋值运算符
类可以用 “=” 控制其对象如何赋值。类似拷贝构造函数,如果类未定义自己的拷贝赋值运算符,编译器会合成一个。
Sales_data trans, accum;
trans = accum; // 使用Sales_data的拷贝赋值运算符
拷贝赋值运算符和用“=”定义变量(拷贝初始化),最大的区别在于:使用拷贝赋值运算符的时候,前提条件是对象必须已经存在,即初始化完毕。而如果还是在构建对象阶段,就需要使用拷贝构造函数。
重载赋值运算符
重载运算符本质是函数,赋值运算是名为operator=的函数。
= 左侧运算对象绑定到隐式this参数,右侧运算对象作为显式参数传递。赋值运算符返回一个指向左侧运算对象的引用。
class Foo {
public:
Foo& operator=(const Foo&); // 赋值运算符
...
};
合成拷贝赋值运算符
如果一个类未定义直接的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。会将右侧运算符对象的每个非static成员赋予左侧运算对象的对应成员。对于数组成员逐个赋值数组元素。
// 等价于合成拷贝赋值运算符
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; // 返回此对象引用
}
// 这样定义了合成拷贝赋值运算符后,可以对Sales_data对象进行拷贝赋值
Sales_data d1, d2;
d1 = d2; // 拷贝赋值运算
// 注意下面是调用拷贝构造函数
Sales_data d3(d1);
Sales_data d4 = d2;
13.1.3 析构函数
析构函数执行与构造函数相反操作:构造函数初始化对象的非static数据成员,析构函数是否对象使用的资源,销毁对象的非static数据成员。
析构函数没有返回值,也不接受参数。析构函数不能被重载,而且对应一个类仅有一个。
class Foo {
public:
~Foo(); // 析构函数
// ..
}
析构函数完成什么工作?
析构函数释放对象在生存期分配的所有资源。
构造函数初始化成员,是按它们在类中出现的顺序进行初始化。析构函数中,先执行函数体,然后按初始化顺序的逆序销毁成员。
注意:内置指针类型的成员不会delete所指向的对象
智能指针与普通指针不同,智能指针是类类型,具有析构函数,在析构阶段自动销毁。
什么时候调用析构函数
无论何时一个对象被销毁,会自动调用其析构函数,具体体现在:
- 变量值离开其作用域时被销毁;
- 当一个对象被销毁时,其成员被销毁;
- 容器(标准库容器或数组)被销毁时,其元素被销毁;
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁;
- 对应临时对象,当创建它的完整表达式结束时被销毁;
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。
需要注意的是:
- 析构函数本身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的;
- 合成析构函数函数不会delete一个指针数据成员,需要定义一个析构函数来释放函数分配的内存;
13.1.4 三/五法则
三个基本操作控制类的拷贝:拷贝构造函数、拷贝赋值运算符、析构函数;
两个新增的操作:移动构造函数、移动赋值运算符。
需要析构函数的类也需要拷贝和赋值操作
因为自定义析构函数往往用来释放合成析构函数无法释放的内存,比如指针数据成员指向的内存。此时,也需要自定义拷贝和赋值操作,因为合成的拷贝和赋值操作,通常无法处理指针指向的内存。
需要拷贝操作的类也需要赋值操作,反之亦然
某些类需要完成的工作,只需要拷贝或赋值,不需要析构。比如,类为每个对象分配一个独有的、唯一的序号。该类需要自定义一个拷贝构造函数为每个新建对象生成该序号。赋值操作也同样需要自定义。
13.1.5 使用=default
将拷贝控制成员定义为=default显示地要求编译器生成合成的版本 -- 合成默认构造函数,合成拷贝构造函数,合成赋值运算符,合成析构函数。
注意:只能对具有合成版本的成员函数使用 =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& operator=(const Sales_data &) = default;
13.1.6 阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符。少数类可能会需要阻止拷贝,如iostream类阻止拷贝,避免多个对象写入或读取相同的IO缓冲。
定义删除的函数
可以通过将拷贝构造函数和拷贝赋值运算符,定义为删除的函数来阻止拷贝。
删除的函数:虽然声明了该函数,但不能以任何方式使用它们。
struct NoCopy {
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy& operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
}
析构函数不能是删除的成员
不能删除析构函数,如果删除析构函数,就无法销毁此类型对象。
合成的拷贝控制成员可能是删除的
如果一个类有一个数据成员不能默认构造、拷贝、复制或销毁,则对应成员函数将被定义为删除的。
一个成员有删除的,或不可访问的析构函数,会导致合成的默认和拷贝构造函数被定义为删除的。