左值和左值引用、右值和右值引用

1. 左值和右值

  • 左值(L-value):能用“取地址&”运算符获得对象的内存地址,表达式结束后依然存在的持久化对象。左值可以出现在等号左边也能够出现在等号右边。
  • 右值(R-value):不能用“取地址&”运算符获得对象的内存地址,表达式结束后就不再存在的临时对象。只能出现在等号右边。

   - 可以做出以下三点理解:

     1)当一个对象被用作右值的时候,用的是对象的值(内容);而被用作左值的时候,用的是对象的身份(在内存中的位置)。总之:左值看地址,右值看内容。

     2)所有的具名变量或者对象都是左值,而右值不具名,如常见的右值有非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等。

        很难得到左值和右值的真正定义,但是有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值。

     3)右值要么是字面常量,要么是在表达式求值过程中创建的对象。

     特例:因为可以用&取得字符串字面值常量的地址,虽然它不能被赋值,但它是一个左值。

int main()
{
    char *p = "1234";
    printf("%d\n", p);
    printf("%d\n", &"1234");
}

   - 为什么右值不能用&取地址呢?

     1)对于临时对象,它可以存储于寄存器中,所以没办法用“取地址&”运算符;

     2)对于(非字符串)常量,它可能被编码到机器指令的“立即数”中,所以没办法用“取地址&”运算符。

 

2. 左值引用和右值引用

   使用引用的目的就在于减少不必要的拷贝。

  • 左值引用:对左值的引用,就是给左值取别名。其基本语法如下:
Type &引用名 = 左值表达式;

   - 变量名实质上是一段连续存储空间的别名,是一个标号(门牌号),通过变量的名字可以使用存储空间。

   - 对一段连续的内存空间只能取一个别名吗?

     在C++中新增加了引用的概念,引用可以看作一个已定义变量的别名,于是我们就可以通过引用为一个内存空间取多个别名。

int main()
{
    int a = 0;
    int &b = a;
    b = 11;
    return 0;
}

   - 普通引用在声明时必须用其它的变量进行初始化,引用作为函数参数声明时不进行初始化。

struct Teacher
{
    char name[64];
    int age;
};

void printfT(Teacher *pT) { cout << pT->age << endl; }

/*
 * pT是t1的别名, 相当于修改了t1
 */
void printfT2(Teacher &pT) { pT.age = 33; }

/*
 * pT和t1的是两个不同的变量,t1 copy一份数据给pT, 只会修改pT变量 ,不会修改t1变量
 */
void printfT3(Teacher pT) { pT.age = 45; }

int main()
{
    Teacher t1;
    t1.age = 35;
    printfT(&t1);
    printfT2(t1);
    printf("t1.age:%d\n", t1.age)   // 33
    printfT3(t1);
    printf("t1.age:%d\n", t1.age);  //35
    return 0;
}

   - 对于引用语法,C++编译器背后做了什么工作呢?

     首先我们知道引用单独定义时,必须初始化,说明它很像一个常量。又因为引用是一个内存空间的别名所以它可以取地址。

     故我们可以得到引用的本质:

     1)引用在C++中的内部实现是一个常指针Type& name <=> Type* const name

     2) C++编译器在编译过程中使用常指针作为引用的内部实现,因此引用所占用的空间大小与指针相同。

     3) 从使用的角度,引用会让人误会其只是一个别名,没有自己的存储空间。这是C++为了实用性而做出的细节隐藏。

   - 函数返回值是引用(引用当左值)

     当函数返回值为引用时,若返回栈变量,不能成为其它引用的初始值,不能作为左值使用。若返回静态变量或全局变量,

     可以成为其他引用的初始值,即可作为右值使用,也可作为左值使用。

     对于引用的理解可以直接看成指针,因为栈变量在函数结束后,内存空间就被释放了,所以这个指针指向的内容就不对了。

   - 对指针的引用

struct Teacher
{
    char name[64];
    int age;
};

// 指针的引用
int getTe(Teacher* &myp)
{
    myp = (Teacher *)malloc(sizeof(Teacher));
    myp->age = 34;
    return 0;
}

int main()
{
    Teacher *p = NULL;
    getTe(p);
    printf("age:%d\n", p->age);
    return 0;
}

   - 常引用(const T &)

