查看原文
其他

C++ 移动函数原理浅析

CPP开发者 2021-07-20

(给CPP开发者加星标,提升C/C++技能)

来源:CSDN - xdesk

先讲个小故事,假如你家里面的书房有一只很漂亮的花瓶,一天,你希望将这个花瓶搬到客厅中,那么你需要怎么做呢?这里提供你一种方法吧!

  1. 你先去请一个手艺最好的瓷工。
  2. 让他在客厅完全按照书房的花瓶做一个一模一样的花瓶。
  3. 然后你敲碎并清理书房的花瓶。

看到这里,估计你要怀疑智商问题了!我不是把花瓶从书房搬到客厅不就可以了吗,如果按照上面这个来搞,早就被请到不正常人类研究中心去了。但是很可惜,早期的C++就是这么弱智的。

例如,如果你要从一个函数中返回一个对象,早期的C++是这么做的。

  1. 将函数中的对象拷贝构造到一个临时对象中。
  2. 释放函数中的对象。
  3. 返回临时对象。

你没看错,C++就是这么弱智。

但是自从C++11 开始,就已经解决这个问题了,这篇文章就是来解析一下这个问题。

1. 右值引用

1.1 定义

首先明白一个概念,什么叫右值;简单的来说,右值就是编译器产生的临时变量、

什么时候产生的是左值,什么时候产生的是右值呢?

  • 返回引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值;变量也是左值。
  • 返回非引用的函数,连同算术、关系、位以及后置的递增/递减运算符,都生成右值。

我们可以对右值定义引用,但是限定了右值的引用只能赋值为右值,例如:

int d = 100;
int& a1 = d; //ok, 左值的引用
int&& a2 = d; //错误,右值引用不能赋值左值
int&& a3 = d * 100//ok,算术运算返回右值
int&& a4 = 100//ok,字面值常量是右值

1.2 std::move函数

那么一个左值是否可以赋值给一个右值引用呢?答案是可以的,使用方法如下:

int d = 100;
int&& a = std::move(d);

这里使用一个std::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>模板的偏实例化,然后使用(static_cast<remove_reference_t<_Ty>&&>(_Arg))返回一个临时变量,生成右值。

2. 函数返回值

假如存在如下代码:

Data GetData()
{
 Data d = { 100200300 };
 return d;
}
int main(int args, char* argv[])
{
 Data d = GetData();
 return 0;
}

那么我们执行return d;应该执行什么操作呢?原理是这样的:

  1. 我们现在main函数中构造一个Data变量temp。
  2. 这个变量temp跟随函数调用传到GetData中。
  3. return d的时候,将d的值拷贝到temp中。

我们看下调用时候汇编代码:

 Data d = GetData();
00852058  lea         eax,[ebp-0F8h]  
0085205E  push        eax  
0085205F  call        GetData (08514F1h)  

这里主要是0085205E push eax将临时变量的地址当做参数传递过去了(函数里面是ebp+8).

返回时候的代码:

 Data d = { 100200300 };
008569A8  mov         dword ptr [d],64h  
008569AF  mov         dword ptr [ebp-0Ch],0C8h  
008569B6  mov         dword ptr [ebp-8],12Ch  
 return d;
008569BD  mov         eax,dword ptr [ebp+8]   //temp的地址 
008569C0  mov         ecx,dword ptr [d]  
008569C3  mov         dword ptr [eax],ecx   //第一个值
008569C5  mov         edx,dword ptr [ebp-0Ch]  
008569C8  mov         dword ptr [eax+4],edx  //拷贝第二个值
008569CB  mov         ecx,dword ptr [ebp-8]  
008569CE  mov         dword ptr [eax+8],ecx   //拷贝第三个值
008569D1  mov         eax,dword ptr [ebp+8]  
}

像上面这个代码的话,临时变量直接就是d了,因为这里是定义。

3. 拷贝构造函数的场景

根据上面的返回值的分析,我们看下如下代码的分析过程:

class CData
{

public:
 CData() = delete;
 CData(int d1, int d2)
 {
  m_iData1 = new int(d1);
  m_iData2 = new int(d2);
  std::cout << "CData(int d1, int d2)" << std::endl;
 }
 CData(const CData& data) 
 {
  m_iData1 = new int(*data.m_iData1);
  m_iData2 = new int(*data.m_iData2);
  std::cout << "CData(const CData& data)" << std::endl;
 }
 CData& operator=(const CData& data)
 {
  if (this != &data)
  {
   free();
   m_iData1 = new int(*data.m_iData1);
   m_iData2 = new int(*data.m_iData2);
  }
  std::cout << "CData& operator=(const CData& data)" << std::endl;
  return *this;
 }
 ~CData()
 {
  std::cout << "~CData()" << std::endl;
  free();
 }
 void free()
 
{
  if (m_iData1 != nullptr)
  {
   delete m_iData1;
   m_iData1 = nullptr;
  }
  if (m_iData2 != nullptr)
  {
   delete m_iData2;
   m_iData2 = nullptr;
  }
 }
 int GetData1()
 
{
  if (m_iData1 != nullptr)
  {
   return *m_iData1;
  }
  else
  {
   return 0;
  }
 }
 int GetData2()
 
{
  if (m_iData2 != nullptr)
  {
   return *m_iData2;
  }
  else
  {
   return 0;
  }
 }
private:
 int* m_iData1;
 int* m_iData2;
};

