一、左值和右值
在掌握右值引用前,必须先知道什么是右值,既然有右值,那么肯定有左值。顾名思义,当我们在赋值的时候a=b,能够被放到等号左边的值即为左值,反则为右值。
可见立即数,函数返回的值等都是右值;而非匿名对象(包括变量),函数返回的引用,const对象等都是左值。
显然作为左值的都是可以长期保存起来的,对应是保存在内存中;但常数、表达式、函数返回值等都是临时值,这些值都保存在寄存器中。可以总结:
- 可以取地址的,有名字的,非临时的就是左值;
- 不能取地址的,没有名字的,临时的就是右值;
int a = 9, b = 8;
a = 8, b = 0; // a,b为左值
// a + 4 = 5; // 错误 a + 4为右值,a + 4为一个临时对象
// -a = 4; // 错误 -a为右值,为一个临时对象
(a) = 5; // 正确 (a)为左值,返回的是a
++a = 3; // 正确 ++a为左值,可以理解为a先+1,然后返回a本身,也即整个表达是a
// a++ = 3; // 错误 a++为右值,可以理解为int tmp = a;a = a +1;return tmp;返回的是一个临时变量
二、左值引用和右值引用
1、左值引用
先看一下传统的左值引用:
int a = 10;
int &b = a; // 定义一个左值引用变量
b = 20; // 通过左值引用修改引用内存的值
左值引用在汇编层面其实和普通的指针是一样的;定义引用变量必须初始化,因为引用其实就是一个别名,需要告诉编译器定义的是谁的引用
int &var = 10;
上述代码是无法编译通过的,因为10无法进行取地址操作,无法对一个立即数取地址,因为立即数并没有在内存中存储,而是存储在寄存器中,可以通过下述方法解决:
const int &var = 10;
使用常引用来引用常量数字10,因为此刻内存上产生了临时变量保存了10,这个临时变量是可以进行取地址操作的,因此var引用的其实是这个临时变量,相当于下面的操作:
const int temp = 10;
const int &var = temp;
根据上述分析,得出如下结论:
- 左值引用要求右边的值必须能够取地址,如果无法取地址,可以用常引用;
但使用常引用后,我们只能通过引用来读取数据,无法去修改数据,因为其被const修饰成常量引用了。
那么C++11 引入了右值引用的概念,使用右值引用能够很好的解决这个问题。
2、右值引用
从本质上理解,创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值(包括立即数);而用户创建的,通过作用域规则可知其生存期的,就是左值(包括函数返回的局部变量的引用以及const对象)。
定义右值引用的格式如下:
类型 && 引用名 = 右值表达式;
右值引用是C++ 11新增的特性,所以C++ 98的引用为左值引用。右值引用用来绑定到右值,绑定到右值以后本来会被销毁的右值的生存期会延长至与绑定到它的右值引用的生存期。
int &&var = 10;
在汇编层面右值引用做的事情和常引用是相同的,即产生临时量来存储常量。但是,唯一一点的区别是,右值引用可以进行读写操作,而常引用只能进行读操作。
三、右值引用的作用
右值引用的存在并不是为了取代左值引用,而是充分利用右值(特别是临时对象)的构造来减少对象构造和析构操作以达到提高效率的目的。
用C++实现一个简单的顺序栈:
class Stack
{
public:
// 构造
Stack(int size = 1000)
:msize(size), mtop(0)
{
cout << "Stack(int)" << endl;
mpstack = new int[size];
}
// 析构
~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;
}
运行结果如下:
Stack(int) // 构造s
Stack(int) // 构造tmp
Stack(const Stack&) // tmp拷贝构造main函数栈帧上的临时对象
~Stack() // tmp析构
operator= // 临时对象赋值给s
~Stack() // 临时对象析构
~Stack() // s析构
为了解决浅拷贝问题,为类提供了自定义的拷贝构造函数和赋值运算符重载函数,并且这两个函数内部实现都是非常的耗费时间和资源(首先开辟较大的空间,然后将数据逐个复制),我们通过上述运行结果发现了两处使用了拷贝构造和赋值重载,分别是tmp拷贝构造main函数栈帧上的临时对象、临时对象赋值给s,其中tmp和临时对象都在各自的操作结束后便销毁了,使得程序效率非常低下。
那么我们为了提高效率,是否可以把tmp持有的内存资源直接给临时对象?是否可以把临时对象的资源直接给s?
在C++11中,我们可以解决上述问题,方式是提供带右值引用参数的拷贝构造函数和赋值运算符重载函数
简而言之:由于需要解决浅拷贝问题,出现了自定义深拷贝,而深拷贝又需要开辟较大空间,故使得调用拷贝构造函数大幅降低性能,所以需要减少拷贝构造的调用,于是出现了右值引用
// 带右值引用参数的拷贝构造函数
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;
}
运行结果如下:
Stack(int) // 构造s
Stack(int) // 构造tmp
Stack(Stack&&) // 调用带右值引用的拷贝构造函数,直接将tmp的资源给临时对象
~Stack() // tmp析构
operator=(Stack&&) // 调用带右值引用的赋值运算符重载函数,直接将临时对象资源给s
~Stack() // 临时对象析构
~Stack() // s析构
程序自动调用了带右值引用的拷贝构造函数和赋值运算符重载函数,使得程序的效率得到了很大的提升,因为并没有重新开辟内存拷贝数据。
mpstack = src.mpstack;
可以直接赋值的原因是临时对象即将销毁,不会出现浅拷贝的问题,我们直接把临时对象持有的资源赋给新对象就可以了。
所以,临时量都会自动匹配右值引用版本的成员方法,旨在提高内存资源使用效率。
带右值引用参数的拷贝构造和赋值重载函数,又叫移动构造函数和移动赋值函数,这里的移动指的是把临时量的资源移动给了当前对象,临时对象就不持有资源,为nullptr了,实际上没有进行任何的数据移动,没发生任何的内存开辟和数据拷贝。
四、将左值转换为右值(std::move)
右值引用只能绑定右值,那是否可以将右值引用绑定到左值呢?可以,一个很简单的写法就是强制转换,如下:
int main()
{
int a = 3;
int &&t = (int &&)a;
t = 9;
cout << a << endl; // a = 9
system("pause");
return 0;
}
其实,C++11提供了更为优雅的转换函数std::move,std::move(a)无论a是左值还是右值都将转换为右值。如下:
int main()
{
int a = 3;
int &&t = std::move(a);
int &&t2 = std::move(3);
system("pause");
return 0;
}
五、std::move实现原理(强制转换)
std::move源码如下:
template<class _Ty> inline
constexpr typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{ // forward _Arg as movable
return (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));
}
可以发现,move是将传入的_Arg值,强制转换为了typename remove_reference<_Ty>::type&&类型,那么typename remove_reference<_Ty>::type是什么呢?接着往下看:
template<class _Ty>
struct remove_reference
{ // remove reference
typedef _Ty type;
};
template<class _Ty>
struct remove_reference<_Ty&>
{ // remove reference
typedef _Ty type;
};
template<class _Ty>
struct remove_reference<_Ty&&>
{ // remove rvalue reference
typedef _Ty type;
};
原来,remove_reference是一个类模板。第一个type类型为传入类型本身;第二个类模板形参是左值引用,type为去掉的引用类型;第三个类模板形参为右值引用,type为去掉引用的类型。从本身字面意思也可知道remove_reference的作用就是去掉引用得到原本的类型。
我们再次回到move。move返回的是typename remove_reference<_Ty>::type&&,原本类型的右值引用。至此move作用就非常清晰了:就是将传入值强制转换为值原类型的右值引用。
六、通用引用及其条件
如果你足够仔细的话,你会发现move形参为_Ty&&,形式上是右值引用,那为什么传入左值不会发生错误呢?这涉及到通用引用,所谓通用引用就是根据接受值类型可以自行推导是左值引用还是右值引用。
对于形如:
template<typename T>
void print(T &&) {}
如果传入参数是左值则会被推导为print(int&),如果参数是右值则推导为print(int&&),除了模板外auto &&也有相同效果,如下:
template<typename T>
void print(T &&) {}
int main()
{
int a = 9;
print(a); // print(int&)
print(8); // print(int&)
auto &&t = 3; // int &&
auto &&t2 = a; // int &
system("pause");
return 0;
}
那是不是所有模板函数形参T&&都是通用引用呢?答案是否定的。条件是:如果声明变量或参数具有T&&
某种推导类型的类型 T
,则该变量或参数为通用引用,否则就是右值引用(无法传入左值)。
也就是传入的参数在编译时需要推导,如果不需要推导,则不是通用引用。如下:
template<typename T>
class B
{
public:
void print(T &&) {}
};
int main()
{
B<int> b;
b.print(3); // 为右值引用
system("pause");
return 0;
}
因为在编译print之前print中的参数已经由B<int> b确定了,所以在print编译时无需推导,故B中的T&&为右值引用。如果改为如下:
template<typename T>
class B
{
public:
template<typename Arg>
void print(Arg &&) {}
};
int main()
{
B<int> b;
b.print(3); // 为右值引用
system("pause");
return 0;
}
因为print时函数模板形参和类模板形参类型时独立的,故在编译print时是需要推导的,故Arg&&为通用引用。同理,下面的int&&也不是通用引用,因为类型已经确定:
template<typename T>
class B
{
public:
void print(int &&) {}
};
int main()
{
B<char> b;
int t = 0;
// b.print(t); // 出错,print(int&&)为右值引用
system("pause");
return 0;
}
通用引用的形式必须是T&&,添加其他修饰都不是通用引用,如下:
template<typename T>
void print(const T&&) // 右值引用
{
}
template<typename T>
void print(std::vector<T>&& params) // 右值引用
{
}
七、引用折叠
上面我们知道通用引用虽然形式上是右值引用,但是却可以接受左值,这是怎么实现的呢?这就是引用折叠。
有如下代码:
template<typename T>
void print(T&& t)
{
}
int main()
{
int a = 9;
print(a);
print(9);
system("pause");
return 0;
}
print(a)时,因为a为左值,会被推导成print(int& &&t)形式,int& &&t 会被折叠为int &,所以最终形式为print(int &)。(左值被推导为左值引用)
print(9)时,为9为右值,所以被推导为print(int&& &&)形式,而int&& &&会被折叠为int&&,所以最终形式为print(int&&)。(右值被推导为右值引用)
引用类型只有两种,所以折叠形式就是4中,为:T& &,T& &&,T&& &,T&& &&。引用折叠规则概况为两种:
T&& &&折叠为T&&
其他折叠为T&
下面使用typedef验证引用折叠,如下:
template<typename T>
class B
{
public:
typedef T& type;
typedef T&& type2;
};
int main()
{
int a = 9;
B<int>::type t = a; // type->int&
B<int&>::type t2 = a; // type->int& &折叠为int&
B<int&&>::type t3 = a; // type->int&& &折叠为int&
B<int>::type2 t4 = 3; // type2->int&&
B<int&>::type2 t5 = a; // type2->int& &&折叠为int&
B<int&&>::type2 t6 = 3; // type2->int&& &&折叠为int&&
system("pause");
return 0;
}
八、完美转发及其意义(std::forward)
通用引用既可以接受左值也可以接受右值,但是通用引用本身是左值。如果在函数模板中继续传递该值给其他函数,势必会改变该值的属性,即都为左值引用。如下:
template<typename T>
void _print(T &&t)
{
cout << (std::is_lvalue_reference<decltype(t)>::value ? "lvalue" : "rvalue") << endl;
}
template<typename T>
void print(T&& t)
{
_print(t);
}
int main()
{
int a = 3;
print(a); // lvalue
print(3); // lvalue
system("pause");
return 0;
}
本来3为右值,传递到_print之后变成了左值。整个属性是被print改变的。那么可否有一种方式以原属性传递呢?答案是std::foward,被称为完美转发。如下代码:
template<typename T>
void _print(T &&t)
{
cout << (std::is_lvalue_reference<decltype(t)>::value ? "lvalue" : "rvalue") << endl;
}
template<typename T>
void print(T&& t)
{
_print(std::forward<T>(t));
}
int main()
{
int a = 3;
print(a); // lvalue
print(3); // rvalue
system("pause");
return 0;
}
在print中传递给_print的值为std::foward<T>(t),传递给_print,接受的值属性和之前保持一致。所谓完美转发就是不改变值原本属性进行转发。
完美转发有什么意义呢?某个功能对左值和右值处理情况不一致,如果将左值和右值引用当作同一种情况使用,可能会会有性能损失。假设有如下代码:
class A
{
public:
A() {}
A(int a) { m_pa = new int(a); }
A(const A &a)
{
if (this != &a)
{
delete m_pa;
m_pa = new int(*a.m_pa);
}
}
~A() { delete m_pa; }
int *m_pa = nullptr;
};
A * _makeA(A &a)
{
return new A(a);
}
A * _makeA(A &&a)
{
A *pa = new A;
pa->m_pa = a.m_pa;
a.m_pa = nullptr;
return pa;
}
template<typename T>
auto makeA(T&& t)
{
return _makeA(t);
}
int main()
{
A a(3);
auto t = makeA(a);
auto t2 = makeA(A(4));
system("pause");
return 0;
}
该代码作用是根据已有的A对象创建新的A对象指针。因为左值具有延时性,所以根据左值创建A指针时是将对象中m_pa进行了深拷贝;根据临时对象创建A指针时,由于临时对象由于已经创建好了m_pa,没有必要再创建将临时对象的m_pa进行深拷贝,只需要将临时对象的m_pa给到新创建的A即可,同时将临时对象的m_pa指向nullptr。这样可以提高性能。
但是通过makeA间接传递给_makeA之后,都调用了_makeA(A&),也就是对m_pa进行了深拷贝,这与原本make(A(4))想调用_makeA(A&&)不一致。
如果我们能在makeA中完美转发t,那么就可以达到要求,这就是std::foward的意义。
九、std::foward原理
先看下std::foward的实现代码,如下:
template<class _Ty> inline
constexpr _Ty&& forward(
typename remove_reference<_Ty>::type& _Arg) _NOEXCEPT
{ // forward an lvalue as either an lvalue or an rvalue
return (static_cast<_Ty&&>(_Arg));
}
template<class _Ty> inline
constexpr _Ty&& forward(
typename remove_reference<_Ty>::type&& _Arg) _NOEXCEPT
{ // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference<_Ty>::value, "bad forward call");
return (static_cast<_Ty&&>(_Arg));
}
我们简化为_foward版本(方便看) ,如下:
template<typename T>
T&& _forward(typename remove_reference<T>::type& t)
{
return static_cast<T&&>(t);
}
template<typename T>
T&& _forward(typename remove_reference<T>::type&& t)
{
return static_cast<T&&>(t);
}
foward给出了两个版本,一个接收左值,一个接收右值。现在我们使用_foward,如下:
void _print(int &t)
{
cout << "lvalue" << endl;
}
void _print(int &&t)
{
cout << "rvalue" << endl;
}
template<typename T>
void print(T &&t)
{
_print(_forward<T>(t));
}
这里有两个疑问:1)如果我们调用print(3),_foward会执行哪个版本?2)如果我们调用print(a),因为_foward中强制转换为了T&&, 是否都调用_print(int&&)版本?
问题1:无论print的参数是左值还是右值,传递给print后t都为左值,所以_foward会执行左值版本,即第一个版本。
问题2:在_foward都将t强制转换为了T&&,按照道理来说,应该都会执行_print(int&&),不信可以执行:
int a = 3;
_print((int&&)(a));
代码,发现确实是调用了_print(int&&)。既然这样_foward为什么还能做到完美转发呢?显然通过_foward(a)之后的调用的肯定是_print(int&)版本。在_foward中使用static_cast<T&&>(或者T&&)和int&&结果不一样呢?原因是引用折叠。具有推导类型的T&&转换会进行引用折叠。而int&&类型是确定的,不能进行折叠。
如果调用print(3),传递给_foward的t为&&类型,然后T&& &&将折叠为T&&,故最终会调用_print(int&&)版本;如果调用print(a),传递给_foward的t为&类型然后T&& &将折叠为T&,所以最终调用_print(int&)版本。