拷贝控制
- 一个类通过定义五种特殊的成员函数来控制此类型对象拷贝、移动、赋值和销毁时做什么,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数:
- 拷贝和移动构造函数定义了同类型的另一个对象初始化本对象时做什么;
- 拷贝和移动赋值运算符定义了一个对象赋予同类型的另一个对象时做什么;
- 析构函数定义了当此类型对象销毁时做什么。
拷贝、赋值与销毁
-
拷贝构造函数
-
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造:
class Foo { public: Foo(); // 默认构造函数 Foo(const Foo&); // 拷贝构造函数 }
-
合成拷贝构造函数:如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个,及时定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。
-
拷贝初始化
// 直接初始化 string dots(10, '.'); string s(dots); // 拷贝初始化 string s2 = dots; string null_book = "9-999-99999-9"; string nines = string(100, '9');
拷贝初始化发生的情况(拷贝初始化是依靠拷贝构造函数和移动构造函数完成的):
- 用 = 定义变量时会发生;
- 将一个对象作为实参传递给一个非引用类型的形参;
- 从一个返回类型为非引用类型的函数返回一个对象;
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
-
参数和返回值:
- 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化;
- 拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。
-
-
拷贝赋值运算符
-
拷贝赋值运算符本质上接受一个与其所在类相同类型的参数:
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; //返回一个此对象的引用 }
-
如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个;
-
-
析构函数
-
析构函数是类的一个成员函数,名字由波浪号接类名构成,其没有返回值也不接受参数,由于析构函数不接受参数,因此不能重载,对于一个给定类,只会有唯一一个析构函数;
-
析构函数释放对象使用的资源,并销毁对象的非static数据成员;
-
成员销毁时发生什么完全依赖于成员的类型,销毁类类型的成员需要执行成员自己的析构函数,内置类型无析构函数,因此销毁内置类型成员什么也不做;
-
隐式销毁一个内置指针类型的成员不会delete它指向的对象,但是智能指针是类类型,所以具有析构函数,因此指向的对象在析构阶段会被销毁;
-
什么时候会调用析构函数:
- 变量在离开其作用域时被销毁;
- 当一个对象被销毁时,其成员被销毁;
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁;
- 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁;
- 对于临时对象,当创建它的完整表达式结束时被销毁;
注:当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
-
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数:
-
对于某些类,合成析构函数被用来阻止该类型的对象被销毁;
-
如果不是上述情况,则合成析构函数的函数体为空:
class Sales_data { public: //成员会被自动销毁,除此之外不需做其他 ~Sales_data(){ }; };
-
在一个析构函数中,首先执行析构函数函数体,然后销毁成员,因此析构函数体自身并不销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。
-
-
三/五法则:
- 需要析构函数的类也需要拷贝和赋值操作;
- 需要拷贝操作的类也需要赋值操作,反之亦然;
- 析构函数不能是删除的;
- 如果一个类有删除的或不可访问的析构函数,那么其默认和拷贝构造函数会被定义为删除的;
- 如果一个类有const或引用成员,则不能使用合成的拷贝赋值操作;
- 所有五个拷贝控制成员应该看作一个整体。
-
使用=default:通过将拷贝控制成员定义为=default来显示的要求编译器生成合成的版本。
-
阻止拷贝:对于某些类来说,拷贝构造函数和拷贝赋值运算符没有合理的意义,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝,即在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的。
-
private拷贝控制:通过将拷贝控制成员声明为 private 并且不定义它们,从而阻止用户代码、友元和成员函数都无法进行拷贝,新标准希望阻止拷贝的类都应使用 =delete 来定义自己的拷贝构造函数和拷贝赋值运算符,而不应将他们声明为 private 的。
-
拷贝控制和资源管理
-
管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源,一旦一个类需要析构函数,则几乎肯定需要一个拷贝构造函数和一个拷贝赋值运算符;
-
如果一个类需要管理类外资源,那我们首先必须确定此类型对象的拷贝语义。一般有两种选择:
- 使类的行为像一个值:意味着它有自己的状态,当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响(eg: string 类的行为像一个值);
- 使类的行为像一个指针:当我们拷贝一个这种类时,副本和原对象使用相同的底层数据( eg: shared_ptr 类提供类似指针的行为)。
-
行为像值的类:为提供类值行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
- 定义一个拷贝构造函数,完成 string 的拷贝,而不是指针拷贝;
- 定义一个析构函数来释放 string;
- 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝 string;
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; } // 赋值操作会销毁左侧运算对象的资源,并从右侧运算对象拷贝数据 HasPtr& HasPtr::operator={const HasPtr &rhs} { auto newp = new string(*rhs.ps); // 拷贝底层string delete ps; // 释放旧内存 ps = newp; // 从右侧运算对象拷贝数据到本对象 i = rhs.i; return *this; // 返回本对象 };
- 编写赋值运算符时:
- 如果一个对象赋予它自身,赋值运算符必须能正确工作;
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作;
- 在编写一个赋值运算符时,一个较好的模式是先将右侧运算符对象拷贝到一个局部临时对象中,当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。
-
行为像指针的类:拷贝指针成员而不是它指向的数据。
- 令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源,拷贝(赋值)一个 shared_ptr 会拷贝(赋值)shared_ptr 所指向的指针,并且 shared_ptr 类自己记录有多少用户共享它所指的对象,当没有用户使用对象时,shared_ptr 类负责释放资源;
- 直接管理资源可以使用引用计数。
-
交换操作
-
除了定义拷贝控制成员外,管理资源的类通常还定义一个swap函数:
class HasPtr { friend void swap(HasPtr&, HasPtr&); // 其他成员定义 }; inline void swap(HasPtr& lhs, HasPtr& rhs) { using std::swap; swap(lhs.ps, rhs.ps)l // 交换指针而不是string数据 swap(lhs.i, rhs.i); // 交换int成员 }
-
如果一个类定义了自己的 swap,那么算法将使用类自定义的版本,否则算法将使用标准库定义的 swap;
-
定义了 swap 的类通常用 swap 来定义它们的赋值运算符,这些运算符使用了一种叫做拷贝并交换的技术,这种技术将左侧运算对象与右侧运算对象的副本进行交换,它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确。
-
动态管理内存类
-
某些类需要自己进行内存管理,一般必须定义自己的拷贝控制成员来管理所分配的内存;
-
使用一个
allocator
来获取原始内存,由于allocator
获取的原始内存是未构造的,我们将在需要添加新元素时使用constructor
在原始内存中创建对象,在删除元素时使用destory
销毁元素; -
每个内存管理类有三个指针成员指向其元素使用的内存
- elements:指向分配的内存中的首元素;
- first_free:指向最后一个实际元素之后的位置;
- cap:指向分配的内存末尾之后的位置。
-
在重新分配内存的过程中移动而不是拷贝元素:
- 一些标准库类(包括string)定义了“移动构造函数”,该函数将资源从给定对象“移动”而不是拷贝到正在在创建的对象,即假设每个string都有一个指向char数组的指针,可以假定string的移动构造函数进行了指针的拷贝,而不是为字符分配内存空间然后拷贝字符;
- 直接调用标准库函数std::move(定义在utility头文件中),表示希望使用string的移动构造函数:
void StrVec::reallocate() { // 我们将分配当前大小两倍的内存空间 auto newcapacity = size() ? 2 * size() : 1; // 分配新内存 auto newdata = allocate(newcapacity); // 将数据从旧内存移动到新内存 auto dest = newdata; auto elem = elements; for (size_t i = 0; i != size(); ++i) alloc.construct(dest++, std::move(*elem++)); free(); elements = newdata; first_free = dest; cap = elements + newcapcity; }
对象移动
-
新标准一个最主要的特性就是可以移动而非拷贝对象的能力;
-
使用移动而不是拷贝的另一个原因在于 IO 类或者 unique_ptr 这样的类,这些类都包含不能被共享的资源(如指针或者IO缓冲),因此这些类型的对象不能被拷贝但是可以被移动;
- 标准库容器string、shared_ptr 类既支持移动也支持拷贝;
-
右值引用
-
左值和右值是表达式的属性,一般而言一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值;
-
右值引用,就是绑定到右值的引用,通过 && 而非 & 来获得右值引用;
-
右值引用的一个重要性质:只能绑定到一个将要销毁的对象;
-
常规引用(左值引用)不能绑定到要求转换的表达式、字面常量或者返回右值的表达式,但右值引用有着完全相反的绑定特性,可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用绑定到一个左值上;
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绑定到右值上
-
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子,可以将一个左值引用绑定到这类表达式的结果上;
-
返回非引用类型的函数,连同算数、关系、位以及后置递增/递减运算符,都生成右值,我们不能将一个左值引用绑定到这类表达式上,但可以将一个 const 的左值引用或一个右值引用绑定到这类表达式上;
-
左值持久而右值短暂:右值要么是字面常量,要么是在表达式求值过程中创建的临时对象;
-
变量是左值,字面值常量是右值;
-
标准库move函数:通过使用move函数,可以显式地将一个左值转换为对应的右值引用类型:
int &&rr3 = std::move(rr1); //OK
- move 告诉编译器,我们有一个左值,但希望像一个右值一样处理,调用 move 就意味着承诺,即除了对 rr1 赋值或销毁,将不再使用它;
- 使用 move 的代码应该使用 std::move 而不是 move,这样可以避免潜在的名字冲突。
-
-
移动构造函数和移动赋值运算符
-
为了让我们自己的类支持移动操作(窃取资源而不是拷贝资源),需要为其定义移动构造函数和移动赋值运算符;
-
移动构造函数
- 第一个参数是该类类型的右值引用,且任何额外的参数都必须有默认实参;
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常 : elements(s.elements), first_free(s.first_free), cap(s.cap) { // 令s进入这样的状态——对其运行析构函数是安全的 s.elements = s.first_free = s.cap = nullptr; // 如果我们忘记改变s.first_free,那么销毁移后原对象就会释放掉我们刚刚移动的内存 }
- 移动构造函数必须确保移后源对象必须不再指向被移动的资源,这些资源的所有权已经属于新创建的对象;
- 与拷贝构造函数不同,移动构造函数不分配任何新内存;
- 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept(在类头文件的声明和定义中都进行指定)。
-
移动赋值运算符
StrVec &StrVec::operator=(StrVec &&rhs) noexcept { // 直接检测自赋值 if (this != &ths) { free(); // 释放已有元素 elements = rhs.elements; // 从rhs中接管资源 first_free = rhs.first_free; cap = rhs.cap; // 将rhs置于可析构状态 rhs.elements = rhs.first_free = rhs.cap = nullptr; } return *this; }
-
移后源对象必须可析构:从一个对象移动数据并不会销毁对象,但有时在移动操作完成后,源对象会被销毁,必须确保移后源对象进入一个可析构的状态。
-
合成的移动操作:
- 与拷贝操作不同,只有一个类没有定义任何自己版本的拷贝控制成员,并且所有非static数据成员都可以移动时,编译器才会为它合成移动构造函数或者移动赋值运算符;
- 如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
-
移动右值,拷贝左值:
- 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定它使用哪个构造函数;
- 如果一个类有一个拷贝构造函数但是未定义移动构造函数,编译器不会合成移动构造函数,这种情况下函数匹配规则保证右值也会被拷贝,即使我们试图通过调用move来移动他们。
-
拷贝并交换赋值运算符和移动操作
class HasPtr{ public: //添加的移动构造函数 HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;} //该赋值运算符既是移动赋值运算符,也是拷贝赋值运算符 HasPtr& operator=(HasPtr rhs){ swap(*this, rhs); return *this;} //其他成员的定义,同13.2.1节(p 453)
对于赋值运算符,有一个非引用参数,这意味着此参数要进行拷贝初始化,这依赖于实参的类型,如果实参是左值则使用拷贝构造函数;若是右值,则使用移动构造函数。
-
移动迭代器
-
新标准库定义了一种移动迭代器适配器,一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配迭代器;
-
一般来说,一个迭代器的解引用运算符返回一个指向元素的左值,而移动迭代器解引用运算符生成一个右值引用;
-
通过调用
make_move_iterator
函数将一个普通迭代器转为一个移动迭代器,此函数接受一个迭代器参数,返回一个移动迭代器:void StrVec::reallocate() { //分配大小两倍于当前规模的内存空间 auto newcapacity = size() ? 2 * size() : 1; auto first = alloc.allocate(newcapacity); //移动元素 auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first); free(); //释放旧内存空间 elements = first; //更新指针 first_free = last; cap = elements + newcapacity; }
-
由于移动一个对象可能销毁原对象,只有确信在为一个元素赋值或者将其传递给一个用户定义的函数不再访问他才能将移动构造器传递给算法。
-
-
-
右值引用成员函数
-
如果一个成员函数同时提供拷贝和移动两种版本的构造函数和赋值运算符,它能从中受益;
-
通常一个版本接受指向const的左值引用(const T&),另一个版本接受一个指向非const的右值引用(T&&);
-
左值和右值引用成员函数
-
通常,我们可以在一个对象上调用成员函数,而不管该对象是左值还是右值
string s1 = "a value", s2 = "another"; //在一个 string 的右值上调用 find 成员 auto n = (s1 + s2).find('a');
-
若希望在自己的类中阻止这种用法,即强制左侧运算对象(即 this 指向的对象)是一个左值,与定义 const 成员函数相同,在参数列表后放置一个引用限定符(且同时出现在函数的声明和定义中):
class Foo { public: Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值 // Foo的其他参数 }; Foo &Foo::&operator=(const Foo &rhs) & { // 执行将rhs赋予本对象所需的工作 return *this; }
-
引用限定符可以是&或者&&,分别指出this可以指向一个左值或者右值,类似于const限定符,引用限定符只能用于非static成员函数;
-
一个函数可以同时使用 const 和 引用限定,但引用限定符必须在 const 限定符之后。
-
-
重载和引用函数
- 可以综合引用限定符和const来区分一个成员函数的重载版本;
- 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
-