右值引用与移动语义
1. 右值引用与移动语义
1.1. 前言
C++11
标准引入了 右值引用
、 移动语义
等概念,让开发者能对资源的管理权进行更细致的控制
1.2. 左值与右值
其实从 C 中就有了左值与右值的概念,当时理解的是“在赋值等号左边的是左值,在右边的
是右值”。现在看来,左值与右值更多是一种语义上的区分,因为 const
对象也不能出现在
等号左边,但显然它是左值。具名对象一般都为左值,而函数返回的非引用临时变量一般为
右值。
int add(int a, int b) { return a + b; } int ret; // 没问题,给左值赋值 ret = add(1, 2); // 错误!无法给右值赋值 add(1, 2) = ret;
1.3. 左值引用与右值引用
C++98
中引入的左值引用就是给变量取一个别名,使用上和变量声明时的名字没有太大区别,
而 C++11
引入的右值引用是用一个变量名绑定了一个右值(一般是一个将亡值)。
1.4. 左值和右值如何影响程序的行为
左值和右值是我们对于对象语义上的区分,实际编程中我们需要通过引用类型告知编译器具体的行为
// 左值引用限定符为 & void func(int &) { std::cout << "left-value reference func is called" << std::endl; } // 左值常量引用限定符为 const & void func(int const &) { std::cout << "const reference func is called" << std::endl; } // 右值引用限定符为 && void func(int &&) { std::cout << "right-value reference func is called" << std::endl; } int main(void) { int a1 = 0; func(a1); int const a2 = 0; func(a2); func(std::move(a1)); }
left-value | reference | func | is | called |
const | reference | func | is | called |
right-value | reference | func | is | called |
left-value reference func is called const reference func is called right-value reference func is called
- 引用限定符是类型的一部分
- 可以通过不同的引用类型调用不同的函数重载,从而实现不同的行为
1.5. std::move
做了什么?
std::move
函数只是简单地把一个 T
类型的变量转换为了 T&&
类型的变量
template<typename T> constexpr std::remove_reference_t<T>&& move(T&& t) noexcept { return static_cast<std::remove_reference_t<T>&&>(t); } void func(int const &) { std::cout << "const reference func is called" << std::endl; } // 右值引用限定符为 && void func(int &&) { std::cout << "right-value reference func is called" << std::endl; } int main(void) { int a1 = 0; func(a1); func(move(a1)); }
const | reference | func | is | called |
right-value | reference | func | is | called |
const reference func is called right-value reference func is called
1.6. 右值对象成员函数限定符
class C { public: // 左值引用对象成员函数限定符为 & void func(void) & { std::cout << "left-value reference func is called" << std::endl; } // 左值常量引用对象成员函数限定符为 const & void func(void) const & { std::cout << "const reference func is called" << std::endl; } // 右值引用对象成员函数限定符为 && void func(void) && { std::cout << "right-value reference func is called" << std::endl; } }; int main(void) { // 具名对象为左值 C c1 = C(); c1.func(); // 使用 const 修饰的对象为常量左值 C const c2 = C(); c2.func(); // std::move 函数返回一个右值引用 std::move(c1).func(); }
left-value | reference | func | is | called |
const | reference | func | is | called |
right-value | reference | func | is | called |
left-value reference func is called const reference func is called right-value reference func is called
1.7. 移动语义与右值的关系
- 在
C++11
标准之前,如果我们想要基于类型C
的对象c1
构造另一个对象c2
,只能使用拷贝的语义。对象c2
需要拷贝对象c1
管理的资源,两个对象分别管理自己拥有资源的生命周期。 C++11
标准引入了移动语义,用于表示将对象c1
的资源的所有权移交给另一个对象c2
。c2
拥有了原本属于c1
的资源,而c1
不再拥有资源的所有权
移动语义可以借助右值引用来实现的,一般来说会用左值引用来实现拷贝的语义,用右值引用来实现移动的语义
class C { // 左值引用用来实现拷贝的语义 C(C const & rhs); // 右值引用用来实现移动的语义 C(C &&rhs); };
1.8. 拷贝构造函数与移动构造函数
一般我们会对需要管理资源的类实现拷贝构造函数和移动构造函数。
#include <vector> #include <cstdint> class IntVector { public: IntVector(std::vector<int> const& vec) { this->size_ = vec.size(); this->array_ = new int[size_]; std::copy(vec.cbegin(), vec.cend(), this->array_); } ~IntVector(void) { delete[] array_; } IntVector(IntVector const &rhs) : size_{rhs.size_} { std::cout << "copy constructor called" << std::endl; this->array_ = new int[size_]; std::copy(rhs.array_, rhs.array_ + rhs.size_, this->array_); } IntVector(IntVector &&rhs) : size_{rhs.size_} { std::cout << "move constructor called" << std::endl; this->array_ = rhs.array_; rhs.array_ = nullptr; rhs.size_ = 0UL; } void PrintElems(void) const { std::cout << this->size_ << " elements: "; for (std::size_t idx = 0UL; idx < size_; ++idx) { std::cout << this->array_[idx] << " "; } std::cout << std::endl; } private: std::size_t size_; int *array_; }; int main(void) { IntVector v1({1, 2, 3}); IntVector v2(v1); v1.PrintElems(); v2.PrintElems(); IntVector v3(std::move(v1)); v1.PrintElems(); v3.PrintElems(); }
copy | constructor | called | ||
3 | elements: | 1 | 2 | 3 |
3 | elements: | 1 | 2 | 3 |
move | constructor | called | ||
0 | elements: | |||
3 | elements: | 1 | 2 | 3 |
copy constructor called 3 elements: 1 2 3 3 elements: 1 2 3 move constructor called 0 elements: 3 elements: 1 2 3
- 通过
v1
拷贝构造出v2
后,v2
拥有了v1
资源的拷贝 - 通过
v1
移动构造出v2
后,v2
拥有了原本属于v1
的资源,v1
不再拥有资源。
对象的资源在被移交后,此对象不应该再被访问,访问被移动的对象是未定义行为
1.9. 拷贝赋值与移动赋值
在类实现过程中,赋值函数一般都会和构造函数同时实现。赋值函数是以运算符的方式进行调用的,一般代表了如下的调用形式:
C c1(1); C c2(2); // 将 c1 赋值给 c2 c2 = c1;
可以参考上面构造函数的实现方式来实现赋值函数
#include <vector> #include <cstdint> class IntVector { public: IntVector(std::vector<int> const& vec) { this->size_ = vec.size(); this->array_ = new int[this->size_]; std::copy(vec.cbegin(), vec.cend(), this->array_); } ~IntVector(void) { delete[] this->array_; } IntVector &operator=(IntVector const &rhs) { std::cout << "copy assignment called" << std::endl; this->size_ = rhs.size_; delete[] this->array_; this->array_ = new int[this->size_]; std::copy(rhs.array_, rhs.array_ + rhs.size_, this->array_); return *this; } IntVector &operator=(IntVector &&rhs) { std::cout << "move assignment called" << std::endl; this->size_ = rhs.size_; rhs.size_ = 0UL; this->array_ = rhs.array_; rhs.array_ = nullptr; return *this; } void PrintElems(void) const { std::cout << this->size_ << " elements: "; for (std::size_t idx = 0UL; idx < size_; ++idx) { std::cout << this->array_[idx] << " "; } std::cout << std::endl; } private: std::size_t size_; int *array_; }; int main(void) { IntVector v1({1, 2, 3}); IntVector v2({4, 5, 6}); v2 = v1; v1.PrintElems(); v2.PrintElems(); v2 = std::move(v1); v1.PrintElems(); v2.PrintElems(); // 自己给自己赋值会发生什么 v2 = v2; v2.PrintElems(); }
copy | assignment | called | ||
3 | elements: | 1 | 2 | 3 |
3 | elements: | 1 | 2 | 3 |
move | assignment | called | ||
0 | elements: | |||
3 | elements: | 1 | 2 | 3 |
copy | assignment | called | ||
3 | elements: | -690496618 | 22737 | 0 |
copy assignment called 3 elements: 1 2 3 3 elements: 1 2 3 move assignment called 0 elements: 3 elements: 1 2 3 copy assignment called 3 elements: 1177283462 23790 0
这种实现是否会有问题?如果用自己给自己赋值会发生什么?
这种情况一般被称为自赋值(self assignment),可以看到 v2 = v2
后结果不再正确,这是因为 rhs
和 this
指向的其实是同一个对象,因此在调用 std::copy
拷贝数组上的元素时,数据已经在被释放重新分配了。
1.9.1. copy-and-swap 与 move-and-swap 技巧
为了解决自赋值导致的问题,我们可以使用 copy-and-swap 技巧。这种技巧简单来说就是先构造一个局部对象,再将自己与局部对象做交换实现
#include <vector> #include <cstdint> class IntVector { public: IntVector(std::vector<int> const& vec) { this->size_ = vec.size(); this->array_ = new int[this->size_]; std::copy(vec.cbegin(), vec.cend(), this->array_); } ~IntVector(void) { delete[] this->array_; } IntVector(IntVector const &rhs) : size_{rhs.size_} { std::cout << "copy constructor called" << std::endl; this->array_ = new int[size_]; std::copy(rhs.array_, rhs.array_ + rhs.size_, this->array_); } IntVector(IntVector &&rhs) : size_{rhs.size_} { std::cout << "move constructor called" << std::endl; this->array_ = rhs.array_; rhs.array_ = nullptr; rhs.size_ = 0UL; } IntVector &operator=(IntVector rhs) { std::cout << "assignment called" << std::endl; this->Swap(rhs); return *this; } void Swap(IntVector &rhs) noexcept { std::swap(this->size_, rhs.size_); std::swap(this->array_, rhs.array_); } void PrintElems(void) const { std::cout << this->size_ << " elements: "; for (std::size_t idx = 0UL; idx < size_; ++idx) { std::cout << this->array_[idx] << " "; } std::cout << std::endl; } private: std::size_t size_; int *array_; }; int main(void) { IntVector v1({1, 2, 3}); IntVector v2({4, 5, 6}); v2 = v1; v1.PrintElems(); v2.PrintElems(); v2 = std::move(v1); v1.PrintElems(); v2.PrintElems(); // 自己给自己赋值不再有问题 v2 = v2; v2.PrintElems(); }
copy | constructor | called | ||
assignment | called | |||
3 | elements: | 1 | 2 | 3 |
3 | elements: | 1 | 2 | 3 |
move | constructor | called | ||
assignment | called | |||
0 | elements: | |||
3 | elements: | 1 | 2 | 3 |
copy | constructor | called | ||
assignment | called | |||
3 | elements: | 1 | 2 | 3 |
copy constructor called assignment called 3 elements: 1 2 3 3 elements: 1 2 3 move constructor called assignment called 0 elements: 3 elements: 1 2 3 copy constructor called assignment called 3 elements: 1 2 3
1.10. 移动语义的作用
1.10.1. 用于处理某些不能拷贝的场景
一个典型的例子就是 unique_ptr
, unique_ptr
对其管理的资源应具有独占的所有权。
我们不能实例化两个指针管理同一份资源,这会导致资源的重复释放。我们只能将资源移交出去,新的指针在接收资源所有权的同时,老的指针就会失效,从而保证了资源只会被释放一次。
1.10.2. 减少不必要的拷贝从而提升性能
C++ 标准模板库中的容器类型,均实现了对应的移动构造和移动赋值函数。