Rvalue references & Move semantis

C++11 - 右值引用和move语义

Posted by Zhgaot on July 5, 2021

文章参考:侯捷-C++11新特性

1 右值引用(Rvalue references)

1.1 概念

右值引用(Rvalue references)是一种新的引用类型,它能够帮助解决非必要的拷贝(unnecessary copying),即:当赋值的右手边是一个右值(rvalue)的话,那么左手边的对象可以去偷取(steal)右手边对象的资源(resources: 内部的资源),而不必执行单独的分配(allocation)。

1.2 Lvalue & Rvalue

  • 左值(Lvalue):可以出现于operator=左侧者
  • 右值(Rvalue):只能出现于operator=右侧者,即右值不可以放在operator=的左边
  • 临时对象属于右值(Rvalue)

1.3 引用绑定规则

int i = 60;
int &r = i;  // 正确:r引用i,可以将一个左值引用绑定到一个左值上
int &&rr = i;  // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 10;  // 错误:(i * 10)是一个右值,不能将一个左值引用绑定到右值上
const int &r3 = i * 10;  // 正确:将一个const的左值引用绑定到一个右值上
int &&rr2 = i * 10;  // 正确:右值引用就是用于绑定到一个右值上的
  1. 可以将一个左值引用绑定到一个左值上
  2. 不能将一个右值引用绑定到一个左值上
  3. 不能将一个左值引用绑定到右值上,但可以将一个const的左值引用绑定到一个右值上
  4. 右值引用就是用于绑定到一个右值上的

1.4 右值引用初探

(1). 在外部调用函数test_moveable,隐式指定形参c的数据类型为vector<MyString>
(2). test_moveable函数内部指定VtypeMyString
(3). test_moveable函数内部生成随机数并转换为字符串然后传给Vtype以初始化一个临时对象;
(4). 将临时对象通过vector的insert方法插入vector尾部;
(5). insert有两个重载:copy重载和move重载;如果传入的是右值的话,会自动调用move重载;当然,如果想强制将左值传入insert的move重载,可以使用move()函数(6.b);
(6). insert的move重载会自动调用用户写的move版本的(浅拷贝)拷贝构造函数,这可以看作是一种偷取行为,举个例子(上述MyString类中有指针指向一个字符串):
a. (深拷贝)拷贝构造函数会将新的指针指向一个新的内存空间,拷贝发生在新的内存空间里的内容拷贝于旧(被拷贝对象)的内存空间里的内容;这样的好处在于安全,不会发生同一块内存空间重复释放的问题;
b. (浅拷贝)拷贝构造函数可以视作专门用于move的时候调用的,它会将新的指针直接指向旧的内存空间,而断掉旧的指针指向旧的内存空间的链接(这很像是一种偷取行为);这样并不能保证绝对安全:当旧对象为右值时(比如临时对象),它今后将不可能被再次使用,上述行事方法是OK的,当旧对象为左值但使用move()函数时,使用者就必须要确定(保证)它今后不再被使用,这样并不绝对安全;
注意:如6.b所述:对一个左值调用move()就意味着承诺:除了对此左值的赋值和销毁外,它将不再被使用

1.5 “右值引用也是左值” 与 “完美转发”

如1.4节的图中所示,Vtype(buf)是一个右值,它会调用insert的move重载,但move版本的insert内部会调用另一个函数(MyString的浅拷贝拷贝构造函数)进行处理,这里会遇到陷阱:传给另一个函数(MyString的浅拷贝拷贝构造函数)的值将是左值,即使它也是右值引用类型:

如果不做其他处理,简单复现上述insert函数,将会出现如下图所示情况(Rvalue经由forward()传给另一个函数却变成了Lvalue):

如上所述我们可知:右值引用也是左值,如果希望自始至终保持右值,则需要完美转发(Perfect Forwarding),使用std::forward()可以实现完美转发

std::forward()的具体实现在move.h头文件中可查看:

2 【重要】示例 —— move-aware class(MoveString)

