上一篇我们把 C++ 版本地图铺好了。C++11 里有一项改动,几乎改变了整个语言写高性能代码的方式:移动语义(Move Semantics)。很多人第一次遇到 std::moveT&& 时会困惑:「这到底是在拷贝还是在移动?」

这一篇我们拆开来看:左值 / 右值是什么、std::move 做了什么、移动构造如何省掉深拷贝,并带你跑 right_ref_demo demo。

这是「现代 C++ 实战」系列的第 3 篇。对应 demo:ref/cpp_demo/basics/right_ref_demo/。建议先读 第 02 篇:C++ 版本演进一览

一、为什么需要移动语义?

想象你在搬家:把书从旧书架搬到新书架,有两种做法:

做法 类比 C++ 中的对应
复印 把每本书复印一份放到新家,旧家原件还在 拷贝构造——两份独立数据
直接搬 把书整箱抬走,旧书架空了 移动构造——资源所有权转移

std::stringstd::vector 这类在堆上持有大块内存的类型,深拷贝意味着:

  1. 分配同等大小的新内存
  2. 逐字节复制内容
  3. 释放旧内存(拷贝完成后)

如果源对象马上就要销毁(比如函数返回的临时对象),再深拷贝一遍纯属浪费。移动语义允许「偷走」即将销毁对象的资源,只改几个指针,不复制数据。

二、左值 vs 右值:值类别直觉

C++ 表达式有值类别(value category),决定它能不能被移动、能不能绑定到 T&&

类别 直觉 例子
左值(lvalue) 有名字、能取地址、可以多次使用 int x = 1;vecfunc() 返回左值引用
右值(rvalue) 临时的、即将销毁的 42std::move(x)、函数返回的临时 string
纯右值(prvalue) 字面量、运算结果 1 + 2std::string("hello")
将亡值(xvalue) std::move 标记、可被移动的对象 std::move(vec)

一个实用判断法:

1
2
3
4
5
int x = 10;
int& ref = x; // OK:x 是左值
// int& ref2 = 10; // 错误:10 是右值,不能绑定到左值引用

const int& cref = 10; // OK:const 左值引用可以绑定右值(延长临时对象生命周期)

左值引用 T& 只能绑左值;右值引用 T&& 专门绑右值(也有例外,见完美转发)。

三、右值引用 T&&

C++11 引入右值引用语法 T&&

1
2
3
4
5
6
7
8
9
10
11
std::string make_name() {
return std::string("WALL-E"); // 返回临时 string(右值)
}

void consume(std::string&& s) { // 只接受右值
// s 的资源可以被「搬走」
}

std::string name = make_name(); // 移动或 RVO,不会无谓深拷贝
consume(std::move(name)); // 显式把 name 当作右值
// 此后不要再使用 name 的内容——已被移走

右值引用的绑定规则(简化版):

表达式 能否绑定 T& 能否绑定 T&&
左值 x ❌(除非 const T&& 特殊场景)
右值临时量 ❌(const T& 可以)
std::move(x)

四、std::move 的本质:类型转换,不移动

这是最常见的误解:std::move 本身不移动任何数据

1
2
3
4
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}

它只做一件事:把表达式强制转换为右值引用,告诉编译器:「这个对象可以被移动了,请优先选择移动构造 / 移动赋值。」

真正的移动发生在移动构造函数移动赋值运算符里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Buffer {
char* data_;
size_t size_;
public:
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}

// 移动构造:「偷」资源
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 源对象置空,析构时不会 double-free
other.size_ = 0;
}

~Buffer() { delete[] data_; }
};
操作 堆内存 速度
拷贝构造 新分配 + 复制 慢,O(n)
移动构造 指针交换 快,O(1)

五、Rule of Five 与 = default

如果一个类管理资源(指针、文件句柄、socket),通常需要显式实现或禁用以下五个函数:

  1. 析构函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符
  4. 移动构造函数(C++11)
  5. 移动赋值运算符(C++11)

现代写法:能用编译器生成的就用 = default

1
2
3
4
5
6
7
8
9
10
class Widget {
std::vector<int> data_; // vector 自带移动语义
public:
Widget() = default;
~Widget() = default;
Widget(const Widget&) = default;
Widget(Widget&&) noexcept = default;
Widget& operator=(const Widget&) = default;
Widget& operator=(Widget&&) noexcept = default;
};

Rule of Zero:如果成员都是 stringvectorunique_ptr 这类 RAII 类型,五个函数全部 = default 即可——编译器生成的移动操作通常是最优的。

六、完美转发 std::forward

写模板函数时,参数可能是左值也可能是右值。如果只用 std::move,会把一切都变成右值,破坏原有语义。

完美转发的目标:传进来是什么类别,就原样传给下一层。

1
2
3
4
template<typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // 保持 arg 原有的值类别
}
工具 作用
std::move(x) 无条件转为右值——「可以拿走了」
std::forward<T>(x) 有条件转发——模板推导时保持左/右值

std::forward 是移动语义与泛型编程的桥梁,后面智能指针、工厂函数、emplace 系列接口都依赖它。本系列第 04–05 篇讲智能指针时会再次遇到。

七、RVO / NRVO:编译器替你「移动」

有时你根本不需要手写 std::move,编译器会直接优化掉拷贝:

1
2
3
4
std::vector<int> make_vec() {
std::vector<int> v = {1, 2, 3};
return v; // NRVO:Named Return Value Optimization
}
优化 含义 你需要做什么
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
2
cd ref/cpp_demo/basics/right_ref_demo
./build.sh --run

若只想跑单个可执行文件:

1
./build.sh --run-args './build/<target_name>'

首次编译需 C++11 及以上(建议 C++17,与系列 Docker 环境一致)。若链接报错,回到 第 00 篇环境搭建 检查编译器版本。

建议实验

  1. 在移动构造里加一行 std::cout,观察何时触发移动、何时仍走拷贝。
  2. std::vector<std::string> 连续 push_back 一个大 string,对比有无移动语义时的耗时(demo 若有 benchmark 可直接跑)。
  3. 故意 return std::move(local),用 -RVO 禁用优化(GCC)对比汇编或日志差异——仅实验,生产代码不要这样写

十、小结

概念 一句话
移动语义 把即将销毁对象的资源转移给新对象,避免深拷贝
T&& 右值引用,绑定临时对象
std::move 类型转换为右值,不移动本身
移动构造/赋值 真正执行资源「偷窃」的地方
std::forward 模板中保持参数原有值类别
RVO/NRVO 编译器省略拷贝/移动的优化,优先于显式 move

移动语义是 C++11 性能革命的基石,也是 智能指针、emplace_back、移动迭代器 等特性的前提。搞懂它,后面几篇「谁拥有内存」才会真正说得通。

现代 C++ 实战系列第 3 篇完。下一篇我们进入 智能指针(上)——unique_ptrshared_ptr 如何用所有权语义替代手动 new/delete

系列导航

篇号 标题 状态
00 环境搭建与项目导览
01 CMake 与现代构建
02 C++ 版本演进一览
03 移动语义与右值引用(本篇)
04 智能指针(上):所有权与 RAII 下一篇

完整大纲见工作区 docs/CPP_SERIES_OUTLINE.md