C++中的左值和右值
左值和右值
⛺️ C++ 中的左值和右值在不同的时期又不同的定义,本节先从最初的左值右值开始
判断左值右值的方法有好几种,但是都存在一定的局限性.
- 根据
=
的位置
📦 位于
=
左边的就是一个左值,右边的就是右值
int a = 90;
可是说变量 a
就是一个左值, 字面量 90
就是一个右值.但是这样判断存在一个很大的问题,比如下面
int b = 10;
int c = b;
变量 b
和变量 c
都是左值
- 根据是否可以取地址
🔑 可以取到地址的都是左值,否则就是右值
因为 C++
中所谓的左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且具有一段相对较长的生命周期. 而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的.
以上的方法还比较简单,但是有一些比较复杂的情况
- 根据上下文灵活判断
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 网站来进行探究
在 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
的产生上,接下来分析一些发生构造的地方.
make_x()
中的x
会进行默认构造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
三次拷贝构造分别是
get_pool()
返回的临时对象调用拷贝构造函数复制了pool
对象make_pool()
返回的BigMemoryPool
临时对象调用复制构造函数复制了get_pool()
返回的临时对象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
添加一个移动构造函数,形参是一个非常量右值引用.
运行结果:
第一个拷贝构造函数的调用是调用 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.3063s | 0.130418s |
值的类别、将左值转换为右值
C++11 中的左值和右值
值类别是 C++11 中新引入的概念,具体来说是表达式的一种属性,该属性将表达式分为三个类别,分别是左值、纯右值和将亡值.但是C++11没有清晰的定义他们,直到C++17标准的推出才得到解决
- 所谓的泛左值是指一个通过苹果能够确定对象、位域和函数的表示的表达式.简单来说,它确定了函数或者对象的标识(具名对象)
- 而纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式
- 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为他们接近生命周期的末尾,另外也有可能是经过右值引用转换产生的(下面会提到将亡值产生的两种情况)
🎉
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;
}
运行结果:
再比如
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;
}
运行结果:
在一个函数内部,参数是一个左值,所以再 move_pool()
内部, my_pool
需要调用 拷贝构造函数进行构造,如果 my_pool
使用移动构造函数进行构造,需要进行类型转换
BigMemoryPool my_pool(static_cast<BigMemoryPool &&>(pool));
在C++11标准中,标准库提供了一个模版函数 std::move()
来实现将左值转化为右值,内部也是使用了 static_cast<>()
实现的,只不过使用 std::move()
语义更加清晰
万能引用、引用折叠和完美转发
参考
《现代C++语言核心特性解析》
霍丙乾