如果你在 C++ 里写过一个带动态内存的类,大概率对这件事不陌生:函数返回一个大的std::vector时,明明是一条临时数据,却总感觉多了一次深拷贝;往容器里塞字符串时,稍不注意整个程序就慢一个数量级。CS106L Lecture 14 的 Move Semantics 这一讲,就是专门解决这些问题的。这篇笔记不是 PPT 原文的复述,而是我把右值引用、移动构造、std::move、完美转发这些概念重新串了一遍之后整理的完整脉络。适合已经能写简单 C++ 类、但还分不清“拷贝”和“移动”到底差在哪的同学。
先说结论:移动语义不会让程序凭空变快,它只是把“必须深拷贝”的场景,换成“偷走对方的资源”的场景。这句话理解到位,后面所有细节都会很顺。
1. 为什么需要移动语义:从一次性能瓶颈说起
1.1 拷贝开销的真相
很多人第一次意识到拷贝开销,是从std::vector<std::string>这类嵌套容器开始的。一个std::string本身可能只有几十字节,但它内部可能指向堆上的一块字符数组。拷贝一个字符串,不只是复制那几十字节,还要 new 一块新的堆内存,把字符逐个搬过去。如果字符串很长,这就是一次实打实的 O(n) 操作。
自定义类也是一样。假设你写了一个Buffer类,里面有一个int* data指向动态数组:
class Buffer { public: Buffer(size_t n) : size(n), data(new int[n]) {} ~Buffer() { delete[] data; } Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) { std::copy(other.data, other.data + other.size, data); } private: size_t size; int* data; };这里构造函数 new 了一块内存,拷贝构造函数又 new 了一块内存并复制所有元素。如果other只是一个马上要被销毁的临时对象,这次深拷贝就是纯浪费。旧对象反正要死,新对象直接把旧对象的data指针拿过来,让旧对象变成空壳,就可以省掉一次分配加复制。移动语义就是在语言层面把这种“偷资源”行为规范化。
1.2 左值、右值是什么
要理解移动,先要理解移动机制靠什么区分对象。C++ 里常见的分类是左值和右值,如果用最简单的话说,左值是有名字、能取地址的对象,右值是临时值、表达式计算结果,通常没有稳定身份。
int a = 42; // a 是左值 int b = a + 1; // a + 1 的结果是右值a + 1这个临时结果不会被再复用,所以如果我们要拿a + 1去构造另一个对象,完全可以让新对象直接接管这个临时结果的资源。C++ 的难点在于:编译器怎么知道一个表达式是左值还是右值?答案是靠表达式类别,而移动语义的入口,就是让函数重载能区分这两种值。
1.3 “偷”而不是“复制”
打个比方。你要把一箱书从客厅搬到书房,拷贝的做法是:拿一沓新纸,把每本书的内容抄一遍,然后把新纸搬过去。移动的做法是:把整个箱子抱起来,放到书房,然后在客厅墙上留个标签“箱子现在在书房”。
箱子还是那个箱子,资源没有重新生成。被移动的对象不是被销毁,而是被“掏空”,留下一个合法的空壳。C++ 里移动操作一般会把源对象的资源指针置空,这样源对象析构时就不会 double free。
这一点想通之后,移动构造函数要做什么就非常清晰了:把目标对象的成员指针指向源对象的资源,再把源对象的成员指针置为 null 或者安全状态。
2. 核心机制:右值引用与移动构造
2.1 右值引用的语法与绑定规则
C++11 引入了右值引用,写法是T&&,专门用来绑定右值。右值引用的出现,让重载可以区分“要复制一个左值”和“要移动一个临时对象”。
void foo(const std::string& s); // 能接收左值和右值 void foo(std::string&& s); // 只接收右值当实参是临时对象时,第二个重载会被选中。这个选择发生在编译期,不产生运行时开销。public 接口通过这个机制,把“这个对象马上要死,你可以偷它”的信号传给了接收方。
需要注意,T&&只是“右值引用”,它本身是一个变量,变量有名字,所以它其实是左值。这句话是移动语义里最容易绕晕的地方:
void bar(std::string&& s) { // 这里 s 是左值,虽然它的类型是右值引用 another_foo(s); // 不会自动触发移动 }因为s有名字,使用它就相当于使用一个左值。要让另一个函数把s当右值处理,必须显式使用std::move(s)。这种设计其实是 C++ 的一种安全策略:你不想让资源在你不注意的时候被偷走。
2.2 移动构造函数怎么写
继续用Buffer举例。要支持移动,需要给类增加移动构造函数:
class Buffer { public: Buffer(size_t n) : size(n), data(new int[n]) {} ~Buffer() { delete[] data; } Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) { std::copy(other.data, other.data + other.size, data); } Buffer(Buffer&& other) noexcept : size(other.size), data(other.data) { other.size = 0; other.data = nullptr; } private: size_t size; int* data; };移动构造函数从other手里接管data指针,然后把other.data置空。这样当other析构时,delete nullptr是安全的。把size清零不是必须的,但能避免一些调试时读到奇怪状态的困惑。
noexcept很重要。std::vector扩容时需要把元素搬去新内存,如果移动构造函数可能抛异常,vector 就不敢用移动,只能退回拷贝,否则元素不完整时会很难处理。标上noexcept是在告诉标准库:这个操作不会失败,你可以放心移动。
移动构造函数的调用场景大概长这样:
std::vector<Buffer> v; v.push_back(Buffer(1024));这里的Buffer(1024)是右值,push_back 内部会优先调用移动构造函数,把临时 Buffer 的资源偷给 vector 里的元素。如果没有移动构造函数,就会退回到拷贝构造,多一次完整深拷贝。
2.3 移动赋值与默认行为
有移动构造,自然就有移动赋值。移动赋值要处理的逻辑比移动构造多一步:先把目标对象自己的旧资源释放掉,再从源对象手里接管新资源。
Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data; // 释放旧资源 size = other.size; data = other.data; // 接管新资源 other.size = 0; other.data = nullptr; } return *this; }自移动检查this != &other不是每次都必须写,但遇到a = std::move(a)这种代码时可以防止把资源清掉再接管自己的空壳。标准库里很多容器确实不保证自移动安全,所以自己写的时候先判断一次没坏处。
如果类里所有成员都能正确移动,C++ 会隐式生成移动构造和移动赋值。但一旦你自己写了析构函数、拷贝构造或拷贝赋值,编译器通常就不会自动生成移动操作。这也是“只要写了析构函数,最好把五个特殊成员函数都考虑一遍”的原因。
3. std::move 与 std::forward:真正的使用姿势
3.1 std::move 只是转型,不是移动指令
从字面意思看,std::move好像真的执行了移动。但它的实现本质上就是一条强制类型转换,把左值转成右值引用。它不搬任何数据,不生成任何汇编指令。
std::string a = "hello"; std::string b = std::move(a); // 这里真正执行移动的是 string 的移动构造std::move(a)只告诉编译器:“请把 a 当右值处理”,然后重载决议找到std::string(std::string&&),移动构造才真正把 a 的堆内存偷给 b。所以不要以为写一个std::move就万事大吉,关键是接收端有没有对应的移动操作。
滥用std::move是新手常见问题。比如在函数里返回一个局部变量时写return std::move(obj);,这往往不是优化,而是阻止了编译器本可以做的返回值优化。后面会细说。
3.2 完美转发与引用折叠
有些场景下,你写一个模板函数,想把参数原封不动地转发给另一个函数,保持参数的左值/右值属性。这时候就需要std::forward<T>。
template <typename T> void wrapper(T&& arg) { target(std::forward<T>(arg)); }这里的T&&不是右值引用,而是转发引用,也叫通用引用。如果调用wrapper(obj),T会被推导为T&,arg是左值引用;如果调用wrapper(std::move(obj)),T会被推导为T,arg是右值引用。
std::forward<T>的作用就是把这个引用属性在转发时保留下来。它本质上也是有条件转型:当T是左值引用时什么都不做,当T不是引用时再把参数转成右值引用。
这里牵扯到引用折叠规则,可以一句话记:T& &折叠成T&,T& &&折叠成T&,其他带&&的情况折叠成T&&。模板推导加上折叠规则,让T&&在模板里变成“左右通吃”的转发引用,而不是单纯的右值引用。
3.3 返回值优化与移动的关系
现代编译器对“返回局部对象”这件事做了大量优化,叫 RVO(Return Value Optimization)和 NRVO(Named Return Value Optimization)。C++17 之后,对于返回纯右值的场景,编译器可以直接在调用方构造返回值,不产生临时对象,拷贝和移动都不需要。
std::string make_string() { std::string result = "hello"; return result; // 现代编译器通常直接构造到调用方,不发生移动 }针对这种代码,再去写return std::move(result);反而可能破坏 NRVO。因为std::move(result)返回的是一个右值引用,编译器被迫走移动路径,而移动至少还要把指针偷过来。原本 NRVO 可以做到零操作,你这么一写反而多了一个移动构造。
我个人的习惯是:返回局部变量时直接return name;,不要画蛇添足加std::move。
4. 典型场景与性能实测
4.1 STL 容器与 std::string 的移动
标准库容器基本都实现了移动语义。std::vector的移动构造函数会把源 vector 的堆缓冲区整个拿走,源 vector 变成空的。之后源 vector 仍然可以正常使用:可以 clear、可以重新 push_back、可以析构。
std::string也有移动构造,但有一个例外要注意:短字符串优化(SSO)。很多标准库实现里,短字符串直接存在对象内部的空间,不涉及堆分配,所以“移动”一个短字符串和“拷贝”一个短字符串成本差不多。如果你看到移动一个短字符串没有变快,不用惊讶,这是实现细节导致的正常现象。
往容器里放对象的时候,移动语义配合得很好:
std::vector<std::string> lines; std::string line = read_line(); lines.push_back(std::move(line)); // line 之后变成空串或未指定状态这里如果不用std::move(line),push_back 会拷贝一次字符串。用std::move之后,vector 直接接管 line 的堆内存,性能差距在线很长、字符串很多时非常明显。
4.2 自定义类的移动实现要点
自己写类时,如果成员本身就是 RAII 类型,通常不需要手写移动操作。std::unique_ptr、std::shared_ptr、容器这些成员都有正确的移动语义,编译器生成的默认移动就会逐个移动成员,效果刚刚好。
比如新版 Buffer 可以用智能指针从源头避免手动管理:
class Buffer { std::unique_ptr<int[]> data; size_t size; public: Buffer(size_t n) : data(std::make_unique<int[]>(n)), size(n) {} // 不需要手写析构、拷贝、移动,智能指针自动处理 };但如果类里有裸指针、文件句柄、socket 这些需要手动管理的东西,还是要遵守 Rule of Five:析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值,要么全部明确处理,要么全部不写让编译器默认,最忌讳只写一个拷贝构造,然后其他操作行为变得诡异。
移动构造里还要注意成员顺序。成员初始化列表是按类中声明的顺序执行的,不是按列表顺序。移动构造里如果处理裸指针,尽量把“接管资源”和“置空源对象”做成一对操作,避免异常期间出现部分移动。
4.3 实测对比:拷贝 vs 移动
我曾在自己的机器上试过构造一个很大的std::vector<std::string>,里面存几千个超长字符串,然后分别用拷贝和移动往另一个 vector 里填充。如果只看 CPU 耗时,量级大概是:
| 操作 | 1 万条长字符串耗时量级 | 是否额外分配堆内存 |
|---|---|---|
| 拷贝 | 几十毫秒到上百毫秒 | 是,每条字符串都重新分配 |
| 移动 | 几毫秒左右 | 否,只偷指针 |
实际数值会因为字符串长度、分配器实现、缓存状态变化很大,但“移动比拷贝低一个数量级以上”这个结论在长字符串场景下很稳定。如果字符串都很短,SSO 会让两者的差距缩小,移动依然不会比拷贝慢。
这个实验结果很直观地说明:移动语义的收益集中在“资源转移”而不是“复制数据”。数据量越大,节省的拷贝成本越明显。
5. 避坑指南:移动后对象状态与常见问题
5.1 移动后的对象到底还能不能用
标准文档对“被移动后的对象”只给了非常宽松的保证:对象处于合法但未指定状态。所谓合法,就是你可以析构它,可以给它重新赋值,可以调用不依赖其内部具体值的成员函数。但你不能假定它一定为空。
a = std::move(b);之后,b可能是空串,也可能不是,这取决于实现。比如std::string的移动操作在大多数实现下会让源 string 变为空,但有些实现出于某些原因可能保留内容。所以写完移动之后,如果你还要依赖源变量的具体值,必须在移动后显式重置:
std::string a = std::move(b); b.clear(); // 如果你确定之后要用 b,最好显式清一下不要试图“猜”移动后的状态,这是很多诡异 bug 的来源。
5.2 常见问题速查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
调用std::move后源对象还能读到旧数据 | 源对象没有被置空;或者只是内置类型,移动就是拷贝 | 不依赖源对象状态,必要时手动复位 |
| vector 扩容时性能没有提升 | 移动构造函数没有标记noexcept | 给移动构造和移动赋值加noexcept |
模板函数里std::forward写成了std::move | 把转发引用和右值引用搞混 | 确认T&&是否为转发引用,按 T 的类型选择 |
返回局部变量用std::move后变慢 | 破坏了 NRVO | 直接return 变量名; |
| 类的移动构造没有被调用 | 类里有自定义析构或其他特殊成员,编译器不自动生成移动操作 | 显式= default或补充移动构造 |
5.3 课程里容易考的细节
CS106L 这份讲义对概念辨析考得很细,几个高频点我记下来了。
第一个是左值和右值的判断。int&& r = 5;之后,r本身是左值,因为它是具名变量。把r传给另一个函数时,不会自动触发移动。
第二个是T&&在模板和非模板中的区别。非模板函数参数里的T&&是右值引用;模板函数里的T&&可能是转发引用。这是两个完全不同的语境。
第三个是std::move不移动、std::forward不转发,它们都是编译期类型转换工具。真正的移动操作由移动构造函数或移动赋值运算符完成。
第四个是移动构造的重载优先级。对于一个右值,同时存在const T&和T&&两个重载时,编译器会优先选择T&&。这正是移动语义能生效的重载基础。
6. 总结与个人实操心得
6.1 我踩过的几个坑
写移动语义代码,我实打实踩过几次坑。第一次是在 map 的 key 上直接std::move,结果 key 被偷走,后面又用它查 map,行为完全不可预测。第二次是写了一个没有noexcept的移动构造,vector 扩容时还是走拷贝,性能优化了个寂寞。第三次是在模板转发函数里习惯了写std::move,把参数转发了出去,导致接收函数拿到的是右值引用,最终在非预期的地方触发了移动。
现在我的默认原则是:不写泛型代码时,先把std::move忘掉;只有在明确知道拷贝是瓶颈、且接收端真的有移动操作时,才显式使用。至于std::forward,只在写转发接口时必须用,平时业务代码基本不需要。
6.2 后续可以怎么延伸
如果这份笔记看完还不够,可以继续研究这几个方向:移动迭代器(std::move_iterator)如何让算法批量移动元素;std::move_if_noexcept如何配合容器的异常安全机制;以及 SSO、COW 等字符串实现细节会对移动语义产生什么影响。
移动语义本身不难,难的是判断什么时候该用。最稳妥的学习方法就是自己去实现一个带动态资源的类,把五个特殊成员函数都写一遍,再丢进std::vector里跑一跑。用过一次,你就能直观感受到“偷资源”和“复制资源”的差别。