CData GetData(int a, int b)
{
 CData data(a, b);
 std::cout << data.GetData1() << std::endl;
 std::cout << data.GetData2() << std::endl;
 return data;
}
void RValueReference()
{
 CData data(100, 200);
 data = GetData(10002000);
}

运行结构:

CData(int d1, int d2)
CData(int d1, int d2)
1000
2000
CData(const CData& data)
~CData()
CData& operator=(const CData& data)
~CData()
~CData()

这个很明显就是先拷贝一个花瓶,然后将原来花瓶打碎的做法。

  1. CData GetData(int a, int b)返回data的时候,调用CData(const CData& data)拷贝构造到临时变量。
  2. 释放函数内部的变量~CData()
  3. 临时变量再次赋值到本地CData& operator=(const CData& data)
  4. 再次释放临时变量。

是不是感觉很弱智呢?弱智的主要原因是因为

CData(const CData& data) 
{
 m_iData1 = new int(*data.m_iData1);
 m_iData2 = new int(*data.m_iData2);
 std::cout << "CData(const CData& data)" << std::endl;
}

这里真实的在拷贝内存,后续接着销毁这些内存。

接下来看看移动构造函数的优化。

4. 移动构造函数

移动构造函数需要我们增加函数:

class CData
{

public:
 CData() = delete;
 CData(int d1, int d2)
 {
  m_iData1 = new int(d1);
  m_iData2 = new int(d2);
  std::cout << "CData(int d1, int d2)" << std::endl;
 }
 CData(const CData& data) 
 {
  m_iData1 = new int(*data.m_iData1);
  m_iData2 = new int(*data.m_iData2);
  std::cout << "CData(const CData& data)" << std::endl;
 }
 CData& operator=(const CData& data)
 {
  if (this != &data)
  {
   free();
   m_iData1 = new int(*data.m_iData1);
   m_iData2 = new int(*data.m_iData2);
  }
  std::cout << "CData& operator=(const CData& data)" << std::endl;
  return *this;
 }

 CData(CData&& data) : m_iData1(data.m_iData1), m_iData2(data.m_iData2) 
 {
  data.m_iData1 = nullptr;
  data.m_iData2 = nullptr;
  std::cout << "CData(CData&& data)" << std::endl;
 }

 CData& operator=(CData&& data)
 {
  if (this != &data)
  {
   free();
   m_iData1 = data.m_iData1;
   m_iData2 = data.m_iData2;
   data.m_iData1 = data.m_iData2 = nullptr;
  }
  std::cout << "CData& operator=(CData&& data)" << std::endl;
  return *this;
 }
 
 ~CData()
 {
  std::cout << "~CData()" << std::endl;
  free();
 }
 void free()
 
{
  if (m_iData1 != nullptr)
  {
   delete m_iData1;
   m_iData1 = nullptr;
  }
  if (m_iData2 != nullptr)
  {
   delete m_iData2;
   m_iData2 = nullptr;
  }
 }
 int GetData1()
 
{
  if (m_iData1 != nullptr)
  {
   return *m_iData1;
  }
  else
  {
   return 0;
  }
 }
 int GetData2()
 
{
  if (m_iData2 != nullptr)
  {
   return *m_iData2;
  }
  else
  {
   return 0;
  }
 }
private:
 int* m_iData1;
 int* m_iData2;
};

CData(CData&& data)使用右值引用来当做参数,此时运行结果如下:

CData(int d1, int d2)
CData(int d1, int d2)
1000
2000
CData(CData&& data)
~CData()
CData& operator=(CData&& data)
~CData()
~CData()

你会发现,输出的基本差不多,改调用的步骤一步都少不了(其实这是肯定的,这个是由函数调用返回值特性决定的)。但是我们这里使用的是CData(CData&& data)CData& operator=(CData&& data),这个函数如下:

CData(CData&& data) : m_iData1(data.m_iData1), m_iData2(data.m_iData2) 
{
 data.m_iData1 = nullptr;
 data.m_iData2 = nullptr;
 std::cout << "CData(CData&& data)" << std::endl;
}

这个与拷贝构造函数的差别就是,这里不用分配内存了,直接进行内存转移。

