C++ 中的左值和右值

425 阅读11分钟

C++中的左值和右值

左值和右值

:tent: C++ 中的左值和右值在不同的时期又不同的定义,本节先从最初的左值右值开始

判断左值右值的方法有好几种,但是都存在一定的局限性.

  1. 根据 = 的位置

:package: 位于 = 左边的就是一个左值,右边的就是右值

int a = 90;

可是说变量 a 就是一个左值, 字面量 90 就是一个右值.但是这样判断存在一个很大的问题,比如下面

int b = 10;
int c = b; 

变量 b 和变量 c 都是左值

  1. 根据是否可以取地址

:key: 可以取到地址的都是左值,否则就是右值

因为 C++ 中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且具有一段相对较长的生命周期. 而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的.

以上的方法还比较简单,但是有一些比较复杂的情况

  1. 根据上下文灵活判断

int x = 1;

int get_val() {
    return x;
}

void set_val(int val) {
    x = val;
}

int main(int argc, char **argv) {

    x++;
    ++x;
    int y = get_val();
    set_val(6);
    
    return 0;
}

首先来看经典问题,x++++x

虽然都是自增运算符,但是却分为不同的左右值,其中 x++ 是右值,因为后置 ++ 操作中,编译器会先生成一份 x 值的临时复制,然后,才对 x (临时复制的版本)进行递增,最后返回临时复制内容. 而前置的则不同,是直接对 x 自增后马上返回自身.

在真实的情况下,可以对 ++x 执行取地址操作,但是不能对 x++ 进行取地址操作

int *p = &++x;
std::cout << std::hex << p << std::endl;

上面代码完全可以正确编译

int *p = &x++;
std::cout << std::hex << p << std::endl;

编译器报错提示如下

main.cpp: In function 'int main(int, char**)':
main.cpp:17:16: error: lvalue required as unary '&' operand
   17 |     int *p = &x++;
      |              

:warning: 这里前置自增和后置自增在这里用来探究左值和右值, 但是大多数的C++编译器最终都把这两个表达式都编译成了相同汇编指令,可以通过 Complier Explore 网站来进行探究

image-20230207204253403

Compiler Explorer官网

get_val() 函数中,该函数返回了一个全局变量 x ,虽然 x 是一个左值,但是经过函数返回变成了一个右值,因为函数返回并不会返回 x 本身,而是 x 的临时复制. 所以 int *p = &get_val(); 也会失败. 对于 set_val() 函数,该函数接受一个参数并且将参数的值赋值到 x,实参 6 是一个右值,但是进入函数后的 val 是一个左值,如果在函数内对 val 使用取地址符,并且不会引起任何问题.

:traffic_light: 最后需要强调的是,一般来说,字面量都是右值,但是,字符串字面量除外.

int main(int argc, char **argv) {

    auto *p = "hello world";

    return 0;
}

代码中的 "hello world" 会被编译到 ELF 文件的 .rodata 数据段,程序在运行时也会为其开辟空间,所以可以使用 & 来获取字符串字面量的内存地址

左值引用和右值引用

左值引用

先说左值引用,比较简单

int a = 10;
int &a_ref = a;

按照 C++ 的规范, 引用必须被初始化,非常量的左值引用也必须引用一个左值, 常量左值引用才可以引用一个右值,当然,也可以绑定到一个左值上(但被绑定的左值不一定是常量),

const int &b = 10;

但从赋值表达式中看不出来有什么太大的作用,但是在形参列表中却有着巨大的作用.一个典型的例子就是拷贝构造函数和拷贝复制运算符函数,通常情况下实现的这两个函数的形参都是一个常量的左值引用

class X {
public:
    X() {}

    X(const X &x) {}

    X &operator=(const X &) {
        return *this;
    }
};

X make_x() {
    return X{};
}

int main(int argc, char **argv) {

    X x1;
    X x2(x1);
    X x3(make_x());

    x3 = make_x();
    
    return 0;
}

以上代码可以正常通过编译,但是如果把拷贝构造函数和拷贝赋值运算符函数中的常量性删除,则 X x3(make_x());x3 = make_x(); 都会编译错误. 因为常量左值引用既可以绑定左值也可以绑定右值, make_x() 返回的是一个右值

 error: cannot bind non-const lvalue reference of type 'X&' to an rvalue of type 'X'

