移动语义

参考:

实验环境:os: centos8.5 / kernel: 4.18.0 / gcc: 8.5.0 / arch: x86-64

1. 左值(lvalue)与右值(rvalue)

1.1 概念

c++ 中将所有值分为了左值和右值,左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束后就不再存在的临时对象。
左值例如:

int a;
Class obj;
字面量等

右值例如:

Class func(void) 函数返回的临时对象
func(Class obj) 调用函数传递的临时对象
int a = 1, 1 是右值等

区分左右值的一个直觉性方法是看能不能对值取地址,能取地址的为左值,不能取地址的为右值(不能取地址并不意味着不存在于内存中,只是短暂存在于内存中)。同时,在代码中左值都是具名化的,右值没有名称。
需要明白的是,从汇编的角度看,无论是左值还是右值在内存中都是一个地址(当然,也可以是一个立即数),左右值的概念是编译器的行为,而不是对象本身携带的属性。

1.2 左值引用与右值引用

  • 左值引用是对左值对象取的一个别名,使用左值引用就像在使用左值本身,将一个左值传递给左值引用基本没有任何开销(无需构造一个新对象,也不需要调用任何拷贝构造函数)。
  • 既然右值对象没有名字,那么有没有方法得到右值对象而不造成任何开销呢,于是就有了右值引用。右值引用接收右值对象,将其变为一个左值(具有名字,可以取地址),延长了右值对象的生命。

但是有规定,左值引用只能绑定到左值,右值引用只能绑定右值:

int a = 1;
int& b = 2;   // 编译错误,左值引用绑定到了右值
int&& c = a;  // 编译错误,右值引用绑定到了左值

1.3 常量左值引用

常量左值引用是在 c++11 前就出现的特性:既可以接收左值(包括左值引用),又可以接收右值(包括右值引用),这个特性的用处是,在拷贝构造函数中,可以接收一个临时对象:

定义拷贝构造函数:
  Class(const Class& obj) { ... }
拷贝构造函数既可以接收左值:
  Class a;
  Class b(a);
也可以接收右值:
  Class a(Class());

2. 引用折叠与通用引用

2.1 引用折叠

如果间接创建了一个引用的引用,那么就会发生引用折叠:

定义函数:
  template <class T>
  void func(T& obj) {}
调用:
  int a = 1;
  int& b = a;
  func(b);

其中,func() 函数形参为模板引用类型,而调用 func() 函数时传递的是一个左值引用类型的实参,那么func()被实例化时,就会出现两个引用的情况(非右值引用):int& & obj。
为此,c++11 定义了引用折叠规则(T 为一个具体类型):

  • T& &、T& &&、T&& & 都折叠成 T&,即折叠成左值引用类型
  • T&& && 折叠为 T&&,即折叠成右值引用类型

必须要明白的是,T 被推导的类型与 obj 对象被推导的类型,这里说的引用折叠规则是针对的 obj 对象最终的左右值类型,对于上面这段代码:

  • T 被推导成 int& 即左值引用类型
  • obj 推导(折叠)成左值引用类型

如果函数调用变为 int a = 1; func(a);,那么:

  • T 被推导成 int 即左值类型
  • obj 推导成左值引用类型

如果函数调用变为 func(1);,那么,编译会报错,为什么呢:

  • T 被推导成 int 即左值类型
  • func() 形参实例化为左值引用,无法接收一个右值

2.2 通用引用

通用引用与常量左值引用一样,既可以接收左值,也可以接收右值:

通用引用:
  template<class T>
  void func(T&& param) {}
接收左值:
  int a = 0;
  func(a);
接收右值:
  func(1);

对于通用引用,c++11 对于模板类型 T 的自动推导又定义了如下规则:

  • 实参为左值(包括左值引用)时,T 被推导为左值引用 T&
  • 实参为右值时,T 被推导为原始类型 T

那么,对于上述的 func() 函数,接收左值时,T 被推导为 T&,形参 T& && param 会发生引用折叠,实例化后为 void func(int& param),param 对象最终变为左值引用类型。
注意通用引用的定义范围:

对于 vector::push_back() 函数接口:
  void std::vector<T>::push_back(T&& val)
push_back() 的形参不是通用引用,因为在创建 std::vector 对象时已经指定了 T 的具体类型

3. std::move() 与 std::forward()

3.1 std::move()

std::move() 用于将左值转型为右值引用(如果值本身就是右值引用,则不做任何改变),函数实现为(MSVC 14.28.29333):

// FUNCTION TEMPLATE move
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

