C++中的左值和右值

C++中的左值和右值

左值和右值

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

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

  1. 根据 = 的位置

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

int a = 90;

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

int b = 10;
int c = b; 

变量 b 和变量 c 都是左值

  1. 根据是否可以取地址

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

因为 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++;
      |              

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

image-20230207204253403

Compiler Explorer官网

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

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

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;// 右值引用

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

#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;
}

⚠️ 注意编译参数,使用 -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标准的推出才得到解决

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

🎉

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() 语义更加清晰

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

参考

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

霍丙乾

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值