右值引用

右值引用相比于左值引用,在类型申明后面需要添加 &&

int a = 10;
int &b = a;  // 左值引用
int &&c = 11;// 右值引用

:dagger: 右值引用的特点之一就是可以延长右值的生命周期

#include<iostream>

class X {
public:
    X() {
        std::cout << "\033[32m X ctor\033[0m\n";
    }

    X(const X &x) {
        std::cout << "\033[31m X copy ctor\033[0m\n";
    }

    ~X() {
        std::cout << "\033[33m X dtor\033[0m\n";
    }

    void show() {
        std::cout << "\033[34m X show\033[0m\n";
    }
};

X make_x() {
    X x;
    return x;
}

int main(int argc, char **argv) {

    X &&x2 = make_x();
    x2.show();

    return 0;
}

:warning: 注意编译参数,使用 -fno-elide-constructors -std=c++14 指定关闭 RVO 优化和C++标准

X &&x2 = make_x();X x2 = make_x();
X ctor
X copy ctor
X dtor
X show
X dtor
X ctor
X copy ctor
X dtor
X copy ctor
X dtor
X show
X dtor

两者的区别主要在 x2 的产生上,接下来分析一些发生构造的地方.

  1. make_x() 中的 x 会进行默认构造
  2. make_x() 中的 return x 会发生拷贝构造,以产生临时对象

关键不同的在于,使用右值引用的方法不会在创建 x2 的时候进行拷贝构造,而 X x2 = make_x(); 会产生一次拷贝构造

右值引用的优化空间和移动语义

频繁调用拷贝构造的问题

#include <iostream>
#include<cstring>


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 << "\033[31m copy big memory pool \033[0m\n";
        ::memcpy(this->pool_, other.pool_, PoolSize);
    }

private:
    char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool &pool) {
    return pool;
}

BigMemoryPool make_pool() {
    BigMemoryPool bigMemoryPool;
    return get_pool(bigMemoryPool);
}

int main(int argc, char **argv) {

    BigMemoryPool my_pool = make_pool();

    return 0;
}

g++ main.cpp -o main -std=c++14 -fno-elide-constructors
 copy big memory pool 
 copy big memory pool 
 copy big memory pool

三次拷贝构造分别是

  1. get_pool() 返回的临时对象调用拷贝构造函数复制了 pool 对象
  2. make_pool() 返回的BigMemoryPool 临时对象调用复制构造函数复制了 get_pool() 返回的临时对象
  3. main 函数中 my_pool 调用其复制构造函数复制 make_pool() 返回的临时对象

以上代码完全正确并且可以通过编译,但是每一次复制构造都会复制整整 4kb 的数据,如果数据量更大,那么将会对程序造成很大影响

移动语义

上面的代码出现了大量的临时对象的构造和析构以及复制. 如果可以将临时对象的内存直接转移到 make_pool 对象中,不就能消除内存对性能的消耗吗

BigMemoryPool(BigMemoryPool &&other) {
        std::cout << 
          "\033[32m move big memory pool \033[0m \n";
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

BigMemoryPool 添加一个移动构造函数,形参是一个非常量右值引用.

运行结果:

image-20230208134732715

第一个拷贝构造函数的调用是调用 get_pool传参的时候,后面两次是临时对象复制的时候调用的 移动构造函数.

作为对比,比较两个程序运行一百万次的时间,同时注释掉 std::cout

const int times = 100000;
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < times; ++i) {
        BigMemoryPool my_pool = make_pool();
    }

    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> diff = end - start;

    std::cout << "\033[31mTime to call make_pool " << diff.count() << std::endl;

拷贝构造移动构造
0.3063s0.130418s

值的类别、将左值转换为右值

C++11 中的左值和右值

值类别是 C++11 中新引入的概念,具体来说是表达式的一种属性,该属性将表达式分为三个类别,分别是左值、纯右值和将亡值.但是C++11没有清晰的定义他们,直到C++17标准的推出才得到解决

