美文网首页C语言C语言&嵌入式IT狗工作室
第1篇:C++中的左值,右值和引用

第1篇:C++中的左值,右值和引用

作者: 铁甲万能狗 | 来源:发表于2019-12-13 12:06 被阅读0次

    本篇,会详细讨论到左值(l-value)右值(r-value)以及和引用(reference),很多C++读物将它们归类为C++中的基础语法部份,我觉得不太妥当。任何学习C++的读者,从很多实例中零散地的例子中知道左值,右值和引用这些语法层面上的概念。很少将他们进行归纳和比较。

    为什么我要特意开一篇来写他们呢?

    • 首先,因为充分理解和对比他们对你深刻理解C++类构造函数和读懂编译器在编译过程中提示一些怪异的警告和错误必须用到这些知识。
    • 其次, C++中有一个称为左值引用(l-value reference),在编程中经常用到这种特性,所以了解本篇的内容非常必要。

    什么是左值/右值?
    左值:就是一个数据对象,它在内存中的位置中有一个可识别的地址,通常左值是一个变量,因此就具有一个特定的内存地址和对应数据类型的值域.

    右值:任何对象只要不是左值,它就是右值。

    从内存管理的角度来分析左值和右值,我在以前的文章已经多次提到一个非常重要的概念,数据对象,它包含两个基本属性

    • 数据值(value):仅含该属性的数据对象就是右值。
    • 存储地址 (storage location):存在该属性的一定是左值

    示例如下,变量d是一个float类型,它有对应的内存地址,所以就是一个左值。

    float d;            //d是一个左值
    float *f=&d;  //d的
    

    进一步,用户自定义类型,例如Person实例的变量p是一个左值,能够使用&取址符获取它的内存地址。推而广之,C++中绝大部分变量都是左值(这个不能作为一个恒定成立的结论)

    class Person{}
    
    Person p;//p是一个左值
    

    说完左值的示例,我们看看右值的示例,148这个数字字面量就是一个右值,换句话说“单个的字面量一定是右值”因为单个的字面量本身并不表示内存中特定的位置。

    int k=148; //右值,因为没有内存地址
    size_t j=k+23; //右值,k+23这个表达式并不具备内存地址。
    

    因此,下面的示例是错误的。C++编译器无法编译通过。

    size_t *p=&(k+23);
    
    

    备注:什么叫“字面量”?就是表示一种文字或数字的符号或度量值。例如1元,就是表示10毛钱的字面量值。

    整数148其实就是某个位置中占4字节尺寸的内存中的二进制数据值的表示形式。

    图1

    同样,对于用户自定义类型类型来说,Person()是一个默认的构造函数,它就是一个右值,而per变量是一个包含其内存地址的左值。

    class Person{};
    Person per=Person();
    

    表达式的左/右问题

    更进一步,对于函数来说,例如

    double mul(double x,double y){return x*y;}
    double r=mul(23,44);
    

    其实函数的本质也是一个表达式,而表达式中传入具体的数字字面量,其计算结果也是一个具体数字字面量,因此,mul(23,44)这样的表达式当然就是一个右值

    那么你认为一切用运算操作符连接不同变量的都是右值吗?答案是否的!

    当我们判断表达式的时候,要依据表达式的含义是否具备表示某个内存区域这层含义,也就是说若能用取之运算符"&"能获取该表达式的内存地址,并且编译器也不报错的话,那该表达式就是左值,反之就是右值,当仅能表示右值时,也就是表达式只能表示运算后某个具体的值

    考虑如下简单的数组

    int arr[5];
    

    arr是左值还是右值?判断这个问题,你要看arr本身它所处的上下文和算术运算符的场景。例如下面的表达式就是一个右值,。

    arr+i;
    

    因为int arr[5];这条声音语句本身就告知编译器在栈中分配一段连续的5倍int类型尺寸的内存块,也就是每个数组元素在运行时就有固定的内存地址,它表示元素第i个元素的内存地址值。而等价的语句,就类似下面语句

    int *p=arr+i;
    

    同样,你可以用取址操作符"&",检验类似怪异的语句&(arr+i),编译器是会报错的,也同样证明arr+i是一个右值。

    那么我们看看这样的数组解引操作*(a+i),这里就是一个左值,因为它其实等价于上面那个隐含语句中int指针变量p,也等介于arr[i],也就是说arr[i]这个表达式就是一个左值。同理,我们也可以用&(*(a+i))&arr[i]这些表达式能够取得对应的元素的内存地址得到佐证。

    左值引用

    因为i是一个左值,而r又是对该左值的一个引用,所以称为左值引用

    int i;
    int & r=i;
    

    由上面的定义引申到函数中的左值引用相关的参数,例如

    #include <math.h>
    
    double triangle(double & a,double &b,double &c){
      if(a<0 || b<0 || c<0 || a+b<=c || b+c<=a || a+c<=b ){
            std::cout<<"这个不是有效的三角形"<<std::endl;
            return -1;
        }
        float k=(a+b+c)/2.0;
        return sqrt(k*(k-a)*(k-b)*(k-c));
    }
    

    当你尝试这么调用,这是错误的,你能够理解个中的原由吗?

    int main(void){
        triangle(3,4,5);
    }
    

    运行错误的原因"无法绑定的非常量左值类引用",如果你是第一次接触引用的话,这个错误提示会有些晦涩难懂的。

    错误的根源还是由于我们向函数传递的3,4,5这是本身是一个单纯的右值。而double triangle(double & a,double &b,double &c)函数签名的参数类型是左值引用,类型不匹配,所以C++编译器会报错。

    图2
    规律1:非常量左值引用仅能绑定到左值,永远不会绑定右值

    若在函数调用之前,你将调用代码改成这样。编译器可以编译通过。你应该知道什么原因了吧!

    int main(void){
      double a,b,c=3,4,5; 
      triangle(a,b,c);
    }
    

    因为在调用前,显示定义了对应类型的左值也就是int类型的变量a,b,c,而调用该triangle函数传入这些参数,C++编译的语法检查知道这是对这些参数的一个左值引用。因此语法上没问题的。

    常量左值引用

    那么有什么方法可以做到直接用右值的方式传递给参数类型为左值引用的函数?答案当然是常量左值引用

    我们将函数的每个左值引用的参数前用const关键字修饰,即

    #include <math.h>
    
    double triangle(const double & a,const double &b,const double &c){
        ....代码略....
    }
    

    然后,我们像第一次一样,直接用右值的数字字面量传递给调用的triangle函数

    triangle(3,4,5);
    

    这次运行是没问题的。这里编译器做了一些什么操作吗?我们现在考虑一下这样的一条简单的语句,这是一个常量左值引用的初始化语句。

    const int &r=5;
    

    其反编译代码如下,首先这里可以分析到很多重要的细节


    图3
    • 首先 赋值符号右边的数字字面量5是是右值,编译器底层通过mov指令将字面量5加载到RAX寄存器当中的低地址的32位,即逻辑上的EAX寄存器
    • 其次在将EAX寄存器中的字面量保存到-12%rbp的位置,这个是main栈帧的参数域
    • 然后编译器又将位于参数域-12%rbp位置的字面量5加载到RAX寄存器(其实,此时字面量仍然用到是RAX寄存器的低地址的32位),即指令lea -0xc(%rbp),%rax做的事情
    • 最后将RAX寄存器中的字面量5保存到main栈帧的局部变量域(即-8%rbp的位置),对于C++语法来说,此时又回归到const int &r=5;这条初始化语句。r是一个左值引用,换言之他是对一个为int类型的变量的引用,而这个变量在这里隐含创建的,图中局部变量域(-8%rbp的位置)的赋值就是最好的证明。换据换说
       const int &r=5;
      
      等价于
      const int i=5; //隐含创建的局部变量
      const int &r=i; //常量的左值引用
      

    OK,说到这里,我应该对左值引用的C++底层操作说得很清楚了,可能又有人蹦出来问,栈中为什么中间有空出的位置,这些问题,我都懒得再回答,你搞清楚内存对齐和栈的内存管理再来看本文吧,我以前的相关文章都说得很清楚。

    我们从汇编的角度来再次解析一下左值和右值

    • 位于随机访问内存(RAM)的数据对象左值,因为此时的数据对象具有明确存储位置(也就是内存地址).
    • 当通过mov指令从RAM中的数据对象加载到寄存器,位于寄存器中数据值就是右值

    备注:要注意,寄存器本身不是内存寻址模型的一部分

    临时对象与引用问题

    #include <iostream>
    #include <stdio.h>
    
    class Foo{
    public:
        int num;
        Foo(int i){
            num=i;
            std::cout<<"Foo object"<<std::endl;
        }
    };
    
    Foo makeFoo(){
        return Foo(100);
    }
    
    

    调用代码

    int main(void){
          Foo &my=makeFOO();
    }
    

    Ok,首先,你先不要将这段代码编译,你通过我们前文所说的内容,你自己思考一下,这段代码能否运行吗?若不能运行的原因是什么?

    其实,这个问题仍然是上面左值引用中提到的问题一样,只是换了一种问题的形式。这段代码的问题和上文图2,C++编译器给出的错误原因是一样的。

    首先my是一个对Foo类型的非常量左值引用,而makeFoo是一个返回临时对象的Foo对象,它是一个右值,之前我们已经给出了一个规律了,请牢记,重要的事情说三遍

    非常量左值引用永远无法绑定右值!!...囧
    非常量左值引用永远无法绑定右值!!...囧
    非常量左值引用永远无法绑定右值!!...囧

    但现在的重点是临时对象,什么是临时变量?,就是没有变量名的对象。因为一个函数栈执行完成后,这些东西都会比其他后面要执行的函数栈所覆盖。由于临时对象的生命周期会在函数执行完后销毁,所以之就是非常量左值引用无法绑定右值的原因

    规则2:非常量引用不能绑定到临时变量,因为临时变量是右值,这牢记这是C++标准中的规定.

    C++标准规定,将临时对象绑定到对const的引用会将临时对象的生存期延长到引用本身的生存期,从而避免了常见的悬挂引用错误。这就是所谓生命周期延长(Lifetime Extension)

    上面的示例,我们通过常量左值引用绑定makeFoo()返回的临时对象,将在当前函数体中的花括号结束处销毁,而不是在分号处.

    int main()
    {
        const Foo& f=makeFoo();
        
        printf("f object:%p\n",&f);
        printf("f num:%d\n",f.num);
        printf("f num address:%p\n",&f.num);
        return 0;
    }
    

    makeFoo()返回的临时对象绑定到常量左值引用f,请注意,f只是返回的临时对象的别名,而不是单独的对象。

    为何要区分左值和右值?

    这个问题,应该很多C++读者都会提出过的疑问?但很少C++读物较为完整性地回答这个问题。

    int i=1; //i是一个左值
    int  x=i+2 //此时i+2这个表达式就是右值
    

    左值就是用来创建右值,因为左值和其他类型相容的左值作为其他运算符的操作数,就构成表达式,例如示例中的i+2就是一个右值。从汇编的角度来说,这些表达式最终会转换为一个或多个寄存器中的数据值参与运算符运算的汇编指令。我们说过位于寄存器的数据值就是右值,就是这个原因。

    再看看,这个示例

    int i=73;
    int x=i;
    

    读者再思考一下i是左值还是右值?如果你还不明确,建议你再看会前文。
    其实是很明显的i是左值,因为i位于随机访问内存中,具有明确的内存地址。但作为操作数参与赋值操作符号,会隐式地转换为右值,也就是此时变量i的值位于寄存器当中(一般会是RAX寄存器)。

    规律:左值可以隐含地转换为右值,但是右值是不能转换为左值

    我们在看看数组的例子,a+3是一个右值,但当我们尝试解引后,它就是一个左值,因为*(a+3)具有一个明确的内存地址,我们通过*(a+3)这个表达式可以向该内存地址所指的位置分配一个不同的值。

    int a[5]
    *(a+3)=71;
    

    规律:右值可以创建左值

    字符串的左/右值问题

    我们再来看看字符串字面量的左右问题?
    如果你对字符串字面量有足够的理解的话,你应该知道像下面尝试通过地址操作符号"&"获取字符串字面量这样怪异的语法对于C/C++来说成立的

    &"Hello World!!";
    

    因此字符串字面量是左值,不管你在那个作用域相同的字符串出现多少次,都是引用字面量池中同一个字符串字面量的地址。

    Ok,再来,看一下这个例子,这个是左值还是右值?

    "H"
    

    和,下面这个赋值操作符右边的'H'是左值还是右值?

    char c='H';
    

    后记

    这里说的很多了,如果我还有其他内容需要补充的,我会再更新本文。

    相关文章

      网友评论

        本文标题:第1篇:C++中的左值,右值和引用

        本文链接:https://www.haomeiwen.com/subject/dthtnctx.html