其中,remove_reference_t<_Ty> 用于去除 _Ty 的引用属性(下文描述),得到原本类型。且 move(_Ty&& _Arg) 函数的形参是一个万能引用类型。
如果调用为 Class a; std::move(a);,那么:

  • _Ty 被推导为左值引用 Class&
  • 形参 Class& && _Arg 被折叠成左值引用 Class&
  • static_cast<remove_reference_t<_Ty>&&>(_Arg) 去掉引用,变为 static_cast<Class&&>(_Arg)
  • _Arg 对象为左值引用,待转型对象为右值引用,即可以实施转型,并返回右值引用

如果调用为 std::move(Class a);,那么:

  • _Ty 被推导为右值引用 Class
  • 形参实例化为 Class&& _Arg
  • static_cast<remove_reference_t<_Ty>&&>(_Arg) 去掉引用,变为 static_cast<Class&&>(_Arg)
  • _Arg 对象为右值引用,待转型对象也为右值引用,什么都不用干,直接返回右值引用

如果实参是一个 const 类型,调用 std::move() 又会发生什么呢,如下代码:

#include <utility>
#include <stdio.h>

void func(int&& val) {
  printf("rvalue ref called\n");
}
void func(int& val) {
  printf("lvalue ref called\n");
}
void func(const int& val) {
  printf("const lvalue ref called\n");
}
void func(const int&& val) {
  printf("const rvalue ref called\n");
}

int main() {
  const int a = 1;
  func(std::move(a));

  return 0;
}

使用 g++ test.cpp -o mytest --std=c++11 进行编译,运行得到:

可见,move() 语义也能施加到 const 类型的变量上,且 const 属性会得到保留。但是注意到最终调用的是常量右值引用的 func() 版本,这个看起来很奇葩,如果定义没有常量右值引用版本,那么最终会调用常量左值引用版本。

3.1.1 remove_reference_t

remove_reference_t 实际上是个类型别名(MSVC 14.28.29333):

// STRUCT TEMPLATE remove_reference
template <class _Ty>
struct remove_reference {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty;
};

template <class _Ty>
struct remove_reference<_Ty&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&;
};

template <class _Ty>
struct remove_reference<_Ty&&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&&;
};

template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;

其主体是三个接收不同左右值属性的模板推导类型。
注意这里有一个 c++ 语法特性,在 3 个结构体中,通过 using type = _Ty; 定义了模板 _Ty 的别名,外部代码可以通过 Class::type 的方式访问这个别名。类似 stl 特性萃取中的 T::value_type。

3.2 std::forward()

3.2.1 完美转发

所谓完美转发,即:

#include <utility>
#include <stdio.h>

void func(int&& val) {
  printf("rvalue ref called\n");
}
void func(int& val) {
  printf("lvalue ref called\n");
}
void func(const int& val) {
  printf("const value called\n");
}

template <class T>
void forward(T&& val) {
  func(std::forward<T>(val));
  // func(val);
}

int main() {
  const int a = 1;
  int b = 1;
  forward(a);
  forward(b);
  forward(1);

  return 0;
}

上述代码中,my_forward() 的形参是个通用引用类型,即可以接收左值和右值。当接收左值的时候,val 实例化为左值引用,接收右值时,val 实例化为右值引用,且右值引用可以看作是左值。
如果调用重载函数 func() 为 func(val); 的方式,那么无论传给 my_forward() 的是左值还是右值,结果都是调用 func() 的左值引用版本。当以 func(std::forward(val)); 的方式调用时,那么会根据传给 my_forward() 的是左值还是右值类型,调用 func() 的不同版本。这便是完美转发,左右值参数经过中间函数再传递给其它函数时,依然保持原本的左右值特性。
同时可以看到,std::forward 也会保持住 const 特性。

3.2.2 std::forward()

源码实现中有两个版本,分别接收左值和右值(MSVC 14.28.29333):

template <class _Ty>
_NODISCARD constexpr _Ty&& forward(
    remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
    return static_cast<_Ty&&>(_Arg);
}

template <class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept { // forward an rvalue as an rvalue
    static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
    return static_cast<_Ty&&>(_Arg);
}

对于如下代码(左值调用):

函数定义:
  template <class T>
  void my_forward(T&& val) {
    func(std::forward<T>(val));
  }
左值函数调用:
  int a = 1;
  my_forward(a);
  • my_forward() 形参为通用引用,T 推导为 int&,val 实例化(引用折叠)为左值引用类型
  • 调用 std::forward 的左值引用版本,std::forward 实例化为 std::forward(int& _Arg) {return static_cast<int&>(_Arg)},即返回左值引用

对于如下代码(右值调用):

函数定义:
  template <class T>
  void my_forward(T&& val) {
    func(std::forward<T>(val));
  }
右值函数调用:
  my_forward(1);
  • my_forward() 形参为通用引用,T 推导为 int,val 实例化为右值引用类型
  • 调用 std::forward 的左值引用版本(右值引用实际为左值),std::forward 实例化为 std::forward(int& _Arg) {return static_cast<int&&>(_Arg)},即返回右值引用

