[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.

 

posted @ 2019-12-30 16:31  郝壹贰叁  阅读(192)  评论(0编辑  收藏  举报