int main()
{
    int a = 10;
    int &b = a;        //普通引用
    const int &c = a;  //常量引用:只能通过c读取a的内存空间

    // 常量引用初始化分为两种
    // 1. 变量 初始化 常量引用
    int x = 20;
    const int& y = x;
    printf("y:%d\n", y);

    // 2. 常量 初始化 常量引用
    // int &m = 10; // 引用是内存空间的别名 字面量10没有内存空间 没有方法做引用
    const int &m = 10; 

    return 0;
}

  const引用结论

    1)Const & int e  相当于 const int * const e

    2)普通引用相当于 int *const e

    3)当使用常量(字面量)这类右值对const引用进行初始化时,C++编译器会为常量值分配空间,并将引用名作为这段空间的别名

       初始化后,将生成一个只读变量。只有常引用才可以用右值表达式初始化,这一点很重要,因为如果不加const,那么这个

       临时的对象是无法进行传递给左值引用的,比如

MyString s = MyString("hello")    // 这个临时对象本身就存在于内存空间,所以无需为这个右值分配空间

     因为MyString("hello")是一个临时对象,即右值,所以MyString实现的拷贝构造函数参数不加const就会报错。

 

  • 右值引用:对右值的引用,就是给右值取别名。其基本语法如下:
Type &&引用名 = 右值表达式;   // 如果是左值表达式,绑定就会出错。这里虽然是个右值引用,但左侧的具名变量本身是个左值

    - 开始介绍右值引用之前,先得了解到底啥是临时对象?

      在C++中创建对象是一个费时、废空间的一个操作,有些固然必不可少,但还有一些对象却在我们不知道的情况下创建了。

      1)以值的方式给函数传参

         给函数传参有两种方式----按值传递和按引用传递。按值传递时,首先将需要传给函数的参数,调用拷贝构造函数创建

         一个副本,所有在函数里的操作都是针对这个副本的,也正是因为这个原因,在函数体里对该副本进行任何操作,都不会影响原参数。

class Test
{
public:
    int a, b;

public:
    Test(Test& t) : a(t.a), b(t.b) { printf("Copy function!\n"); }
    Test(int m = 0,int n = 0) : a(m), b(n) { printf("Construct function!\n"); }
    virtual ~Test() {}

public:
    int GetSum(Test ts)
    {
        int tmp = ts.a + ts.b;
        ts.a = 1000;           //此时修改的是tm的一个副本
        return tmp;
    }
};

int main()
{
    Test tm(10,20);
    printf("Sum = %d \n",tm.GetSum(tm));
    printf("tm.a = %d \n",tm.a);
    return 0;
}

         当函数执行结束后,这个临时的对象就会被销毁了。可以将 int GetSum(Test ts)改成 int GetSum(Test &ts) 来避免产生这个拷贝了。

      2)类型转换生成的临时对象

int main()
{
    Test tm(10,20), sum;
    sum = 1000;  // 调用 Test(int m = 0,int n = 0) 构造函数,还会调用一次赋值运算符
    printf("Sum = %d \n",tm.GetSum(sum));
}

      3)函数返回一个对象

         当函数需要返回一个对象,他会在栈中创建一个临时对象或也叫匿名对象(如果是类对象,则会调用拷贝构造函数),存储函数的返回值。

         这个临时对象在表达式 sum = Double(tm) 结束后就自动销毁了,这个临时对象就是右值。

         按理说下面这个例子中Double函数返回时会触发拷贝构造函数,但实际运行后却没有,猜想是被编译器优化了,可以在编译时设置编译

         选项-fno-elide-constructors用来关闭返回值优化效果。

class Test
{
public:
    int a;

public:
    Test(Test& t) : a(t.a) { printf("Copy Construct!\n"); }
    Test(int m = 0) : a(m) { printf("Construct!\n"); }
    virtual ~Test() {};

public:
    Test& operator=(const Test& t)
    {
        a = t.a;
        printf("Assignment Operator!\n");
        return *this;
    }
};

Test Double(Test& ts)
{
    Test tmp;
    tmp.a = ts.a * 2;
    return tmp;
}

int main()
{
    Test tm(10), sum;
    sum = Double(tm);
    printf("sum.a = %d\n",sum.a);
    return 0; 
}

    - 引入右值引用的目的:右值引用是C++11中新增加的一个很重要的特性,它主要用来解决以下问题。

      1)函数返回临时对象造成不必要的拷贝操作通过使用右值引用,右值不会在表达式结束之后就销毁了,而是会被“续命”,

         的生命周期将会通过右值引用得以延续,和变量的声明周期一样长。

int g_constructCount = 0;
int g_copyConstructCount = 0;
int g_destructCount = 0;
 
class Test
{
public:
    Test() { cout << "construct: " << ++g_constructCount << endl; }
    Test(const Test& a) { cout << "copy construct: " << ++g_copyConstructCount << endl; }
    ~Test() { cout << "destruct: " << ++g_destructCount << endl; }
};
 
Test GetTestObj() { return Test(); }
 
int main() 
{
    Test a = GetTestObj();
    return 0;
}

