C++中的move语义

C++11引入了move语义,弥补了C++的"值传递"带来的拷贝大量数据带来的性能问题的不足。那么,move语义的原理到底是什么呢?以及move语义有哪些新坑呢?

TLDR:

  • move语义的核心概念是lvalue(左值)和rvalue(右值),本质上需要理解编译器认为rvalue的内存是暂时的因此其内存可以被重用以避免拷贝。
  • std::move并不move;它只是无条件的将lvalue强制转换成rvalue,别的什么也不做。
  • std::forward<T>并不转发,它会在传递参数的时候保持lvalue为lvalue,保持rvalue为rvalue。一般情况下仅在定义模板函数时使用。

lvalue和rvalue

当我们谈起lvalue和rvalue的时候,我们首先应当意识到它们原始的定义:lvalue在赋值符号左边,而rvalue在右侧。最原始的含义,lvalue对应的lifetime要到对应的左值变量不在当前作用域才结束,而rvalue的lifetime一直是临时的。后续对lvalue和rvalue的演化非常复杂,并且不同的标准,如C++11,C++17,C++20均有差异。这种演化逐渐抛弃了"赋值符号左右边"的概念,转而以lifetime作为本质属性。

有标识符(glvalue) 无标识符
可以由此move lvalue
不可由此move xvalue prvalue

在这里,我们需要牢记的一点是:

rvalue对应的lifetime是临时的;一旦一个变量标识符成为rvalue,后面任意对它的内容的引用都是未定义行为(UB)。

这里额外要注意的是,Data && D中尽管D是对rvalue的引用,但D本身是一个lvalue(它是一个变量标识符)。

std::move

std::move并不move,它做的事情是把原先的lvalue/rvalue无条件转换(cast)成为rvalue。

这对于普通的表达式对应的rvalue而言是顺理成章的,它们本身就只能被使用一次;但这个行为对于变量标识符lvalue一旦转变成rvalue后,很容易会带来bug。

在libstdc++12中,std::move的实现为:

c++ 复制代码
#if __cplusplus >= 201703L
# define _GLIBCXX_NODISCARD [[__nodiscard__]]
#else
# define _GLIBCXX_NODISCARD
#endif

  template<typename _Tp>
    struct remove_reference
    { typedef _Tp   type; };



  template<typename _Tp>
    _GLIBCXX_NODISCARD
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

那为什么还使用std::move这个看似具有迷惑性的名字呢?此时就要想起rvalue对应的lifetime是临时的这个准则------------当我们讲lvalue转换成rvalue后,对rvalue的引用都将变得不靠谱。这是一种编程契约:一旦转化成rvalue,当前语句就可以随意处置这个原本还是lvalue的rvalue了。事实上,STL中对move相关的操作都假定了应用层面的代码遵循了这一契约。

move构造函数的正确形式

应当遵循下面的规约:

  1. 参数为T&&形式;
  2. 不能抛异常;因此应当加上noexcept(但加上它并不能保证不抛异常)。

class Tree为例。

c++ 复制代码
class Tree {
  Tree *parent;
  int value;
  std::string name;
};

move构造函数的正确签名形式为:

c++ 复制代码
Tree(Tree&&tree) noexcept;

对于move构造函数的实现,应当遵循下面的规约:

  1. move构造函数可以假定move的对象(source)的内存可以完全被自己所用;
  2. 构造函数应当保证在被调用后source处于合法状态;其中尤其要求source的指针被置为nullptr;
  3. 建议被调用后source的域值被置为该域值对应的类型的默认值。

对于Tree,可以实现为

c++ 复制代码
Tree(Tree&&tree) noexcept: 
  parent(std::exchange(tree.parent, nullptr)),  // 必须将tree.parent置为nullptr
  value(std::exchange(tree.value), 0),          // 可以为value(tree.value),或value(std::move(tree.value))
  name(std::exchange(tree.name), std::string{}) // 或name(std::move(tree.name))
  {}

注意事项:

  1. std::exchange会返回第一个参数的值,并且将第一个参数的结果修改为第二个参数的结果;此外,该函数的内部实现会move第一个参数,因此不需要对第一个参数使用std::move。
  2. 指针必须置为nullptr,这是因为只有置为nullptr参能保证source在离开它的作用域调用析构函数是,对应的指针执行delete函数等,导致指针对应的地址被析构两次。

move构造函数蜕化为copy构造函数

如果一个class/struct/union仅定义了copy构造函数而没有move构造函数,则在实际调用时会fallback到copy构造函数。

c++ 复制代码
#include <iostream>
#include <string>
#include <utility>

struct Tree {
  Tree* parent = nullptr;
  int value;
  std::string name;

  Tree() = default;

  Tree(Tree const& tree)
      : parent(tree.parent), value(tree.value), name(tree.name) {
    std::cout << "copy constructor\n";
  }

  Tree operator=(Tree const& tree) {
    std::cout << "copy assignment\n";
    if (this == &tree) {
      return *this;
    }
    this->parent = tree.parent;
    this->value = tree.value;
    this->name = tree.name;
    return *this;
  }

  // Tree(Tree&& tree) noexcept
  //     : parent(std::exchange(tree.parent, nullptr)),
  //       value(std::exchange(tree.value, 0)),
  //       name(std::move(tree.name)) {}

