来点 C++:左值、右值与 Constructor

date
Oct 27, 2021
slug
lvalue_rvalue_and_constructor_cpp
status
Published
tags
C++
summary
谜语人左右值
type
Post
state
over
General Sort

啥是左值右值

左值就是在内存里有确定地址的东西(lvalue is something that points to a specific memory location),正常来讲,也就是变量。
而右值则是没有确定地址的、临时的量(literal constant),就像各种字面量等等。二者有什么基本的规则呢?看几个例子:
int d = 10;   // ok, it is definitely correct!
//int 12 = d; // WRONG!! compiler tell "expression must be a modifiable lvalue"
所以可以看到,赋值运算符的左侧必须是一个 lvalue,如果不是的话编译器将会提醒你“expression must be a modifiable lvalue”,这个地方必须是一个 lvalue

右边必须是 rvalue?

赋值运算符的右侧必须是一个 rvalue 吗?显然不是的:
int d = 10;  // lvalue = rvalue
int f = d;   // lvalue = lvalue
int* g = &f  // lvalue = lvalue

取址符知道

当你尝试:
int* g = &45;  //error: lvalue required as unary ‘&’ operand
聪明的编译器就会 declare: error: lvalue required as unary ‘&’ operand
所以说取址符知道如何分辨 lvalue 与 rvalue:它能够取到地址的就是 lvalue,反之则为 rvalue

函数返回值中的 l 与 r

这里有一个函数:
int GetSix()
{
    return 6;
}
我们来用它做一些实验:
int d = GetSix();  // It is definitely correct
//GetSix() = 4;   // WRONG!!! Compiler say: "lvalue required as left operand of assignment"
这个函数的返回值是一个 rvalue,它是临时的,没有办法置于赋值运算符的左侧。

返回值为引用类型

