第十三章 拷贝控制
- 定义一个类时,需要显式或隐式地指定在此类型地对象拷贝、移动、赋值和销毁时做什么。
- 一个类通过定义五种特殊的成员函数来控制这些操作。即拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)、析构函数(destructor)。
- 拷贝和移动构造函数定义了当用同类型的另一个对象初始化对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。这些操作统称为拷贝控制操作。
一、拷贝、赋值与销毁
1. 拷贝构造函数
- 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:
Foo(); // 默认构造函数
// 它在几种情况下都会被隐式地使用,因此,拷贝构造函数通常不应该是explicit的。
Foo(const Foo&); // 拷贝构造函数
// ...
};
- 合成的拷贝构造函数(synthesized copy constructor):会将参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
- 每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。
class Data {
public:
Data(const Data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
Data::Data(const Data& orig): bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue) {}
- 拷贝初始化:将右侧运算对象拷贝到正在创建的对象中,如果需要,还需进行类型转换。
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
sting null_book = "9-999999-99"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
- 拷贝初始化出现场景:
- 用 = 定义变量时。
- 将一个对象作为实参传递给一个非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
- 拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。因为如何其参数不是引用类型,则调用永远不会成功 -- 为了调用拷贝构造函数,必须拷贝它的实参,但为了拷贝实参,又需要调用拷贝构造函数,如此无限循环。
- 希望使用一个explicit构造函数,就必须显式地使用。
2. 拷贝赋值运算符
- 重载赋值运算符:
- 重写一个名为
operator=
的函数 - 通常返回一个指向其左侧运算对象的引用
- 重写一个名为
class Foo {
public:
Foo& operator=(const Foo&); // 赋值运算符
};
- 合成拷贝赋值运算符:
- 如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
- 对于某些类,合成拷贝赋值运算符用来禁止该类型对象的赋值。否则,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。
// 等价于合成拷贝赋值运算符
Data& Data::operator=(const Data& rhs) {
bookNo = res.bookNo; // 调用string::operator=
units_sold = res.units_sold; // 使用内置的int赋值
revenue = res.revenue; // 使用内置的double赋值
return *this;
}
3. 析构函数
- 构造函数初始化对象的非static数据成员,还可能做一些其他工作;而析构函数释放对象使用的资源,并销毁对象的非static数据成员。
- 名字由波浪号接类名构成。没有返回值,也不接受参数。由于它不接受参数,所以它不能重载。
- 调用时机:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(标准库和数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用
delete运算符
时被销毁。 - 对于临时对象,当创建它的完整表达式结束时被销毁。
{ // 新作用域
// p 和 p2 都指向动态分配的对象
Data *p = new Data; // p 是要给内置指针
auto p2 = make_shared<Data>(); // p2 是一个智能指针
Data item(*p); // 拷贝构造函数将*p拷贝到item中
vector<Data> vec; // 局部对象
vec.push_back(*p2); // 拷贝p2指向的对象
delete p; // 对p指向的对象执行析构函数
}
// 退出局部作用域,对item、p2和vec调用析构函数
- 合成析构函数:
- 当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。
- 在(空)析构函数执行完毕后,成员会自动销毁。
- 注意:析构函数体本身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。
4. 三/五法则
- 有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。当前,C++11还包括移动构造函数和移动赋值运算符。
- 需要析构函数的也需要拷贝和赋值操作,即如果一个类需要自定义析构函数,几乎可以肯定它也需要定义拷贝赋值运算符和拷贝构造函数。
- 需要拷贝操作的类也需要赋值操作,反之亦然。
5. 使用=default
- 可以通过将拷贝控制成员定义为
=default
来显式地要求编译器生成合成地版本。
class Data {
public:
// 拷贝控制成员,使用default
Data() = default;
Data(const Data&) = default;
Data& operator=(const Data&);
~Data() = default;
};
Data& Data::operator=(const Data&) = default;
- 合成的函数将隐式地声明为内联的。如果不希望合成的成员是内联函数,应该只对成员的类外定义使用
=default
。 - 只能对具有合成版本的成员函数使用
=default
,即默认构造函数或拷贝控制成员。
6. 阻止拷贝
- 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。但是对于某些类来说,这些操作可能没有合理的意义,所以必须采用某种机制阻止拷贝或赋值。例如:iostream类阻止了拷贝,以避免多个对象写入或者读取相同的IO缓冲。
- 定义删除的函数来阻止拷贝:
=delete
。虽然声明了它们,但是不能以任何方式使用它们。
struct NoCopy {
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(cosnt NoCopy&) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
};
- 注意:析构函数不能是删除的成员。对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
- 本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝成员就应该被定义为删除的。
二、拷贝控制和资源管理
- 通常,管理类外资源的类必须定义拷贝控制成员。
- 类的行为可以像一个值,也可以像一个指针。
- 行为像值:对象有自己的状态,副本和原对象是完全独立的。
- 行为像指针:共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。
// 行为像值
class HasPtr {
public:
HasPtr(const string &s = string()): ps(new string(s)), i(0) {}
HasPtr(const HasPtr &p): ps(new string(*p.ps)), i(p.i) {} // 对ps指向的string,每个HasPtr对象都有自己的拷贝
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps;}
private:
string *ps;
int i;
};
// 注意:
// 1. 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
// 2. 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
// 一个好的方法:在销毁左侧运算对象资源之前拷贝右侧运算对象。
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
auto newp = new string(*rhs.ps); // 拷贝底层string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
return *this; // 返回本对象
}
// 行为像指针
// 注意:
// 1. 令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。
// 2. shared_ptr会自动管理资源,有时希望直接管理资源,这种情况下,就得使用 引用计数 了。
// 引用计数的工作方式:
// 1. 除了初始化对象外,每个构造函数(拷贝构造函数除外)还有创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
// 2. 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
// 3. 析构函数递减计数器,指出共享状态的用户又少了一个。如果计数器变为0,则析构函数释放状态。
// 4. 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
class HasPtr {
public:
// 构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(const string &s = string()): ps(new string(s)), i(0), use(size_t(1)) {}
// 拷贝构造函数拷贝三个数据成员,并递增计数器
HasPtr(const HasPtr &p): ps(p.ps), i(p.i), use(p.use) {++*use;}
//
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
string *ps;
int i;
size_t *use; // 用来记录有多少对象共享*ps的成员
};
HasPtr::~HasPtr() {
// 不能无条件的delete
if(--*use == 0) { // 如果引用计数变为0
delete ps; // 释放string内存
delete use; // 释放计数器内存
}
}
HasPtr& HasPtr::operator=(const HasPtr$ rhs) {
++*rhs.use; // 先递增右侧运算符对象的引用计数
if(--*use == 0) { // 然后递减本对象的引用计数
delete ps; //
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
三、交换操作
- 管理资源的类通常还定义一个名为
swap
的函数。 - 它经常用于重排元素顺序的算法,尽量用类的自定义版本的
swap
,而不是std::swap
。与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
class HasPtr{
friend void swap(HasPtr&, HasPtr&);
// ...
HasPtr& operator=(HasPtr );
};
inline void swap(HasPtr &lhs, HasPtr& &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是string数据,减少不必要的内存分配
swap(lhs.i, rhs.i);
}
// 拷贝并交换,将左侧运算对象与右侧运算对象的一个副本进行交换
HasPtr& HasPtr::operator=(HasPtr rhs){
// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而deleter了rhs中的指针
}
四、拷贝控制示例
需求描述:两个类命名为Message和Folder,分别表示电子邮件(或者其他类型的)消息和消息目录。每个Message对象可以出现在多个Folder中。但是,任意给定的Message的内容只有一个副本。这样,如果一条Message的内容被改变,则从它所在的任何Folder俩浏览此Message时,都会看到改变后的内容。
五、动态内存管理类
待整理...
六、对象移动
- 很多拷贝操作后,原对象会被销毁,因此引入移动操作可以大幅度提升性能。
- 标准库容器、string和shared_ptr类既可以支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
- 在新标准中,我们可以用容器保存不可拷贝的类型,只要它们可以被移动即可。
1. 右值引用
- 为了支持移动操作,引入了一种新的的引用类型--右值引用。所谓的右值引用就是必须绑定到右值的引用,通过
&&
来获得右值引用。 - 它的一个重要性质:只能绑定到一个将要销毁的对象。因此,可以自由地将一个右值引用的资源"移动"到另一个对象中。
- 左值有持久的状态。而右值要么是字面常量,要么是在表达式求值过程中创建的临时变量,所以右值是短暂的。
- 由于右值引用只能绑定到临时对象,所以被引用的对象将要被销毁,且该对象没有其他用户。
- move函数:
int &&rr2 = std::move(rr1);
- move告诉编译器,我们有一个左值,但我希望像右值一样处理它。
- 调用move意味着:除了对rr1赋值或者销毁它外,我们将不再使用它。
2. 移动构造函数和移动赋值运算符
- 移动构造函数:
- 和拷贝构造函数一样,移动构造函数第一个参数也是该类类型的一个引用,不同的是,这个引用参数是一个右值引用。
- 除了完成资源移动,移动构造函数还必须确保移后源对象处于这样的一个状态 -- 销毁它是无害的。
- 不分配任何新内存,只是接管给定的内存。
- 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
// 移动操作不应该抛出任何异常
StrVec::StrVec(StrVec &&s) noexcept: elements(s.elements), first_free(s.first_free), cap(s.cap) {
//
s.elements = s.first_free = s.cap = nullptr;
}
- 移动赋值运算符:
- 在移动操作之后,移后原对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
// 直接检测自赋值
if (this != &rhs) {
free(); // 释放已有元素
elements = rhs.elements; // 从rhs接管资源
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr; // 将rhs置为可析构状态
}
return *this;
}
- 移动迭代器:make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
- 建议:小心地使用移动操作,以获得性能提升。通常在类代码中使用。
3. 右值引用和成员函数
- 区分移动和拷贝的重载函数通常有一个版本接受一个
const T&
,而另一个版本接受一个T&&
。 - 移动构造函数和移动赋值运算符接受一个(通常是非const的)右值引用;而拷贝版本则接受一个(通常是const的)普通左值引用。
- 引用限定符:在参数列表后面防止一个&,限定只能向可修改的左值赋值而不能向右值赋值。