第 13 章 拷贝控制
标签: C++Primer 学习记录 拷贝控制
13.1 拷贝、赋值与销毁
-
拷贝控制成员,5个函数,分别是拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。其中,拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
-
实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。当一个类没有定义这些拷贝控制成员时,编译器会自动地定义缺失的操作,但编译器定义的版本的行为可能并非我们所想。
-
拷贝构造函数,第一个参数是自身类类型的引用,且任何额外参数都有默认值的构造函数。拷贝构造函数的第一个参数类型必须是一个引用类型,因为如果不是引用类型,则在进行形参初始化时,会调用拷贝构造函数,这样就会导致无限循环。
-
直接初始化时,实际上是编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。而拷贝初始化是将右侧运算对象拷贝到正在创建的对象中,需要的话还会进行类型转换。
string dots(10, '.'); // 直接初始化 string s(dots); // 拷贝初始化 string s2 = dots; // 拷贝初始化 string null_book = "99"; // 先隐式类型转换,再拷贝初始化
-
拷贝初始化在以下情况中会发生
- 使用
=
定义变量 - 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 标准库容器插入元素(insert、push)时,会对元素进行拷贝;而 emplace则是进行直接初始化
- 使用
-
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。但是在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
// 第一行的代码会被编译器改写为第二行形式的代码 string null_book = "99"; // 先隐式类型转换,再拷贝初始化 string null_book("99"); // 编译器略过了拷贝构造函数
-
在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。而在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
-
销毁类类型成员时需要执行成员自己的析构函数,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。所以,隐式销毁一个分配动态内存的内置指针类型的成员,并不会 delete它所指向的对象,需要显式调用 delete来回收资源。
-
析构函数体自身并不会直接销毁成员,成员是在析构函数体执行完后的隐含的析构阶段中被销毁的。
-
当对象被销毁时,会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其成员被销毁。
- 对于动态分配的对象,当对指向它的指针应用 delete运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
-
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝构造函数和拷贝赋值运算符。
-
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然。不过,这都并不意味着需要析构函数。
-
可以通过将拷贝控制成员定义为
=default
来显示要求编译器生成合成的版本,不过只能对具有合成版本的成员函数使用。当在类内用=default
修饰成员的声明时,合成的函数将隐式地声明为内敛的。如果不希望合成的成员时内联函数,只对成员的类外定义使用=default
。
class A {
public:
// 拷贝控制成员,使用 default
A() = default;
A(const A&) = default;
A& operator=(const A&);
~A() = default;
};
A& A::operator=(const A&) = default;
- 在某些情况下,拷贝赋值成员的操作没有合理的意义,比如 iostream类就不应该定义拷贝操作。这时可以通过它参数列表后面加上
=delete
来指出我们希望将它定义为删除的,阻止这个函数的调用。
- =delete必须出现在函数第一次声明的时候。
- 可以对任何函数指定 =delete,对于非拷贝控制成员,也可以引导函数的匹配过程。
- 析构函数不能定义为
=delete
。如果一个类或其类成员的析构函数被删除,就无法销毁此类型的对象,编译器将不允许定义该类型的变量或创建该类型的临时变量。虽然不能定义这种类型的变量或成员,但可以动态分配这种类型的对象,只是不能释放这些对象!
struct NoDtor {
NoDtor() = default;
~NoDtor() = delete; // 不能销毁 NoDtor类型的对象
};
NoDtor nd; // 错误,析构函数是删除的
NoDtor *p = new NoDtor(); // 正确,但是不能 delete p
delete p; // 错误
- 在某些情况下,编译器会将合成的拷贝控制成员定义为删除的。
- 类的成员的析构函数是删除或不可访问的,则类的合成析构函数被定义为删除的。同时,类的合成拷贝构造函数也会被定义为删除的(否则,就可能创建出无法销毁的对象)。
- 类的成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个 const成员(无法被重新赋值),或是引用成员(赋值之后,左侧对象仍然指向赋值前对象,而不会与右侧运算对象指向相同的对象),则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除或不可访问的,或是类有一个没有类内初始化器的引用成员,或是类有一个没有类内初始化器且无法默认构造的 const成员,则该类的默认构造函数被定义为删除的。
- C++在新标准之前,通过将一个拷贝成员函数声明为 delete和只声明不定义来阻止外部代码、友元和成员函数进行拷贝。试图拷贝对象的用户代码在编译阶段被标记为错误(因为不可访问),成员函数或友元中的拷贝操作将会导致链接错误(因为只有声明没有定义)。
13.2 拷贝控制和资源管理
- 在定义拷贝操作时,可以使类的行为看起来像一个值或者一个指针。在重载拷贝赋值运算符时,要注意处理自赋值情况,一个较好的方法是在销毁左侧运算对象之前先拷贝右侧运算对象。
- 拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
class HasPtr { public: HasPtr(const std::string &str = std::string()) : ps(new std::string(str)), i(0) {} // 对 ps指向的 string,每个 HasPtr对象都有自己的拷贝 HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {} // 拷贝赋值运算符 HasPtr& operator=(const HasPtr &rhs); ~HasPtr() { delete ps; } private: std::string *ps; int i; }; HasPtr& HasPtr::operator=(const HasPtr &rhs) { auto newp = new string(*rhs.ps); // 先拷贝右侧对象 delete ps; // 释放左侧内存 i = rhs.i; // 更新左侧对象 ps = newp; return *this; // 返回 *this }
- 行为像指针的类彼此共享状态,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。对于这种类指针拷贝,类的不同对象在析构时不能单方面的释放资源,只有当最后一个对象被销毁时,才能释放资源。其行为类似于 shared_ptr,存在一个引用计数来控制何时释放资源。
class HasPtr { public: // 构造函数分配新的 string值、新的初值为 1的计数器 // 计数器也必须使用动态内存,以保证不同对象中的 *use是同一个对象 HasPtr(const std::string &str = std::string()) : ps(new std::string(str)), i(0), use(new std::size_t(1)) {} // 拷贝构造函数,传递计数器指针,递增右侧对象的计数器 HasPtr(const HasPtr &rhs) : ps(new std::string(*rhs.ps)), i(rhs.i), use(rhs.use) { ++*rhs.use; } // 拷贝赋值运算符 HasPtr& operator=(const HasPtr &rhs); // 析构函数,递减计数器,当计数器变为 0时,释放 ps和 计数器指针所指向的内存 ~HasPtr() { if (--*use == 0) { delete ps; delete use; } } private: std::string *ps; int i; std::size_t *use; // 计数器,记录有多少个对象共享 *ps成员 }; 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; }
13.3 交换操作
-
如果一个类定义了自己的 swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的 swap。
- 标准库定义的 swap,为了交换两个对象,需要进行一次拷贝和两次赋值。以交换两个类值 HasPtr对象为例:
HasPtr temp = v1; // 创建 v1的值的一个临时副本 v1 = v2; // 将 v2的值赋予 v1 v2 = tmp; // 将保存的 v1的值赋予 v2
- 实际上,这些内存分配是不必要的。直接交换两个指针,而不是对象本身,往往具有更高的运行效率。
string *temp = v1.ps; v1.ps = v2.ps; v2.ps = temp;
-
自定义非成员函数版本的 swap函数,对于分配了资源的类来说,可以大大地优化性能。注意,swap函数应该调用 swap,而不是 std::swap。这是因为使用
swap()
调用方式,由于普通函数的优先级高于模板函数,所以会优先调用类版本的 swap函数。而如果该类对象没有定义 swap函数,则会调用标准库中的模板函数。 -
定义 swap的类通常用 swap来定义它们的赋值运算符,使用了拷贝并交换的技术,将左侧运算对象与右侧运算对象的一个副本进行交换。这种技术自动就是异常安全的,且能正确处理自赋值。
// 注意 rhs是按值传递的,意味着 HasPtr的拷贝构造函数将 // 右侧运算对象中的 string拷贝到 rhs HasPtr& HasPtr::operator=(HasPtr rhs) { // 交换左侧运算对象和局部变量 rhs的内容 swap(*this, rhs); // rhs现在指向本对象曾经使用的内存 return *this; // rhs被销毁,从而 delete了 rhs中的指针 }
13.4 拷贝控制示例
- 一般来说,分配资源的类通常需要自定义拷贝控制成员。同样的,一些进行簿记工作的类也需要自定义拷贝控制成员,比如每个对象都要有不同 ID的类等。
13.5 动态内存管理类
13.6 对象移动
-
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。很多情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。另外,就是像 IO类或 unique_ptr这样的类,本身不能被拷贝,但可以移动。
-
旧标准中,容器保存对象只能使用拷贝,效率往往较低。新标准中,可以用容器保存不可拷贝的类型,只要它们能被移动即可。
-
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都返回左值。左值表达式通常表示的是一个对象的身份,不能将其绑定到要求转换的表达式、字面常量和返回右值的表达式。
-
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都返回右值。右值表达式通常表示对象的值,不可以绑定到左值上。另外,一个 const的左值引用也可以绑定到这些对象上。
int i = 42; int &r = i; // 正确,r引用 i int &&rr = i; // 错误,不能将一个右值引用绑定到左值上 int &r2 = i * 42; // 错误,i*42是一个右值 const int &r3 = i * 42; // 正确,可以将一个 const的左值引用绑定到右值上 int &&r2 = i * 42; // 正确
-
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。右值引用指向将要被销毁的对象,因此可以从绑定到右值引用的对象“窃取”其状态,也就是移动其数据,而不用发生多余的拷贝与析构操作。
-
变量是左值,因为变量是持久的,直至离开作用域才被销毁。因此,不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用也不行。
int &&rr1 = 42; // 正确,字面常量是右值 int &&rr2 = rr1; // 错误,表达式 rr1是左值!
-
可以通过标准库中的
move
函数来显式地将一个左值转换为对应的右值引用类型。在对一个对象使用 move函数后,可以对这个移后源对象进行销毁或赋值操作,但不能再使用它!使用 move的代码应该使用 std::move,而不是 move,这是因为具有转换为右值引用功能的函数就是标准库中的函数模板,而不使用 std,则可能引起潜在的名字冲突。 -
移动构造函数,第一个参数是该类类型的一个右值引用,而其他额外参数都必须有默认实参。移动构造函数不分配任何新内存,接管对象的内存。在接管内存之后,将给定对象中的指针都置为 nullptr,移后源对象将不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常 // 成员初始化其接管 s中的资源 : elements(s.elements), first_free(s.first_free), cap(s.cap) { // 必须要将给定对象中的指针都置为 nullptr,以确保移后源对象可析构 s.elements = s.first_free = s.cap = nullptr; }
-
移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,需要通知标准库。除非标准库知道移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。以 vector执行 push_back为例来说明这种情况。
- vector在执行 push_back时,vector可能会重新分配内存空间,会将元素从旧空间移动到新空间。假设现在使用移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,此时旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。此时, vector可能就丢失了自身数据。
- 而如果使用的是拷贝构造函数且发生了异常,此时旧元素是保持不变的,vector只要释放新分配(但还未成功构造的)内存并返回就可以了。vector中的元素仍然存在。
- 为了避免这种潜在问题,除非 vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。
- 不抛出异常的移动构造函数和移动赋值运算符的声明和定义处都必须指定
noexcept
。
-
移动赋值运算符也必须检查自赋值情况,因为此右值可能是 move调用自身返回的结果。
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;
- 移后源对象必须满足两个条件:
- 置于析构安全的状态,其中的指针都被置为 nullptr,移后源对象将不再指向被移动的资源。
- 对象仍然是有效的,可以安全地为其赋予新的值或者使用一些不依赖其当前值的操作。
-
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有非 static数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
-
与拷贝操作不同,移动操作永远不会隐式地定义为删除的函数。如果既没有显式地要求生成
=default
的移动操作,又不满足编译器合成移动操作的条件,编译器根本就不会合成它们。而如果用=default
显式要求编译器生成移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
- 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数时,移动构造函数会被定义为删除的。
- 有类成员的移动构造函数或移动赋值运算符被定义为删除的或不可访问的,则类的移动构造函数会被定义为删除的。
- 类似拷贝构造函数,如果类的析构函数被定义为为删除的或不可访问的,则类的移动构造函数会被定义为删除的。
- 类似拷贝赋值运算符,如果有类成员是 const的或是引用,则类的移动赋值运算符会被定义为删除的。
// 假定 Y是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y men; // hasY将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); // 错误,移动构造函数是删除的
- 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
- 左值参数只能调用拷贝操作,右值参数会优先调用移动操作(精确匹配,而拷贝操作往往需要进行一次到 const的转换)。所以如果没有移动操作,右值参数也会调用拷贝操作。
StrVec v1, v2;
v1 = v2; // v2是左值,使用拷贝赋值
StrVec getVec(istream &); // getVec返回一个右值
v2 = getVec(cin); // 右侧对象是一个右值,使用移动赋值
- 使用拷贝并交换技术实现的赋值运算符,如果在类中同时定义了一个移动构造函数,则该赋值运算符实际上也是一个移动赋值运算符。
class HasPtr {
public:
// 添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;}
// 赋值运算符同时支持移动和拷贝操作
HasPtr& operator=(HasPtr rhs)
{
// 交换左侧运算对象和局部变量 rhs的内容
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而 delete了 rhs中的指针
}
// 其他成员的定义
};
-
标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
-
const T&的左值引用和 T&&的右值引用可以用来重载函数的拷贝和移动版本。
void push_back(const X&); // 拷贝,绑定到任意类型的 X
void push_back(X &&); // 移动,只能绑定到类型 X的可修改的右值
- 在参数列表后放置引用限定符 &或 &&,可以限定能够调用该函数的对象的左值/右值属性。引用限定符必须同时出现在函数的声明和定义中。
class Foo {
public:
Foo& operator=(const Foo&) &;
};
Foo& Foo::operator=(const Foo rhs&) &
{
// 执行将 rhs赋予本对象所需的工作
...
return *this;
}
Foo& retFoo(); // 返回左值
Foo retVal(); // 返回右值
Foo i, j;
i = j; // 正确,i是左值
retFoo() = j; // 正确,retFoo()返回左值
retVal() = j; // 错误,retVal()返回左值
- 一个函数可以同时使用 const和引用限定。引用限定符必须跟随在 const的后面。
class Foo {
public:
Foo someMen() & const; // 错误,const限定符必须在前
Foo otherMen() const &; // 正确
};
- 如果定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加。
class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // 错误,必须加上引用限定符
// 定义函数类型的类型别名
using Comp = bool(const int&, const int&);
Foo sorted(Comp*);
Foo sorted(Comp*) const; // 正确,两个版本都没有引用限定符
};