当我们把函数改造成这样:
int& GetSix()
{
    int static _temp = 6;
    return _temp;
}
再去做如上的实验,都是没有错误、完全可行的。为什么?
当我们说引用类型的时候,在引用什么?引用的就是一个已经存在的、确定的内存地址。所以:
  • 首先,先说说第一句。这句 int d = GetSix() 还是将返回的引用类型的值(当然是 lvalue赋付给了变量 d
  • 第二句。首先明确一点,这个函数是将指向函数 GetSix() 中的 static 类型变量的一个引用作为返回值给了出去。这个引用是一个 lvalue,故这个语句也是成立的
效果就是函数中的变量 _temp 的值变为了 4

左值引用

左值引用讲述了一个什么样的事情呢?其实很简单,就是只有 lvalue 才能被 reference。
一个 rvalue 连内存地址都没有,去哪里引用呢?😂
int hello = 5;
int& helloRef = hello;  // It is definitely correct
// int& hiRef = 5;      // WRONG!!!
编译器将在第二行报错:cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’
这个例子也是一样的:
void ReceiveRef(int& _ref)  { //pass }

/* invoke: */
int d = 3;
ReceiveRef(d);    //Correct, 'd' is a lvalue
//ReceiveRef(5);  //WRONG!!!!!!

左值变右值

就像隐式转换一样,这是一件很自然的事情,举个例子:
int d = 4, f = 5;
int g = d + f;  // lvalue = lvalue + lvalue
加法运算符的两侧都需要是 rvalue,但是在这里却是两个 lvalue,就是在运算过程中很自然的变化成了 rvalue。
那 rvalue 能(自然地)变为 lvalue 吗?很遗憾,不可以。但是允许将 rvalue 变为一个 const lvalue,这也是左值引用中例子报错中显示不能将 rvalue 赋给 non-const lvalue 的原因

const reference

尝试:
const int& d = 4;  //Corrcet
//int& d = 4;      //WRONG!!!
这里其实是编译器创造了内存空间然后再做为 lvalue 赋给了 d,(the compiler creates an hidden variable for you)也就是相当于:
int _hidden = 4;
const int& d = _hidden;
当然,为什么设为 const 之后就可以了呢。const 类型的引用是只读的,也就是这个内存空间虽然有了,但是不可更改,这是一件符合自然直觉的事情。

右值引用

在 C++11 中引入了右值引用,右值引用只能引用 rvalue 不能引用 lvalue:
int&& rRef = 5;  // Correct!!

int d = 4;
//int&& rRef = d;  //WRONG!!!!

左值变右值

使用 std::move() 可以将一个 lvalue 强制转化为一个 rvalue 返回。但是这里有几个谜语人一样的点需要注意:
  • std::move(d) 本身是一个 rvalue:
    • std::move(d) 的返回类型是 int&&,也就是一个 rvalue。当它没有赋给任何 int&& 类型的变量的时候,它本身就是一个 rvalue
    • 如果使用 int&& rRef = std::move(d) 那么这个右值引用变量 rRef 实则是一个 lvalue。因为它是一个有内存地址的变量。
这篇文章中有一个直观的例子:
// 形参是个右值引用
void change(int&& right_value) {
    right_value = 8;
}
 
int main() {
    int a = 5; // a是个左值
    int &ref_a_left = a; // ref_a_left是个左值引用
    int &&ref_a_right = std::move(a); // ref_a_right是个右值引用
 
    change(a); // 编译不过,a是左值,change参数要求右值
    change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
    change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值
     
    change(std::move(a)); // 编译通过
    change(std::move(ref_a_right)); // 编译通过
    change(std::move(ref_a_left)); // 编译通过
 
    change(5); // 当然可以直接接右值,编译通过
     
    cout << &a << ' ';
    cout << &ref_a_left << ' ';
    cout << &ref_a_right;
    // 打印这三个左值的地址,都是一样的
}

在 constructor 中

其实分清楚 lvalue 与 rvalue 就是因为这个问题一直没有搞懂。用下面的代码做实验:
🔑
注意编译时一定要使用参数 -fno-elide-constructors-O0 以禁用对于构造函数的优化
#include <iostream>
using namespace std;

class A {
public:
    int x;
    A(int x) : x(x)
    {
        cout << "Constructor" << endl;
    }
    A(A& a) : x(a.x)
    {
        cout << "Copy Constructor" << endl;
    }
    A& operator=(A& a)
    {
        x = a.x;
        cout << "Copy Assignment operator" << endl;
        return *this;
    }
    A(A&& a) : x(a.x)
    {
        cout << "Move Constructor" << endl;
    }
    A& operator=(A&& a)
    {
        x = a.x;
        cout << "Move Assignment operator" << endl;
        return *this;
    }
};

A GetA()
{
    return A(1);
}

A&& MoveA(A& a)
{
    return std::move(a);
}

int main()
{
    cout << "-------------------------1-------------------------" << endl;
    A a(1);
    cout << "-------------------------2-------------------------" << endl;
    A b = a;
    cout << "-------------------------3-------------------------" << endl;
    A c(a);
    cout << "-------------------------4-------------------------" << endl;
    b = a;
    cout << "-------------------------5-------------------------" << endl;
    A d = A(1);
    cout << "-------------------------6-------------------------" << endl;
    A e = std::move(a);
    cout << "-------------------------7-------------------------" << endl;
    A f = GetA();
    cout << "-------------------------8-------------------------" << endl;
    A&& g = MoveA(f);
    cout << "-------------------------9-------------------------" << endl;
    d = A(1);
}
结果如下:
-------------------------1-------------------------
Constructor
-------------------------2-------------------------
Copy Constructor
-------------------------3-------------------------
Copy Constructor
-------------------------4-------------------------
Copy Assignment operator
-------------------------5-------------------------
Constructor
Move Constructor
-------------------------6-------------------------
Move Constructor
-------------------------7-------------------------
Constructor
Move Constructor
Move Constructor
-------------------------8-------------------------
-------------------------9-------------------------
Constructor
Move Assignment operator
逐个情况分析吧:
  • 第一个。就是普通的初始化,constructor 没什么问题
  • 第二个。是首先创建了新实例 b,然后用 a 去初始化它。有新实例产生,那就需要构造,所以是使用 copy constructor
  • 第三个。与第二个一样的,有新实例产生,所以使用 copy constructor
  • 第四个。与前面不一样了,这里的 b 不是新的实例,所以不需要构造,所以直接使用 copy assign operator
  • 第五个:
    • 有新实例被创造,需要构造
    • 而且使用 rvalue 来实例化了一个临时对象 A(1),可以认为也是一个 rvalue。赋完值是没有地方去的,所以直接 move
    • 综合就是:A(1) 使用了 constructor;后面的过程使用了 move constructor
  • 第六个:
    • 有实例被创造,需要构造
    • 使用 std::move()a 转化为一个 rvalue,随后赋给 e
    • 综合使用 move constructor
🔥
用 rvalue 初始化一个新实例,使用 move constructor
  • 第七个:与第五个如出一辙,但是多了一个 move constructor,是从哪里来的呢?
    • 第一个是当函数 return 的时候 A(1) 作为 rvalue 进行了一次 move constructor 来返回
    • 第二个就是函数的返回值作为 rvalue 进行了一次初始化新实例 f 的 move constructor
  • 第八个:
    • 首先没有新实例被创造出来,所以不需要构造;
    • 其次,将 a 变为 rvalue 之后一直是作为引用的形式进行传参,也不牵涉 move, copy 等
    • 所以什么都没有
  • 第九个:
    • 有新的临时实例 A(1) 被创造,所以有 constructor
    • 使用临时实例这个 rvalue 来更新 d,所以是 move constructor(参考我们在五、六中得出的结论)
    •  

Reference(是参考不是引用)

一文读懂C++右值引用和std::move
作者:rickonji 冀铭哲 C++11引入了右值引用,有一定的理解成本,工作中发现不少同事对右值引用理解不深,认为右值引用性能更高等等。本文从实用角度出发,用尽量通俗易懂的语言讲清左右值引用的原理,性能分析及其应用场景,帮助大家在日常编程中用好右值引用和std::move。 首先不考虑引用以减少干扰,可以从2个角度判断:左值 可以取地址、位于等号左边;而右值 没法取地址,位于等号右边 。 a可以通过 & 取地址,位于等号左边,所以a是左值。 5位于等号右边,5没法通过 & 取地址,所以5是个右值。 再举个例子: 同样的,a可以通过 & 取地址,位于等号左边,所以a是左值。 A()是个临时值,没法通过 & 取地址,位于等号右边,所以A()是个右值。 可见左右值的概念很清晰,有地址的变量就是左值,没有地址的字面值、临时值就是右值。 引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。 个人认为,引用出现的本意是为了降低C语言指针的使用难度,但现在指针+左右值引用共同存在,反而大大增加了学习和理解成本。 左值引用大家都很熟悉, 能指向左值,不能指向右值的就是左值引用 : 引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。 但是,const左值引用是可以指向右值的: const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用 const &作为函数参数的原因之一,如 std::vector的 push_back : 如果没有 const, vec.push_back(5) 这样的代码就无法编译通过了。 再看下右值引用,右值引用的标志是&&,顾名思义,右值引用专门为右值而生, 可以指向右值,不能指向左值 : 下边的论述比较复杂,也是本文的核心,对理解这些概念非常重要。 有办法, std::move : 在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。 std::move是一个非常有迷惑性的函数,不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量, 但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换: static_cast (lvalue)。
一文读懂C++右值引用和std::move

🤣 很同意:
notion image

© CHEN Shu 2021 - 2024