C++移动语义详解(附带实例)
移动语义是现代 C++ 提升性能的关键特性。它们支持移动而不是复制那些复制成本较高的资源或对象。然而,它要求类实现移动构造函数和赋值操作符。在某些情景下,编译器会提供它们,但实际上,通常你需要自己显式实现它们。
本节中,我们将展示如何基于以下 Buffer 类实现移动构造函数和赋值操作符:
1) 写一个接受此类类型右值引用的构造函数:
2) 将右值引用的所有数据成员赋值到当前对象。这可以在如下构造函数主体中,或在初始化列表中(更好的方式)完成:
3) 将右值引用的所有数据成员赋予默认值:
为了实现类的移动赋值操作,请执行如下步骤:
1) 写一个接受此类类型右值引用的赋值操作符并返回引用:
2) 检查右值引用没有指向this同一对象,如果它们不同,则执行步骤 3~5:
3) 从当前对象处理所有资源(如内存、句柄等):
4) 将右值引用的所有数据成员赋值给当前对象:
5) 将右值引用的所有数据成员赋予默认值:
6) 无论步骤 3)~步骤 5) 执行与否,返回当前对象的引用:
将它们放在一起,Buffer 类的移动赋值操作符如下:
移动构造函数将递归调用类数据成员的移动构造函数。类似地,移动赋值操作符递归调用类数据成员的移动赋值操作符。
移动对于太大以至于不能复制的对象(如字符串或容器)或不能被复制的对象(如 unique_ptr 智能指针)有性能提升。不是所有类都应该实现复制和移动语义。有些类只应该可移动,另一些类可同时复制和移动。另外,类能复制却不能移动是没有意义的,尽管技术上可以这么做。
不是所有类型都能从移动语义上获利。对于内置类型(如 bool、int 或 double)、数组或 POD,移动实际上是复制操作。另外,移动语义对于右值(临时对象)有性能提升。右值是没有名称的对象,在表达式运算期间临时存在,在下一分号则销毁:
在右值语境下,移动语义很重要。这是因为它允许你从将被销毁的临时对象中获取资源的所有权,当移动操作完成后,用户不需要再使用它。
另外,左值不能被移动,它们只能被复制。这是因为它们在移动操作后,还需要被访问,用户期望对象在相同的状态。例如,在前一示例中,表达式 b=a 将 a 赋值给 b。
在此操作完成后,左值对象 a 还能被用户使用且应该处于之前同样的状态。另外,a+b 的结果是临时的,它的数据可以被安全地移动到 c。
移动构造函数区别于复制构造函数,因为它接收类类型T的右值引用(T&&),与之相反,复制构造函数T则接受左值引用(T const &)。类似地,移动赋值接受右值引用 T&operator=(T&&),与之相反,复制赋值接受左值引用 T& operator=(T const &),即两者都返回 T& 类引用。编译器基于参数类型、右值或左值,以选择合适的构造函数或赋值操作符。
当移动构造函数或移动赋值操作符存在,右值被自动移动。左值也可移动,但需要显式的静态转换为右值引用。这可通过 std::move() 函数完成,它执行了 static_cast<T&&>:
以下示例展示了 Buffer 对象不同的构造和赋值方式:
如 Buffer 示例所见,实现移动构造函数和移动赋值操作符的代码很相似(移动构造函数代码在移动赋值操作符中可见)。这可通过在移动构造函数中调用移动赋值操作符来避免:
本节中,我们将展示如何基于以下 Buffer 类实现移动构造函数和赋值操作符:
class Buffer { unsigned char* ptr; size_t length; public: Buffer(): ptr(nullptr), length(0) {} explicit Buffer(size_t const size): ptr(new unsigned char[size] {0}), length(size) {} ~Buffer() { delete[] ptr; } Buffer(Buffer const& other): ptr(new unsigned char[other.length]), length(other.length) { std::copy(other.ptr, other.ptr + other.length, ptr); } Buffer& operator=(Buffer const& other) { if (this != &other) { delete[] ptr; ptr = new unsigned char[other.length]; length = other.length; std::copy(other.ptr, other.ptr + other.length, ptr); } return *this; } size_t size() const { return length; } unsigned char* data() const { return ptr; } };
C++移动语义的具体实现
为了实现类的移动构造函数,如下做:1) 写一个接受此类类型右值引用的构造函数:
Buffer(Buffer&& other) { }
2) 将右值引用的所有数据成员赋值到当前对象。这可以在如下构造函数主体中,或在初始化列表中(更好的方式)完成:
ptr = other.ptr; length = other.length;
3) 将右值引用的所有数据成员赋予默认值:
other.ptr = nullptr; other.length = 0;将它们放在一起,Buffer 类的移动构造函数如下:
Buffer(Buffer&& other) { ptr = other.ptr; length = other.length; other.ptr = nullptr; other.length = 0; }
为了实现类的移动赋值操作,请执行如下步骤:
1) 写一个接受此类类型右值引用的赋值操作符并返回引用:
Buffer& operator=(Buffer&& other) { }
2) 检查右值引用没有指向this同一对象,如果它们不同,则执行步骤 3~5:
if (this != &other) { }
3) 从当前对象处理所有资源(如内存、句柄等):
delete[] ptr;
4) 将右值引用的所有数据成员赋值给当前对象:
ptr = other.ptr; length = other.length;
5) 将右值引用的所有数据成员赋予默认值:
other.ptr = nullptr; other.length = 0;
6) 无论步骤 3)~步骤 5) 执行与否,返回当前对象的引用:
return *this;
将它们放在一起,Buffer 类的移动赋值操作符如下:
Buffer& operator=(Buffer&& other) { if (this != &other) { delete[] ptr; ptr = other.ptr; length = other.length; other.ptr = nullptr; other.length = 0; } return *this; }
深度剖析C++移动语义
除非用户定义的复制构造函数、移动构造函数、复制赋值操作符、移动赋值操作符或析构函数已经存在,否则编译器默认提供移动构造函数和移动赋值操作符。当编译器提供它们时,它们智能地移动成员。移动构造函数将递归调用类数据成员的移动构造函数。类似地,移动赋值操作符递归调用类数据成员的移动赋值操作符。
移动对于太大以至于不能复制的对象(如字符串或容器)或不能被复制的对象(如 unique_ptr 智能指针)有性能提升。不是所有类都应该实现复制和移动语义。有些类只应该可移动,另一些类可同时复制和移动。另外,类能复制却不能移动是没有意义的,尽管技术上可以这么做。
不是所有类型都能从移动语义上获利。对于内置类型(如 bool、int 或 double)、数组或 POD,移动实际上是复制操作。另外,移动语义对于右值(临时对象)有性能提升。右值是没有名称的对象,在表达式运算期间临时存在,在下一分号则销毁:
T a; T b = a; T c = a + b;在前面示例中,a、b 和 c 是左值,它们是有名称的对象,在生命周期内,名称可指向此对象。另外,当你运算表达式 a+b,编译器创建临时对象(此例中赋值给 c),然后销毁(当遇到分号时)。这些临时对象被称为右值,因为它们通常出现在赋值表达式的右边。C++11 中,我们可通过右值引用 && 来引用这些对象。
在右值语境下,移动语义很重要。这是因为它允许你从将被销毁的临时对象中获取资源的所有权,当移动操作完成后,用户不需要再使用它。
另外,左值不能被移动,它们只能被复制。这是因为它们在移动操作后,还需要被访问,用户期望对象在相同的状态。例如,在前一示例中,表达式 b=a 将 a 赋值给 b。
在此操作完成后,左值对象 a 还能被用户使用且应该处于之前同样的状态。另外,a+b 的结果是临时的,它的数据可以被安全地移动到 c。
移动构造函数区别于复制构造函数,因为它接收类类型T的右值引用(T&&),与之相反,复制构造函数T则接受左值引用(T const &)。类似地,移动赋值接受右值引用 T&operator=(T&&),与之相反,复制赋值接受左值引用 T& operator=(T const &),即两者都返回 T& 类引用。编译器基于参数类型、右值或左值,以选择合适的构造函数或赋值操作符。
当移动构造函数或移动赋值操作符存在,右值被自动移动。左值也可移动,但需要显式的静态转换为右值引用。这可通过 std::move() 函数完成,它执行了 static_cast<T&&>:
std::vector<Buffer> c; c.push_back(Buffer(100)); // move Buffer b(200); // copy c.push_back(b); c.push_back(std::move(b)); // move对象移动后,它必须保持有效状态。然而,并不要求这个状态是什么。为了一致性,你应该将所有成员设置为默认值(数字类型为 0,指针为 nullptr,布尔类型为 false 等)。
以下示例展示了 Buffer 对象不同的构造和赋值方式:
Buffer b1; // default constructor Buffer b2(100); // explicit constructor Buffer b3(b2); // copy constructor b1 = b3; // assignment operator Buffer b4(std::move(b1)); // move constructor b3 = std::move(b4); // move assignment在对象 b1、b2、b3 和 b4 的创建或赋值中用到的构造函数和赋值操作符在每行的注释中可见。
如 Buffer 示例所见,实现移动构造函数和移动赋值操作符的代码很相似(移动构造函数代码在移动赋值操作符中可见)。这可通过在移动构造函数中调用移动赋值操作符来避免:
Buffer(Buffer&& other) : ptr(nullptr), length(0) { *this = std::move(other); }在此示例中,有两点必须注意:
- 在构造函数初始化列表中的成员初始化是必要的,因为这些成员可能被后续的移动操作符所使用(如 ptr 成员);
- 静态转换 other 为右值引用。没有显式转换,复制赋值操作符被调用。这是因为即使右值作为参数传递给构造函数,当它被赋予名称时,它也会被绑定为左值。因此,other 实际上是左值,为了调用移动赋值操作符,它必须转换为右值引用。