C++-右值引用和移动语义

文章目录

  • 右值引用和移动语义
    • 右值引用
    • 移动语义
      • 实现拷贝构造函数
      • 实现拷贝赋值函数
      • 类设计的局限性
      • 使用右值引用实现移动语义
      • 实现移动构造函数
      • 实现移动赋值函数
    • 何时使用移动语义
    • 其他
      • 移动构造函数不会被调用?
      • 如果 RVO 默认执行优化工作,我为什么要关心实现移动语义?
      • 我们在Holder示例中做了 RAII
      • 标记移动构造函数和移动赋值运算符为noexcept
      • 使用copy-and-swap进一步优化和增强异常安全性
      • 完美转发(forwarding)

右值引用和移动语义

右值引用

在 C++ 中,存在一些临时、生存期短的值,它们无法以任何方式更改,这些值就是右值
令人惊讶的是,现代 C++(C++11 及更高版本)引入了一种可以绑定到临时对象的新类型:右值引用(rvalue reference),通过在某种类型之后放置&&符号来表示。右值引用使你能够修改这些临时对象的值。
举个例子

std::string   s1     = "Hello ";
std::string   s2     = "world";
std::string&& s_rref = s1 + s2;    // the result of s1 + s2 is an rvalue
s_rref += ", my friend";           // I can change the temporary string!
std::cout << s_rref << '\n';       // prints "Hello world, my friend"

这里创建了两个字符串s1和s2,将他们相加并把结果(临时字符串,即右值)放入std::string&& s_rref。现在s_rref是对临时对象的引用,或右值引用。它没有const限定,所以可以修改临时字符串。如果没有引入"右值引用"的概念,这是不可能的做到的。
为了更好地区分,我们将传统的 C++ 引用(单&符号)称为左值引用。

乍一看,这似乎毫无用处。然而,右值引用为实现移动语义铺平了道路,这是一种可以显着提高应用程序性能的技术。

移动语义

移动语义是一种以最佳方式移动资源的新方法,它基于右值引用,避免临时对象的不必要拷贝。
理解什么是移动语义的最好方法是围绕动态资源构建一个包装类,并在资源移入和移出函数时跟踪它。但是请记住,移动语义不仅仅适用于类!
让我们看一下以下示例:

class Holder
{
  public:

    Holder(int size)         // Constructor
    {
      m_data = new int[size];
      m_size = size;
    }

    ~Holder()                // Destructor
    {
      delete[] m_data;
    }

  private:

    int*   m_data;
    size_t m_size;
};

这是一个处理动态内存块的简单类。当你选择自己管理内存时,你应该遵循所谓的Rule of Three:如果你的类定义了以下一个或多个方法,则应该明确定义所有这三个方法:

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符
    C++ 编译器会默认生成它们。不幸的是,当你的类处理动态资源时,合成的函数通常是错误、不符合预期的。编译器无法生成像上面例子中的构造函数,因为它对我们类的逻辑一无所知。

实现拷贝构造函数

拷贝构造函数用于从另一个现有对象创建一个新对象。例如:

Holder h1(10000); // regular constructor
Holder h2 = h1;   // copy constructor
Holder h3(h1);    // copy constructor (alternate syntax)

让我们坚持三原则,先实现拷贝构造函数

Holder(const Holder& other)
{
  m_data = new int[other.m_size];  // (1)
  std::copy(other.m_data, other.m_data + other.m_size, m_data);  // (2)
  m_size = other.m_size;
}

在这里,Holder从传入的现有对象中初始化一个新对象other:我创建了一个相同大小的新数组 (1),然后将实际数据复制other.m_data到m_data(即this.m_data) (2)。

实现拷贝赋值函数

拷贝赋值函数用已存在的对象替换另一个已存在的对象。例如:

Holder h1(10000);  // regular constructor
Holder h2(60000);  // regular constructor
h1 = h2;           // assignment operator

拷贝赋值函数实现

Holder& operator=(const Holder& other) 
{
  if(this == &other) return *this;  // (1)
  delete[] m_data;  // (2)
  m_data = new int[other.m_size];
  std::copy(other.m_data, other.m_data + other.m_size, m_data);
  m_size = other.m_size;
  return *this;  // (3)
}

