目录
右值引用
首先我们先来看一下传统的左值引用。
<span style="color:#000000"><code>int main()
{
int a = 10;
int &b = a; // 定义一个左值引用变量
b = 20; // 通过左值引用修改引用内存的值
return 0;
}
</code></span>
反汇编如下:
<span style="color:#000000"><code>int a = 10;
00BA43BE mov dword ptr [a],0Ah
int &b = a;
00BA43C5 lea eax,[a] // 将a的地址存入寄存器eax
00BA43C8 mov dword ptr [b],eax // 将eax寄存器内容放入b的内存
b = 20;
00BA43CB mov eax,dword ptr [b] // 把b内存的值(a的地址)存入eax寄存器
00BA43CE mov dword ptr [eax],14h // 将20放入eax记录的地址的内存里面
</code></span>
根据反汇编我们可以看出,我们定义的**左值引用在汇编层面其实和普通的指针是一样的**定义引用变量必须初始化,因为引用其实就是一个别名,我们需要告诉编译器我们定义的是谁的引用,我们在使用引用变量时,汇编指令会自动进行解引用操作。
<span style="color:#000000"><code>int &var = 10;
</code></span>
上述代码是无法编译通过的,因为10是无法进行取地址操作的,我们无法对一个立即数取地址,因为立即数并没有在内存中存储,而是存储在寄存器中,我们可以通过下述方法解决:
<span style="color:#000000"><code>const int &var = 10;
</code></span>
- 我们使用常引用来引用常量数字10,因为此刻内存上产生了临时变量保存了20,这个临时变量是可以进行取地址操作的,因此var引用的其实是这个临时变量,相当于下面的操作:
<span style="color:#000000"><code>const int temp = 10;
const int &var = temp;
</code></span>
汇编代码如下:
<span style="color:#000000"><code>const int &var = 10;
011543C5 mov dword ptr [ebp-20h],0Ah // 将10存入就是内存栈上产生的临时变量中
011543CC lea eax,[ebp-20h] // 将临时变量的地址存入eax寄存器中
011543CF mov dword ptr [var],eax // 将eax寄存器中的内存放入var内存中
</code></span>
根据上述分析,我们得出如下结论:
- 左值引用要求右边的值必须能够取地址,如果无法取地址,可以用常引用
但使用常引用后,我们只能通过引用来读取数据,无法去修改数据,因为其被const修饰成常量引用了。
那么C++11 引入了右值引用的概念,使用右值引用能够很好的解决这个问题。
左值与右值
C++对于左值和右值没有标准定义,但是有一个被广泛认同的说法:可以取地址的,有名字的,非临时的就是左值;不能取地址的,没有名字的,临时的就是右值.
可见立即数,函数返回的值等都是右值;而非匿名对象(包括变量),函数返回的引用,const对象等都是左值。
从本质上理解,创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值(包括立即数);而用户创建的,通过作用域规则可知其生存期的,就是左值(包括函数返回的局部变量的引用以及const对象)。
右值引用及其作用分析
右值引用的表示方法为
<span style="color:#000000"><code>Datatype && variable
</code></span>
- 右值引用是C++ 11新增的特性,所以C++ 98的引用为左值引用。右值引用用来绑定到右值,绑定到右值以后本来会被销毁的右值的生存期会延长至与绑定到它的右值引用的生存期。
<span style="color:#000000"><code>int &&var = 10;
</code></span>
- 接下来我们分析一下上述代码的汇编指令:
<span style="color:#000000"><code>int &&var = 10;
00CE43C5 mov dword ptr [ebp-20h],0Ah
00CE43CC lea eax,[ebp-20h]
00CE43CF mov dword ptr [var],eax
</code></span>
我们发现,在汇编层面右值引用做的事情和常引用是相同的,即产生临时量来存储常量。但是,唯一 一点的区别是,右值引用可以进行读写操作,而常引用只能进行读操作。
右值引用的存在并不是为了取代左值引用,而是**充分利用右值(特别是临时对象)的构造来减少对象构造和析构操作以达到提高效率的目的,**
我们接下来用C++实现一个简单的顺序栈:
<span style="color:#000000"><code>class Stack
{
public:
// construct
Stack(int size = 1000)
:msize(size), mtop(0)
{
cout << "Stack(int)" << endl;
mpstack = new int[size];
}
// Destroy
~Stack()
{
cout << "~Stack()" << endl;
delete[]mpstack;
mpstack = nullptr;
}
// 拷贝构造
Stack(const Stack &src)
:msize(src.msize), mtop(src.mtop)
{
cout << "Stack(const Stack&)" << endl;
mpstack = new int[src.msize];
for (int i = 0; i < mtop; ++i)
{
mpstack[i] = src.mpstack[i];
}
}
// 赋值重载
Stack& operator=(const Stack &src)
{
cout << "operator=" << endl;
if (this == &src)
return *this;
delete[]mpstack;
msize = src.msize;
mtop = src.mtop;
mpstack = new int[src.msize];
for (int i = 0; i < mtop; ++i)
{
mpstack[i] = src.mpstack[i];
}
return *this;
}
int getSize()
{
return msize;
}
private:
int *mpstack;
int mtop;
int msize;
};
Stack GetStack(Stack &stack)
{
Stack tmp(stack.getSize());
return tmp;
}
int main()
{
Stack s;
s = GetStack(s);
return 0;
}
</code></span>
运行结果及分析如下:
我们为了解决浅拷贝问题,为类提供了我们自定义的拷贝构造函数和赋值运算符重载函数,并且这两个函数内部实现都是非常的耗费时间和资源(首先开辟较大的空间,然后将数据逐个复制),我们通过上述运行结果发现了两处使用了拷贝构造和赋值重载,分别是tmp拷贝构造main函数栈帧上的临时对象、临时对象赋值给s,其中tmp和临时对象都在各自的操作结束后便销毁了,使得程序效率非常低下。
那么我们为了提高效率,是否可以把tmp持有的内存资源直接给临时对象?是否可以把临时对象的资源直接给s?
在C++11中,我们可以解决上述问题,方式是提供带右值引用参数的拷贝构造函数和赋值运算符重载函数.
<span style="color:#000000"><code>// 带右值引用参数的拷贝构造函数
Stack(Stack &&src)
:msize(src.msize), mtop(src.mtop)
{
cout << "Stack(Stack&&)" << endl;
/*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/
mpstack = src.mpstack;
src.mpstack = nullptr;
}
// 带右值引用参数的赋值运算符重载函数
Stack& operator=(Stack &&src)
{
cout << "operator=(Stack&&)" << endl;
if(this == &src)
return *this;
delete[]mpstack;
msize = src.msize;
mtop = src.mtop;
/*此处没有重新开辟内存拷贝数据,把src的资源直接给当前对象,再把src置空*/
mpstack = src.mpstack;
src.mpstack = nullptr;
return *this;
}
</code></span>
我们看到,程序自动调用了带右值引用的拷贝构造函数和赋值运算符重载函数,使得程序的效率得到了很大的提升,因为并没有重新开辟内存拷贝数据。
<span style="color:#000000"><code>mpstack = src.mpstack;
</code></span>
- 可以直接赋值的原因是临时对象即将销毁,不会出现浅拷贝的问题,我们直接把临时对象持有的资源赋给新对象就可以了。
所以,临时量都会自动匹配右值引用版本的成员方法,旨在提高内存资源使用效率。带右值引用参数的拷贝构造和赋值重载函数,又叫移动构造函数和移动赋值函数,这里的移动指的是把临时量的资源移动给了当前对象,临时对象就不持有资源,为nullptr了,实际上没有进行任何的数据移动,没发生任何的内存开辟和数据拷贝。
右值引用的绑定规则
- 非const左值引用只能绑定到非const左值;
- const左值引用可绑定到const左值、非const左值、const右值、非const右值;
- 非const右值引用只能绑定到非const右值;
- const右值引用可绑定到const右值和非const右值,不能绑定到左值。
测试程序
<span style="color:#000000"><code>struct A { A(){} };
A lvalue; // 非const左值对象
const A const_lvalue; // const左值对象
A rvalue() {return A();} // 返回一个非const右值对象
const A const_rvalue() {return A();} // 返回一个const右值对象
// 规则一:非const左值引用只能绑定到非const左值
A &lvalue_reference1 = lvalue; // ok
A &lvalue_reference2 = const_lvalue; // error
A &lvalue_reference3 = rvalue(); // error
A &lvalue_reference4 = const_rvalue(); // error
// 规则二:const左值引用可绑定到const左值、非const左值、const右值、非const右值
const A &const_lvalue_reference1 = lvalue; // ok
const A &const_lvalue_reference2 = const_lvalue; // ok
const A &const_lvalue_reference3 = rvalue(); // ok
const A &const_lvalue_reference4 = const_rvalue(); // ok
// 规则三:非const右值引用只能绑定到非const右值
A &&rvalue_reference1 = lvalue; // error
A &&rvalue_reference2 = const_lvalue; // error
A &&rvalue_reference3 = rvalue(); // ok
A &&rvalue_reference4 = const_rvalue(); // error
// 规则四:const右值引用可绑定到const右值和非const右值,不能绑定到左值
const A &&const_rvalue_reference1 = lvalue; // error
const A &&const_rvalue_reference2 = const_lvalue; // error
const A &&const_rvalue_reference3 = rvalue(); // ok
const A &&const_rvalue_reference4 = const_rvalue(); // ok
// 规则五:函数类型例外
void fun() {}
typedef decltype(fun) FUN; // typedef void FUN();
FUN & lvalue_reference_to_fun = fun; // ok
const FUN & const_lvalue_reference_to_fun = fun; // ok
FUN && rvalue_reference_to_fun = fun; // ok
const FUN && const_rvalue_reference_to_fun = fun; // ok
</code></span>
说明:
- 一些支持右值引用但版本较低的编译器可能会允许右值引用绑定到左值,例如g++4.4.4就允许,但g++4.6.3不允许,clang++3.2也不允许等。
- 右值引用绑定到字面值常量同样符合上述规则,例如:int &&rr = 123;,这里的字面值123虽然被称为常量,可它的类型为int,而不是const int。123是非const右值,int &&rr = 123;语句符合上述规则三。
转移语义
右值引用是用来支持转移语义的。**转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。**临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
通过转移语义,临时对象中的资源能够转移其它的对象里。
在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。**要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。**对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
普通的函数和操作符也可以利用右值引用操作符实现转移语义。
实现转移构造函数和转移赋值函数
虽然普通的函数和操作符也可以利用右值引用实现转移语义(如上述的示例),但转移语义通常是通过转移构造函数和转移赋值操作符实现的,转移构造函数的原型为Classname(Typename&&) ,而拷贝构造函数的原型为Classname(const Typename&) ,转移构造函数不会被编译器自动生成,需要自己定义,只定义转移构造函数也不影响编译器生成拷贝构造函数,如果传递的参数是左值,就调用拷贝构造函数,反之,就调用转移构造函数。
例如,我们分析上述示例:
<span style="color:#000000"><code>class Stack
{
public:
······
// 拷贝构造
Stack(const Stack &src)
:msize(src.msize), mtop(src.mtop)
{
cout << "Stack(const Stack&)" << endl;
mpstack = new int[src.msize];
for (int i = 0; i < mtop; ++i)
{
mpstack[i] = src.mpstack[i];
}
}
// 转移构造函数
Stack(Stack &&src)
:msize(src.msize), mtop(src.mtop)
{
mpstack = src.mpstack;
src.mpstack = nullptr;
}
// 转移赋值函数
Stack& operator=(Stack &&src)
{
if(this == &src)
return *this;
delete[]mpstack;
msize = src.msize;
mtop = src.mtop;
mpstack = src.mpstack;
src.mpstack = nullptr;
return *this;
}
private:
int *mpstack;
int mtop;
int msize;
};
</code></span>
从以上代码可以看出,拷贝构造函数在堆中重新开辟了一个大小为src.msize的int型数组,然后每个元素分别拷贝,**而转移构造函数则是直接接管参数的指针所指向的资源,效率大大提升!**需要注意的是转移构造函数实参必须是右值,一般是临时对象,如函数的返回值等,对于此类临时对象一般在当行代码之后就被销毁,而采用转移构造函数可以延长其生命期,可谓是物尽其用,同时有避免了重新开辟数组,对于上述代码中的转移构造函数,有必要详细分析一下:
<span style="color:#000000"><code>Stack(Stack &&src)
:msize(src.msize), mtop(src.mtop)
{
mpstack = src.mpstack;
src.mpstack = nullptr;
}
</code></span>
src是一个右值引用,通过它间接访问实参(临时对象)的资源来完成资源转移,src绑定的对象(必须)是右值,但src本身是左值;
因为src是函数的局部对象,因此src.mpstack = nullptr 必不可少,否则函数结尾调用析构函数销毁src时仍然会将资源释放,转移的资源还是被系统收回。
标准库函数 std::move
既然编译器只对右值引用才能调用转移构造函数和转移赋值函数,而所有命名对象都只能是左值引用,如果已知一个命名对象不再被使用而想对它调用转移构造函数和转移赋值函数,也就是把一个左值引用当做右值引用来使用,怎么做呢?标准库提供了函数 std::move,这个函数以非常简单的方式将左值引用转换为右值引用。
<span style="color:#000000"><code> void ProcessValue(int& i)
{
std::cout << "左值引用,i = " << i << std::endl;
}
void ProcessValue(int&& i)
{
std::cout << "右值引用,i = " << i << std::endl;
}
int main()
{
int a = 0;
ProcessValue(a);
ProcessValue(std::move(a));
}
</code></span>
std::move在提高 swap 函数的的性能上非常有帮助,一般来说,swap函数的通用定义如下:
<span style="color:#000000"><code>template <class T> swap(T& a, T& b)
{
T tmp(a); // copy a to tmp
a = b; // copy b to a
b = tmp; // copy tmp to b
}
</code></span>
有了 std::move,swap 函数的定义变为 :
<span style="color:#000000"><code>template <class T> swap(T& a, T& b)
{
T tmp(std::move(a)); // move a to tmp
a = std::move(b); // move b to a
b = std::move(tmp); // move tmp to b
}
</code></span>
通过 std::move,一个简单的 swap 函数就避免了 3 次不必要的拷贝操作。
move()源码如下:
<span style="color:#000000"><code> // TEMPLATE FUNCTION move
template<class _Ty> inline
typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{ // forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
</code></span>
其实move就是返回传入的实参的右值引用类型,做了一个类型强转。
完美转发
Perfect Forwarding,即完美转发,也被译为精确传递,适用于这样的场景:需要将一组参数原封不动的传递给另一个函数。
“原封不动”不仅仅是参数的值不变,在 C++ 中,除了参数值之外,还有一下两组属性:
左值/右值和 const/non-const。 **完美转发就是在参数传递过程中,所有这些属性和参数值都不能改变。**在泛型函数中,这样的需求非常普遍。
例如在进行函数包装的时候,func函数存在下列重载:
<span style="color:#000000"><code>void func(const int);
void func(int);
void func(int&&);
</code></span>
如果要将它们包装到一个函数cover内,以实现:
<span style="color:#000000"><code>void cover(typename para)
{
func(para);
}
</code></span>
使得针对不同实参能在cover内调用相应类型的函数,似乎只能通过对cover进行函数重载,这使代码变得冗繁,另一种方法就是使用函数模板,但在C++ 11之前,实现该功能的函数模板只能采用值传递,如下:
<span style="color:#000000"><code>template<typename T>
void cover(T para)
{
...
func(para);
...
}
</code></span>
但如果传递的是一个相当大的对象,又会造成效率问题,要通过引用传递实现形参与实参的完美匹配(包裹const属性与左右值属性的完美匹配),就要使用C++ 11新引入的引用折叠规则:
函数形参 T的类型 推导后的函数形参
T& A& A&
T& A&& A&
T&& A& A&
T&& A&& A&&
因此,对于前例的函数包装要求,采用以下模板就可以解决:
<span style="color:#000000"><code>template<typename T>
void cover(T&& para)
{
...
func(static_cast<T &&>(para));
...
}
</code></span>
如果传入的是左值引用,转发函数将被实例化为:
<span style="color:#000000"><code>void func(T& && para)
{
func(static_cast<T& &&>(para));
}
</code></span>
应用引用折叠,就为:
<span style="color:#000000"><code>void func(T& para)
{
func(static_cast<T&>(para));
}
</code></span>
如果传入的是右值引用,转发函数将被实例化为:
<span style="color:#000000"><code>void func(T&& &¶)
{
func(static_cast<T&& &&>(para));
}
</code></span>
应用引用折叠,就是:
<span style="color:#000000"><code>void func(T&& para)
{
func(static_cast<T&&>(para));
}
</code></span>
对于以上的static_cast<T&&>,实际上只在para被推导为右值引用的时候才发挥作用,由于para是左值(右值引用是左值),因此需要将它转为右值后再传入func内。
所以最终版本为
<span style="color:#000000"><code>template<typename T>
void cover(T&& para)
{
func(forward(forward<T>(para)));
}
</code></span>
std::forward的实现与static_cast<T&&>(para)稍有不同
std::forward函数的用法为forward(para) ,若T为左值引用,para将被转换为T类型的左值,否则para将被转换为T类型右值。
标准库函数std::forward
先给一个代码示例,实现一个简单的vector,来描述forward的应用场景,示例代码如下:
<span style="color:#000000"><code>// 容器里面元素的类型
class A
{
public:
A(){}
// 带左值引用参数的赋值函数
A& operator=(const A &src)
{
cout << "operator=" << endl;
return *this;
}
// 带右值引用参数的赋值函数
A& operator=(A &&src)
{
cout << "operator=(A&&)" << endl;
return *this;
}
};
// 容器的类型
template<typename _Ty>
class Vector
{
public:
// 引用左值的push_back函数
void push_back(const _Ty &val)
{
addBack(val);
}
// 引用右值的push_back函数
void push_back(_Ty &&val)
{
// 这里传递val时,要用move转换成右值引用类型,
// 因为val本身是左值,有名字有地址,见前面引用折叠部分的说明
addBack(std::move(val));
}
private:
enum { VEC_SIZE = 10 };
_Ty mvec[VEC_SIZE];
int mcur;
template<typename _Ty>
void addBack(_Ty &&val)
{
/*
这里val本身永远是左值,所以不可能调用
容器内部对象的右值引用参数的operator=赋值函数
*/
mvec[mcur++] = val;
}
};
int main()
{
Vector<A> vec;
A a;
vec.push_back(a); // 调用A的左值引用的赋值函数
vec.push_back(A()); // 理应调用A的右值引用参数的赋值函数,却调用了左值引用的赋值函数
return 0;
}
</code></span>
代码运行打印如下:
operator=
operator=
vec.push_back(A())这句代码传入的是临时对象,最终却没有调用A对象的右值引用参数的赋值函数operator=,主要原因就是在Vector中addBack函数里面,val永远被当作左值了,无法保持它接收的实参的引用类型,是左引用还是右引用,此时std::forward就要起作用了,它称作“类型完美转发”,也就是说可以保持实参数据的左引用或者右引用类型,上面的addBack函数修改如下:
<span style="color:#000000"><code>template<typename _Ty>
void addBack(_Ty &&val)
{
/*
这里使用std::forward,可以获取val引用的实参的引用类型,
是左引用,还是右引用,原理就是根据“引用折叠规则”
int&+&&->int& int&&+&&->int&&
*/
mvec[mcur++] = std::forward<_Ty>(val);
}
</code></span>
修改完addBack函数,重新运行上面代码,打印如下:
<span style="color:#000000"><code>operator=
operator=(A&&)
</code></span>
完美!vec.push_back(A())这句代码,最终调用了A对象的右值引用参数的赋值函数operator=,符合预期。因为在addBack中使用了std::forward类型完美转发机制,它的源码实现如下:
<span style="color:#000000"><code>
// TEMPLATE FUNCTION forward
template<class _Ty> inline
_Ty&& forward(typename remove_reference<_Ty>::type& _Arg)
{ // forward an lvalue
return (static_cast<_Ty&&>(_Arg));
}
template<class _Ty> inline
_Ty&& forward(typename remove_reference<_Ty>::type&& _Arg) _NOEXCEPT
{ // forward anything
static_assert(!is_lvalue_reference<_Ty>::value, "bad forward call");
return (static_cast<_Ty&&>(_Arg));
}
</code></span>
上面是C++库里面提供的两个forward重载函数,分别接收左值和右值引用类型,进行一个类型强转,(static_cast<_Ty&&>(_Arg))。
总结:
- std::move是获取实参的右值引用类型;
- std::forward是在代码实现过程中,保持实参的原有的引用类型(左引用或者右引用类型)。