C++移动语义-移动构造函数和移动赋值运算符重载
一、对象移动
C++ 11 标准的一个最主要特性是可以移动而非拷贝对象的能力。虽然在多数情况下都会发生对象拷贝,但在某些情况下,对象拷贝后就立即被销毁了,在这种情况下,移动而非拷贝对象可以大幅度提升性能。
比如我们自己写一个strVector类,当vector需要扩容的时候,会重新分配内存,此时从旧内存中将元素拷贝到新内存是不必要的,更好的方式是移动元素。
使用移动而不是拷贝的另一个原因源于IO类或 unique_ptr 这样的类。这些类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动。
在旧的C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝,如果对象比较大,或者是对象本身要求分配内存空间(如string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在C++ 11标准之后,我们可以用容器保存不可拷贝的类型,只要它们能够被移动即可。
二、移动构造函数和移动赋值运算符
类似string类(以及其他标准库类),如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。为了让我们自己的类支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。这里的“窃取”行为,就称之为“移动语义”(move semantics)。换成白话文,就是“移为己用”。
类似拷贝构造函数,移动构造函数的第一个参数是该类型的一个引用。不同于拷贝构造函数,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移动后的源对象处于这样一种状态————销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源,因为这些资源的所有权已经归属于新创建的对象。
class StrVec
{
public:
StrVec(StrVec&&) noexcept; // 移动构造函数
// 其他成员函数定义
private:
std::string* elements; // 指向数组首元素的指针
std::string* first_free; // 指向数组的第一个空闲元素的指针
std::string* cap; // 指向数组尾后位置的指针
};
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。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移动后的源对象会被销毁,意味着将在其上运行析构函数。
2.1 移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此通知给标准库。否则,它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做出一些额外的工作。
一种通知标准库的方法是在我们的构造函数中指明 noexcept。 这是我们承诺一个函数不抛出异常的一种方法。并且,我们必须在类的头文件的声明中以及它的定义中(如果定义在类外的话),都指定noexcept。
移动一个对象通常会改变它的值。如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。在此情况下,vector将不能满足自身保持不变的要求。
另一方面,如果vector使用了拷贝构造函数且发生了异常,它可以很容易地满足要求,因为在新内存中构造元素时,旧元素保持不变,如果此时发生了异常,vector可以释放新分配的内存并返回,而其旧内存中原有的元素仍然存在。
为了避免这种潜在问题,除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望vector在重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库,我们的移动构造函数可以安全使用。这一点就是通过将移动构造函数标记为 noexcep 来做到的。
2.2 移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将其标记为 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;
}
return *this;
}
在此例中,我们直接检查this指针和rhs的地址是否相同,如果相同,右侧和左侧运算对象指向相同的对象,就不需要做任何事情。
2.3 移动后的源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。因此,我们编写移动操作时,必须确保移动后的源对象进入一个可析构的状态。
参考文献:
- 《C++ Primer第五版》
- 《深入理解C++ 11:C++11新特性解析与应用》