首先是对自赋值的一点保护(1)。然后,由于我们正在用另一个类替换这个类的内容,让我们擦除当前数据(2)。剩下的就是我们在拷贝构造函数中编写的相同代码。按照惯例,返回对此类的引用 (3)。

拷贝构造函数和赋值运算符的关键在于它们都将一个对象的常量引用作为输入参数,并进行拷贝。常量引用所指的对象保持不变。

类设计的局限性

我们的类可以正常工作,但缺乏一些优化。考虑以下函数:

Holder createHolder(int size)
{
  return Holder(size);
}

它按值返回一个Holder对象。我们知道,当一个函数按值返回一个对象时,编译器必须创建一个临时对象(右值)。现在,由于其内部动态内存分配,我们的对象是一个重量级对象,因此创建这个临时对象是一个代价很高的操作。考虑如下代码:

int main()
{
  Holder h = createHolder(1000);
}

来自createHolder()的临时对象被传递给拷贝构造函数。根据我们目前的设计,拷贝构造函数m_data通过从临时对象复制数据来分配自己的指针。这里存在两次高代价的内存分配:a) 在创建临时对象期间,b) 在实际对象拷贝构造操作期间。

类似的过程发生在赋值运算中:

int main()
{
  Holder h = createHolder(1000); // Copy constructor
  h = createHolder(500);         // Assignment operator
}

赋值运算符中的代码删除分配的内存空间,然后通过从临时对象复制数据来重新分配内存。这里存在两次高代价的内存分配:a) 在创建临时对象期间,b) 在实际对象赋值运算中。

在createHolder函数返回时,我们已经有一个完全可用的临时对象,它是一个右值,它将在下一条指令中消失。那么试想一下:为什么在构造/赋值阶段,我们不直接将临时对象中分配的数据直接移动到目标对象上,而不是执行多次代价高昂的拷贝操作?

以前,按值返回重量级对象没法优化。幸运的是,在 C++11 及更高版本中,我们被允许(并鼓励)这样做,通过Holder使用移动语义改进我们当前的类。简而言之,我们将从临时对象中窃取现有数据,而不是进行无用的拷贝。

使用右值引用实现移动语义

添加新版本的拷贝构造函数和赋值运算符,以便它们可以在输入中获取一个临时对象来窃取数据。窃取数据就是修改数据所属的对象:如何修改临时对象?通过使用右值引用。

在这一点上,我们很自然地遵循另一个 C++ 模式,称为Rule of Five。它是之前看到Rule of Three的扩展,它指出任何需要移动语义的类都必须声明两个额外的成员函数:

  • 移动构造函数-从临时对象窃取数据并创建新对象;
  • 移动赋值运算符-从临时对象窃取数据并来替换现有的对象。

实现移动构造函数

一个典型的移动构造函数:

Holder(Holder&& other)     // <-- rvalue reference in input
{
  m_data = other.m_data;   // (1)
  m_size = other.m_size;
  other.m_data = nullptr;  // (2)
  other.m_size = 0;
}

它接受对另一个Holder对象的右值引用作为输入。这是关键部分:作为右值引用,我们可以修改它。所以让我们先窃取它的数据(1),然后将其设置为空(2)。这里没有深拷贝,我们只是移动了资源!将右值引用数据设置为某种有效状态 (2) 以防止它在临时对象死亡时被意外删除是很重要的:最好始终将被盗对象保持在某种明确定义的状态。

实现移动赋值函数


Holder& operator=(Holder&& other)     // <-- rvalue reference in input  
{  
  if (this == &other) return *this;

  delete[] m_data;         // (1)

  m_data = other.m_data;   // (2)
  m_size = other.m_size;

  other.m_data = nullptr;  // (3)
  other.m_size = 0;

  return *this;
}

在清理现有资源 (1) 之后,我们从作为右值引用传入的另一个对象中窃取数据 (2)。我们不要忘记像在移动构造函数中所做的那样将临时对象置于某种有效状态 (3)。其他一切都只是常规赋值运算符的职责。

现在我们有了新的方法,编译器足够聪明,可以检测你是创建一个带有临时值(右值)还是常规值(左值)的对象,并相应地触发正确的构造函数/运算符。例如:


