跳转至

6右值引用

左值和右值

如何判断

以等号左右作为区分?

最简单的字面理解:等号左边为左值,等号右边为右值。
如此判断,以下代码是没错的。

int x = 1;
int y = 3;
int z = x + y;

但情况可能会更复杂一些,这个判定规则不准确

int a = 1; //a是左值
int b = a; //等号右边的a就不是右值

能否取地址

但凡能取地址的就是一个左值。

字符串字面量是左值

通常字面量都是一个右值,除字符串字面量以外

  1. 编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会为其开辟空间,来存储这一段字符串
auto p = &"hello world"; //p的类型是const char*
  1. 但其他的字面量都不会开辟空间,不会提前把值存下来。而是直接赋值给变量
int a = 200000000; 
//200000000不会事先存下来
//而是在程序运行时,直接赋值给a变量

练习

x++; //右值
/* 后加加
1. 先把x的值存到一个临时变量中
2. 再对x自增1
3. 然后返回临时变量。临时变量是一个将亡值,是一个右值
 */

++x; //左值
/* 前加加
1. 先对x自增1
2. 然后返回x
 */
int x = 1; //x左值; 1是右值
int get_val() {
    return x;
} //返回时会发生一次复制,到一个临时变量上,因此get_val()是右值
void set_val(int val) { //val左值,生命周期是函数内
    x = val;
}
int main() {
    int y = get_val(); //y是左值
    set_val(6);   //6是右值
}
void set_val2(const int& val) { 
    //set_val2(6),
    //val是一个右值引用,但是val却是左值
    auto p = &val; //因为它可以被取地址
    x = val;
}

set_val2(6); //6是右值

解释

  1. 函数参数都是左值
  2. 因为右值引用是变量类型

左值引用

左值引用让C++编程在一定程度上脱离了危险的指针。

非常量左值引用

non-const左值引用,引用的必须是一个左值,右值不行。

  • non-const意味着可能会修改这块内存,因此它必须指向一块内存,而右值是没有内存的
//右值不行,编译错误
//  因为int&代表之后可能会修改x1,但7没有内存
int& x1 = 7; 

//必须是一个左值才行
int a = 1;
int& x2 = a;

常量左值引用

const左值引用,左值、右值都可以绑定

  • const意味着肯定不会修改这块内存,因此可以指向一个右值
const int& x = 11; //右值也行,编译不会报错
//因此,右值11的生命周期被延长。在后续的代码中,可以简单理解为:11会替代x
int y = x;

const int x2 = 11;
//然而,这里的右值11,当语句结束后,就会被销毁

【作用】通常函数的形参都是常量左值引用,因为这样,它就又能接收左值、也能接收右值,还能避免拷贝

class X{
public:
    X() {}
    X(const X&) {}
    X& operator=(const X&) { return *this; }
};
X make_x() { return X(); }  //make_x()返回的是右值

int main() {
    X x1;
    X x2(x1);
    X x3(make_x());
    x3 = make_x();
}

【缺点】常量性,因此右值引用诞生了

  • 一旦使用了常量左值引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除外)

右值引用

右值引用只能引用右值,在类型后面加$$

int i = 0;
int& j = i;   //左值引用
int&& k - 11; //右值引用(延长右值的生命周期)

//编译报错,因为右值引用只能引用右值
int&& k = i ;

作用:避免重复构造

对于字面量来说,右值引用可能看不出效果。但,对于临时对象,右值引用就很有用处了,可以 避免重复构造

#include<iostream>
class X{
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X& x) { std::cout << "X copy ctor" << std::endl; }
    ~X() { std::cout << "X dtor" << std::endl; }
    void show() { std::cout << "show X" << std::endl; }
};

一、禁用所有编译器优化,X应被构造了三次

X make_x() {
    X x1;   //x1的构造。输出:X ctor
    return x1;
}
int main() {
    X x2 = make_x();
    /*
    make_x()
        1. 将x1拷贝给一个临时对象tmp。输出:X copy ctor
        2. x1生命周期结束,被析构。输出:X dtor
    X x2 = tmp;
        1. tmp拷贝给x2。输出:X copy ctor
        2. tmp析构。输出:X dtor
     */
    x2.show(); //输出:show x
}
//x2被析构。输出:X dtor

因此输出:

X ctor
X copy ctor
X dtor
X copy ctor
X dtor
show x
X dtor

二、开启函数返回值优化

X make_x() {
    X x1;  //输出:X ctor
    return x1;
}
int main() {
    X x2 = make_x();
    //编译器的返回值优化:x1的内存直接给了x2。因此这里没有触发任何构造
    x2.show(); //输出:show X
}
//输出:X dtor

因此输出:

X ctor
show X
X dtor

三、禁用所有编译器优化,但使用右值引用

X make_x() {
    X x1;      //构造x1。输出:X ctor
    return x1;
}
int main() {
    X&& x2 = make_x();
    /*
    make_x()
        1. 将x1拷贝给一个临时对象tmp。因此输出:X copy ctor
        2. x1完成使命,被析构。因此输出:X dtor
    X&& x2 = tmp; x2是tmp的右值引用
        tmp是一个临时对象,因此是将亡值,将亡值是右值
        x2对此将亡值进行引用
     */
    x2.show(); //输出:show X
}
//右值引用x2生命周期结束。输出:X dtor

