C++ 移动函数原理浅析
(给CPP开发者加星标,提升C/C++技能)
来源:CSDN - xdesk
先讲个小故事,假如你家里面的书房有一只很漂亮的花瓶,一天,你希望将这个花瓶搬到客厅中,那么你需要怎么做呢?这里提供你一种方法吧!
你先去请一个手艺最好的瓷工。 让他在客厅完全按照书房的花瓶做一个一模一样的花瓶。 然后你敲碎并清理书房的花瓶。
看到这里,估计你要怀疑智商问题了!我不是把花瓶从书房搬到客厅不就可以了吗,如果按照上面这个来搞,早就被请到不正常人类研究中心去了。但是很可惜,早期的C++就是这么弱智的。
例如,如果你要从一个函数中返回一个对象,早期的C++是这么做的。
将函数中的对象拷贝构造到一个临时对象中。 释放函数中的对象。 返回临时对象。
你没看错,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 = { 100, 200, 300 };
return d;
}
int main(int args, char* argv[])
{
Data d = GetData();
return 0;
}
那么我们执行return d;
应该执行什么操作呢?原理是这样的:
我们现在main函数中构造一个Data变量temp。 这个变量temp跟随函数调用传到GetData中。 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 = { 100, 200, 300 };
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(1000, 2000);
}
运行结构:
CData(int d1, int d2)
CData(int d1, int d2)
1000
2000
CData(const CData& data)
~CData()
CData& operator=(const CData& data)
~CData()
~CData()
这个很明显就是先拷贝一个花瓶,然后将原来花瓶打碎的做法。
CData GetData(int a, int b)
返回data的时候,调用CData(const CData& data)
拷贝构造到临时变量。释放函数内部的变量 ~CData()
。临时变量再次赋值到本地 CData& operator=(const CData& data)
。再次释放临时变量。
是不是感觉很弱智呢?弱智的主要原因是因为
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)'
在成员函数中:
成员函数的右边放一个&代表左值引用函数,说明这个接口只能被左值调用,但是不能被右值使用。 成员函数的右边放&&代表右值引用函数,说明这个接口只能被右值调用。
例如:
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;
}
CValue GetVec() &
右边放一个&代表左值引用函数,说明这个接口只能被左值调用,例如v1.GetVec()
,但是不能被右值使用,例如:v1.GetVec().GetVec()
(因为v1.GetVec()
是右值,所以调用出错)。CValue GetVec2() &&
右边放&&代表右值引用函数,说明这个接口只能被右值调用,例如v1.GetVec().GetVec2()
,不能单独v1.GetVec2()
(因为v1为左值,调用出错)。
7. 默认的移动函数
在某些情况下,编译器也会默认合成移动函数,合成规则如下:
合成移动函数的条件:
如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器就不会合成移动构造函数和移动赋值函数了。 只有当一个类没有定义自己的任何拷贝成员,并且所有的非static成员都可以移动的时候,编译器才会为他们合成移动构造函数和移动赋值函数。 移动函数也强制指定,例如=default,但是有成员不能被移动的时候,就算指定了=default,那么移动函数也会被删除。
合成移动函数定义为删除原则
当类的类成员定义了自己的拷贝构造函数且未定义移动构造函数。 类非static成员的移动函数被定义为删除的时候。 类的析构函数定义为不可访问或者删除的时候。 当类的成员存在成员引用或者const的时候,移动赋值运算符被定义为删除的。
8. 总结
移动函数通过&&y右值引用来指定,主要为了解决拷贝构造的时候需要深拷贝底层数据的问题,移动函数一般转移底层内存数据。 如果在成员函数中,右侧使用&则限定函数只能被左值调用,&&限定只能被右值调用。
- EOF -
1、C++ 内存对齐
2、一次蓝屏故障分析
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
↓↓↓
点赞和在看就是最大的支持❤️