int main()
{
  Holder h1(1000);                // regular constructor
  Holder h2(h1);                  // copy constructor (lvalue in input)
  Holder h3 = createHolder(2000); // move constructor (rvalue in input) (1) 

  h2 = h3;                        // assignment operator (lvalue in input)
  h2 = createHolder(500);         // move assignment operator (rvalue in input)
}

何时使用移动语义

移动语义提供了一种更智能的方式来传递重量级的东西。你只需创建一次重量级资源,然后以自然的方式将其移动到需要的地方。正如我之前所说,移动语义不仅与类有关。当你需要更改资源的所有权时,你可以使用它。但是请记住,与指针不同,你不会共享任何内容:如果对象 A 从对象 B 窃取数据,则对象 B 中的数据不再存在,因此不再有效。

其他

移动构造函数不会被调用?

如果你运行上面的最后一个片段,你会注意到在 (1) 期间如何不调用移动构造函数。改为调用常规构造函数:这是由于称为返回值优化 (RVO-Return Value Optimization)的技巧。现代编译器能够检测到你正在按值返回一个对象,并且它们应用了一种返回快捷方式来避免无用的拷贝。

你可以告诉编译器绕过这种优化:例如,GCC 支持-fno-elide-constructors标志。在启用此类标志的情况下编译程序并再次运行它:构造函数/析构函数调用的数量将显着增加。

如果 RVO 默认执行优化工作,我为什么要关心实现移动语义?

RVO 只与返回值有关(输出),与函数参数无关(输入)。有很多地方可以将可移动对象作为输入参数传递从而优化速度。 C++11 之后,所有算法和容器都进行了扩展以支持移动语义。因此,如果你将标准库与Rule of five的类一起使用,你将获得显著的优化提升。

我可以移动左值吗?
是的,你可以使用std::move标准库中的实用程序函数。它用于将左值转换为右值。假设我们想从左值中窃取:


int main()
{
  Holder h1(1000);     // h1 is an lvalue
  Holder h2(h1);       // copy-constructor invoked (because of lvalue in input)
}

这不起作用:由于h2在输入中接收到左值,因此触发拷贝构造函数。我们需要在h2上调用移动构造函数以使其从h1窃取数据,因此:

int main()
{
Holder h1(1000); // h1 is an lvalue
Holder h2(std::move(h1)); // move-constructor invoked (because of rvalue in input)
}
这里std::move已将左值h1转换为右值:编译器在输入中看到这样的右值,然后在h2上触发移动构造函数。对象h2将在其构建阶段窃取h1的数据。

请注意,此后h1是一个空对象(hollow object)。当我们在移动构造函数中将被盗对象的数据设置为有效状态时,我们做了一件正确的事(other.m_data = nullptr)。现在你可能想要重用h1,以某种方式测试它或让它超出作用域而析构。

我们在Holder示例中做了 RAII

资源获取即初始化 (RAII-Resource Acquisition Is Initialization)是一种 C++ 技术,你可以在其中围绕资源(文件、套接字、数据库连接、分配的内存等)包装一个类。资源在类构造函数中初始化并在类析构函数中清除。这样你就可以确保避免资源泄漏

标记移动构造函数和移动赋值运算符为noexcept

C++11 关键字的noexcept意思是“这个函数永远不会抛出异常”。它用于优化。有人说移动构造函数和移动赋值运算符永远不应该抛出异常。基本原理是:你不应在其中分配内存或调用其他代码。你应该只复制数据并将其他对象设置为空。更多信息: 这里, 这里.

使用copy-and-swap进一步优化和增强异常安全性

类中的所有构造函数/赋值运算符Holder都充满了重复代码,这不是很好。此外,如果分配在拷贝赋值运算符中引发异常,则源对象可能会处于错误状态。copy-and-swap技术解决这两个问题。更多信息:这里,这里。

完美转发(forwarding)

这种技术允许你跨多个模板和非模板函数移动数据而不会发生错误类型转换(即完美)。更多信息:这里,这里。

参考:

  1. https://www.internalpointers.com/post/c-rvalue-references-and-move-semantics-beginners

你可能感兴趣的:(C++,c++,右值引用,c++11,移动,move)