2.1 代码编写知识点

  1. 有参构造函数(param constructor):形参为外部传入的字符串,必须要加const,这是因为在初始化MoveString对象时一般会传入一个临时字符串;
  2. 拷贝构造函数和拷贝赋值函数:形参为被拷贝的对象,函数内部可以直接访问该对象的private成员,这是因为类的权限范围的最小单元是类而不是函数!
  3. 当要对原有对象的指针成员进行操作时,比如拷贝复制、析构对象时,请务必考虑要首先判断其指针成员是否有所指向,如果有要先释放,再进行后续操作(不包括move版本);
  4. 编写move constructor与move assignment时,必须将旧对象的指针指向空,比如在1.4节中的图,临时对象在传入函数完成浅拷贝后会被释放,其内部的指针所指向的空间也必定会被释放,如果不在move constructor与move assignment中将临时对象内的指针置空,则必定会使新对象内的指针也无法使用!

2.2 MoveString代码

/* =============== move_String.hpp =============== */
#pragma once
#include <iostream>
#include <string.h>

class MoveString {
  friend std::ostream &operator<<(std::ostream &out, const MoveString &str);

public:
  MoveString();                                   // default constructor
  MoveString(const char *cstr);                   // param constructor
  MoveString(const MoveString &other);            // deepcopy constructor
  MoveString(MoveString &&other);                 // move constructor
  MoveString &operator=(const MoveString &other); // deepcopy assignment
  MoveString &operator=(MoveString &&other);      // move assignment
  ~MoveString();                                  // destructor

private:
  void set_str_(const char *cstr);

private:
  char *m_str_;
  size_t len_; // string length without '\0'
};

void MoveString::set_str_(const char *cstr) {
  this->m_str_ = new char[this->len_ + 1];
  strcpy(this->m_str_, cstr); // '\0' also copy
}

// default constructor
MoveString::MoveString() : m_str_(nullptr), len_(0) {}

// param constructor
MoveString::MoveString(const char *cstr) : len_(strlen(cstr)) {
  this->set_str_(cstr);
}

// deepcopy constructor
MoveString::MoveString(const MoveString &other) : len_(strlen(other.m_str_)) {
  this->set_str_(other.m_str_);
}

// move constructor
MoveString::MoveString(MoveString &&other)
    : m_str_(other.m_str_), len_(other.len_) {
  other.m_str_ = nullptr; // must break the old pointer-addr link!
  other.len_ = 0;
}

// deepcopy assignment
MoveString &MoveString::operator=(const MoveString &other) {
  if (this == &other)
    return *this;
  this->len_ = strlen(other.m_str_);
  if (this->m_str_ != nullptr) {
    delete[] this->m_str_;
    this->m_str_ = nullptr;
  }
  this->set_str_(other.m_str_);
}

// move assignment
MoveString &MoveString::operator=(MoveString &&other) {
  if (this == &other)
    return *this;
  if (this->m_str_ != nullptr) {
    delete[] this->m_str_;
    this->m_str_ = nullptr;
  }
  this->m_str_ = other.m_str_;
  this->len_ = other.len_;
  other.m_str_ = nullptr;
  other.len_ = 0;
  return *this;
}

// destructor
MoveString::~MoveString() {
  if (this->m_str_ != nullptr) {
    delete[] this->m_str_;
    this->m_str_ = nullptr;
  }
}

std::ostream &operator<<(std::ostream &out, const MoveString &str) {
  out << str.m_str_;
  return out;
}

/* =============== main.cpp =============== */
#include "move_String.hpp"
#include <iostream>
using namespace std;

void test_MoveString() {
  MoveString str1("Hello World!");
  cout << "str1: " << str1 << endl;
  MoveString str2(str1);
  cout << "str2: " << str2 << endl;
  MoveString str3;
  str3 = str2;
  str2 = str3;
  cout << "str3: " << str3 << endl;
  MoveString str4("Aloha!");
  str2 = str4;
  cout << "str2 = str4 = " << str2 << endl;
}

int main() {
  test_MoveString();
  return 0;
}

2.3 move-aware元素对于不同容器的影响

  1. 由下述例子可知,move-aware以及右值引用其实只对vector容器的影响最大,而对于其他节点型容器的效果很小;
  2. 以第一张图片(vector)举例:对于容器(任何容器)对象的拷贝和move,使用move进行右值引用来偷取都要比直接调用容器的拷贝构造函数的速度要快的多,这是因为直接调用拷贝构造函数是将容器内每一个元素进行一份深拷贝,而使用move则直接进行指针的交换,因此速度极快。