所以说编译器会判断是不是右值或者返回临时变量,如果是的话,就会调用移动函数,进行底层数据直接转移而不是拷贝,因为右值和函数返回值仅在当前语句中使用

5. 函数移动参数

当然右值引用可以用作参数,例如vector的push_back函数,如下:

void push_back(const _Ty& _Val)
// insert element at end, provide strong guarantee
 emplace_back(_Val);
}

void push_back(_Ty&& _Val)
// insert by moving into element at end, provide strong guarantee
 emplace_back(_STD move(_Val));
}

6. 右值引用和成员函数

看个如下代码:

class CValue
{

public:
 CValue(int aa = 100) : a(aa) { }
 CValue& operator=(const CValue& v)
 {
  a = v.a;
  return *this;
 }
 CValue operator+(const CValue& v)
 {
  a += v.a;
  return *this;
 }
 int a;
};
int main(int args, char* argv[])
{
 CValue v1(100);
 CValue v2(200);
 CValue v3(300);
 v1 + v2 = v3;
 return 0;
}

没错v1 + v2 = v3;这个代码进入可以编译通;这个是C++的历史原因,但是很明显这个不是我们想要的目的,由此我们可以修改类的定义为:

class CValue
{

public:
 CValue(int aa = 100) : a(aa) { }
 CValue& operator=(const CValue& v) &
 {
  a = v.a;
  return *this;
 }
 CValue operator+(const CValue& v)
 {
  a += v.a;
  return *this;
 }
 int a;
};

此时编译结果为:

error C2678: binary '=': no operator found which takes a left-hand operand of type 'CValue' (or there is no acceptable conversion)
note: could be 'CValue &CValue::operator =(const CValue &) &'
note: while trying to match the argument list '(CValue, CValue)'

在成员函数中:

  1. 成员函数的右边放一个&代表左值引用函数,说明这个接口只能被左值调用,但是不能被右值使用。
  2. 成员函数的右边放&&代表右值引用函数,说明这个接口只能被右值调用。

例如:

class CValue
{

public:
 CValue(int d) { 
  m_data = new int(d); 
 }

 CValue(const CValue& v)
 {
  m_data = new int(*v.m_data);
 }
 CValue(CValue&& v)  noexcept : m_data(v.m_data)
 {
  v.m_data = nullptr;
 }

 CValue& operator=(CValue&& v) noexcept
 {
  m_data = v.m_data;
  v.m_data = nullptr;

  return *this;
 }

 ~CValue()
 {
  if (m_data != nullptr)
  {
   delete m_data;
   m_data = nullptr;
  }
 }

 void show()
 
{
  std::cout << *m_data << std::endl;
 }

 CValue GetVec() &
 
{
  CValue v(300);
  return v;
 }

 CValue GetVec2() &&
 
{
  CValue v(500);
  return v;
 }
private:
 int* m_data;
};

int main()
{
 CValue v1(100);

 v1.GetVec().GetVec2();

 v1.show();
 return 0;
}
  1. CValue GetVec() &右边放一个&代表左值引用函数,说明这个接口只能被左值调用,例如v1.GetVec(),但是不能被右值使用,例如:v1.GetVec().GetVec()(因为v1.GetVec()是右值,所以调用出错)。
  2. CValue GetVec2() &&右边放&&代表右值引用函数,说明这个接口只能被右值调用,例如v1.GetVec().GetVec2(),不能单独v1.GetVec2()(因为v1为左值,调用出错)。

7. 默认的移动函数

在某些情况下,编译器也会默认合成移动函数,合成规则如下:

合成移动函数的条件:

  1. 如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器就不会合成移动构造函数和移动赋值函数了。
  2. 只有当一个类没有定义自己的任何拷贝成员,并且所有的非static成员都可以移动的时候,编译器才会为他们合成移动构造函数和移动赋值函数。
  3. 移动函数也强制指定,例如=default,但是有成员不能被移动的时候,就算指定了=default,那么移动函数也会被删除。

合成移动函数定义为删除原则

  1. 当类的类成员定义了自己的拷贝构造函数且未定义移动构造函数。
  2. 类非static成员的移动函数被定义为删除的时候。
  3. 类的析构函数定义为不可访问或者删除的时候。
  4. 当类的成员存在成员引用或者const的时候,移动赋值运算符被定义为删除的。

8. 总结

  1. 移动函数通过&&y右值引用来指定,主要为了解决拷贝构造的时候需要深拷贝底层数据的问题,移动函数一般转移底层内存数据。
  2. 如果在成员函数中,右侧使用&则限定函数只能被左值调用,&&限定只能被右值调用。


- EOF -


推荐阅读  点击标题可跳转

1、C++ 内存对齐

2、一次蓝屏故障分析

3、C++ 条件变量使用详解


关注『CPP开发者』

看精选C++技术文章 . 加C++开发者专属圈子

↓↓↓


点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存