目录
一、前言
二 、什么是左值什么是右值?
🔥左值🔥
🔥右值 🔥
三、什么是右值引用?
💧左右引用的“引出”💧
💧左值引用 💧
💧右值引用 💧
💢右值引用的理解
💢std::move()
💧左值引用 vs 右值引用 💧
四、右值引用的应用场景
🥝 移动语义
🍇 完美转发
五、右值引用有什么作用?
六、共勉
一、前言
在C++的世界中,每一个临时对象的诞生都伴随着资源的消耗与转移。但你是否曾想过,这些临时对象的生命周期可以更加高效,它们的资源可以无缝地转移到需要它们的地方?这就是右值引用的魅力所在。
右值引用,一个在C++11中闪耀登场的革命性特性,它不仅仅是一个语言的扩展,更是对资源管理哲学的一次深刻反思。它允许我们以一种前所未有的方式,对临时对象进行资源的“移动”,而不是简单的“复制”。
下文,我们将一起揭开右值引用的神秘面纱,探索它如何改变我们对C++编程的认知,以及如何利用这一特性,编写出更加高效、优雅的代码。
二 、什么是左值什么是右值?
理解左值(Lvalue)和 右值(Rvalue)是掌握C++中引用和内存管理的基础。让我们从概念上和实际代码中来解析它们的区别。
- 这张图形化地展示了左值(Lvalue)和右值(Rvalue)在C++中的区别。左值被表示为一个稳定的盒子,指向一个内存地址,表明它是持久的,有一个明确的存储位置。而右值则被表示为一个临时的云状形状,代表它是一个临时值,没有持久的内存地址。
🔥左值🔥
左值:最初指的是可出现在赋值语句左边的实体,引入了const关键字之后,常规变量和 const 变量都可视为左值, 因为可通过地址访问它们,但常规变量属于可修改的左值, 而 const 变量属于不可修改的左值。
- 左值的本质:是可以操作的一块内存区域,一般可以通过&取到该内存起始地址,这块内存的值可以被修改(const对象除外)。除了const对象的其他左值都可以出现在赋值语句左边。
常见的左值有:
1、变量(对象);
2、const 变量(对象);
3、对指针解引用,*(指针)
;
4、数组元素,a[1]
;
5、结构体成员、类成员,s.m_a
、ps->ma
;
int main()
{
int i = 0;
i = 1; // i是变量(对象),左值
const int ci = 5;
//ci = 6;错误 // ci是const变量(对象),是左值但不能出现在赋值号左边
int *pi = &i;
*pi = 1; // *pi 对指针解引用,左值
int arr[5] = {0};
arr[0] = 1; // arr[0] 是数组元素,左值
struct {
int m_a;
int m_b;
} st, *pst=&st;
st.m_a = 1; // 结构体成员,左值
pst->m_b = 2;
return 0;
}
🔥右值 🔥
右值:一般是没有明确的内存位置,无法使用&获取地址,值不可被修改。例如:立即数、常量、字面值、&(变量)。
- 右值本质:指一种表达式,其结果是值而非值所在的位置。一般是没有明确的内存位置,无法使用&获取地址,值不可被修改的。例如:立即数、常量、字面值、&(变量)。另外,很多时候左值可以当右值使用。
- 关于右值,有一个重要的原则,是在需要右值的地方 (除了移动构造函数) 可以用左值来代替,但是不能把右值当成左值(也就是位置)使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。
常见的右值有:
- 字面常量(立即数),引号括起的字符串除外(它们由其地址表示),42、'a';
- 算术运算符(+、-、*、/、%、正号、负号)的求值结果,1+2;
- 逻辑运算符(&&、||、!)的求值结果,a!=10;
- 关系运算符(>、>=、<、<=、==、!=)的求值结果,a>10 && a<100;
- 函数的非引用返回值。这种返回值位于临时内存单元中,运行到下一条语句时,它们可能不再存在;
- 当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则运算的结果是右值。
int get100()
{
return 100;
}
int main()
{
int i = 0, c=0;
i = 42; // 字面值42,右值
c = 'a'; // 字面值'a',右值
i = 1+2; // 算术运算符的求值结果(1+2),右值
i = (c!='a'); // 逻辑运算符的求值结果 (c!='a'),右值
i = (c >= 'a'); // 关系运算符的求值结果(c >= 'a'),右值
i = get100(); // 函数的非引用返回值get100(),右值
i>0?i:c = i>0?1:0; // 条件运算符的两个表达式都是左值,则可以做左值,本例的 i>0?i:c
// 条件运算符的两个表达式都是右值,则为右值,本例的 i>0?1:0;
i = c; // c是左值,左值的值可以当右值使用
return 0;
}
三、什么是右值引用?
在学习 右值引用 之前,需要先来看看 左值引用(其实就是我们之前的 ----- 引用),引用 是
C++
相对于C
语言 的升级点之一,引用 既能像指针那样获取资源的地址,直接对资源进行操纵,也不必担心多重 引用 问题,对于绝大多数场景来说,引用 比 指针 好用得多
💧左右引用的“引出”💧
想象一下,你正在搬家,而你有一辆车用来运送你的物品。为了方便起见,我们可以将“搬家”看作是一次资源的转移过程。
左值引用的情况
- 如果你想把你的家具从旧家搬到新家,通常你会自己一件件地把家具搬到车上,然后开车把它们带到新家。这有点像“复制”家具,因为你在原地“复制”了这些家具,然后把它们运到了新家。
- 在编程中,这种情况就像是传统的左值引用,你只是通过复制(深拷贝)的方式,将资源从一个对象传递给另一个对象,这可能会导致性能开销增大。
右值引用的情况
- 现在,假设你有一位朋友,他刚好也在搬家,而且他的新家离你的新家很近。他不想要他的旧家具了,而你正好需要这些家具。那么,他可以直接把这些家具“交给”你,而不需要你再自己搬运或复制。
- 这就类似于右值引用。在这个过程中,你的朋友并没有复制家具,而是直接将他的资源(家具)“转移”给了你。你得到了家具,而他不再拥有这些家具。这种“资源转移”非常高效,避免了不必要的复制操作。
💧左值引用 💧
左值引用是一个很好用的工具,它有像指针指向一块空间的能力,同时它不像指针那样危险、复杂,换句话说,引用是指针的改进版,在C++的学习中,有80%的场景都会用到引用而非指针,由于之前已经详细的讲过指针啦,这里就不再深究,只是帮助大家简单的回忆一下,如果对引用不了解,可以去看这篇文章: 引用详解
- 怎样声明、定义左值引用:左值引用的定义需要使用
&
,格式一般是类型 &变量名 = 变量
。左值引用定义必须初始化,且初始化后无法改变该引用关联的对象。下面例子介绍了左值引用的定义、const左值引用初始化为左值、const左值引用初始化为右值。
int a = 10;
int* pa = &a; // 指针
int& r = a; // 引用
- 引用的底层依然是 ---- 指针
- 我们之前使用的所有引用都称为 左值引用,主要用于引用各种 变量,如果想引用 常量,需要使用
const
修饰
int get100()
{
return 100;
}
int main()
{
int i = 0;
int &ri = i; // 左值引用
// int &*pri = &ri; // 不能定义引用的指针。报错:cannot declare pointer to ‘int&’
// int &&rri = ri; // 不能定义引用的引用。在C++11标准里,这是一个右值引用
int *pi = &i;
int *&rpi = pi; // pi是左值,rpi是指针的引用
int &r_pi = *pi; // *pi是左值,可以初始化左值引用
int arr[5] = {0};
int &rarr = arr[0]; // arr[0]是左值,可以初始化左值引用
// const 左值引用可以被初始化为一些左值
const int &cri = i; // const 左值引用
// const int *&crpi = pi; // 报错:用 ‘int*’ 初始化 ‘const int*&’ 无效
const int &cr_pi = *pi; // const 左值引用
const int &crarr= arr[0];
// const 左值引用可以被初始化为右值
const int &ri0 = 42;
const int &ri1 = 'a';
const int &ri2 = 1+2;
const int &ri3 = (ri1 != 'a');
const int &ri4 = (ri1 >= 'a');
const int &ri5 = get100(); // 函数的非引用返回值,右值
const int &ri6 = i>0?1:0;
// ri0 = 1; // 报错,const引用是只读的,其值不能修改
return 0;
}
另外,应尽可能将引用形参声明为 const ,有以下三个理由:
- 使用 const 可以避免无意中修改数据的编程错误;
- 使用 const 使函数能够处理 const 和非 const 实参, 否则将只能接受非 const 数据;
- 使用 const 引用使函数能够正确生成并使用临时变量。
💧右值引用 💧
💢右值引用的理解
在
C++11
中,新增了 右值引用 的概念,就是将 左值引用 中的&
变为&&
,右值引用 可以直接引用 左值引用 中需要加const
引用的值;也可以通过函数move
引用 左值引用 直接引用的值
- 怎样定义右值引用?右值引用使用
&&
来定义,也是必须初始化,初始化后无法改变其绑定的对象。定义右值引用格式:类型 &&引用名称 = 右值;
。定义一个右值引用可以参考下面代码:
int &&rl = 13; // 13是一个临时变量,进行右值引用
int *pi = &rl;
- 将 【右值】 关联到【右值引用】导致该【右值】被存储到特定的位置,且可以获取该位置的地址。也就是说,虽然不能将运算符&用于 13,但可将其用于 rl。通过将数据与特定的地址关联,使得可以通过右值引用来访问该数据。
右值引用只能绑定到一个右值,右值要么是字面常量,要么是在表达式求值过程中创建的临时对象; 而临时对象有两个特点:
- 该对象将要被销毁;
- 该对象没有其他用户再使用它。
这就意味着,右值引用的代码是最后使用这个对象的了,可以自由地接管所引用的对象的资源。
如上图,变量a、b相加之后会产生一个值,这个值就是临时量,但它在内存中肯定是存在某个地址的,没有右值引用之前,这个值使用完就会被销毁,我们也不会知道它的内存地址。
- 现在,这块内存可以被右值引用关联,关联后,右值引用甚至可以改变内存的内容,等右值引用使用完再销毁。
看看下面关于右值引用的例子,加深理解:
#include <iostream>
using namespace std;
int get100()
{
return 100;
}
void fun(int &&rri)// 右值引用作为函数形参
{
rri = 0;
}
int main()
{
// 1、右值引用必须被初始化为右值
int &&ri0 = 42; // 将 42 存到一个临时量,然后引用这个临时量
int &&ri1 = 'a'; // 将 'a' 存到一个临时量,然后引用这个临时量
int &&ri2 = 1+2; // 将 1+2 存到一个临时量,然后引用这个临时量
int &&ri3 = (ri1 != 'a');
int &&ri4 = (ri1 >= 'a');
int &&ri5 = get100(); // 函数的非引用返回值,右值
int &&ri6 = ri0>0?1:0;
// 2、右值引用的内容可以被修改
ri0 = 1;
cout << "ri0=" << ri0 << endl; // 输出:ri0 = 1
// 3、虽然没办法获取右值的地址,但可以获取右值引用的地址,并改变该地址的值
int *pi = &ri0;
*pi = 2;
cout << "pi=" << pi << ", *pi=" << *pi << ", ri0=" << ri0 << endl;
// 4、传入右值
fun(1+2);
return 0;
}
注意:
- 左值引用 可以通过其他手段引用 右值,比如加
const
;右值引用 也可以通过其他手段引用 左值,比如move
函数 - 赋值语句左边的一定是 左值,但右边的不一定是 右值,比如
int a = b
💢std::move()
无论是 左值引用 还是 右值引用,本质上都是在给 资源 起别名,当 左值引用 引用 左值 时,是直接指向 资源,从而对 左值 进行操作;当 右值引用 引用 右值 时,则是先将 常量 等即将被销毁的临时资源 “转移” 到特定位置,然后指向该位置中的 资源,对 右值 进行操作
int a = 10;
// 左值引用 引用 左值
int& ra = a;
// 右值引用 引用 右值
int&& rr = 10;
虽然不能将左值绑定在一个右值应用上, 但我们可以显式地将一个左值转换为对应的右值引用类型。通过调用
std::move
的标准库函数可以获取绑定到左值上的右值引用,此函数定义在 utility 头文件中。
- 对于 「常量 / 临时对象 / 表达式结果」 等 右值,编译器会直接转移资源,但对于用户自定义的 左值,编译器不敢轻举妄动,只敢给用户提供一个 转移变量资源 的函数
move
,有了move
之后,右值引用 就能引用 左值 了
int a = 10;
// 左值引用 引用 右值
const int& r = 10;
// 右值引用 引用 左值
int&& rr = move(a);
- 语法还支持给 右值引用 加
const
,这样做的含义是 不能修改右值引用后的值
int main()
{
int a = 10;
const int&& crr = 10;
const int&& crra = move(a);
++crr; // 【报错】
++crra; // 【报错】
return 0;
}
- 一般情况下是不会这样干的,右值引用 是为了移走资源,加了
const
还不如直接改用 const 左值引用
不要轻易使用
move
函数,左值 中的资源可能会被转走,在C++11
之后,几乎所有的STL
容器都增加了一个 移动构造 函数,其中就用到了 右值引用
- 如果此时我们直接将 左值
move
后构造一个新对象,会导致原本左值中的 资源 丢失
// move 转移资源
int main()
{
string str = "Hello World!";
cout << "str: " << str << endl;
// 使用 move 函数后
string tmp = move(str);
cout << "str: " << str << endl;
return 0;
}
- 所以一般情况下不要轻易使用
move
移动函数,除非你确定该资源后续不再使用
💧左值引用 vs 右值引用 💧
在
C++11
之前,使用 const 左值引用 也可以引用 右值,并且在我们之前的学习中只使用 左值引用 也没什么大问题啊,那为什么还要搞出一个 右值引用 呢?
- 答案是 右值引用可以提高资源的利用率,进而提高整体效率
- 有了右值引用之后,之前只能 【读取】、【拷贝】的临时资源变得更有价值了,可以在右值引用后进行操作,也可以将资源转移以减少拷贝
下面是 左值引用 与 右值引用 的对比图
特征 | 左值引用 | 右值引用 |
语法 | Type& lvalueRef = variable; | Type&& rvalueRef = std::move(variable); |
绑定对象 | 现有对象 | 临时对象或可移动对象 |
典型对象 | 函数参数、返回类型 | 移动语义、完美转发 |
示例 | int x = 10; int& ref = x; | int&& rref = 10; |
可重新赋值 | 是 | 是 |
可为nullptr | 否 | 是(需谨慎使用) |
可以折叠(C++11) | 无 | Type&& && 折叠为 Type&& |
生命周期延长 | 否(延长临时对象的生命周期) | 是(绑定到临时对象,如果绑定到右值则延长生命周期) |
四、右值引用的应用场景
右值引用的主要作用:
移动语义:允许临时对象的资源(如内存、文件句柄等)被“移动”到另一个对象中,而不是进行拷贝。这可以避免不必要的资源复制,提高程序效率。
完美转发:在模板编程中,能够将函数的参数以左值或右值的形式完美转发给另一个函数。
🥝 移动语义
允许临时对象的资源(如内存、文件句柄等)被“移动”到另一个对象中,而不是进行拷贝。这可以避免不必要的资源复制,提高程序效率。
场景描述
我们有一个简单的类
MyString
,它管理一个字符串的资源(动态分配的字符数组)。我们将演示在没有右值引用的情况下,如何进行复制,以及如何通过右值引用来优化这个过程。
#include <iostream>
#include <cstring>
class MyString {
public:
char* data;
// 构造函数
MyString(const char* str)
{
data = new char[strlen(str) + 1];
strcpy(data, str);
std::cout << "构造函数调用,分配资源:" << data << std::endl;
}
// 拷贝构造函数(深拷贝)
MyString(const MyString& other)
{
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
std::cout << "拷贝构造函数调用,复制资源:" << data << std::endl;
}
// 析构函数
~MyString()
{
std::cout << "析构函数调用,释放资源:" << data << std::endl;
delete[] data;
}
};
int main()
{
MyString str1("Hello");
MyString str2 = str1; // 触发拷贝构造函数
return 0;
}
解释:
- 构造函数:分配内存并复制传入的字符串。
- 拷贝构造函数:当我们复制一个
MyString
对象时,这个函数会被调用,它会分配新内存并复制字符串。 - 析构函数:释放动态分配的内存。
输出结果:
在这个例子中,当我们复制
str1
给str2
时,触发了拷贝构造函数,执行了一次深拷贝,这意味着我们复制了整个字符串,占用了额外的内存,并且进行了不必要的复制操作。
使用右值引用(转移语义)的情况
#include <iostream>
#include <cstring>
class MyString {
public:
char* data;
// 构造函数
MyString(const char* str)
{
data = new char[strlen(str) + 1];
strcpy(data, str);
std::cout << "构造函数调用,分配资源:" << data << std::endl;
}
// 拷贝构造函数(深拷贝)
MyString(const MyString& other)
{
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
std::cout << "拷贝构造函数调用,复制资源:" << data << std::endl;
}
// 移动构造函数(右值引用)
MyString(MyString&& other) noexcept
{
data = other.data; // 直接“偷取”资源
other.data = nullptr; // 清空源对象的指针
std::cout << "移动构造函数调用,转移资源:" << data << std::endl;
}
// 析构函数
~MyString()
{
if (data)
{
std::cout << "析构函数调用,释放资源:" << data << std::endl;
delete[] data;
}
else
{
std::cout << "析构函数调用,资源已被转移,无需释放" << std::endl;
}
}
};
int main()
{
MyString str1("Hello");
MyString str2 = std::move(str1); // 触发移动构造函数
return 0;
}
解释:
- 移动构造函数:当我们使用
std::move
将str1
的资源“转移”给str2
时,这个函数会被调用。移动构造函数不会复制字符串,而是直接将指针“偷取”过来,避免了内存分配和复制的开销。原来的对象(str1
)的指针被置为nullptr
,表示资源已经被转移。
输出结果:
对比说明
- 不使用右值引用:在复制
MyString
对象时,触发了拷贝构造函数,进行了深拷贝,占用了额外的内存,并增加了不必要的复制操作。 - 使用右值引用:在资源转移时,移动构造函数直接“偷取”资源,避免了深拷贝,提高了效率,同时也避免了内存资源的重复释放。
- 这个例子展示了右值引用如何通过转移语义避免不必要的资源复制,从而优化代码的性能。对于初学者,记住:右值引用的核心思想就是“转移”而不是“复制”。
🍇 完美转发
“完美转发”是指在C++中能够将参数原封不动地传递给另一个函数,不管它是左值还是右值。通过使用右值引用和
std::forward
,我们可以实现这一点。下面我会用一个简单的代码例子来说明完美转发,并展示如果不用右值引用会发生什么情况。
#include <iostream>
#include <utility> // 包含 std::forward
// 一个简单的函数,接受左值引用
void process(int& x) {
std::cout << "左值引用函数调用, 值: " << x << std::endl;
}
// 一个简单的函数,接受右值引用
void process(int&& x) {
std::cout << "右值引用函数调用, 值: " << x << std::endl;
}
// 一个接受任意参数的模板函数,并完美转发给 process 函数
template <typename T>
void forwardToProcess(T&& arg) {
process(std::forward<T>(arg)); // 完美转发
}
int main() {
int a = 5;
// 调用时传入左值
forwardToProcess(a);
// 调用时传入右值
forwardToProcess(10);
return 0;
}
代码解释
-
process(int& x):这是一个接收左值引用的函数。如果传入的是一个左值(如变量
a
),这个函数会被调用。 -
process(int&& x):这是一个接收右值引用的函数。如果传入的是一个右值(如字面量
10
),这个函数会被调用。 -
forwardToProcess(T&& arg):这是一个模板函数,使用了
T&&
,它可以接受任何类型的参数(无论是左值还是右值)。这个函数使用std::forward<T>(arg)
来将参数转发给process
函数,从而实现完美转发。- 如果
arg
是一个左值,std::forward<T>(arg)
会把它当作左值传递。 - 如果
arg
是一个右值,std::forward<T>(arg)
会把它当作右值传递。
- 如果
输出结果:
- 假设我们不使用
std::forward
,而是直接调用process(arg)
,如下所示:
template <typename T>
void forwardToProcess(T&& arg) {
process(arg); // 不使用 std::forward
}
- 那么无论传入的是左值还是右值,
arg
都会被当作左值处理,因为arg
在forwardToProcess
函数内部是一个左值。这就意味着无论传入的是a
还是10
,都会调用process(int& x)
函数。
总结
- 完美转发:通过使用
std::forward
,可以将参数的原始类型(左值或右值)完美地传递给另一个函数。这在泛型编程中非常重要,因为它避免了不必要的拷贝或不正确的引用。 - 没有使用
std::forward
:所有传入的参数都会被当作左值处理,即使传入的是右值,也会失去右值的特性,导致潜在的性能损失或不正确的行为。
五、右值引用有什么作用?
【右值引用】是
C++11引入的一个新特性
,用于实现移动语义和完美转发,其作用主要包括以下几点:
- 实现移动语义:右值引用可以绑定到临时对象(右值),通过将资源的所有权从一个对象转移到另一个对象,避免了不必要的复制和销毁操作,提高程序效率。移动语义在大规模数据结构中尤为重要,例如std::vector、std::string等。
- 支持完美转发:右值引用还可以用于函数模板的完美转发,即将参数以原始的形式传递给下一个函数,避免了不必要的复制和类型转换,提高了程序效率。
- 避免内存泄漏:右值引用可以使用std::move()函数将对象强制转换为右值,使得该对象的所有权可以被移交,从而避免了内存泄漏的问题。
总之,右值引用是C++中一项非常重要的特性,通过实现移动语义、完美转发等功能,能够提高程序效率、避免内存泄漏,并在标准库中得到了广泛的应用。
六、共勉
以下就是我对 【C++11】右值引用 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对 【C++11】 的理解,请持续关注我哦!!!