文章目录
第 13 章 拷贝控制
13.1 拷贝、赋值与销毁
五个拷贝控制操作:
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
13.1.1 拷贝构造函数
拷贝构造函数: 构造函数第一个参数是自身类类型的引用,且任何额外参数都有默认值。
class Foo
{
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
// ...
};
拷贝构造函数的参数几乎总是一个 const 的引用,并且通常不应该是 explicit。
(1)合成构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。
即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
- 类类型:使用拷贝构造函数来拷贝
- 内置类型:直接拷贝
- 数组:逐元素地拷贝一个数组类型的成员
(2)拷贝初始化
拷贝初始化和直接初始化的差异:
string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-99999-9"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化不仅在我们用 ’ = ’ 定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
(3)参数和返回值
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
(4)编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝 / 移动构造函数,直接创建对象。即,编译器被允许将下面的代码:
string null_book = "9-999-99999-9"; // 拷贝初始化
改写为:
string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝 / 移动构造函数,但在这个程序点上,拷贝 / 移动构造函数必须是存在且可访问的(例如,不能是 private 的)。
13.1.2 拷贝赋值运算符
拷贝赋值运算符接受一个与其所在类相同类型的参数:
class Foo
{
public:
Foo& operator=(const Foo&); // 赋值运算符
// ...
};
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
(1)合成拷贝赋值运算符
与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
其拷贝原理和拷贝构造函数几乎一样。
13.1.3 析构函数
(1)析构函数完成什么工作
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自已的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
(2)什么时候会调用析构函数
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
(3)合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。
析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
13.1.4 三 /五法则
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
13.1.5 使用 = default
我们可以通过将拷贝控制成员定义为 = default 来显式地要求编译器生成合成的版本:
class Sales_data
{
public:
//拷贝控制成员;使用 default
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(即,默认构造函数或拷贝控制成员)。大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式的还是显示的。
13.1.6 阻止拷贝
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上 = delete 来指出我们希望将它定义为删除的:
struct NoCopy
{
NoCopy() = default; // 使用合成的默认构造函数;
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopys) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
// 其他成员
};
与 = default 不同,= delete 必须出现在函数第一次声明的时候,并且我们可以对任何函数指定 = delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default)。
(1)析构函数不能是删除的成员
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象:
struct NoDtor
{
NoDtor() = default; // 使用合成默认构造函数
~NoDtor() = delete; // 我们不能销毁 NoDtor 类型的对象
};
NoDtor nd; // 错误∶ NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确∶ 但我们不能 delete p
delete p; // 错误∶ NoDtor 的析构函数是删除的
(2)合成的拷贝控制成员可能是删除的
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
- 某个成员的析构函数 = delete 或 不可访问
==>
默认构造函数、合成析构函数 、合成拷贝构造函数 = delete - 某个成员的拷贝构造函数 = delete 或 不可访问
==>
合成拷贝构造函数 = delete - 某个成员的拷贝赋值运算符 = delete 或 不可访问
==>
合成拷贝赋值运算符 = delete - 有 const 成员 或 引用成员且无类内初始化器
==>
默认构造函数 = delete
(3)private 拷贝控制
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为 private的来阻止拷贝:
class PrivateCopy
{
// 无访问说明符;接下来的成员默认为 private的;
// 拷贝控制成员是 private 的,因此普通用户代码无法访问
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopys);
// 其他成员
public:
PrivateCopy() = default; // 使用合成的默认构造函数
~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝它们;
};
13.2 拷贝控制和资源管理
13.2.1 行为像值的类
class HasPtr
{
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
// 对ps 指向的 string,每个 HasPtr 对象都有自己的拷贝
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) { }
HasPtr& operator=(const HasPtr&);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
(1)类值拷贝赋值运算符
赋值运算符通常组合了析构函数和构造函数的操作。通常需要考虑到两点:
- 能够将一个对象赋予其自身
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作
// 第 1 种版本,需考虑释放和拷贝的顺序
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // 拷贝底层 string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
// 第 2 种版本,无需考虑释放和拷贝的顺序
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
if (this == &rhs)
return *this;
delete ps; // 释放旧内存
auto newp = new string(*rhs.ps); // 拷贝底层 string
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
13.2.2 定义行为像指针的类
class HasPtr
{
public:
// 构造函数分配新的 string 和新的计数器,将计数器置为 1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) { }
// 拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享 *ps 的成员;
};
HasPtr::~HasPtr ()
{
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; // 将数据从 rhs 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
13.3 交换操作
除了定义拷贝控制成员,管理资源的类通常还定义一个名为 swap 的函数。对于那些与重排元素顺序的算法一起使用的类,定义 swap 是非常重要的。这类算法在需要交换两个元素时会调用 swap。
如果一个类定义了自己的 swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的 swap。对于上述 HasPtr 类,我们更希望 swap 交换指针,而不是分配 string 的新副本。即,我们希望这样交换两个 HasPtr:
class HasPtr
{
friend void swap(HasPtr&,HasPtrs);
// 其他成员定义,与 13.2.1 节中一样
};
inline
void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs.i); // 交换 int 成员
}
每个 swap 调用应该都是未加限定的。即,每个调用都应该是 swap,而不是 std::swap。如果存在类型特定的 swap 版本,其匹配程度会优于 std 中定义的版本。因此,如果存在类型特定的 swap 版本,swap 调用会与之匹配。如果不存在类型特定的版本,则会使用 std 中的版本(假定作用域中有 using 声明)。
(1)在赋值运算符中使用 swap
定义 swap 的类通常用 swap 来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的—个副本进行交换:
// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数
// 将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
//交换左侧运算对象和局部变量 rhs 的内容
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针
}
在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。
13.4 拷贝控制示例(*)
13.5 动态内存管理类(*)
13.6 对象移动
13.6.1 右值引用
右值引用只能绑定到一个将要销毁的对象上。其可以是字面常量、要求转换的表达式或返回右值的表达式等这些临时对象。这些对象具有以下两个特性:
- 所引用的对象将要被销毁
- 该对象没有其他用户
int i = 42;
int &r = i; // 正确∶ r 引用 i
int &&rr = i; // 错误∶不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; // 错误∶ i * 42 是一个右值
const int &r3 = i * 42; // 正确∶我们可以将一个 const 的引用绑定到一个右值上
int &&rr2 = i * 42; // 正确∶ 将 rr2 绑定到乘法结果上
(1)标准库 move 函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件 < utility > 中。
int &rr3 = std::move(rr1); // ok
move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它。在调用 move 之后,我们不能对移后源对象的值做任何假设。
使用 move 的代码应该使用 std::move 而不是 move。这样做可以避免潜在的名字冲突。
13.6.2 移动构造函数和移动赋值运算符
移动构造函数的第一个参数是该类类型的一个引用。这个引用参数在移动构造函数中是一个右值引用。任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态 —— 销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源 —— 这些资源的所有权已经归属新创建的对象。
作为一个例子,我们为 StrVec类定义移动构造函数,实现从一个 strVec 到另一个 StrVec 的元素移动而非拷贝∶
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常
//成员初始化器接管 s 中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// 令 s 进入这样的状态 ———— 对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的 strVec 中的内存。在接管内存之后,它将给定对象中的指针都置为 nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。StrVec 的析构函数在 first_free 上调用 deallocate。如果我们忘记了改变 s.first_free,则销毁移后源对象就会释放掉我们刚刚移动的内存。
(1)移动操作、标准库容器和异常
由于移动操作"窃取"资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
移动操作通常不抛出异常,但抛出异常也是允许的。
(2)移动赋值运算符
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
//直接检测自赋值
if (this != &rhs)
{
free(); // 释放已有元素
elements = rhs.elements; // 从 rhs 接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 将rhs 置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。我们的 StrVec 的移动操作满足这一要求,这是通过将移后源对象的指针成员置为 nullptr 来实现的。
移动操作还必须保证对象仍然是有效的。一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。
另一方面,移动操作对移后源对象中留下的值没有任何要求。因此,程序不应该依赖于移后源对象中的数据。
(3)合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员(拷贝构造函数、拷贝赋值运算符、析构函数),且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
(4)没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数,编译器不会合成移动构造函数,这意味着此类将有拷贝构造函数但不会有移动构造函数。如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用 move 来移动它们时也是如此:
class Foo
{
public:
Foo() = default;
Foo(const Foo&); // 拷贝构造函数
// 其他成员定义,但 Foo 未定义移动构造函数
};
Foo x;
Foo y(x); // 拷贝构造函数;x 是一个左值
Foo z(std::move(x)); // 拷贝构造函数,因为未定义移动构造函数
13.6.3 右值引用和成员函数
除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝 / 移动构造函数和赋值运算符相同的参数模式 —— 一个版本接受一个指向 const 的左值引用,第二个版本接受一个指向非 const 的右值引用。
例如,定义了 push back 的标准库容器提供两个版本:一个版本有一个右值引用参数,而另一个版本有一个 const 左值引用。假定 X 是元素类型,那么这些容器就会定义以下两个 push_back 版本:
void push_back(const X&); // 拷贝:绑定到任意类型的 X
void push_back(X&&); // 移动:只能绑定到类型 X 的可修改的右值
我们可以将能转换为类型 X 的任何对象传递给第一个版本的 push_back。此版本从其参数拷贝数据。对于第二个版本,我们只可以传递给它非 const 的右值。此版本对于非 const 的右值是精确匹配(也是更好的匹配)的,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本。此版本会从其参数窃取数据。
一般来说,我们不需要为函数操作定义接受一个 const X&& 或是一个(普通的)X&参数的版本。
(1)右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。例如:
string s1 = "a value", s2 = "another";
auto n = (sl + s2).find('a');
此例中,我们在一个 string 右值上调用 find 成员,该 string 右值是通过连接两个 string 而得到的。有时,右值的使用方式可能令人惊讶∶
s1 + s2 = "wow! ";
此处我们对两个 string 的连接结果 —— 一个右值,进行了赋值。
在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象(即,this 指向的对象)是一个左值。
我们指出 this 的左值 / 右值属性的方式与定义 const 成员函数相同,即,在参数列表后放置一个引用限定符:
class Foo
{
public:
Foo &operator = (const Foo&) &; // 只能向可修改的左值赋值
// Foo 的其他参数
};
Foo &Foo::operator = (const Foo &rhs) &&
{
// 执行将 rhs 赋予本对象所需的工作
return *this;
}
引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。类似 const 限定符,引用限定符只能用于(非 static)成员函数,且必须同时出现在函数的声明和定义中。
对于 & 限定的函数,我们只能将它用于左值;对于 && 限定的函数,只能用于右值:
Foo &retFoo(); // 返回一个引用;retFoo 调用是一个左值
Foo retVal(); // 返回一个值;retVal 调用是一个右值
Foo i, j; // i 和 j 是左值
i = j; // 正确∶ i 是左值
retFoo() = j; // 正确∶ retFoo() 返回一个左值
retVal() = j; // 错误∶ retVal() 返回一个右值
i = retVal(); // 正确∶ 我们可以将一个右值作为赋值操作的右侧运算对象
一个函数可以同时用 const 和引用限定。在此情况下,引用限定符必须跟随在 const限定符之后。
(2)重载和引用函数
引用限定符也可以区分重载版本。我们可以综合引用限定符和 const 来区分一个成员函数的重载版本。
例如,我们将为 Foo 定义一个名为 data 的 vector 成员和一个名为 sorted 的成员函数,sorted 返回一个 Foo 对象的副本,其中 vector 已被排序:
class Foo
{
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的 Foo
// Foo 的其他成员的定义
private:
vector<int> data;
};
// 本对象为右值,因此可以原址排序
Foo Foo::sorted() &&
{
sort(data.begin(), data.end());
return *this;
}
// 本对象是 const 或是一个左值,哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const &
{
Foo ret(*this); // 拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 排序副本
return ret; // 返回副本
}
当我们对一个右值执行 sorted 时,它可以安全地直接对 data 成员进行排序。对象是一个右值,意味着没有其他用户,因此我们可以改变对象。当对一个 const 右值或一个左值执行 sorted 时,我们不能改变对象,因此就需要在排序前拷贝 data。
编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted版本:
retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted(); // retFoo() 是一个左值,调用 Foo::sorted() const &