13.3 交换操作
除了定义拷贝控制成员,管理资源的类通常还定义了一个名为swap的函数。为了交换两个对象我们需要进行一次拷贝和两次赋值。
1 2 3 4 |
//交换两个类值HasPtr对象的代码可能像下面这样: HasPtr temp = v1; //创建v1的值的一个临时副本 v1 = v2; //将v2的值赋予v1 v2 = temp; //将保存的v1的值赋予v2 |
这段代码将原来v1中的string拷贝了两次——第一次是HasPtr的拷贝构造函数将v1拷贝给temp,第二次是赋值运算符将temp赋予v2。将v2赋予v1的语句还拷贝了原来v2中的string。
理论上,这些内存分配都是不必要的。我们更希望swap交换指针,而不是分配string的新副本:
1 2 3 |
string *temp = v1.ps; //为v1.ps中的指针创建一个副本 v1.ps = v2.ps; //将v2.ps中的指针赋予v1.ps1 v2.ps = temp; //将保存的v1.ps中原来的指针赋予v2.ps |
- 编写我们自己的swap函数
12345678910class HasPtr {friend void swap (HasPtr&, HasPtr&);//其他成员定义,与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定义为friend以便能访问HasPtr的数据成员。由于swap的存在就是为了优化代码,我们将其声明为inline函数。 - swap函数应该调用swap,而不是std::swap
非常仔细的读者可能会奇怪为什么swap函数中的using声明没有隐藏HasPtr版本swap的声明。我们将在18.2.3节解释为什么这段代码能正常工作。 - 在赋值运算符中使用swap
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:
12345678//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数将//右侧运算对象中的string拷贝到rhsHasPtr& HasPtr::operator=(HasPtr rhs){//交换左侧运算对象和局部变量rhs的内容swap(*this, rhs); //rhs现在指向本对象曾经使用的内存return *this; //rhs被销毁,从而delete了rhs中的指针}
在这个版本的赋值运算符中,参数并不是一个引用,我们将右侧运算对象以传值方式传递给了赋值运算符。因此,rhs是右侧运算对象的一个副本。
13.4 拷贝控制示例
我们将Folder类的设计留作练习。但是,我们假定Folder类包含名为addMsg和remMsg成员,分别在完成给定Folder对象的消息集合中添加和删除Message的工作。
- Message类
12345678910111213141516171819202122class Message {friend class Folder;public://folders被隐式初始化为空集合explicit Message (const std::string &str = ""):contents(str) { }//拷贝控制成员,用来管理指向本Message的指针Message(const Message&); //拷贝构造函数Message& operator=(const Message&); //拷贝赋值运算符接受一个与其所在类相同类型的参数~Message(); //析构函数//从给定Folder集合中添加/删除本Messagevoid save(Folder&);void remove(Folder&);private:std::string contents; //实际消息文本std::set<Folder*> folders(); //包含本Message的Folder//拷贝构造函数、拷贝赋值运算符和析构函数所使用的工具函数//将本Message添加到指定的Folder中void add_to_Folders(const Message&);//从folders中的每个Folder中删除本Messagevoid remove_from_Folders)();}; - save和remove成员
除拷贝控制成员外,Message类只有两个公共成员:save,将本Message存放在给定Folder中;remove,删除本Message。
12345678910void Message::save(Folder &f){folders.insert(&f); //将给定Folder添加到我们的Folder列表中f.addMsg(this); //将本Message添加到f的Message集合中}void Message::remove(Folder &f){folders.remove(&f); //将给定Folder从我们的Folder列表中删除f.remMsg(this); //将本Message从f的Message集合中删除} - Message类的拷贝控制成员
当我们拷贝一个Message时,得到的副本应该与原Message出现在相同的Folder中。因此,我们必须遍历Folder指针的set,对每个指向原Message的Folder添加一个指向新Message的指针。
12345678910111213//将本Message添加到指向m的Folder中void Message::add_to_Folders(const Message &m){for (auto f : m.folders) //对每个包含m的Folderf->addMsg(this); //向该Folder添加一个指向本Message的指针}//Message的拷贝构造函数拷贝给定对象的数据成员:Message::Message(const Message &m):contents(m.contents), folders.(m.folders){add_to_Folders(m); //将本消息添加到指向m的Folder中} - Message的析构函数
123456789101112//从对应的Folder中删除本Messagevoid Message::remove_from_Folders(){for (auto f : folders) //对folders中每个指针f->remMsg(this); //从该Folder中删除本Message}//函数remove_from_Folders的实现类似add_to_Folders,//有了remove_from_Folders函数,编写析构函数就简单了Message::~Message(){remove_from_Folders();} - Message的拷贝赋值运算符
123456789Message& Message::operator=(const Message &rhs){//通过先删除指针再插入它们来处理自赋值情况remove_from_Folders(); //更新已有Foldercontents = rhs.contents; //从rhs拷贝消息内容folders = rhs.folders; //从rhs拷贝Folder指针add_to_Folders(rhs); //将本Message添加到那些Folder中return *this;} - Message的swap函数
我们通过两遍扫描folders中每个成员来正确处理Folder指针。第一遍扫描将Message从它们所在的Folder中删除。接下来我们调用swap来交换数据成员。最后对folders进行第二遍扫描来添加交换过的Message:
1234567891011121314151617void swap(Message &lhs, Message &rhs){using std::swap; //本例中严格来说不需要,但这是一个好习惯//将每个消息的指针从它(原来)所在Folder中删除for (auto f: lhs.folders)f->remMsg(&lhs);for (auto f: rhs.folders)f->remMsg(&rhs);//交换contents和Folder指针setswap(lhs.folders, rhs.folders); //使用swap(set&, set&)swap(lhs.contents, rhs.contents); //使用swap(string&, string&)//将每个Message的指针添加到它的新Folder中for (auto f: lhs.folders)f->addMsg(&lhs);for (auto f: rhs.folders)f->addMsg(&rhs);}
13.5 动态内存管理类(P464)
13.6 对象的移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。在某些情况下对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
使用移动而不是拷贝的另一个原因源于IO类或unique_ptr这样的类。这些类都包括不能被共享的资源。因此,这些类型的对象不能拷贝但可以移动。
NOTE:标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
- 右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用(rvalue reference)。我们通过&&而不是&来获得右值引用。右值引用一个重要性质——只能绑定到一个将要销毁的对象。 - 一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
- 对于常规引用(我们可以称之为左值引用),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
123456int i = 42;int &r = i; //正确:r引用iint &&rr = i; //错误:不能将一个右值引用绑定到一个左值上int &r2 = i*42; //错误:i*42是一个右值const int &r3 = i*42; //正确:我们可以将一个const的引用绑定到一个右值上int &&rr2 = i*42; //正确:将rr2绑定到乘法结果上 - 左值持久;右值短暂
由于右值引用只能绑定到临时对象,我们得知:(1)所引用的对象将要被销毁;(2)该对象没有其他用户。这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
NOTE:右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。 - 变量是左值
12int &&rr1 = 42; //正确:字面常量是右值it &&rr2 = rr1; //错误:表达式rr1是左值!
其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。
NOTE:变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。 - 标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显示地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用。
12#inclue <utility>int &&rr3 = std::move(rr1); //okmove调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味承诺:除了对rr1赋值或销毁它外,我们将不再使用它。
NOTE:我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。 - 移动构造函数和移动赋值运算符
类似string类(及其他标准库类),如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。
- 类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
- 除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。
- 作为一个例子,我们为StrVec类定义移动构造函数,实现从一个StrVec到另一个StrVec的元素移动而非拷贝:
1234567StrVec::StrVec(StrVec &&s) noexcept //移动操作不应抛出任何异常//c成员初始化器接管s中的资源:elements(s.elements), first_free(s.first_free), cap(s.cap){//令s进入这样的状态———对其运行析构函数是安全的s.elements = s.first_free = s.cap = nullptr;} - 移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。一种通知标准库的方法是在我们的构造函数中指明noexcept。noexcept是新标准引入的。
NOTE:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。 - 移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。
12345678910111213StrVec &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;} - 移后源对象必须可析构
WARNING:在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。 - 合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
NOTE:只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。 - 移动右值,拷贝左值……
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。 - ……但如果没有移动构造函数,右值也被拷贝