现代 C++ 实战(03):移动语义与右值引用
上一篇我们把 C++ 版本地图铺好了。C++11 里有一项改动,几乎改变了整个语言写高性能代码的方式:移动语义(Move Semantics)。很多人第一次遇到 std::move 和 T&& 时会困惑:「这到底是在拷贝还是在移动?」
这一篇我们拆开来看:左值 / 右值是什么、std::move 做了什么、移动构造如何省掉深拷贝,并带你跑 right_ref_demo demo。
这是「现代 C++ 实战」系列的第 3 篇。对应 demo:
ref/cpp_demo/basics/right_ref_demo/。建议先读 第 02 篇:C++ 版本演进一览。
一、为什么需要移动语义?
想象你在搬家:把书从旧书架搬到新书架,有两种做法:
| 做法 | 类比 | C++ 中的对应 |
|---|---|---|
| 复印 | 把每本书复印一份放到新家,旧家原件还在 | 拷贝构造——两份独立数据 |
| 直接搬 | 把书整箱抬走,旧书架空了 | 移动构造——资源所有权转移 |
对 std::string、std::vector 这类在堆上持有大块内存的类型,深拷贝意味着:
- 分配同等大小的新内存
- 逐字节复制内容
- 释放旧内存(拷贝完成后)
如果源对象马上就要销毁(比如函数返回的临时对象),再深拷贝一遍纯属浪费。移动语义允许「偷走」即将销毁对象的资源,只改几个指针,不复制数据。
二、左值 vs 右值:值类别直觉
C++ 表达式有值类别(value category),决定它能不能被移动、能不能绑定到 T&&。
| 类别 | 直觉 | 例子 |
|---|---|---|
| 左值(lvalue) | 有名字、能取地址、可以多次使用 | int x = 1;、vec、func() 返回左值引用 |
| 右值(rvalue) | 临时的、即将销毁的 | 42、std::move(x)、函数返回的临时 string |
| 纯右值(prvalue) | 字面量、运算结果 | 1 + 2、std::string("hello") |
| 将亡值(xvalue) | 被 std::move 标记、可被移动的对象 |
std::move(vec) |
一个实用判断法:
1 | int x = 10; |
左值引用 T& 只能绑左值;右值引用 T&& 专门绑右值(也有例外,见完美转发)。
三、右值引用 T&&
C++11 引入右值引用语法 T&&:
1 | std::string make_name() { |
右值引用的绑定规则(简化版):
| 表达式 | 能否绑定 T& |
能否绑定 T&& |
|---|---|---|
左值 x |
✅ | ❌(除非 const T&& 特殊场景) |
| 右值临时量 | ❌(const T& 可以) |
✅ |
std::move(x) |
❌ | ✅ |
四、std::move 的本质:类型转换,不移动
这是最常见的误解:std::move 本身不移动任何数据。
1 | template<typename T> |
它只做一件事:把表达式强制转换为右值引用,告诉编译器:「这个对象可以被移动了,请优先选择移动构造 / 移动赋值。」
真正的移动发生在移动构造函数或移动赋值运算符里:
1 | class Buffer { |
| 操作 | 堆内存 | 速度 |
|---|---|---|
| 拷贝构造 | 新分配 + 复制 | 慢,O(n) |
| 移动构造 | 指针交换 | 快,O(1) |
五、Rule of Five 与 = default
如果一个类管理资源(指针、文件句柄、socket),通常需要显式实现或禁用以下五个函数:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数(C++11)
- 移动赋值运算符(C++11)
现代写法:能用编译器生成的就用 = default:
1 | class Widget { |
Rule of Zero:如果成员都是 string、vector、unique_ptr 这类 RAII 类型,五个函数全部 = default 即可——编译器生成的移动操作通常是最优的。
六、完美转发 std::forward
写模板函数时,参数可能是左值也可能是右值。如果只用 std::move,会把一切都变成右值,破坏原有语义。
完美转发的目标:传进来是什么类别,就原样传给下一层。
1 | template<typename T> |
| 工具 | 作用 |
|---|---|
std::move(x) |
无条件转为右值——「可以拿走了」 |
std::forward<T>(x) |
有条件转发——模板推导时保持左/右值 |
std::forward 是移动语义与泛型编程的桥梁,后面智能指针、工厂函数、emplace 系列接口都依赖它。本系列第 04–05 篇讲智能指针时会再次遇到。
七、RVO / NRVO:编译器替你「移动」
有时你根本不需要手写 std::move,编译器会直接优化掉拷贝:
1 | std::vector<int> make_vec() { |
| 优化 | 含义 | 你需要做什么 |
|---|---|---|
| RVO | 返回临时对象,直接在调用方内存构造 | 正常 return {} |
| NRVO | 返回命名局部变量,省略拷贝/移动 | 不要 return std::move(v)——反而可能阻止 NRVO |
| 移动 | 优化不可行时的后备 | 编译器自动选移动构造 |
经验法则:局部变量 return v; 不要包 std::move,让编译器先尝试 RVO/NRVO。
八、常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
move 后继续使用对象 |
对象处于「有效但未指定状态」 | 移走后只赋新值或销毁 |
对 const 对象 std::move |
仍走拷贝(移动构造通常是非 const) | 确保对象可变 |
移动构造函数未标 noexcept |
std::vector 扩容时可能退回拷贝 |
移动操作加 noexcept |
返回局部变量时 return std::move(v) |
可能阻止 NRVO | 直接 return v; |
误以为 std::move 会清空源对象 |
只有类型对应的移动赋值/构造才会 | 看具体类型的实现 |
九、demo 导览:right_ref_demo
ref/cpp_demo/basics/right_ref_demo/ 把移动语义拆成可运行示例,典型包含:
| 主题 | 你会看到什么 |
|---|---|
| 左值 / 右值识别 | 哪些表达式能绑 &,哪些不能 |
| 移动构造 vs 拷贝构造 | 日志或计数器对比调用次数 |
std::move 效果 |
资源指针被「偷走」后的状态 |
| 容器中的移动 | vector::push_back 触发移动而非拷贝 |
std::forward 雏形 |
模板参数转发 |
运行方式
1 | cd ref/cpp_demo/basics/right_ref_demo |
若只想跑单个可执行文件:
1 | ./build.sh --run-args './build/<target_name>' |
首次编译需 C++11 及以上(建议 C++17,与系列 Docker 环境一致)。若链接报错,回到 第 00 篇环境搭建 检查编译器版本。
建议实验
- 在移动构造里加一行
std::cout,观察何时触发移动、何时仍走拷贝。 - 对
std::vector<std::string>连续push_back一个大 string,对比有无移动语义时的耗时(demo 若有 benchmark 可直接跑)。 - 故意
return std::move(local),用-RVO禁用优化(GCC)对比汇编或日志差异——仅实验,生产代码不要这样写。
十、小结
| 概念 | 一句话 |
|---|---|
| 移动语义 | 把即将销毁对象的资源转移给新对象,避免深拷贝 |
T&& |
右值引用,绑定临时对象 |
std::move |
类型转换为右值,不移动本身 |
| 移动构造/赋值 | 真正执行资源「偷窃」的地方 |
std::forward |
模板中保持参数原有值类别 |
| RVO/NRVO | 编译器省略拷贝/移动的优化,优先于显式 move |
移动语义是 C++11 性能革命的基石,也是 智能指针、emplace_back、移动迭代器 等特性的前提。搞懂它,后面几篇「谁拥有内存」才会真正说得通。
现代 C++ 实战系列第 3 篇完。下一篇我们进入 智能指针(上)——
unique_ptr与shared_ptr如何用所有权语义替代手动new/delete。
系列导航
| 篇号 | 标题 | 状态 |
|---|---|---|
| 00 | 环境搭建与项目导览 | ✅ |
| 01 | CMake 与现代构建 | ✅ |
| 02 | C++ 版本演进一览 | ✅ |
| 03 | 移动语义与右值引用(本篇) | ✅ |
| 04 | 智能指针(上):所有权与 RAII | 下一篇 |
完整大纲见工作区 docs/CPP_SERIES_OUTLINE.md。











