🌈 个人主页: 小小、码农的 CSDN 博客
🔥 系列方向: C++之旅
💪 学习宣言: 行动胜于空谈,实践出真知
C++ 中有一类对象非常特殊:
它马上就要销毁了,但我们却可能还要花费大量时间,把它管理的资源完整拷贝一份。
例如一个函数返回了一个临时 string,这个临时对象马上就要死亡。如果还按照传统深拷贝的方式,把它内部的数据重新申请空间、逐字节复制一遍,显然有些浪费。
C++11 引入右值引用和移动语义,核心就是为了解决这类问题:
既然这个对象马上就不用了,与其复制它的资源,不如直接把资源接管过来。
后面的 std::move、万能引用、引用折叠、完美转发,其实都是沿着这条主线继续发展出来的。
📌 本文学习路线
text左值与右值 ↓ 左值引用 & / 右值引用 && ↓ 为什么 const T& 还不够? ↓ 移动构造 / 移动赋值 ↓ std::move ↓ 万能引用 T&& ↓ 引用折叠 ↓ std::forward ↓ 完美转发
一、先搞清楚左值和右值
理解右值引用之前,必须先知道什么是左值和右值。
我们先看几个表达式:
cpp
int a = 10;
int b = 20;
const int c = 30;
a;
c;
a + b;
10;
string("hello");
这里的:
cpp
a
c
都是左值。
而:
cpp
10
a + b
string("hello")
通常属于右值。
最开始可以这样理解:
左值通常拥有明确的身份和比较持久的生命周期,我们可以找到这个对象。
例如:
cpp
int a = 10;
a 是一个真正存在的变量。
我们可以:
cpp
&a;
拿到它的地址。
即使它是:
cpp
const int a = 10;
虽然不能修改:
cpp
a = 20; // error
但:
cpp
&a;
依然成立。
所以:
const并不会把左值变成右值。
右值则通常是一些临时产生的值。
例如:
cpp
10
这个字面量。
或者:
cpp
a + b
产生的计算结果。
以及:
cpp
string("hello")
创建出来的匿名临时对象。
这些对象通常没有一个供我们长期使用的名字,很多情况下在当前表达式结束后就没有继续存在的意义了。
因此初学时可以先抓住:
text
左值:有身份、通常比较持久
右值:临时值、匿名对象、即将消失的值
不过不要简单背成:
text
等号左边就是左值
等号右边就是右值
因为左值完全可以出现在等号右边:
cpp
int a = 10;
int b = a;
这里:
cpp
a
依旧是左值。
真正重要的是这个表达式本身代表的对象属于什么值类别。
二、右值引用到底解决了什么问题
C++98 就已经存在我们熟悉的引用:
cpp
int a = 10;
int& r = a;
C++11 引入右值引用以后,为了进行区分,我们把以前这种:
cpp
T&
称为左值引用。
而 C++11 新增的:
cpp
T&&
称为右值引用。
例如:
cpp
int a = 10;
int& r1 = a; // 左值引用绑定左值
int&& r2 = 10; // 右值引用绑定右值
普通左值引用不能直接绑定右值:
cpp
int& r = 10; // error
但是我们以前学引用的时候还见过一个特殊情况:
cpp
const int& r = 10;
这却是允许的。
所以问题来了:
既然
const T&本来就能接收右值,C++11 为什么还非要搞出一个T&&?
关键区别就在:
text
const T&:我只想借这个对象看看,不能修改它
T&&:这个对象快不用了,我有机会修改甚至接管它的资源
例如函数传参时:
cpp
void Func(string s);
如果传入一个很大的 string,按值传参可能需要构造新的对象。
以前我们通常优化成:
cpp
void Func(const string& s);
这样不需要拷贝,而且又保证函数不能修改原对象。
对于普通参数传递来说非常好。
但另外一个问题出现了:
cpp
string Func()
{
string s("hello");
return s;
}
调用:
cpp
string ret = Func();
Func 中的局部变量:
cpp
s
离开函数以后就要销毁,因此我们不能直接返回它的普通左值引用:
cpp
string& Func(); // 如果引用局部对象,这是错误设计
因为函数结束以后局部对象生命周期结束,引用会悬空。
那么按照传统思路,就只能把 s 中的数据复制出来。
假设一个 string 内部管理了一块很大的堆空间:
text
s
│
└──────> 一大块堆空间
传统深拷贝意味着:
text
申请新空间
↓
复制所有字符
↓
形成新string
↓
原来的s销毁
但仔细想:
s马上就要销毁了,它那块空间马上也没有人用了。
那为什么还要复制?
完全可以:
text
原来的s
│
└──────> 堆空间
↑
│
新对象直接接管
然后让原来的 s 不再拥有这块资源。
这就是移动语义。
需要特别注意:
右值引用本身不会自动转移资源。
cpp
string&& r = string("hello");
这里只是建立了一个右值引用。
真正把资源"偷走"的操作,需要我们在类的:
cpp
移动构造
移动赋值
中自己实现。
假设我们自己写一个简单的字符串类:
cpp
class String
{
public:
String(const char* str = "")
{
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String()
{
delete[] _str;
}
private:
char* _str;
};
传统拷贝构造必须深拷贝:
cpp
String(const String& s)
{
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
整个过程:
text
s._str ──────> [ hello... ]
↓ 完整复制
this->_str ──> [ hello... ]
如果字符串非常大,这个复制成本就很高。
但是如果传进来的是一个右值:
cpp
String(String&& s)
我们知道:
这个对象通常已经没有继续保存原资源的必要。
所以可以直接:
cpp
String(String&& s) noexcept
: _str(s._str)
{
s._str = nullptr;
}
整个过程变成:
text
移动前:
s._str ───────────> [ 一大块资源 ]
移动后:
s._str ───> nullptr
this->_str ────────> [ 原来的那块资源 ]
没有重新申请一块同样大的空间,也没有把所有数据复制一遍。
只是把:
cpp
指针
接了过来。
这就是移动构造。
移动赋值也是同样的思想:
cpp
String& operator=(String&& s) noexcept
{
if (this != &s)
{
delete[] _str;
_str = s._str;
s._str = nullptr;
}
return *this;
}
传统拷贝:
text
复制资源
移动:
text
转移资源的所有权
所以移动操作的优势在管理大量动态资源的对象上尤其明显:
text
string
vector
list
各种自定义资源管理类
C++11 以后,STL 中也大量增加了右值引用版本的接口。
例如 vector::push_back 可以粗略理解为拥有两套:
cpp
void push_back(const T& x);
void push_back(T&& x);
当我们:
cpp
string s = "hello";
v.push_back(s);
s 是左值。
因此走:
cpp
push_back(const T&);
容器不能随便破坏 s,一般需要把数据复制进去。
但是:
cpp
v.push_back(string("hello"));
string("hello") 是临时对象。
于是可以走:
cpp
push_back(T&&);
然后利用移动构造把临时对象内部的资源转移到容器中。
这就是为什么右值引用最终能够真正提升 STL 的效率。
还有一点需要说清楚。
例如:
cpp
string Func()
{
string s("hello");
return s;
}
实际编译时,现代编译器可能进行:
text
RVO / NRVO
直接消除部分甚至全部拷贝、移动。
所以实际运行时不一定真的看到一次移动构造。
但是从 C++11 的语义机制来看:
当无法进行拷贝消除时,移动构造给了这类即将销毁的对象一种比深拷贝更高效的处理方式。
三、std::move:主动告诉编译器"这个对象可以被移动"
前面移动语义主要针对右值:
cpp
string("hello")
但有时候我们手里明明是一个左值:
cpp
string a = "hello";
我们却非常确定:
a后面已经不准备继续使用了,我希望把它内部的资源转给另一个对象。
例如:
cpp
string a = "hello";
string b = a;
这里 a 是左值。
所以通常调用:
cpp
string(const string&);
进行拷贝。
如果我们明确不再需要 a 原来的内容,可以:
cpp
string b = std::move(a);
这样就有机会调用:
cpp
string(string&&);
执行移动构造。
不过:
std::move这个名字非常容易骗人。
它自己并没有搬走任何东西。
cpp
std::move(a)
本质上做的是一次类型转换:
把表达式
a转换成一个可以作为右值处理的表达式。
可以粗略理解成:
cpp
static_cast<T&&>(a);
真正发生资源转移的地方仍然是:
cpp
移动构造
或者:
cpp
移动赋值
所以:
cpp
string b = std::move(a);
完整逻辑应该理解成:
text
a本来是左值
↓
std::move(a)
↓
把它转换成可匹配右值引用的表达式
↓
匹配 string(string&&)
↓
移动构造内部真正转移资源
因此:
std::move不是"移动函数",而更像是"允许移动"的类型转换工具。
移动之后的对象还能不能用?
例如:
cpp
string a = "hello";
string b = std::move(a);
对于标准库中大多数支持移动的对象来说,移动后的 a 仍然是一个有效对象,可以被析构,也可以重新赋值。
例如:
cpp
a = "world";
没有问题。
但是:
不要依赖移动后
a的具体内容。
你可能在自己的环境中观察到:
cpp
a.empty() == true
但不能因此把"移动以后一定变成空字符串"当成通用规则。
更合适的理解是:
text
对象还活着、还合法
但它原来的值通常不能再依赖
所以:
cpp
std::move(a);
通常意味着程序员在表达:
我已经准备放弃
a原来的值了。
还有一个非常经典的问题:
cpp
const string a = "hello";
string b = std::move(a);
这里会不会移动?
通常不会。
因为:
cpp
a
本身带:
cpp
const
所以:
cpp
std::move(a)
得到的仍然带有 const 属性,可以理解为:
cpp
const string&&
但是普通移动构造通常长这样:
cpp
string(string&& s);
它需要修改源对象,例如把源对象内部的指针置空。
const string&& 不能绑定到:
cpp
string&&
于是最终通常只能匹配:
cpp
string(const string&);
进行拷贝。
所以:
cpp
std::move(x)
也不意味着:
一定会调用移动构造。
它只是给出了"按照右值方向进行匹配"的机会。
最终到底是移动还是拷贝,还要看:
text
对象类型
const属性
有没有移动构造
函数重载
等因素。
四、万能引用、引用折叠与完美转发
移动语义解决以后,还有一个非常隐蔽的问题。
来看一个包装函数:
cpp
void Func(int& x)
{
cout << "左值" << endl;
}
void Func(int&& x)
{
cout << "右值" << endl;
}
我们直接调用:
cpp
int a = 10;
Func(a); // 左值
Func(10); // 右值
没有任何问题。
现在我们希望增加一层 Wrapper:
cpp
template<class T>
void Wrapper(T&& x)
{
Func(x);
}
然后:
cpp
Wrapper(10);
10 明明是右值。
按直觉:
cpp
Func(x);
是不是也应该收到右值?
其实不是。
这里有一句非常重要的话:
一个有名字的右值引用变量,在表达式中本身是左值。
例如:
cpp
int&& r = 10;
虽然:
cpp
r
的声明类型是:
cpp
int&&
但是后面写:
cpp
r
这个表达式时,它有名字、可以重复使用,也可以:
cpp
&r;
所以表达式:
cpp
r
是左值。
同理:
cpp
template<class T>
void Wrapper(T&& x)
{
Func(x);
}
进入函数以后:
cpp
x
已经有名字了。
因此直接:
cpp
Func(x);
会把它作为左值继续传递。
这就叫:
值类别在转发过程中丢失了。
不过这里的:
cpp
T&&
还有一个非常特殊的地方。
例如:
cpp
template<class T>
void Wrapper(T&& x);
这里 T 需要通过实参自动推导。
这种特定场景下的:
cpp
T&&
并不只能接右值。
它既可以接:
cpp
Wrapper(10);
也可以接:
cpp
int a = 10;
Wrapper(a);
因此这种形式常被称为:
text
万能引用
更标准的名称是:
text
转发引用(forwarding reference)
注意:
不是看到
T&&就一定是万能引用。
必须是类似:
cpp
template<class T>
void Func(T&& x);
这里的 T 需要直接参与模板类型推导时,才有这种特殊行为。
例如:
cpp
void Func(string&& x);
这就是普通右值引用,不是万能引用。
万能引用为什么能够同时接左值和右值?
背后依赖两个东西:
text
特殊的模板类型推导规则
+
引用折叠
例如:
cpp
template<class T>
void Wrapper(T&& x);
如果传右值:
cpp
Wrapper(10);
T 推导为:
cpp
int
于是:
cpp
T&&
就是:
cpp
int&&
所以可以接右值。
但如果:
cpp
int a = 10;
Wrapper(a);
传进来的是左值。
这里 T 会推导成:
cpp
int&
于是原本的:
cpp
T&&
就相当于:
cpp
int& &&
看起来出现了"引用的引用"。
普通代码不能直接写:
cpp
int& & x; // 不能这么写
但是在:
text
模板推导
typedef / using
decltype
等类型推导过程中,却可能间接产生这种组合。
C++ 对此规定了一套引用折叠规则:
text
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&
非常好记:
只要出现一个
&,最终就是左值引用;只有&& + &&才保持右值引用。
所以:
cpp
Wrapper(a);
时:
text
T = int&
T&&
↓
int& &&
↓
int&
最终参数变成左值引用。
而:
cpp
Wrapper(10);
时:
text
T = int
T&&
↓
int&&
最终参数是右值引用。
这就是万能引用能够同时接收左值和右值的根本原因。
但我们刚才说了:
cpp
template<class T>
void Wrapper(T&& x)
{
Func(x);
}
即使 x 的声明类型最终是:
cpp
int&&
由于它有名字:
cpp
x
这个表达式还是左值。
所以单靠万能引用,只能做到:
把各种类型的参数接进来。
却还做不到:
按照参数原来的左值/右值属性继续传出去。
这时候就需要:
cpp
std::forward
把代码改成:
cpp
template<class T>
void Wrapper(T&& x)
{
Func(std::forward<T>(x));
}
这就是完美转发最核心的形式。
来看完整例子:
cpp
#include <iostream>
#include <utility>
using namespace std;
void Func(int& x)
{
cout << "左值" << endl;
}
void Func(const int& x)
{
cout << "const左值" << endl;
}
void Func(int&& x)
{
cout << "右值" << endl;
}
template<class T>
void Wrapper(T&& x)
{
Func(std::forward<T>(x));
}
int main()
{
int a = 10;
const int b = 20;
Wrapper(a);
Wrapper(b);
Wrapper(30);
return 0;
}
输出:
text
左值
const左值
右值
std::forward<T>(x) 的作用可以简单理解为:
根据最开始模板推导得到的
T,恢复实参原来的值类别。
例如:
cpp
Wrapper(a);
其中:
text
a 是左值
↓
T 推导成 int&
↓
std::forward<int&>(x)
↓
继续作为左值传递
而:
cpp
Wrapper(10);
其中:
text
10 是右值
↓
T 推导成 int
↓
std::forward<int>(x)
↓
重新作为右值传递
所以完美转发解决的不是:
把所有东西都变成右值。
而是:
左值进来还是左值,右值进来还是右值,
const等属性也尽量保持原样,把参数原本的信息继续传递下去。
这就是:
cpp
std::forward
和:
cpp
std::move
最关键的区别。
可以简单对比:
| 工具 | 核心作用 |
|---|---|
std::move(x) |
无条件把 x 按右值方向转换 |
std::forward<T>(x) |
根据 T 恢复 x 原来的左/右值属性 |
所以:
cpp
std::move
更像是在说:
这个对象我不要原来的值了,可以移动。
而:
cpp
std::forward
是在说:
这个参数原来是什么样,我就继续按什么样传下去。
STL 中存在大量这样的代码。
例如 C++11 的 emplace 系列接口,概念上就会出现类似结构:
cpp
template<class... Args>
void emplace_back(Args&&... args)
{
// 在目标位置构造对象
// std::forward<Args>(args)...
}
这里:
cpp
Args&&...
负责通过万能引用接收各种参数。
而:
cpp
std::forward<Args>(args)...
负责保持这些参数原来的属性继续传递给真正的构造函数。
所以:
text
万能引用
+
引用折叠
+
std::forward
最终共同实现了:
完美转发。
五、总结
右值引用这一整套知识看起来语法很多,但其实从头到尾只是在解决两个问题。
第一个问题是:
一个马上就要销毁的对象,为什么还要花高成本复制资源?
于是 C++11 引入:
cpp
T&&
让程序能够识别并操作右值。
再通过:
cpp
移动构造
移动赋值
把原来的:
text
复制资源
变成:
text
接管资源
从而减少不必要的深拷贝。
如果我们手里本来是一个左值,但是明确知道以后不再需要它原来的内容:
cpp
std::move(a)
可以把它转换成适合参与右值重载匹配的表达式。
但是必须记住:
std::move自己不移动资源,真正的移动发生在移动构造和移动赋值中。
第二个问题是:
一个参数经过多层函数传递以后,怎样保持它最开始的左值、右值属性?
于是产生:
text
万能引用
↓
模板类型推导
↓
引用折叠
↓
std::forward
↓
完美转发
引用折叠只需要记住:
text
& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&
也就是:
有
&就折成&,只有两个&&才是&&。
最后再把这一整篇压缩成一张图:
text
C++11右值引用
│
为什么需要右值引用?
│
临时对象马上就要销毁
│
不想再进行昂贵的深拷贝
│
▼
移动构造 / 移动赋值
│
直接转移资源所有权
│
┌──────────────┴──────────────┐
│ │
本来就是右值 本来是左值
│ │
T&& std::move(x)
│ │
└──────────────┬──────────────┘
│
移动语义
另一条线:
参数经过模板包装层
│
▼
T&&
│
万能/转发引用
│
模板类型推导
│
引用折叠
│
但参数一旦有名字就是左值
│
▼
std::forward<T>(x)
│
▼
保持原来的左/右值属性
│
▼
完美转发
所以右值引用真正需要抓住的并不是几个 && 的语法,而是两句话:
移动语义解决"资源没必要再复制"的问题。
完美转发解决"参数传着传着别把原来的属性传丢了"的问题。
这两套机制最终都大量进入了现代 C++ 的 STL 和泛型编程体系中。