文章目录
第十三章 拷贝控制
- 一个类通过定义五种特殊的成员函数来控制这些操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。
- 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么,析构函数定义了当此类型对象销毁时做什么,这些操称为拷贝控制操作。
13.1 拷贝、赋值与销毁
- 从拷贝构造函数、拷贝赋值运算符和析构函数作为开始。
13.1.1 拷贝构造函数
- 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
- 拷贝构造函数通常不应该是explicit,因为几种情况下都会被隐式地使用。
class Foo
{
Foo();//默认构造函数
Foo(const Foo&);//拷贝构造函数
//...
};
- 如果没有为一个类定义拷贝构造函数,编译器会定义一个。
- 对某些类来说,编译器的合成拷贝构造函数用来阻止我们拷贝该类类型的对象。
- 关于与合成的拷贝构造函数等价的拷贝构造函数的声明。
class Sales_data
{
public:
//其他成员和构造函数的定义,如前
//与合成的拷贝构造函数等价的拷贝构造函数的声明
Sales_data(const Sales_data &);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
}
//与Sales_data的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo), //使用string的拷贝构造函数
units_sold(orig.units_sold), //拷贝orig.units_sold
revenue(orig.revenue) //拷贝orig.revenue
{
} //空函数体
- 直接初始化和拷贝初始化之间的差异。
- 当直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。
- 当拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
string dots(10, '.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string null_book = "9-999-99999-9"; //拷贝初始化
string nines = string(100, '9'); //拷贝初始化
- 拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生。
- 将一个对象作为实参传递给一个非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
- 拷贝构造函数被用来初始化非引用类类型参数,如果其参数不是引用类型,则调用永远也不会成功,会形成循环拷贝。
- 使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了。
- 当传递一个实参或函数返回一个值,不能隐式使用一个explicit构造函数。
vector<int> v1(10); //正确:直接初始化
vector<int> v2 = 10; //错误:接受大小参数的构造函数是explicit的
void f(vector<int>); // f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector
- 在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。
string null_book = "9-999-99999-9"; //拷贝初始化
string null_book("9-999-99999-9"); //编译器略过了拷贝构造函数
- 即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(不能是private)。
13.1.2 拷贝赋值运算符
- 与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。
Sales_data trans, accum;
trans = accum; //使用Sales_data 的拷贝赋值运算符
- 首先关于重载运算符,本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。
class Foo
{
public:
Foo &operator=(const Foo &); //赋值运算符
//...
}
- 如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个合成拷贝赋值运算符。
//等价于合成拷贝赋值运算符
Sales_data &
Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; // i调用string::operator
units_sold = rhs.units_sold; // 使用内置的int赋值
revenue = rhs.revenue; // 使用内置的double赋值
return *this; // 返回一个此对象的引用
}
13.1.3 析构函数
- 析构函数释放对象使用的资源,并销毁对象的非static数据成员。
class Foo
{
~Foo();//析构函数
//...
};
- 由析构函数不接受参数,因此它不能被重载,对一个给定类,只会有唯一一个析构函数。
- 构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。
- 构造函数中,成员的初始化是在函数体执行之前完成的,按照它们在类中出现的顺序进行初始化。
- 析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。
- 通常,析构函数释放对象在生存期分配的所有资源。
- 析构函数中不存在类似构造函数中初始化列表的东西来控制成员如何销毁,是隐式的。
- 隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
- 什么时候会调用析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
{
// 新作用域
// p和p2指向动态分配的对象
Sales_data *p = new Sales_data; // p是一个内置指针
auto p2 = make_shared<Sales_data>(); // p2是一个shared_ptr
Sales_data item(*p); // 拷贝构造函数将*p拷贝到item中
vector<Sales_data> vec; // 局部对象
vec.push_back(*p2); // 拷贝p2指向的对象
delete p; // 对p指向的对象执行析构函数
}
// 退出局部作用域;对item、p2和vec调用析构函数
// 销毁p2会递减其引用计数;如果引用计数变为0, 对象被释放
// 销毁vec会销毁它的元素
- 当指向一个对象的的引用或指针离开作用域时,析构函数不会执行。
- 合成析构函数
class Sales_data
{
public:
//成员会被自动销毁,除此之外不常要做其他事情
~Sales data() {}
//其他成员的定义,如前
};
- 在(空)析构函数体执行完毕后,成员会被自动销毁。
13.1.4 三/五法则
- 有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
- 在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
- C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。
- 但是通常被看作一个整体,因为需要析构函数的类也需要拷贝和赋值操作。
- 下例子中,类使用合成的拷贝构造函数和拷贝赋值运算符,在两个对象上都会调用HasPtr的析构函数,此代码会导致此指针被delete两次。
class HasPtr
{
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
~HasPtr() { delete ps; }
//错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
//其他成员的定义,如前
}
HasPtr f(HasPtr hp) // HasPtr是传值参数,所以将被拷贝
{
HasPtr ret = hp; //拷贝给定的HasPtr
//处理ret
return ret; // ret和hp被销毁
}
- 如此调用函数指向无效内存。
HasPtr p("some values");
f(p); //当f结束时,p.ps指向的内存被释放
HasPtr q(p); //现在p和q都指向无效内存!
- 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
- 无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
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 NoCopy &) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
//其他成员
}
- 析构函数不能是删除的成员,对于删除了析构函数的类型,虽然不能定义这种类型的变量或成员,却可以动态分配这种类型的对象,但是不能释放这些对象。
struct NoCopy
{
NoDtor() = default; //使用合成默认构造函数
~NoDtor() = delete; //我们不能销毁NoDtor类型的对象
};
NoDtor nd; //错误: NoDtor的析构函数是删除的
NoDtor *p = new NoDtor(); //正确:但我们不能delete p
delete p; //错误: NoDtor的析构函数是删除的
- 对某些类来说,编译器将这些合成的成员定义为删除的函数。
- 如果类的某个成员的析构函数是删除的或不可访问的(例如是private的),则类的合成析构函数被定义为删除的。
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的,如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
- 本质上规则的含义是如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的,原因是,如果没有这条规则,我们可能会创建出无 法销毁的对象。
- 在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝。
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;
}
HasPtr &HasPtr::operator=(constHasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; //返回本对象
}
- 对于一个赋值运算符来说,在销毁左侧运算对象资源之前拷贝右侧运算对象。
//这样编写赋值运算符是错误的!
HasPtr &HasPtr::operator=(constHasPtr &rhs)
{
delete ps; //释放旧内存
//如果rhs和*this是同一个对象,就会将从已释放的内存中拷贝数据!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}
13.2.2 定义行为像指针的类
- 对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身。
- 定义一个使用引用计数的类。
classHasPtr
{
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的成员
}
- 析构函数不能无条件地delete ps,可能还有其他对象指向这块内存。
HasPtr::~HasPtr()
{
if (--*use == 0) //如果引用计数变为0
{
delete ps; //释放string内存
delete use; //释放计数器内存
}
}
- 赋值运算符必须处理自赋值,我们通过先递增rhs中的计数然后再递减左侧运算对象中的计数来实现这一点
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的函数。
- 交换两个类值可能是这样。
HasPtr temp = v1; //创建v1的值的一个临时副本
v1 = v2; //将v2的值赋子v1
v2 = temp; //将保存的v1的值赋予v2
- 理论上,这些内存分配都是不必要的,希望交换指针,而不是分配新副本。
string *temp = v1.ps; //为v1.ps中的指针创建一个副本
v1.ps = v2.ps; //将v2.ps中的指针赋予v1.ps
v2.ps = temp; //将保存的v1.ps中原来的指针赋子v2.ps
- 编写自己的swap函数。
class HasPtr
{
friend void swap(HasPtr &, 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;
}
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); //交换指针,而不是string数据
swap(lhs.i, rhs.i); //交换int成员
}
- 如果一个类的成员有自己类型特定的swap函数,调用std::swap就是错误的。
- 假定有另一个命名为Foo的类,它有一个类型为HasPtr的成员h。
void swap(Foo &lhs, Foo &rhs)
{
//错误:这个函数使用了标准库版本的swap,而不是HasPtr版本
std::swap(lhs.h, rhs.h);
//交换类型 Foo 的其他成员
}
- 正确如下。
void swap(Foo &lhs, Foo &rhs)
{
using std::swap; //没有隐藏HasPtr版本swap的声明
swap(lhs.h, rhs.h); //使用HasPtr版本的swap
//交换类型Foo的其他成员
}
- 定义swap的类通常用swap来定义它们的赋值运算符,这些运算符使用了一种名为拷贝井交换的技术。
//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
HasPtr &HasPtr::operator=(HasPtr rhs)
{
//交换左侧运算对象和局部变责rhs的内容
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而delete了rhs中的指针
}
- 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。
13.4 拷贝控制示例
- 两个类命名为Message和Folder,分别表示电子邮件和消息目录。
- 每个Message对象可以出现在多个Folder中,但是,任意给定的Message的内容只有一个副本。
- 如果一条Message的内容被改变,则从它所在的任何Folder来浏览此Message时,都会看到改变后的内容。
class Message
{
friend class Folder;
public:
// folders被隐式初始化为空集合
explicit Message(const std::string &str = "") : contents(str) {}
// 拷贝控制成员,用来管理指向本Message的指针
Message(const Message &); //拷贝构造函数
Message &operator=(const Message &); //拷贝赋值运算符
~Message(); //析构函数
//从给定Folder集合中添加/删除本Message
void 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中删除本Message
void remove_from_Folders();
};
void Message::save(Folder &f)
{
folders.insert(&f); //将给定Folder添加到我们的Folder列表中
f.addMsg(this); //将本Message添加到f的Message集合中
}
void Message::remove(Folder &f)
{
folders.erase(&f); //将给定Folder从我们的Folder列表中删除
f.remMsg(this); //将本Message从f的Message集合中删除
}
//将本Message添加到指向m的Folder中
void Message::add_to_Folders(const Message &m)
{
for (auto f : m.folders) //对每个包含m的Folder
{
f->addMsg(this); //向该Folder添加一个指向本Message的指针
}
}
Message::Message(const Message &m) : contents(m.contents), folders(m.folders)
{
add_to_Folders(m); //将本消息添加到指向m的Folder中
}
//从对应的Folder中删除本Message
void Message::remove_from_Folders()
{
for (auto f : folders) //对folders中每个指针
{
f->remMsg(this); //从该Folder中删除本Message
}
}
Message::~Message()
{
remove_from_Folders();
}
Message &Message::operator=(const Message &rhs)
{
//通过先删除指针再插入它们来处理自赋值情况
remove_from_Folders(); //更新已有Folder
contents = rhs.contents; //从rhs拷贝消息内容
folders = rhs.folders; //从rhs拷贝Folder指针
add_to_Folders(rhs); //将本Message添加到那些Folder中
return *this;
}
void 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指针set
swap(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 动态内存管理类
- 某些类需要在运行时分配可变大小的内存空间,这种类通常可以使用标准库容器来保存它们的数据。
- 如果实现标准库vector类的一个简化版本。StrVec。
- 每个StrVec有三个指针成员指向其元素所使用的内存。
- elements,指向分配的内存中的首元素。
- first_free,指向最后一个实际元素之后的位置。
- cap,指向分配的内存末尾之后的位置。
- 除了这些指针之外,StrVec还有一个名为alloc的静态成员,其类型为allocator。
- alloc成员会分配StrVec使用的内存,我们的类还有4个工具函数。
- alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
- free会销毁构造的元素并释放内存。
- chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
- reallocate在内存用完时为StrVec分配新内存。
//类vector类内存分配策略的简化实现
class StrVec
{
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {} // allocator成员进行默认初始化
StrVec(const StrVec &); //拷贝构造函数
StrVec &operator=(const StrVec &); //拷贝赋值运算符
~StrVec(); //析构函数
void push_back(const std::string &); //拷贝元素
size_t size() const { return first_free - elements; } // size成员返回当前真正在使用的元素的数目
size_t capacity() const { return cap - elements; } // capacity成员返回StrVec可以保存的元素的数量
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }
//...
private:
Static std::allocator<std::string> alloc; //分配元素
//被添加元素的函数所使用
void chk_n_alloc() //当没有空间容纳新元素,会为StrVec重新分配内存
{
if (size() == capacity())
reallocate();
}
//工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
std::pair<std::string *, std::string *> alloc_n_copy(const std::string *, const std::string *);
void free(); //销毁元素并释放内存
void reallocate(); //获得更多内存并拷贝已有元素
std::string *elements;
std::string *first_free; //指向数组第一个空闲元素的指针
std::string *cap; //指向数组尾后位置的指针
};
void StrVec::push_back(const string &s)
{
chk_n_alloc(); //确保有空间容纳新元素
//在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s); //当我们用allocator分配内存时,必须记住内存是未构造的
}
// alloc_n_copy成员
pair<string *, string *> StrVec::alloc_n_copy(const string *b, canst string *e)
{
//分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
//初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
return {data, uninitialized_copy(b, e, data)};
//返回的pair的first成员指向分配的内存的开始位置,second成员则是uninitialized_copy的返回值,此值是一个指针,指向最后一个构造元素之后的位置。
}
// free成员
void StrVec::free()
{
//不能传递给deallocate一个空指针,如果elements为0,函数什么也不做
if (elements)
{
//逆序销毁旧元素
for (auto p = first_free; p != elements; /*空*/)
{
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
//拷贝控制成员
StrVec::StrVec(const StrVec &s)
{
//调用alloc_n_copy分配空间以容纳与s中一样多的元素
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec::~StrVec() { free(); }
StrVec &StrVec::operator=(const StrVec &rhs)
{
//调用alloc_n_copy分配内存,大小与rhs中元素占用空间一样多
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
- 在编写reallocate成员函数之前,我们稍微思考一下此函数应该做什么。
- 为一个新的、更大的string数组分配内存。
- 在内存空间的前一部分构造对象,保存现有元素。
- 销毁原内存空间中的元素,并释放这块内存。
void StrVec::reallocate()
{
//我们将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 * size() : 1;
//分配新内存
auto newdata = alloc.allocate(newcapacity);
//将数据从旧内存移动到新内存
auto dest = newdata; //指向新数组中下一个空闲位置
auto elem = elements; //指向旧数组中下一个元素
for (size_ti = 0; i != size(); ++i)
{
alloc.construct(dest++, std::move(*elem++));//std::move移动语义
}
free(); //一旦我们移动完元素就释放旧内存空间
//更新我们的数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
13.6 对象移动
- 新标准的一个最主要的特性是可以移动而非拷贝对象的能力。
- 很多情况下都会发生对象拷贝,在其中某些情况下,对象拷贝后就立即被销毁了,在这些情况下,移动而非拷贝对象会大幅度提升性能。
- 标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
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绑定到乘法结果上
int &&rr1 = 42; //正确:宇面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值!
- 左值持久,右值短暂。
- 右值引用指向将要被销毁的对象,因此可以从绑定到右值引用的对象“窃取”状态。
- 变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
- 虽然不能将一个右值引用直接绑定到一个左值上,但可以显式地将一个左值转换为对应的右值引用类型。
- 可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,定义在头文件utility中。
int &&rr3 = std::move(rr1); // ok
- 调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。
13.6.2 移动构造函数和移动赋值运算符
- 类似string类(及其他标准库类),如果自己创建的类也同时支持移动和拷贝,那么也能从中受益。
- 如为StrVec类定义移动构造函数。
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) //移动操作不应抛出任何异常
//成员初始化器接管s中的资源
{
//令s进入这样的状态一一对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
- 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
- 如果移动到一般出异常了,就会产生问题,移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。
- 移动赋值运算符执行与析构函数和移动构造函数相同的工作。
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;
}
}
- 在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
- 与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符,但条件大不相同。
- 只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
struct X
{
int i; //内置类型可以移动
std::string s; // string定义了自己的移动操作
}
struct hasX
{
X mem; // X有合成的移动操作
};
X x, x2 = std::move(x); //使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); //使用合成的移动构造函数
- 与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。
- 将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则
- 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。
//假定y是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY
{
hasY() = default;
hasY(hasY &&) = default;
Y mem; // hasY将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); //错误:移动构造函数是删除的
- 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,这些成员默认地被定义为删除的。
- 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则使用哪个构造函数。
StrVec v1, v2;
v1 = v2; // v2是左值,使用拷贝赋值
StrVec getVec(istream &); // getVec返回一个右值
v2 = getVec(cin); // getVec(cin)是一个右值;使用移动赋值
- 如果没有移动构造函数,右值也被拷贝。
class Foo
{
public:
Foo() = default;
Foo(const Foo &); //拷贝构造函数
//其他成员定义,但Foo未定义移动构造函数
};
Foo x;
Foo y(x); //拷贝构造函数,x是一个左值
Foo z(std::move(x)); //拷贝构造函数,因为未定义移动构造函数
- 拷贝并交换赋值运算符和移动操作。
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;
}
//其他成员的定义
};
hp = hp2; // hp2是一个左值,hp2通过拷贝构造函牧来拷贝
hp = std::move(hp2); //移动构造函数移动hp2
- 所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作,如前所述,某些类必须定义拷贝构造函数、 拷贝赋值运算符和析构函数才能正确工作,这些类通常拥有一个资源,而拷贝成员必须拷贝此资源,一般来说,拷贝一个资源会导致一些额外开销,在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
- 新标准库中还定义了一种移动迭代器适配器,
make_move_iterator函数。 - 由于一个移后源对象具有不确定的状态,对其调用std::move是危险的,当调用move时,必须绝对确认移后源对象没有其他用户。
13.6.3 右值引用和成员函数
- 除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,也能从中受益。
- 如push_back的标准库容器提供两个版本。
void push_back(const X &); //拷贝:绑定到任意类型的X
void push_back(X &&); //移动:只能绑定到类型X的可修改的右值
- 通常在一个对象上调用成员函数,而不管该对象是一个左值还是右值。
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');
s1 + s2 = "wow'";
- 新标准库类仍然允许向右值赋值,但是,我们可能希望在自己的类中阻止这种用法。
- 在参数列表后放置一个引用限定符强制左侧运算对象是个左值。
class Foo
{
public:
Foo &operator=(const Foo &) &; //只能向可修改的左值赋值
// Foo的其他参数
};
Foo &Foo::operator=(const Foo &rhs) &
{
//执行将rhs赋子本对象所需的工作
return *this;
}
Foo &retFoo(); //返回一个引用,retFoo调用是一个左值
Foo retVal(); //返回一个值,retVal调用是一个右值
Foo i, j; // i和j是左值
i = j; //正确:上是左值
retFoo() = j; //正确:retFoo ()返回一个左值
retVal() = j; //错误:retVal ()返回一个右值
i = retVal(); //正确:我们可以将一个右值作为赋值操作的右侧运算对象
- 一个函数可以同时用const和引用限定,在此情况下,引用限定符必须跟随在const限定符之后。
class Foo
{
public:
Foo someMem() &const; //错误:const限定符必须在前
Foo anotherMem() const &; //正确:const限定符在前
}
- 重载和引用函数,引用限定符也可以区分重载版本。
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; //返回副本
}
retVal().sorted(); // retVal()是一个右值,调用Foo::sorted() &&
retFoo().sorted(); // retFoo()是一个左值,调用Foo::sorted() const &
- 如果定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加。
版权声明:本文为ryacber原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。