C++ - 左值 & 右值

关于左值和右值的定义

  • lvalue(locator value), 即左值,就是有名字的变量(对象),可以被赋值,可以在多条语句中使用。代表一个在内存中占有确定位置的对象,换句话说就是有一个地址。
  • 而右值呢,就是临时变量(对象),没有名字,只能在一条语句中出现,不能被赋值。在内存中没有确切地址,它们只是在计算的周期临时储存在寄存器中。

简单的例子

1
2
3
4
5
6
7
8
int foo(){
return 1;
}

int main(){
foo() = 2;
}
`
  • 我们编译时会得到报错:

    1
    error: lvalue required as left operand of assignment
  • 意思是赋值的左操作数需要是一个左值。

  • 不是说foo() = 2这句一定是错的。当foo()返回的是左值的时候,这个赋值语句便是合法的。

1
2
3
4
5
6
7
8
int g_var = 1;
int &foo(){
return g_var;
}

int main(){
foo() = 2;
}
  • 上面的代码就是合法的。foo()返回一个引用,这是一个左值。
  • C++的vector、map等容器重载的运算符[] 返回的也是左值,比如我们可以对map[“10086”]进行赋值。

左值引用

1
2
3
4
int a = 1;
int &b = a;
int &c = 2;//错误
string &str = string();//错误
  • 在上面的代码,第二行是正确的,b是一个左值引用,一个左值赋给左值引用,合法。第三行错误,因为2是右值,右值赋给左值引用非法。第四行同样。

右值引用(C++11)

  • 我们有时会看到这样的函数声明:

    1
    void Function(std::vector<int>&& nums);
  • 在参数前面有’&&’符号。这表明nums是一个右值引用。

  • 我们看一段代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    #include <iostream>

    class Intvec{
    public:
    explicit Intvec(size_t num = 0):
    m_size(num), m_data(new int[m_size]){
    log("构造函数");
    }

    ~Intvec(){
    log("析构函数");
    if(m_data){
    delete []m_data;
    m_data = NULL;
    }
    }

    Intvec(const Intvec& other):
    m_size(other.m_size), m_data(new int[m_size]){
    log("拷贝构造函数");
    for(int i = 0; i < m_size; ++i)
    m_data[i] = other.m_data[i];
    }

    Intvec& operator=(const Intvec& other){
    log("赋值运算");
    Intvec tmp(other);
    std::swap(m_size, tmp.m_size);
    std::swap(m_data, tmp.m_data);
    return *this;
    }

    private:
    void log(const char* msg){
    std::cout << "[" << this << "] " << msg << std::endl;
    }
    size_t m_size;
    int* m_data;
    };

    int main(){
    Intvec v1(20);
    Intvec v2;
    printf("++++++++++++++++++\n");
    v2 = v1;
    printf("*******************\n");
    v2 = Intvec(33);
    printf("#####################\n");
    }
    ```

    - 运行后,我们会看到如下结果:
    ```c++
    [0x7fffffffe510] 构造函数
    [0x7fffffffe520] 构造函数
    ++++++++++++++++++
    [0x7fffffffe520] 赋值运算
    [0x7fffffffe4e0] 拷贝构造函数
    [0x7fffffffe4e0] 析构函数
    *******************
    [0x7fffffffe530] 构造函数
    [0x7fffffffe520] 赋值运算
    [0x7fffffffe4e0] 拷贝构造函数
    [0x7fffffffe4e0] 析构函数
    [0x7fffffffe530] 析构函数
    #####################
    [0x7fffffffe520] 析构函数
    [0x7fffffffe510] 析构函数
  • 可以看到,在第二次赋值时,执行了很多工作。尤其是,它有一对额外的构造/析构调用。不幸的是,这是个额外工作,没有任何用,因为在拷贝赋值运算符的内部,另一个临时拷贝的对象在被创建和析构。

  • 在这里还有一个问题,左值引用的赋值函数,被一个右值作为参数也成功运行了。因为重载operator=的参数是一个常量左值引用。

  • 左值引用只能绑定左值,右值引用只能绑定右值,如果绑定的不对,编译就会失败。但是,常量左值引用例外,它可以算是一个“万能”的引用类型,它可以绑定非常量左值、常量左值、右值,而且在绑定右值的时候,常量左值引用还可以像右值引用一样将右值的生命期延长,缺点是,只能读不能改。

  • 我们添加另一个operator=到类中去:

    1
    2
    3
    4
    5
    6
    7
    Intvec& operator=(Intvec&& other)
    {
    log("右值引用的赋值运算");
    std::swap(m_size, other.m_size);
    std::swap(m_data, other.m_data);
    return *this;
    }
  • 然后再执行相同的main函数,会得到

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    [0x7fffffffe510] 构造函数
    [0x7fffffffe520] 构造函数
    ++++++++++++++++++
    [0x7fffffffe520] 赋值运算
    [0x7fffffffe4e0] 拷贝构造函数
    [0x7fffffffe4e0] 析构函数
    *******************
    [0x7fffffffe530] 构造函数
    [0x7fffffffe520] 右值引用的赋值运算
    [0x7fffffffe530] 析构函数
    #####################
    [0x7fffffffe520] 析构函数
    [0x7fffffffe510] 析构函数
  • && 语法是新的右值引用。的确如它名字一样-给我们一个右值的引用,在调用之后将被析构。

  • 我们再来尝试点有趣的事情,把左值引用的operator=注释掉,再编译,会得到:

    1
    error: use of deleted function ‘Intvec& Intvec::operator=(const Intvec&)’
  • 错误指向的是”v2 = v1;”这是因为右值引用的operator=的参数只能是右值,不能是左值。

  • 然后,我们将报错的这一行改成:

    1
    v2 = std::move(v1);
  • 编译运行成功,得到结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [0x7fffffffe510] 构造函数
    [0x7fffffffe520] 构造函数
    ++++++++++++++++++
    [0x7fffffffe520] 右值引用的赋值运算
    *******************
    [0x7fffffffe530] 构造函数
    [0x7fffffffe520] 右值引用的赋值运算
    [0x7fffffffe530] 析构函数
    #####################
    [0x7fffffffe520] 析构函数
    [0x7fffffffe510] 析构函数
  • 因为我们在operator=中是使用std::swap来实现的,我们重写一个main函数,来看看右值引用对对象内容的变化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    int main(){
    Intvec v1(20);
    Intvec v2;
    printf("++++++++++++++++++\n");
    v2 = Intvec(33);//在右值引用运算符重载函数中,对象内容进行了交换。临时创建的Intvec(33)本来作为右值,是不能改变的,但是作为右值引用后,内容发生变化。
    v1.m_data[0] = 5;
    v2.m_data[0] = 9;
    printf("#####################\n");
    v2 = std::move(v1);//std::move作用:将左值转成右值。因为函数内部使用std::swap,这样做会导致v1与v2内容进行了交换
    printf("-------------------\n");
    printf("v1 size: %d v1 data[0]: %d v2 size: %d v2 data[0]: %d\n",
    v1.m_size, v1.m_data[0], v2.m_size, v2.m_data[0]);
    }
  • 结果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [0x7fffffffe510] 构造函数
    [0x7fffffffe520] 构造函数
    ++++++++++++++++++
    [0x7fffffffe530] 构造函数
    [0x7fffffffe520] 右值引用的赋值运算
    [0x7fffffffe530] 析构函数
    #####################
    [0x7fffffffe520] 右值引用的赋值运算
    -------------------
    v1 size: 33 v1 data[0]: 9 v2 size: 20 v2 data[0]: 5
    [0x7fffffffe520] 析构函数
  • 可以看到,右值引用可以对右值更改。

  • 我们将右值引用写成swap而不是复制,是考虑到右值是本就该消亡的值,所以为提升性能,我们没有使用复制,而是将右值的内容”偷”了过来。

  • 当一个左值马上要结束它的生命周期,我们又想将其赋给另一对象时,可以用std::move将其转成右值,再进行赋值,这时会优先调用右值引用的赋值函数,能够有效减少内存的复制。

  • 右值引用和相关的移动语义是C++11标准中引入的最强大的特性之一。这里只是管中窥豹,只可见其一斑。要更深入理解,需要多去阅读源码,查阅资料。

右值引用的意义

  • 直观意义:为临时变量续命,也就是为右值续命,因为右值在表达式结束后就消亡了,如果想继续使用右值,那就会动用昂贵的拷贝构造函数。(关于这部分,推荐一本书《深入理解C++11》)
  • 右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
  • 转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
  • 通过转移语义,临时对象中的资源能够转移其它的对象里。
  • 在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
  • 普通的函数和操作符也可以利用右值引用操作符实现转移语义。

但是这几点总结的不错

  • std::move执行一个无条件的转化到右值。它本身并不移动任何东西;
  • std::forward把其参数转换为右值,仅仅在那个参数被绑定到一个右值时;
  • std::move和std::forward在运行时(runtime)都不做任何事。