因此输出:

X ctor
X copy ctor
X dtor
show X
X dtor

【综上】右值引用延长了右值的生命周期,从而减少了对象的复制,提升程序的性能

【如何关闭函数返回值优化?】

  1. GCC加命令行参数:-fno-elide-constructors
  2. CMake: set(CMAKE_CXX_FLAGS "-fno-elide-constructors")

移动语义

引言

如上面的例子上所谈,在没有编译器优化的情况下,会发生三次拷贝。如果X是一个大对象,那是一个极大的开销、数据复制也很慢,频繁申请和释放内存还很容易产生内存碎片。

class BigMemoryPool {
public:
    static const int PoolSize = 4096; 
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool() { if (pool_ != nullptr) delete[] pool_; }
    BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize]) {
        std::cout << "copy big memory pool." << std::endl; //一次拷贝就4kb
        memcpy(pool_, other.pool_, PoolSize);
    }
private:
    char* pool_;
};
BigMemoryPool get_pool(const BigMemoryPool& pool) {
    return pool; //copy
}
BigMemoryPool make_pool() {
    BigMemoryPool pool;
    return get_pool(pool);
}
int main() {
    BigMemoryPool my_pool = make_pool();
}

C++的复制构造函数通常被设计为一个深拷贝,但对于某些情况,是没有必要进行深拷贝的。
比如以下情况

BigMemoryPool b;
{
    BigMemoryPool a;
    b = a;
}
  • a即将被销毁,因此a里的pool_很快就不要用了
  • 那么b=a能不能不触发深拷贝,用浅拷贝即可,将a的内存偷过来?

移动语义

加一个移动构造函数即可

  • 在移动构造函数中,用浅拷贝即可
  • 赋值时
    • b = std::move(a)即可触发移动构造函数,而不是拷贝构造函数
    • 或者,如果a是一个右值,也会触发移动构造函数
class BigMemoryPool{
public:
    //要偷人家东西,不能有const,因此要传non-const的右值引用类型
    BigMemoryPool(BigMemoryPool&& other) noexcept {
        std::cout << "move big memory pool." << std::endl;
        pool_ = other.pool_;   //将other的内存指针直接赋值过来
        other.pool_ = nullptr; //将ohter的置空
    }
}

BigMemoryPool b;
{
    BigMemoryPool a;
    b = std::move(a);
}

【移动】移动,就是把变量内部所管理的内存移动出来。

【默认的移动构造函数】同复制构造函数一样,编译器在一些条件下会生成一份移动构造函数

这些条件包括

  1. 没有任何的复制函数,包括复制构造函数和复制赋值函数
  2. 没有任何的移动函数,包括移动构造函数和移动赋值函数
  3. 没有析构函数

虽然这些条件严苛。但默认的移动构造函数和默认的复制构造函数并没有什么区别,因此不必对编译器提供的默认移动构造函数由太多期待。

noexcept】虽然使用移动语义在性能上有很大收益,但却有很大风险,这些风险来自异常

  • 如果在移动构造函数中发生了异常,一部分资源移动成功,而有一部分资源移动失败
  • 这会造成源对象和目标对象都不完整,这种情况无法预测
  • 因此需要在编写移动语义函数时,建议确保函数不会抛出异常
  • 如果不能确保函数会不会抛出异常,可使用noexcept限制该函数。如此,当函数抛异常时,程序会调用std::terminate强行中止

值类型

“值类型”(左值、右值)是表达式的一种属性

  • 右值不能被取地址;而左值可以
  • 纯右值,即包括非字符串的字面量,如1、1.0等
graph TD
    expression表达式 --> glvalue泛左值
    expression表达式 --> rvalue右值

    glvalue泛左值 --> lvalue左值
    glvalue泛左值 --> xvalue将亡值
    rvalue右值 --> xvalue将亡值
    rvalue右值 --> prvalue纯右值
struct Point
{
    float x;
    float y;
};

//将亡值(临时对象)属于右值的范畴
auto* p = &Point(); //编译报错:error: taking address of rvalue [-fpermissive]

//使用右值引用,可以延续将亡值的生命周期,因为将亡值也有实际的一块内存
const auto& a = Point(); //引用完之后,将亡值又可以接着用,因此又是泛左值的概念
auto* p = &a; //因为可以对它取地址

书中原文(“评估”翻译成“求值”更好)

  1. 所谓泛左值是指一个通过评估(求值)能够确定对象、位域或函数标识的表达式。简单来说,它确定了对象或者函数的标识(具名对象)
  2. 纯右值是指一个通过评估(求值)能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式
  3. 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用的转换产生的
  4. ……

如何产生将亡值

第一种:使用类型转换将泛左值转换为该类型的右值引用

static_cast<BigMemoryPool&&>(my_pool);

第二种:在C++17中引入,称它为临时量实质化,指的是纯右值转换到临时对象的过程