graph TD;
A{表达式 expression}
A -->B[泛左值 glvalue]
A -->C[右值 rvalue]
B -->D(左值 lvalue)
B --> E(将亡值 xvalue)
C -->E
C --> F(纯右值 prvalue)
  1. 所谓的泛左值是指一个通过苹果能够确定对象、位域和函数的表示的表达式.简单来说,它确定了函数或者对象的标识(具名对象)
  2. 而纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式
  3. 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为他们接近生命周期的末尾,另外也有可能是经过右值引用转换产生的(下面会提到将亡值产生的两种情况)

:tada:

C++98 中的左值对应于这里的 左值(lvalue),而 纯右值(prvalue) 对应于 C++98中的右值

将亡值产生的途径

  • 使用类型转换

可以使用类型转换吧把一个泛左值转换为该类型的右值引用

static_cast<BigMemoryPool&&>(my_pool);
  • 临时量实质化

临时量实质化在 C++17 中引入;所谓的临时量实质化是指,纯右值转换到临时对象的过程,比如函数值返回. 每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这里的临时对象就是一个将亡值


struct X {
    int a;
};

int main(int argc, char **argv) {

    int b = X().a;
    return 0;
}

上面的代码中 X() 是一个纯右值,访问其成员变量需要一个泛左值,所以这里就会发生一次临时量的实质化,将 X() 转换为将亡值,最后再访问其成员变量

C++17 标准之前,临时变量是纯右值,只有转换为右值引用的类型才是将亡值


struct X {
    X() {
        std::cout << "X ctor\n";
    }

    X(const X &x) {
        std::cout << "X copy ctor\n";
    }

    int a;
    double b;
};

X get1() {
    return X();
}

X get2() {
    return get1();
}


int main(int argc, char **argv) {

    int b = get2().a;
    return 0;
}

以上代码在关闭 RVO 优化的前提下, C++17标准下编译并运行只有一个构造

在C++14标准下编译并运行有一次构造,两次拷贝

即使在关闭了 RVO 优化后,C++17编译出来的仍然只有一次拷贝. 因为返回的都是右值,而且也没有临时量的初始化.

总结一下就是,如果返回的是一个纯右值,无论调用多少次都不会发生复制,因为纯右值没有实质化,只要在实质化的时候才会将其变成一个将亡值

在 C++14 标准下,如果开始了 RVO 优化,也会只有一次构造,但是这个取决于编译器,但我试了好几个编译器,在开启 RVO 优化时,都和C++17未开启 RVO 优化一致

总结一下就是, 非C++17标准的编译器需要使用 RVO 优化才可以减少复制. C++17标准的编译器默认就不进行复制

将左值转换为右值

如果把右值引用绑定到左值的话,会编译失败,但是可以通过类型转换的方式把一个左值显式的转换成一个将亡值,但是转换之后依然和之前有着相同的声明周期

int i = 0;
int &&k = static_cast<int &&>(i);

这样转换的目的何在? 主要是为了让一个左值可以使用 移动语义

int main(int argc, char **argv) {

    BigMemoryPool my_pool1;
    BigMemoryPool my_pool2 = my_pool1;
    BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1);
    
    return 0;
}

运行结果:

image-20230208154407099

再比如

void move_pool(BigMemoryPool &&pool) {
    std::cout << "call move_pool " << std::endl;
    BigMemoryPool my_pool(pool);
}

int main(int argc, char **argv) {

    move_pool(make_pool());

    return 0;
}

运行结果:

image-20230208160439317

在一个函数内部,参数是一个左值,所以再 move_pool() 内部, my_pool 需要调用 拷贝构造函数进行构造,如果 my_pool 使用移动构造函数进行构造,需要进行类型转换

    BigMemoryPool my_pool(static_cast<BigMemoryPool &&>(pool));

image-20230208160516782

在C++11标准中,标准库提供了一个模版函数 std::move() 来实现将左值转化为右值,内部也是使用了 static_cast<>() 实现的,只不过使用 std::move() 语义更加清晰

万能引用、引用折叠和完美转发

万能引用

上面提到,常量左值引用既可以引用左值又可以引用右值,是一个几乎万能的引用,但可惜的是由于其常量性,导致它的使用范围受到一些限制