  // Tree operator=(Tree&& tree) noexcept {
  //   std::cout << "move assignment\n";
  //   if (this == &tree) {
  //     return *this;
  //   }
  //   this->parent = std::exchange(tree.parent, nullptr);
  //   this->value = tree.value;
  //   this->name = std::exchange(tree.name, std::string{});
  //   return *this;
  // }

};

int main(void) {
  Tree aTree;
  Tree bTree = std::move(aTree);
  bTree = std::move(aTree);
}

自动生成move构造函数

在下面的情形下,编译器会自动为class/struct/union生成move构造函数

  1. 没有用户自定义的move构造函数
  2. 没有用户自定义的copy构造函数
  3. 没有用户自定义的move运算符
  4. 没有用户自定义的copy运算符
  5. 没有用户自定义的析构函数

编译器自动生成的move构造函数签名符合上述move构造函数签名要求。其实现是对source每个域变量调用std::move初始化this域变量的方式;即其实现类似于上面Tree的例子。

move赋值运算符的正确形式

move赋值运算符和move构造函数类似。但多了一种情形:可能会出现调用自赋值情形的move赋值运算符(如Tree t; t = std::move(t);)。

对于这种情形,C.65建议是:

Make move assignment safe for self-assignment

以Tree为例。

move赋值运算符的正确签名为:

c++ 复制代码
Tree operator=(Tree&&tree) noexcept;

其实现为:

c++ 复制代码
Tree operator=(Tree&&tree) noexcept {
  if (this == &tree) {
    return *this;
  }
  this.parent = std::exchange(tree.parent, nullptr);
  this.value = std::move(tree.value);
  this.name = std::exchange(tree.name, std::string{});
  return *this;
}

注意:理论上并不要求检查this&tree,而只要保证该赋值不crash;这是因为std::move(t)意味着t的内存已经可以被move赋值运算符所用了。但是为了保证不造成惊奇,检查this&tree是必要的。

自动生成move赋值运算

在下面的情形下,编译器会自动为class/struct/union生成move赋值运算符:

  1. 没有用户自定义的move运算符
  2. 没有用户自定义的move构造函数
  3. 没有用户自定义的copy构造函数
  4. 没有用户自定义的copy运算符
  5. 没有用户自定义的析构函数

同样,编译器会生成正确形式的通过调用域变量std::move并会合理置nullptr地move赋值运算符。TODO:检验是否会考虑自赋值?

rule-of-five/six

由于

  • copy构造函数和copy赋值运算符通常会被编译器自动生成(仅在显式delete情形下不会)
  • 而move构造函数和move赋值运算符会在上面提及地情形下自动生成
  • 默认构造函数会在没有用户自定义构造函数时自动生成
  • 默认析构函数会在没有用户自定义的情形下自动生成

因此,优秀实践建议如果定义了copy构造函数、move构造函数、copy赋值运算符、move赋值运算符、析构函数其中之一,则应当把其余的也都显式定义(可以使用defaultdelete),这成为rule-of-five。

  • 这里没有包含默认构造函数时因为它通常是no-op;如果包含了默认构造函数,则称为rule-of-six。
  • 反之,如果没有打算实现当中之一的函数,那么就不应当手动实现其他函数;这被称为rule-of-zero。

匹配规则举例

编号 构造函数签名
Tree(Tree&);
Tree(const Tree&);
Tree(Tree&&);
template <typename T> T(const T&);
template <typename T> T(T&);
template <typename T> T(const T&&);
c++ 复制代码
Tree getTree(); // 函数原型
auto w = getTree();
// 
c++ 复制代码
Tree& getTree(); // 函数原型
auto w = getTree();
// 
c++ 复制代码
const Tree& getTree(); // 函数原型
auto w = getTree();
// 

std::unique_ptr<T>的例子

对于函数的return变量,它是rvalue,因此可以return一个std::unique_ptr<T>类型的变量。

remove const

TODO

std::forward<T>

之前的&形式 变为 之后的&形式
& & &
&& & &
& && &
&& && &&

可见,std::forward有条件地cast。其用法一般主要在写模板函数/类时需要用到,此处按下不表。

参考链接

相关推荐
奋斗的小花生2 小时前
c++ 多态性
开发语言·c++
闲晨2 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
UestcXiye3 小时前
《TCP/IP网络编程》学习笔记 | Chapter 3:地址族与数据序列
c++·计算机网络·ip·tcp
霁月风4 小时前
设计模式——适配器模式
c++·适配器模式
jrrz08285 小时前
LeetCode 热题100(七)【链表】(1)
数据结构·c++·算法·leetcode·链表
咖啡里的茶i5 小时前
Vehicle友元Date多态Sedan和Truck
c++
海绵波波1075 小时前
Webserver(4.9)本地套接字的通信
c++
@小博的博客5 小时前
C++初阶学习第十弹——深入讲解vector的迭代器失效
数据结构·c++·学习
爱吃喵的鲤鱼6 小时前
linux进程的状态之环境变量
linux·运维·服务器·开发语言·c++
7年老菜鸡7 小时前
策略模式(C++)三分钟读懂
c++·qt·策略模式