4. 类构造语义

4.1 拷贝构造

有如下代码:

#include <vector>
#include <stdio.h>
#include <string.h>

// 拷贝构造函数调用计数
static int copy_construct_count = 0;

class Base {
public:
  Base(int _size) {
    size = _size;
    buf = new char[size];
  }
  ~Base() {
    delete[] buf;
  }
  Base(const Base& obj) {
    size = obj.size;
    buf = new char[size];
    // 内存复制
    memcpy(buf, obj.buf, size);
    copy_construct_count++;
  }
public: 
  char* buf = nullptr;
  int size = 0;
}; 

int main() {
  std::vector<Base> vec;
  vec.reserve(100);
  for (int i=0; i<100; ++i) {
    vec.push_back(Base(100));
  }
  printf("count  %d\n", copy_construct_count);
  return 0;
}

使用 g++ test.cpp -o mytest --std=c++11 编译并执行:

可见,代码调用了 100 次拷贝构造函数。
我们知道,vector 存储元素是有自己的一片存储区域的。上述代码中,reserve() 函数预先分配了 100*sizeof(Base) 字节的内存。这里每插入一个 Base 对象,需要先构造一个临时对象,然后调用拷贝构造来构造 vector 管理的对象。

4.2 移动构造

有如下代码:

#include <vector>
#include <stdio.h>
#include <string.h>

// 拷贝构造函数调用计数
static int copy_construct_count = 0;
// 移动构造函数调用计数
static int move_construct_count = 0;

class Base {
public:
  Base(int _size) {
    size = _size;
    buf = new char[size];
  }
  ~Base() {
    delete[] buf;
  }
  Base(const Base& obj) {
    size = obj.size;
    buf = new char[size];
    // 拷贝资源
    memcpy(buf, obj.buf, size);
    copy_construct_count++; 
  }
  Base(Base&& obj) {
    // 移动资源
    size = obj.size;
    buf = obj.buf;
    obj.size = 0;
    obj.buf = nullptr;
    move_construct_count++;
  }
public:
  char* buf = nullptr;
  int size = 0;
};

int main() {
  std::vector<Base> vec;
  vec.reserve(100);
  for (int i=0; i<100; ++i) {
    vec.push_back(Base(100));
  }
  printf("copy: %d, move: %d\n", copy_construct_count, move_construct_count);
  return 0;
}

使用 g++ test.cpp -o mytest --std=c++11 编译并执行:

可见,代码调用了 100 次移动构造函数,0 次拷贝构造函数。

4.3 效率分析

上述代码中,vector::push_back() 函数接口的定义(http://www.cplusplus.com/reference/vector/vector/push_back/):

void push_back (const value_type& val);
void push_back (value_type&& val);

即可以接收左值和右值,当我们传入左值的时候,调用左值引用版本,同时调用类的拷贝构造函数。当我们传入右值的时候,调用右值引用版本,同时调用类的移动构造函数。如果类没有定义移动构造函数,那么将调用拷贝构造函数(常量左值引用可以接收右值)。

  • 拷贝构造是拷贝其它类的资源,移动构造是拿取其他类的资源作为自己的资源,其它类被移动构造后,那么这个类应当不会再被使用了。
  • 一般移动构造发生在创建的临时对象身上,因为临时对象的资源在临时对象析构后就不再使用,所以拿过来比复制会更合理。同时,一些左值也可以通过 std::move() 变为右值被移动构造,但是必须注意,资源被移动后类不应该再被使用。
  • 同样还有赋值运算符重载和移动赋值运算符重载,前者也是拷贝别人的资源,后者拿取别人的资源。但是跟前面的两个构造函数的一点区别是,类本身也是有自己的资源的,那么这个时候如何处理自身的资源呢?答案是取决于实现,要么丢弃,要么两个对象互换资源。

4.4 vector::emplace_back()接口

emplace_back() 接口能接收构造容器元素所需的参数,然后原地构造类对象。所谓原地构造,即 vector 预先申请了一块内存区域,在用户调用 emplace_back() 的时候,通过接口传递进来的参数,在预申请的内存块上调用 placement new 来调用类的构造函数构造出对象,省去了创建临时对象的过程,只需要调用一次构造函数即可。而 push_back() 插入一个元素需要调用一次构造函数,如果不是插入的右值,还需要调用一次拷贝构造。、

5. 总结

左值、右值、std::move()、std::forward()、完美转发等概念和函数的出现,是为了移动语义服务的。而移动语义的作用就是将一个临将死亡对象的资源转移过来,然后继续使用,转移过程中,无需发生拷贝动作。

posted @ 2022-02-10 13:44  小夕nike  阅读(146)  评论(0编辑  收藏  举报