void foo(int &&i) {} //i右值引用

template<typename T> //t万能引用
void bar(T &&t) {}

int get_val() {      //
    return 5;
}
int &&x = get_val(); //x 右值引用

auto &&y = get_val(); //y 万能引用

万能引用既可以绑定左值也可以绑定右值(甚至 const 和 volatile 的值都可以)

引用折叠

其实可以发现,所谓的万能引用是因为发生了类型推导,同时还因为C++11中添加了一套引用折叠推导的规则-引用折叠,规定了不同的引用类型互相作用的情况下应该如何推导出最终的类型

类模版T 实际类型最终类型
T&RR&
T&R&R&
T&R&&R&
T&&RR&&
T&&R&R&
T&&R&&R&&

从上面可以发现,只要有左值引用参与进来,最后的推导类型都是右值引用.

只有在模版类型是右值引用并且实际类型是右值引用或者非引用类型的时候才会推导出右值引用类型

万能引用的形式必须是 T&& 或者 auto && 也就是必须在初始化的时候推导出来,如果在推导中出现中间过程,则不是一个万能引用

template<typename T>
void foo(std::vector<T>&&t){}

完美转发

万能引用和引用折叠的主要用途就是实现完美转发

#include<iostream>
#include<string>

template<typename T>
void show_type(T t) {
    std::cout << typeid(t).name() << std::endl;
}

template<typename T>
void normal_forwarding(T t) {
    show_type(t);
}

int main(int argc, char **argv) {

    std::string s{"hello world"};
    normal_forwarding(s);
    
    return 0;
}

normal_forward() 是一个常规的转发函数模版,但是因为传参是按值传参,所以 std::string 会在转发过程中发生一次临时对象的复制. 其中一个解决办法就是将参数类型改为 按照引用传参.但是这样就会带来一个问题,如果传递过来的是一个右值,代码就无法通过编译

std::string get_string() {
    return "hi";
}

int main(int argc, char **argv) {

    std::string s{"hello world"};
    normal_forwarding(get_string());

    return 0;
}

虽然可以使用常量引用来解决此处问题,但是假如需要改变参数类型呢?

所以,万能引用非常好的解决了这个问题.

为了将参数的左右值属性也传递给目标函数,需要使用 static_cast<>() 类型转换

同移动语义一样,显式的使用static_cast<>() 进行转发不是一个便捷的方法,在 C++11的标准库中提供了一个 std::forward 函数模版,在函数内部也是使用 static_cast<>() 进行类型转换,只不过使用了 std::forward 转发语义就会更加清晰.

针对局部变量和右值引用的隐式移动操作

#include<iostream>

struct X {
    X() {
        std::cout << "\033[31m ctor \033[0m\n";
    }

    X(const X &x) {
        std::cout << "\033[32m copy ctor \033[0m\n";
    }

    X(const X &&x) {
        std::cout << "\033[33m move ctor \033[0m\n";
    }

    int a;
};

X foo(X &&x1) {
    return x1;
}

int main(int argc, char **argv) {

    X r=foo(X{});

    return 0;
}

以上代码在C++20之前的标准是不会调用任何的移动构造函数的,因为函数的形参在函数内部是一个左值,对于左值要调用复制构造函数. 要实现移动语义,可以使用 return std::move()

C++20 标准规定在这种情况下,可以隐式采用移动语义完成赋值.具体规则如下:

可隐式移动对象必须是一个非易失或一个右值引用的非易失自动存储对象,在这种情况下可以使用移动代替复制

  1. return 或者 co_return 语句中的返回对象是函数或者 lambda 表达式中的对象或形参
  2. throw 语句中抛出的对象是函数或 try 代码块中的对象

void foo() {
    X x;
    throw x;
}

int main(int argc, char **argv) {

    try {
        foo();
    }
    catch (...) {
        
    }

    return 0;
}

运行结果

image-20230209101607324

总结

右值引用的出现主要是改善在数据转移时的效率,避免无谓的拷贝

参考

《现代C++语言核心特性解析》

霍丙乾 你可能不知道的 C++ 编译器的隐式移动优化_哔哩哔哩_bilibili