C++ 移动语义

C++ 移动语义
小米里的大麦C++ 移动语义
1. 左值 vs 右值
1. 从一段代码开始
我们从最底层开始,看这几行代码:
1 | int a = 10; |
问一个问题:哪些是左值?哪些是右值? 可能大多数人的第一反应是:等号左边的是左值,等号右边的是右值。
这是最常见的误区,必须纠正:左值右值跟等号左右没关系。
2. 本质判断标准
我们换个角度问问题:
a能不能取地址?比如&a合法吗?10能不能取地址?比如&10合法吗?
答案是:a 合法,10 不合法。现在给一个 更本质的判断标准:
能不能被持续 “占有” 一块内存,并且有名字,可以反复用?
回来看刚才那几个:
1 | int a = 10; |
思考:
a赋值给b之后,a还在吗?还能继续用吗?10赋值结束之后,它还能被你再访问吗?
答案:
a是个 有名字、有固定位置 的东西(能一直拿它用,能取地址)10只是个 临时值,用完就没了(不能对它取地址)- 注意:
10不是 “旧对象新对象”,它压根就不是 “一个我们能抓住的对象”
3. 第一个判断题
1 | int x = 10; |
r1这行能编译。对/错?r2这行能编译。对/错?
先纠正一个概念:int& 不是指针,它是 引用,引用必须绑定到 “一个真实存在、可持续存在的对象”。 逐行看:
1 | int x = 10; |
原因一句话:左值引用(T&)只能绑定左值,10 是右值(临时值)。
4. 正式定义
- 左值 = 有身份、有地址、能反复使用
- 右值 = 临时值、即将消失、不能取地址
5. 函数返回值的情况
下一个思考题:
1 | int foo() { return 10; } |
foo() 返回的是 10,那这里:
foo()是左值还是右值?- 这行代码能不能编译?
答案: foo() 这种 “按值返回” 的结果,是个 临时值,表达式用完就没了。所以:
foo()这个表达式是 右值int& r = foo();这行 不能编译(左值引用绑不了右值)
但有个 “转折” 很好玩:
1 | const int& r = foo(); // ✅ 这个能编译 |
因为 const 左值引用可以绑定右值,并且会把临时值 “续命” 到 r 的生命周期结束。
6. 为什么 const int& 可以绑右值?
问题来了:为啥 const int& 可以绑右值,但 int& 不行?
核心原因就一句话:
- 右值是临时的,马上就要销毁
- 如果允许
int&绑定它,你就能修改一个 “马上要消失” 的东西 - 这会制造悬空引用和未定义行为
而:
const int&只能读,不能改- 编译器就可以安全地 “延长这个临时值的生命周期”
所以它其实是在做一件事:给临时值续命,并且保证你不能乱改它。 对比:
1 | int& r = 10; // ❌ |
如果第一行合法,就可以:r = 20;,这其实是在改一个 “本来不存在实体” 的临时值,这就炸了!
7. 右值引用登场
下一个判断题(关键):
1 | int x = 10; |
这行能不能编译?想想:
10是右值&&是什么引用?- 它是专门为谁准备的?
答案:能编译! && 是右值引用,& 是左值引用,&& 和 & 分别用于相反的情况。所以我们只需要记住:
T&:专绑左值(有名字、能取地址)T&&:专绑右值(临时值)
8. 小练习总结
1 | int x = 10; |
能编译的是:a、c、d
9. 小结
- 左值:有名字、能取地址、能长期存在
- 右值:临时值、表达式结果、不能直接取地址
T&(左值引用)只能绑定左值T&&(右值引用)只能绑定右值const T&(const 左值引用)可以绑定右值(但不能修改)
2. 右值引用(&&)与移动构造函数
1. 为什么需要右值引用?
一个关键问题:为什么 C++ 要专门搞一个 “右值引用”?如果已经有 const T& 能绑右值了,那 T&& 是为了解决什么问题?
先看这段代码:
1 | std::string s1 = "hello"; |
这里发生的是啥?是 拷贝。那再看这个:
1 | std::string s2 = std::string("hello"); |
右边这个 std::string("hello") 是啥?左值还是右值?
答案:
- 是一个 匿名临时对象
- 是 右值
- 没人再用它
- 表达式结束就要销毁
关键问题来了: 既然它马上要销毁,那我们还有必要 “拷贝” 它内部的数据吗?还是可以 “直接偷走” 它内部那块内存?
很显然:右值反正没人要了,直接 “搬走/偷走” 它的资源最划算。
2. 移动语义的核心
直接把匿名临时对象给一个 “名字” 就能延长生命周期,并且没有拷贝构造,省资源还快。需要纠正的是:不是 “给右值起名字来延长生命周期”,移动语义更核心的是:
把右值内部的资源指针/句柄挪给新对象,然后把旧对象改成 “空壳”,让它析构时不再释放那份资源。
这就叫 move(移动)。
3. 为什么 const T& 不够用?
为啥 const T& 不够用?因为:
const T&绑定右值以后,不能改它- 但 “移动” 这件事,本质上需要 改旧对象(把它置空)
所以要一个新东西:
T&&:我明确告诉你 “这东西是右值,没人要了,你可以安全地把它掏空”。
4. 移动构造函数示例
来看一个自定义类的移动构造函数:
1 | class MyString |
关键点:
- 参数是
MyString&&(右值引用) - 直接 “偷走”
other.data指针 - 把
other置为空壳(data = nullptr) noexcept标记(移动操作不应该抛出异常)
5. 返回值优化示例
1 | std::string make() |
这里 make() 的返回值,适合被拷贝还是被移动?
答案是:移动!
- 返回值是右值
- 右值没人再用
- 可以安全 “掏空”
- 所以优先走移动构造
这就是移动语义存在的根本原因:减少不必要的深拷贝。
3. 移动赋值运算符
1. 移动构造 vs 移动赋值
你觉得 “移动构造” 和 “移动赋值” 最大的区别是啥?
核心区别一句话:
- 移动构造:对象还没出生
- 移动赋值:对象已经存在了
用例子看:
1. 移动构造(新对象刚创建)
1 | std::string s1 = "hello"; |
这里 s2 是第一次被创建,调用的是:string(string&&) 即 移动构造。
2. 移动赋值(对象已经存在)
1 | std::string s1 = "hello"; |
这里 s2 早就存在了,调用的是:operator=(string&&) 即 移动赋值。
所以移动构造和移动赋值的关键区别不在于 “等号和圆括号”,而是:是初始化阶段,还是已存在对象的替换阶段。
2. 移动赋值运算符的实现
实现移动赋值运算符,需要注意以下几点:
1 | class MyString |
实现要点:
- 自赋值检查:
if (this != &other)(虽然移动后 other 是空壳,但这是好习惯) - 先释放自己原有的资源(避免内存泄漏)
- 偷走对方的资源(指针直接赋值)
- 把对方置为空壳(避免析构时重复释放)
- 返回
*this(支持链式赋值) - 标记
noexcept(移动操作不应抛异常)
3. 完整的规则类示例
一个遵循 “三/五法则” 的完整类:
1 | class MyString |
4. 小测试
1 | std::string s1 = "hello"; |
记住口诀:有名字 = 左值 = 默认拷贝;想移动就 move。
4. std:: move 的原理与使用场景
1. std:: move 的本质
std::move(x) 本质就是把 x 强制转换成右值引用,它干的事接近于:
1 | static_cast<T&&>(x); |
它不移动数据! 它只是告诉编译器:这个对象你可以当右值看待了。 真正发生移动的是 —— 移动构造函数 / 移动赋值函数。
2. 哪一行在 “偷资源”?
一个关键的问题:
1 | std::string s = "hello"; |
到底是哪一行代码 “偷走” 了 s 里的资源?是 std::move 偷的?还是 std::string 的某个函数偷的?
答案:
std::move(s):只做 “把 s 变成右值” 的动作- 真正 “偷资源” 的,是这句在 初始化 t 时触发的
std::string的移动构造函数
所以 “偷” 的是 std::string(std::string&&)(移动构造)。
3. move 之后对象还能用吗?
一个小检查(很重要,跟踩坑有关):用了 std::move(s) 之后,s 还能不能用?注意问题:不是问 “还能不能读出原来的内容”,而是问:还能不能继续当一个正常对象用(比如再赋值、再 push_back)?
答案: std::move(s) 之后:
s还活着,没被析构- 只是变成了 “被移动过的状态”
- 标准要求:它必须是 valid but unspecified,也就是:对象还能用,但里面是啥不保证
所以可以对 s 做这些:
- 再赋值:
s = "abc"; s.clear()、s.size()- 继续
push_back(一般都行)
但你不能依赖这些:
- 不能指望
s还是"hello" - 不能写业务逻辑基于
s的内容
总之:move 之后的对象能 “重新用”,但别指望它 “还保持原样”。
4. 常见使用场景
1. 从函数返回大对象
1 | std::vector<int> createVector() |
建议:让编译器自动优化,不要手动 std::move 返回值。
2. 转移容器内容
1 | std::vector<int> v1 = {1, 2, 3}; |
3. unique_ptr 所有权转移
1 | std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>(); |
4. 函数参数传递
1 | void process(std::string data); // 按值传递 |
5. 常见陷阱
陷阱 1:对 const 对象使用 move
1 | const std::string s = "hello"; |
原因:std::move(s) 把 s 转成 const string&&,但移动构造函数需要 string&&(非 const),所以只能匹配到拷贝构造函数。
结论:别对 const 对象用 move,没用。
陷阱 2:move 后继续使用原对象的内容
1 | std::string s = "hello"; |
正确做法:move 之后要么重新赋值,要么别再依赖它的内容。
陷阱 3:有名字的变量永远是左值
1 | std::string s1 = "hello"; |
口诀:变量默认是左值;想移动就 move。
5. 拷贝 vs 移动的选择
1. 何时发生拷贝?
| 场景 | 示例 | 发生什么 |
|---|---|---|
| 左值初始化 | T t1 = t2;(t2 是左值) | 拷贝构造 |
| 左值赋值 | t1 = t2; | 拷贝赋值 |
| 函数按值传左值 | func(obj); | 拷贝构造 |
| 函数返回左值引用 | return obj;(obj 是局部变量) | 拷贝构造(或移动) |
2. 何时发生移动?
| 场景 | 示例 | 发生什么 |
|---|---|---|
| 右值初始化 | T t1 = T(); | 移动构造 |
| std:: move 左值 | T t1 = std::move(t2); | 移动构造 |
| 函数返回临时对象 | return T(); | 移动构造(或 RVO) |
| 右值赋值 | t1 = T(); | 移动赋值 |
3. 性能考量
拷贝的成本:
- 深拷贝:分配新内存 + 复制所有数据
- 时间复杂度:O(n)
- 可能抛异常(内存分配失败)
移动的成本:
- 指针/句柄转移
- 时间复杂度:O(1)
- 通常不抛异常(应标记 noexcept)
性能对比示例:
1 | // 拷贝:可能分配几 MB 内存,复制大量数据 |
4. 选择策略
策略 1:默认让编译器决定
1 | std::vector<int> create() |
策略 2:明确要转移所有权时用 move
1 | std::unique_ptr<Resource> getResource(); |
策略 3:函数内部对局部变量不用 move
1 | std::vector<int> create() |
策略 4:按值传递 + 内部 move
1 | class MyClass |
为什么这样更好?
- 调用方传左值:拷贝一次(在参数传递时)
- 调用方传右值:移动一次(在参数传递时)
- 内部再 move 到成员变量:移动一次
- 总共:左值调用 = 1 拷贝 +1 移动;右值调用 = 2 移动
对比传统写法(const 引用 + 拷贝):
- 左值调用:1 拷贝
- 右值调用:1 拷贝(多了一次不必要的深拷贝)
5. 决策流程图
1 | 要复制一个对象吗? |
6. 完美转发(std:: forward)
1. 从一个问题开始
先问一个铺垫问题:void foo(std::string&& s);,如果在函数里这样写:foo(s);,能不能调用成功?(注意:s 是函数参数,类型是 std::string&&)
答案是:不能直接 foo(s),必须 foo(std::move(s))。
1 | void bar(std::string&& s) |
为什么第一行不行?
- 因为 s 有名字
- 有名字就是左值
- 而 foo 需要的是右值引用
所以即便 s 的类型是 string&&,只要它有名字,它就是左值。
2. 模板函数的困境
现在关键来了,如果我们写模板函数:
1 | template<typename T> |
我们根本不知道:
- 传进来的原本是左值?
- 还是右值?
但 arg 一旦有名字,就变成左值了。这时候就需要:std::forward<T>(arg)
3. 转发引用(万能引用)
紧接着先不讲定义,来一个思考题:如果调用:
1 | std::string s = "hello"; |
wrapper 里应该怎么 “原样转发” 给 foo 才是正确行为?首先声明不会报错,它能推导出来,而且这是模板里最经典的规则之一。
答案:当参数写成 T&&,并且 T 需要推导时,它不是普通的 “右值引用”,而是:转发引用(forwarding reference,也叫万能引用),推导规则是:
- 传进来是左值:
T推导成T = std::string& - 传进来是右值:
T推导成T = std::string
4. 引用折叠规则
紧接的一个问题(接上面):如果 T = std::string&,那 T&& 变成什么类型?
答案是:std::string& && 会折叠成 std::string&。 这就是 引用折叠规则:
| 组合 | 结果 |
|---|---|
& & | & |
& && | & |
&& & | & |
&& && | && |
所以我们只需要记住:只要出现一个
&,结果就是左值引用。
5. 完整推导过程
我们把整个过程串起来,当写出:
1 | std::string s = "hello"; |
推导过程是:
s是左值T推导成std::string&T&&变成std::string& &&- 折叠后是
std::string&
所以 arg 在函数里是:左值引用。那如果调用:
1 | wrapper(std::string("hi")); |
这次传进来是右值。T 推导成 std::string(不是 std::string&&),然后参数类型是 T&&,所以变成:std::string&&。
总结:
- 传左值:
T = std::string&,参数变std::string& - 传右值:
T = std::string,参数变std::string&&
这就是 “万能引用/转发引用” 能同时接住左值和右值的原因。
6. std:: forward 的作用
在 wrapper 里,arg 这个变量 有名字,所以表达式 arg 永远是左值。
但我们希望:
- 原来是左值 → 继续当左值传
- 原来是右值 → 继续当右值传
std::forward<T>(arg) 就是干这个的。
7. 最后的判断题
在 wrapper 里,如果写:
1 | foo(arg); |
对于 wrapper(std::string("hi")) 这种传右值的调用,会不会错误地变成 “拷贝/绑定到左值” 那一套?
这里就是关键坑了——会!
它不会 “自己判断”,原因很简单:在 wrapper 里面,arg 是一个 有名字的变量,只要有名字,它就是左值表达式。所以:foo(arg);,哪怕最开始传进来的是右值,到这里也会变成:
- 以左值的身份传给
foo - 走拷贝版本
- 或者根本匹配不到
foo(T&&)
这就把 “移动机会” 浪费掉了。这也是为什么必须写:
1 | foo(std::forward<T>(arg)); |
它的作用是:
- 如果原本是左值 → 继续左值
- 如果原本是右值 → 还原成右值
它不是 “判断”,它是利用 T 的推导结果做类型恢复。
8. 为什么不能用 std:: move 替代?
一个关键问题:为什么在模板里不能直接用 std::move(arg)?为什么必须用 std::forward<T>(arg)?
答案:
move会无脑把东西变右值,哪怕传进来本来是左值也会被你 “偷走”forward会按原样转发:传进来是左值就保持左值,传进来是右值才转右值
所以:
- 写库/模板:用
forward - 自己明确要把某个变量 “交出去不再用”:用
move
9. 完美转发完整示例
1 | template<typename T> |
10. 小测试
写一个函数:
1 | void setName(std::string name); |
如果调用方传右值,比如 setName("abc"),希望里面存到成员变量时更快,那在函数内部更适合用:
- A.
member = name; - B.
member = std::move(name);
选哪个?—— B
name是按值传进来的副本- 函数里已经没人再用它
- 所以可以安全
std::move(name) - 避免一次拷贝
这就是 “值传递 + 内部 move” 的常见写法。
7. emplace vs insert/push_back
1. 基本概念
1. push_back()
1 | std::vector<std::string> v; |
这里会发生什么?
"hello"先变成一个临时std::string- 然后把这个临时对象放进 vector(拷贝或移动)
2. emplace_back()
1 | std::vector<std::string> v; |
它干的事是:
- 直接在 vector 内部那块内存上构造
std::string - 不需要先产生一个临时 string 再搬进去
关键区别一句话:
- push_back 是 “把一个对象塞进去”
- emplace_back 是 “在里面直接构造对象”
2. 性能对比
问题来了:
1 | std::string s = "abc"; |
这两行有性能差别吗?这里基本没差别。 我们慢慢推。
1 | std::string s = "abc"; |
s 是啥?是左值,所以会调用:拷贝构造,再看:
1 | v.emplace_back(s); |
s 还是左值。emplace_back 会把参数 原样转发 给构造函数。那最终还是:用 std::string(const std::string&) 构造,也就是——还是拷贝。所以这里两行几乎一样。
3. emplace_back 真正有优势的场景
关键问题来了(非常重要):那什么时候 emplace_back 才真的有优势? 比如下面这种:
1 | v.emplace_back(10, 'a'); |
请问:这行如果用 push_back 要怎么写?
注意: push_back 不是不能传多个参数,而是——它 只能接收一个 “已经构造好的对象”。看这个:
1 | v.emplace_back(10, 'a'); |
这里是调用 std::string(size_t n, char c) 构造函数。意思是:构造一个 "aaaaaaaaaa"(10 个 a)。如果用 push_back,你得先构造一个对象:
1 | v.push_back(std::string(10, 'a')); |
区别在哪?
push_back:先构造一个临时 string,再把它移动进 vectoremplace_back:直接在 vector 里面那块内存上构造,没有那个临时对象
因为 push_back(std::string(...)) 多出来的那一步,基本就是一次移动构造(一般很便宜)。
所以现实里常见结论是:
- emplace_back 更通用、更顺手
- 但 不一定总是更快
- 差距常常小到测不出来(除非对象很重/没移动/有奇怪构造)
4. emplace_back 的陷阱
但还有一个更关键、更容易踩坑的点:emplace_back 可能会 “选中我们没想到的构造函数”,比如:
1 | std::vector<std::vector<int>> vv; |
一个判断题:这两行都能编译吗?还是有一行会报错?
答案是:
push_back({1,2,3})✅ 能编译emplace_back({1,2,3})❌ 可能报错(在很多编译器下)
原因不在 vector,而在花括号的推导规则。 我们慢慢推一下。
1 | vv.push_back({1,2,3}); |
这里 {1,2,3} 会被当成:std::vector<int>{1,2,3} 临时对象,然后 push_back 接收一个对象,没问题。但:
1 | vv.emplace_back({1,2,3}); |
emplace_back 是模板函数,模板遇到 {} 初始化列表时:
- 类型推导可能失败
- 或选不到正确构造函数
因为 {} 在模板推导里是个 “特殊怪物”。解决办法一般是:
1 | vv.emplace_back(std::initializer_list<int>{1,2,3}); |
所以总结一句非常实用的话:
- emplace_back 更灵活,但有时会因为模板推导踩坑
- 不是所有场景都盲目用 emplace
5. 左值场景
如果写:
1 | v.emplace_back(someObj); |
而 someObj 是左值,它会调用移动构造吗?
答案:不会。 原因就一句话:
someObj是左值emplace_back(someObj)会把它当左值转发进去- 最后还是走拷贝构造
想移动,必须明确表态:
1 | v.emplace_back(std::move(someObj)); |
两者都行。
6. 总结对比表
| 场景 | push_back | emplace_back | 性能差异 |
|---|---|---|---|
| 传左值 | 拷贝构造 | 拷贝构造 | 无差异 |
| 传右值 | 移动构造 | 移动构造 | 无差异 |
| 传构造参数 | 需先构造临时对象 | 直接原地构造 | emplace 略优 |
| 花括号初始化 | 通常正常 | 可能推导失败 | push_back 更安全 |
| 无移动构造的类型 | 拷贝构造 | 原地构造 | emplace 明显优 |
7. 使用建议
什么时候两者几乎一样?
- 传左值
- 或传右值但类型有高效移动构造
什么时候 emplace 更好?
- 直接传构造参数(如
emplace_back(10, 'a')) - 类型没有移动构造
- 想避免多一次临时对象
什么时候别乱用 emplace?
- 花括号推导复杂场景
- 构造函数很多、可能选错构造函数
8. 记忆口诀
- emplace_back(参数…):在里面直接构造
- push_back(对象):把对象塞进去
- 传左值 = 拷贝
- 想移动就 move
8. 总结
到这一步,我们已经打通了一整条现代 C++ 主线:
1. 概念回顾
- RAII —— 资源跟对象走
- 左值/右值 —— 谁能被反复使用
- 右值引用 —— 专门为移动准备
- std:: move —— 只是类型转换
- 移动构造/赋值 —— 真正偷资源
- std:: forward —— 模板里保留值类别
- emplace_back —— 原地构造对象
2. 速查表
1. 左值 vs 右值判断
1 | 有名字 + 能取地址 + 能反复用 = 左值 |
2. 引用绑定规则
1 | T& → 只能绑左值 |
3. 拷贝 vs 移动
1 | 有名字的变量 = 左值 = 默认拷贝 |
4. move vs forward
1 | 自己用 = std::move() |
5. push_back vs emplace_back
1 | 已有对象 = push_back |
3. 注意事项
- 不要对 const 对象用 move(没用,还是拷贝)
- move 之后的对象别乱用(内容不保证)
- 模板里用 forward,别用 move(会破坏左值)
- 返回值不要手动 move(让编译器优化)
- emplace_back 不是银弹(传左值时没优势)
- 移动操作标记 noexcept(让容器更高效)
4. 最后的话
- 移动语义是现代 C++ 的基石之一。
- 记住核心思想:右值反正没人要了,直接 “偷走” 它的资源最划算。
- 记住行动口诀:变量默认是左值;想移动就 move;模板转发用 forward。













