Cycoe@Home

右值引用与移动语义

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 的资源的所有权移交给另一个对象 c2c2 拥有了原本属于 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 后结果不再正确,这是因为 rhsthis 指向的其实是同一个对象,因此在调用 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_ptrunique_ptr 对其管理的资源应具有独占的所有权。

我们不能实例化两个指针管理同一份资源,这会导致资源的重复释放。我们只能将资源移交出去,新的指针在接收资源所有权的同时,老的指针就会失效,从而保证了资源只会被释放一次。

1.10.2. 减少不必要的拷贝从而提升性能

C++ 标准模板库中的容器类型,均实现了对应的移动构造和移动赋值函数。

Author: (Cycoe) (cycoejoo@163.com)
Date: <2024-05-12 Sun 15:47>
Generator: Emacs 29.3 (Org mode 9.6.15)
Built: <2024-05-12 Sun 20:13>