[c++] Lvalues and Rvalues
Ref: 从4行代码看右值引用
Ref: c++右值引用以及使用
初步认识
一、左值引用
若干有意思的写法。传统的c++引用被称为左值引用如下:
int const & x -- a reference to const int
const int & x -- a reference to const int
int & const x -- ill formed code that should trigger a compiler error
int const * x -- a (non-const) pointer to const int
const int * x -- a (non-const) pointer to const int
int * const x -- a const pointer to (non-const) int
- Revisited
等价于Rvalue的函数可以,如下:
int i = 1;
int& getRef() { return i; }
getRef() += 1;
- References
左值引用:
A a;
A& a_ref1 = a; // an lvalue reference
- const 左值引用
引用当然要引用一个变量,而不是值。
但是如果是一个const的左值引用,是可以绑定到右值上的。即如下写法是符合语法规范的:
const int & i = 10;
* 再来一个对比,当不使用const时,常数作为参数会给模板带来问题。
template<class T> void f1(T&) {} f1(i) // i是一个int,模板参数类型T是int f1(ci) // ci是一个const int,模板参数T是const int fl(5) // 错误:传递给一个&参数的实参必须是一个左值
* 使用了const,作为左值的常数可以绑定为右值。
template<class T> void f2(const T&) {} f2(i) // i是一个int,模板参数类型T是int,因为非const可以转化为const f2(ci) // ci是一个const int,模板参数T是int f2(5) // 看前面,const的引用可以绑定右值,T是int
二、右值引用
-
双地址符号
A&& a_ref2 = a + a; // an rvalue reference
-
右值引用的原理
右值虽然无法获取地址,但是 右值引用是可以获取地址的,该地址表示临时对象的存储位置。
右值引用的汇编原理涉及到”临时变量“。如下,右值引用的汇编
int && iii = 10;
0x08048400 mov $0xa,%eax 0x08048405 mov %eax,-0xc(%ebp) 0x08048408 lea -0xc(%ebp),%eax 0x0804840b mov %eax,-0x4(%ebp)
第一句将10赋值给eax,第二句将eax放入-0xc(%ebp)处,前面说到“临时变量会引用关联到右值时,右值被存储到特定位置”,在这段程序中,-0xc(%ebp)便是该临时变量的地址,后两句通过eax将该地址存到iii处。
同时,我们可以深入理解下临时变量,在本程序中,有名字的1(名字为i)和没有名字的10(临时变量)的值实际是按同一方式处理的,也就是说,临时变量根本上来说就是一个没有名字的变量而已。它的生命周期和函数栈帧是一致的。也可以说临时变量和它的引用具有相同的生命周期。
三、相互赋值
左值引用和右值引用的相互赋值。
能将右值引用赋值给左值引用,该左值引用绑定到右值引用指向的对象。
在早期的c++中,引用没有左右之分,引入了右值引用之后才被称为左值引用,所以说左值引用其实可以绑定任何对象。这样也就能理解为什么const左值引用能赋予常量值。
int&& iii = 10; int& ii = iii; //ii等于10,对ii的改变同样会作用到iii
四行代码的故事
一、第1行代码的故事
在C++11中所有的值必属于:左值、将亡值、纯右值三者之一。
这行代码会产生几种类型的值呢?答案是会产生两种类型的值,一种是左值i,一种是函数getVar()返回的临时值,这个临时值在表达式结束后就销毁了,而左值i在表达式结束后仍然存在,这个临时值就是右值,具体来说是一个纯右值,右值是不具名的。区分左值和右值的一个简单办法是:看能不能对表达式取地址,如果能,则为左值,否则为右值。
所有的具名变量或对象都是左值,而匿名变量则是右值。
int i = getVar();
二、第2行代码的故事
T&& k = getVar();
第二行代码和第一行代码很像,只是相比第一行代码多了“&&”,他就是右值引用。
我们知道左值引用是对左值的引用,那么,对应的,对右值的引用就是右值引用,而且右值是匿名变量,我们也只能通过引用的方式来获取右值。
虽然第二行代码和第一行代码看起来差别不大,但是实际上语义的差别很大,这里,getVar()产生的临时值不会像第一行代码那样,在表达式结束之后就销毁了,而是会被“续命”,他的生命周期将会通过右值引用得以延续,和变量k的声明周期一样长。
延续这个 "临时值” 做什么?有什么用?
-
右值引用的第一个特点 ----> 临时变量
(1) 临时变量导致了 “性能损失”。
#include <iostream> using namespace std; int g_constructCount=0; int g_copyConstructCount=0; int g_destructCount=0;
struct A { A() { cout<<"construct: "<<++g_constructCount<<endl; } A(const A& a) { cout<<"copy construct: "<<++g_copyConstructCount <<endl; }
~A() { cout<<"destruct: "<<++g_destructCount<<endl; } }; A GetA() { return A(); } int main() { A a = GetA();
return 0; }
为了清楚的观察临时值,在编译时设置编译选项 -fno-elide-constructors 用来关闭返回值优化效果。输出结果:
construct: 1 // A() copy construct: 1 // A() --> 临时变量 destruct: 1 // A()销毁 copy construct: 2 // a = getA() destruct: 2 // 临时变量销毁 destruct: 3 // a销毁
(2) 右值引用 带来 性能优化,减少了一次拷贝操作;但其实编译器自己内部也会默认进行优化。
int main() {
A&& a = GetA(); return 0; } 输出结果: construct: 1 copy construct: 1 destruct: 1 destruct: 2
-
右值引用的第二个特点 ----> 初始化推断
“右值引用” 类型的变量可能是左值,也可能是右值。它是左值还是右值取决于它的初始化。
(1) T&& t在发生自动类型推断的时候,它是未定的引用类型(universal references),
(2) 如果被一个左值初始化,它就是一个左值;
(3) 如果它被一个右值初始化,它就是一个右值。
template<typename T> void f(T&& t){}
双地址也不一定代表 “右值引用”,要看用的 ”谁" 初始化的,”谁“是个什么类型。
f(10); / /t是右值,因为10是个右值 int x = 10; f(x); // t是左值,因为x是个左值
-
右值引用的第三个特点 ----> Universal References
仅仅是当发生自动类型推导(如函数模板的类型自动推导,或auto关键字)的时候,T&& 才是 universal references (综合引用),也就是:“左引用、右引用都有可能”。
template<typename T> void f(T&& param); // 模板T类型是不定的,所以需要类型推导,也就是T&& 是universal references template<typename T> class Test { Test(Test&& rhs); };
param 是 universal reference,因为模版函数f发生了类型推断。
rhs 是 Test&& 右值引用,Test&&并没有发生类型推导,这是因为Test&&是确定的类型了。
正是因为右值引用可能是左值也可能是右值,依赖于初始化,并不是一下子就确定的特点,我们可以利用这一点做很多文章,比如后面要介绍的"移动语义"和"完美转发"。
引用折叠
这里再提一下"引用折叠",正是因为引入了右值引用,所以可能存在左值引用与右值引用和右值引用与右值引用的折叠,C++11确定了引用折叠的规则,规则是这样的:
- 所有的右值引用叠加到右值引用上仍然还是一个右值引用;
- 所有的其他引用类型之间的叠加都将变成左值引用。
template<class T> void f3(T&&) {} f3(i) // 实参是左值,模板参数T是int& f3(ci) // 实参是左值,模板参数T是一个const int&
但是当T被推断为int&时,函数f3会实例化成如下的样子:
void f3<int&>(int& &&)
此时开始按照如下 “引用折叠” 法则对上述的问题进行处理。
X& &&,X&& & --> 折叠成 X&
X&& && --> 折叠成 X&&
然后根据 "右值引用折叠规则" 可以知道,上述实例化方式应该被折叠成如下样子:
void f3<int&>(int&)
这两个规则导致了两个重要的结果:
l 如果一个函数参数是一个指向模板类型参数的右值引用,如T&&,则它能被绑定到一个左值,且
l 如果实参是一个左值,则推断出的模板实参类型将时一个左值引用,且函数参数被实例化为一个普通左值引用参数(T&)
值得注意,参数为T&&类型的函数可以接受所有类型的参数,左值右值均可。在前面,同样介绍过,const的左值引用做参数的函数同样也可以接受所有类型的参数。
三、第3行代码的故事
指针悬挂
类中有指针类形式,就不能进行直接相互赋值,否则就会产生指针悬挂问题。
(1) 实现拷贝构造函数;
(2) 实现等号复制函数。
误删第二次
一个带有堆内存的类,必须提供一个深拷贝的拷贝构造函数,因为默认的拷贝构造函数是浅拷贝,会发生“指针悬挂”的问题。
如果不提供深拷贝的拷贝构造函数,上面的测试代码将会发生错误(编译选项-fno-elide-constructors),内部的m_ptr将会被删除两次,
(1) 第一次是临时右值析构的时候删除一次;
(2) 第二次外面构造的a对象释放时删除一次;
而这两个对象的m_ptr是同一个指针,这就是所谓的指针悬挂问题。
"深拷贝" 方案
提供深拷贝的拷贝构造函数则可以保证正确。
但是,临时变量在拷贝构造完成之后就销毁了,如果堆内存很大的话,那么,这个拷贝构造的代价会很大,带来了额外的性能损失。
#include <iostream> #include <chrono> using namespace std; class A { public: A(): m_ptr(new int(0)) { cout << "construct" << endl; } A(const A& a): m_ptr(new int(*a.m_ptr)) // 初始化方法:int(整型的指针指向的数字),对m_ptr单独进行了赋值。 { cout << "copy construct" << endl; } ~A(){ delete m_ptr;} private: int* m_ptr; // 堆内存 }; A GetA() { return A(); } int main() { A a = GetA(); // 这里的初始化,其实是深拷贝 return 0; }
"移动构造" 方案
为什么会匹配到这个构造函数?因为这个构造函数只能接受右值参数,而函数返回值是右值,所以就会匹配到这个构造函数。
这里的A&&可以看作是临时值的标识,对于临时值我们仅仅需要做浅拷贝即可,无需再做深拷贝,从而解决了前面提到的临时变量拷贝构造产生的性能损失的问题。这就是所谓的移动语义,右值引用的一个重要作用是用来支持移动语义的。
需要注意的一个细节是,我们提供移动构造函数的同时也会提供一个拷贝构造函数,以防止移动不成功的时候还能拷贝构造,使我们的代码更安全。
class A { public: A(): m_ptr(new int(0)){}
A(const A& a): m_ptr(new int(*a.m_ptr)) { cout << "copy construct" << endl; }
A(A&& a): m_ptr(a.m_ptr) // 没有发生类型推断,是确定的右值引用类型 { a.m_ptr = nullptr; cout << "move construct" << endl; }
~A(){ delete m_ptr;}
private: int* m_ptr; };
int main(){ A a = Get(false); }
std::move()
我们知道移动语义是通过”右值引用“来匹配临时值的,那么,普通的左值是否也能借助移动语义来 优化性能 呢,那该怎么做呢?
事实上C++11为了解决这个问题,提供了std::move方法来将左值转换为右值,从而方便应用移动语义。
move是将对象资源的所有权从一个对象转移到另一个对象,只是转移,没有内存的拷贝,这就是所谓的move语义。
{ std::list< std::string> tokens; //省略初始化... std::list< std::string> t = tokens; //这里存在拷贝 }
std::list< std::string> tokens; std::list< std::string> t = std::move(tokens); //这里没有拷贝
四、第4行代码故事
template <typename T>void f(T&& val){ foo(std::forward<T>(val)); }
完美转发
C++11引入了完美转发:在函数模板中,完全依照模板的参数的类型(即保持参数的左值、右值特征),将参数传递给函数模板中调用的另外一个函数。
C++11中的std::forward正是做这个事情的,他会按照参数的实际类型进行转发。
forward转发
/* todo */
这部分,非常依赖模板的使用。在模板章节进一步学习。
End.