C++11右值引用与移动语义 —— 从拷贝资源到转移资源

🌈 个人主页: 小小、码农的 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 和泛型编程体系中。

相关推荐
for_ever_love__1 小时前
python基础语法学习: requirements.txt
开发语言·python·学习
XiaoYu1__1 小时前
JavaGUI文件管理系统开发实战:从静态展示到动态交互的演进
java·开发语言
for_ever_love__1 小时前
python基础语法学习: 动态类型
开发语言·python·学习
Zenova EdgeOS1 小时前
工业网关数据持久化:从同步到 WAL 的工程实战
网络·数据库·oracle
霸道流氓气质1 小时前
MCP(Model Context Protocol)核心概念与Java SDK集成
java·开发语言
企查查数据服务1 小时前
全军禁入下客商准入风控,关联图谱与穿透核查
大数据·开发语言·数据库·php
晴天162 小时前
前端 SSR、BFF 原理介绍
前端·javascript·网络·html
PC2005-cloud2 小时前
DSH 白嫖指南:接入 Command Code Go、WorkBuddy 与 Trae 的免费额度
开发语言·后端·golang
千里码aicood2 小时前
基于Python语言的测量程序设计
开发语言·python