// 上面代码关掉返回值优化后输出:
construct: 1          // return Test()
copy construct: 1     // 临时对象构造
destruct: 1           // return Test()对象销毁
copy construct: 2     // a对象构造
destruct: 2           // 临时对象销毁
destruct: 3           // a对象销毁

//-------------------------------------------------------------------------------------------------

// 但是如果使用右值引用来接收返回值呢?
int main() 
{
    Test &&a = GetTestObj();
    return 0;
}

// 输出如下
construct: 1          // return Test()
copy construct: 1     // 临时对象构造
destruct: 1           // return Test()对象销毁
destruct: 2           // a这个对象其实就是那个临时对象了,main结束后才销毁

         通过右值引用,比之前少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。

         我们可以利用这个特点做一些性能优化,即避免临时对象的拷贝构造和析构。

      2)通过右值引用传递临时参数:使用字面值(如1、3.15f、true),或者表达式等临时变量作为函数实参传递时,按左值引用传递参数会被编译器阻止。

         而进行值传递时,将产生一个和参数同等大小的副本。C++11提供了右值引用传递参数,不申请局部变量,也不会产生参数副本。

static float  global = 1.111f;

void offset(float &&f) { global += f; }   // 通过右值引用传递参数
void offset(float& f)  { global -= f; }   // 重载了offset函数,而且是左值传递
float getFloat() { return 4.444f; }

int main()
{
    float u = 10.000f;
    cout << "global:" << global << endl;

    offset(3.333f);   // 这里会调用右值引用参数的函数
    cout << "global:" << global << endl;

    offset(getFloat() + 2.222);
    cout << "global:" << global << endl;

    offset(u);        // 执行的是按左值引用的offset函数,右值引用无法初始化为左值.
    cout << "global:" << global << endl;
    return 0;
}

       对于非模板函数,函数参数有确定的类型,右值引用只能与右值绑定,只接收右值实参,可以将它看作是临时变量的别名,不会将临时

         变量再复制1次,和按值传递相比提高了效率。这一点同3)进行区别。

      3)模板函数中如何按照参数的实际类型进行转发:当右值引用和模板结合的时候,就复杂了。T&&并不一定表示右值引用,它可能是个左值

         引用又可能是个右值引用。如果函数模板表示的是右值引用的话,肯定是不能传递左值的,但是事实却是可以。这里的&&是一个未定义的引用类型,

         称为universal references,它必须被初始化,它是左值引用还是右值引用却决于它的初始化,如果它被一个左值初始化,它就是一个左值引用;

         如果被一个右值初始化,它就是一个右值引用。

         注意:只有当发生自动类型推断时(如函数模板的类型自动推导,或auto关键字),&&才是一个universal references

// Test是一个特定的类型,不需要类型推导,所以&&表示右值引用  
template<typename T>
class Test 
{
  Test(Test&& rhs);
};

// 右值引用
void f1(Test&& param);

// 在调用这个f之前,这个vector<T>中的推断类型已经确定了,所以调用f函数的时候没有类型推断了,所以是右值引用
template<typename T>
void f2(std::vector<T>&& param); 

// universal references仅仅发生在 T&& 下面,任何一点附加条件都会使之失效, 所以是右值引用
template<typename T>
void f3(const T&& param);

// 这里T的类型需要推导,所以 && 是一个 universal references
template<typename T>
void f(T&& param);

int main()
{
    int x = 1;
    int && a = 2;
    string str = "hello";
    f(1);               // 参数是右值 T 推导成了int, 所以是int&& param, 右值引用
    f(x);               // 参数是左值 T 推导成了int&, 所以是int&&& param, 折叠成 int&, 左值引用
    f(a);               // 虽然 a 是右值引用,但它还是一个左值,T推导成了int&
    f(str);             // 参数是左值, T 推导成了string&
    f(string("hello")); // 参数是右值, T 推导成了string
    f(std::move(str));  // 参数是右值, T 推导成了string
}

          所以最终还是要看T被推导成什么类型,如果T被推导成了string,那么T&&就是string&&,是个右值引用,如果T被推导为string&

          就会发生类似string& &&的情况,对于这种情况,c++11增加了引用折叠的规则,本质如下:

              所有的引用折叠最终都代表一个引用,要么是左值引用,要么是右值引用。规则就是:

              如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用。

          引用折叠存在四种情形,根据上面的规则我们可以知道:

              1)左值-左值 T& &     <=>   int &

              2)左值-右值 T& &&    <=>   int &

              3)右值-左值 T&& &    <=>   int &

              4)右值-右值 T&& &&   <=>   int &&

          因为1,2,3中都存在一个左值引用。

  

posted @ 2020-05-27 21:37  _yanghh  阅读(3483)  评论(0编辑  收藏  举报