Lecture 14 (Week 7 - Thursday): 移动语义 (Move Semantics)
Lecture 14 (Week 7 - Thursday): 移动语义 (Move Semantics)
概述
上一讲我们学会了拷贝(拷贝构造/拷贝赋值),但拷贝数据是非常昂贵的——想想从 takePhoto() 这样的函数返回一个包含 3840×2160 像素的照片对象:临时对象马上要被销毁,却还要先深拷贝一遍它的数据。本讲引入左值(lvalue)与右值(rvalue)的概念(区分”持久对象”与”临时对象”),并用右值引用 T&& 重载特殊成员函数,实现移动语义:把临时对象的资源”偷”过来而不是复制,把 O(n) 的拷贝变成 O(1) 的指针窃取。最后介绍 std::move(它只是类型转换,不是移动本身)与 Rule of Zero / Three / Five。
核心特性与语法详解
1. 拷贝的代价与问题场景
- 定义与目的:按值传递/返回、容器扩容等场景都会触发深拷贝,每次拷贝都要重新分配内存并逐元素复制(O(n))。当源对象是即将销毁的临时量时,这份拷贝完全是浪费——我们真正需要的只是”转移资源所有权”。
- 核心语法(问题场景):
Photo selfie = takePhoto(); // takePhoto() 返回临时对象,马上要销毁 // 却要先深拷贝它的像素数据,然后销毁原对象:纯浪费 - 设计意图与最佳实践:区分两种情况——对持久对象(可能以后还要用)做拷贝;对临时对象(用完即弃)做移动。这就是移动语义要解决的问题。
2. 左值(lvalue)与右值(rvalue)
- 定义与目的:C++ 需要一种机制来判断一个表达式是”持久的”还是”临时的”,从而决定走拷贝还是移动。左值/右值就是这种”临时性(temporariness)”的泛化。
- 核心语法:
void foo(Photo pic) { Photo* p1 = &pic; // ✅ pic 是左值:可以取地址 Photo* p2 = &takePhoto(); // ❌ 编译错误:takePhoto() 是右值,无地址 } - 设计意图与最佳实践:直观判断标准——能否取地址:有确定地址的是左值,没有的是右值。另一个视角:左值可以出现在
=两侧,右值只能出现在右侧(5 = y;不合法)。生命周期上,左值活到作用域结束,右值活到语句结束。幻灯片还给出了一个更精细的分类图(glvalue/rvalue → lvalue/xvalue/prvalue),但课程明确说”别担心这个”,掌握左值/右值两层就够用。注意int& b = a;中的b是左值引用,它引用左值。
3. 右值引用 T&& 与左值/右值重载
- 定义与目的:左值引用
Type&只能绑定左值;右值引用Type&&只能绑定右值(临时量)。通过重载&和&&两个版本的同名函数,编译器就能根据实参是左值还是右值自动选择版本。 - 核心语法:
void upload(Photo& pic); // 左值版本:pic 是持久的,必须保持有效 void upload(Photo&& pic); // 右值版本:pic 是临时的,可以偷走它的资源 - 设计意图与最佳实践:右值引用参数告诉我们”这个对象用完即弃”,因此我们可以随意窃取它的资源,把它置于任意状态(甚至无效)都没关系。这是移动构造/移动赋值的语法基础。
4. 移动构造与移动赋值
- 定义与目的:让新对象/已有对象窃取临时对象的资源(通常是复制指针、接管堆内存),并把源对象置为”空”(如
nullptr),这样源对象析构时不会破坏被偷走的数据。复杂度 O(1),与 O(n) 的深拷贝形成鲜明对比。 - 核心语法:
T(T&& other) noexcept; // 移动构造 T& operator=(T&& other) noexcept; // 移动赋值// 移动构造:偷指针 + 源对象置空 Photo::Photo(Photo&& other) noexcept : width(other.width), height(other.height), data(other.data) { other.data = nullptr; // 关键!源对象析构时 delete nullptr 无害 } - 设计意图与最佳实践:移动操作的实现套路固定——(1)偷走源对象的资源指针;(2)把源对象置为安全空状态(valid-but-unspecified)。务必加
noexcept:容器(如std::vector)扩容时,若移动构造可能抛异常,它会回退到拷贝以保证强异常安全,导致性能白白损失。移动后源对象仍是一个”合法但状态未指定”的对象:可以析构、可以重新赋值,但不能假设它还有原来的内容。
5. std::move:只是类型转换
- 定义与目的:编译器只在遇到右值时才自动选移动。但我们有时明确知道某个左值以后不会再用了(例如数组平移
elems[i] = elems[i-1]后,旧位置不再需要),此时需要”手动把左值变成右值”来强制触发移动。 - 核心语法:
elems[i] = std::move(elems[i - 1]); // 把左值转成右值引用,触发移动赋值 - 设计意图与最佳实践:
std::move(x)不做任何移动,它只是static_cast<T&&>(x)——把左值x的类型转换成右值引用,从而让重载决议选中移动版本。它就像const_cast一样是”opt-in”:你主动声明”这个对象我不要了”。因此:除非有充分理由(性能关键、确定不再使用),否则不要到处乱用std::move;移动后再使用源对象是未定义行为级别的错误(实际是 valid-but-unspecified,但语义上不应再用)。
6. Rule of Zero / Three / Five
- 定义与目的:定义移动后,”要不要都写”的规则升级为五件套。
- 核心语法:
- Rule of Zero:类不管理外部资源时,编译器生成的 SMF 全部够用,一个都不写。
- Rule of Three:需要自定义析构(管理外部资源)时,必须同时自定义拷贝构造和拷贝赋值;否则两个对象会共享底层资源。
- Rule of Five:定义了 Three 中任何一个时,还应(非强制但强烈建议)定义移动构造和移动赋值;否则移动会退化为拷贝,性能变差。
- 设计意图与最佳实践:
struct Post { Photo photo; std::string caption; };是 Rule of Zero 的范例——编译器生成的 Post 的 SMF 会自动调用Photo和std::string各自的 SMF。反之,管理外部资源的类(如 Photo 的int* data)必须遵守 Three/Five。
代码示例与逐步解说(核心)
示例 1:左值 vs 右值——取地址测试(C++11)
// C++11
#include <iostream>
int getFive() { return 5; } // 返回临时值
int main() {
int a = 4;
int* p1 = &a; // ✅ a 是左值,有地址
std::cout << "&a = " << p1 << '\n';
// int* p2 = &getFive(); // ❌ 编译错误:getFive() 的返回值是右值(临时量),没有地址
int* p3 = &a; // a 可以出现在 = 左侧:a = 5; ✅
// getFive() = 5; // ❌ 右值不能出现在 = 左侧
(void)p3;
}
- 代码做什么:演示判断左值/右值的两个标准:能否取地址、能否出现在
=左侧。a可以,函数返回值不行。 - 特性机制解说:左值有确定的存储位置(地址),生命周期到作用域结束;右值(此处是 prvalue——纯右值,如函数返回值、字面量)没有地址,生命周期到语句结束。注意:虽然标准里还有 xvalue 等细分(
std::move(x)的结果就是 xvalue,属于 glvalue,理论上可取地址),但课程层面只需记住”临时对象是右值、持久对象是左值”。编译器正是靠这个分类来决定调用哪个重载。
示例 2:左值/右值重载——& 与 &&(C++11)
// C++11
#include <iostream>
#include <string>
void upload(const std::string& pic) { // 左值重载:持久对象,只能引用
std::cout << "upload(lvalue&): " << pic << '\n';
}
void upload(std::string&& pic) { // 右值重载:临时对象,可以偷资源
std::cout << "upload(rvalue&&): " << pic << '\n';
}
std::string takePhoto() { return "selfie.jpg"; }
int main() {
std::string selfie = takePhoto();
upload(selfie); // 左值 → 调用第一个重载
upload(takePhoto()); // 右值 → 调用第二个重载
upload("direct"); // 字符串字面量隐式转为临时 std::string → 右值重载
}
- 代码做什么:定义同名
upload的两个重载,实参为左值时走&版本,为右值时走&&版本,编译器自动选择。 - 特性机制解说:重载决议根据实参的”值类别(value category)”挑选版本:左值绑定
&,右值绑定&&(const 左值引用也能绑定右值,但非 const 左值引用不能)。这正是移动语义的钥匙:函数拿到Type&&参数时,就知道”这个对象是临时的、可以破坏”,于是可以偷走它的资源而不必拷贝。幻灯片中uploadToInsta(takePhoto())只传左值引用会报 “candidate function not viable: expects lvalue” 的编译错误,正是因为没有&&版本可匹配。
示例 3:Photo 类——拷贝 vs 移动(改写自幻灯片)(C++11)
// C++11
#include <algorithm>
#include <iostream>
class Photo {
public:
Photo(int w, int h) : width(w), height(h), data(new int[w * h]) {}
~Photo() { delete[] data; }
// 拷贝构造:深拷贝(O(n))
Photo(const Photo& other)
: width(other.width), height(other.height),
data(new int[width * height]) {
std::copy(other.data, other.data + width * height, data);
std::cout << "copy ctor (O(n))\n";
}
// 移动构造:偷指针(O(1))
Photo(Photo&& other) noexcept
: width(other.width), height(other.height), data(other.data) {
other.data = nullptr; // 源对象置空:析构 delete nullptr 无害
std::cout << "move ctor (O(1))\n";
}
// 拷贝赋值:先释放旧数据,再深拷贝
Photo& operator=(const Photo& other) {
if (this == &other) return *this;
delete[] data;
width = other.width;
height = other.height;
data = new int[width * height];
std::copy(other.data, other.data + width * height, data);
return *this;
}
// 移动赋值:释放自己的旧数据,偷走对方的
Photo& operator=(Photo&& other) noexcept {
if (this == &other) return *this;
delete[] data; // 先清掉自己原来的像素
width = other.width;
height = other.height;
data = other.data; // 偷走对方的数据
other.data = nullptr; // 源对象置空
return *this;
}
int get(int i) const { return data[i]; }
private:
int width;
int height;
int* data;
};
Photo takePhoto() { return Photo(10, 10); }
int main() {
Photo selfie = takePhoto(); // 移动构造(临时对象)——编译器可能进一步做 RVO 省略
Photo pic = selfie; // 拷贝构造(左值)
pic = takePhoto(); // 移动赋值(临时对象)
pic = selfie; // 拷贝赋值(左值)
}
- 代码做什么:
Photo selfie = takePhoto();源是临时对象 → 移动构造,把data指针直接拿过来(O(1));Photo pic = selfie;源是左值selfie→ 拷贝构造(O(n));pic = takePhoto();→ 移动赋值;pic = selfie;→ 拷贝赋值。每个操作打印自己的名字。 - 特性机制解说:移动构造只做三件事——复制宽高、复制指针、把源对象指针置
nullptr。置空至关重要:takePhoto()的临时对象在语句结束时析构,delete[] data对nullptr是空操作,于是被偷走的内存安全存活。若不置空,临时对象的析构会把刚偷来的数据 delete 掉(幻灯片中的 “Oh no… the destructor deletes our stolen data” 场景)。移动赋值额外多一步:先delete[]自己的旧数据,否则旧像素泄漏。移动后源对象处于 valid-but-unspecified 状态——可以安全析构/重新赋值,但内容未指定。另外注意:Photo selfie = takePhoto();在开启优化的编译器上可能被 RVO(Return Value Optimization) 完全省略(直接在selfie处构造),连移动构造都不调用——这是允许的优化,本讲先按”会调用移动构造”来理解,RVO 细节下节再谈。
示例 4:完整 String 类——移动构造与移动赋值(含 noexcept、源对象置空)(C++11)
// C++11:完整 String 类(深拷贝 + 移动)
#include <cstring>
#include <iostream>
#include <utility>
class String {
public:
String() : buf_(nullptr), size_(0) {}
String(const char* s) : size_(std::strlen(s)) {
buf_ = new char[size_ + 1];
std::strcpy(buf_, s);
}
// 拷贝构造:深拷贝(O(n))
String(const String& other) : size_(other.size_) {
buf_ = new char[size_ + 1];
std::strcpy(buf_, other.buf_);
std::cout << "copy ctor\n";
}
// 拷贝赋值:自赋值检查 → 释放旧 → 深拷贝
String& operator=(const String& other) {
if (this == &other) return *this;
delete[] buf_;
size_ = other.size_;
buf_ = new char[size_ + 1];
std::strcpy(buf_, other.buf_);
return *this;
}
// 移动构造:偷指针 + 源对象置空(O(1))
String(String&& other) noexcept
: buf_(other.buf_), size_(other.size_) {
other.buf_ = nullptr;
other.size_ = 0;
std::cout << "move ctor\n";
}
// 移动赋值:释放自己的旧资源 → 偷对方的 → 源对象置空
String& operator=(String&& other) noexcept {
if (this == &other) return *this;
delete[] buf_;
buf_ = other.buf_;
size_ = other.size_;
other.buf_ = nullptr;
other.size_ = 0;
return *this;
}
~String() { delete[] buf_; }
void print() const { std::cout << (buf_ ? buf_ : "(empty)") << '\n'; }
std::size_t size() const { return size_; }
private:
char* buf_;
std::size_t size_;
};
String makeString() { return String("hello"); }
int main() {
String s1("CS106L");
String s2 = s1; // 拷贝构造(左值)
String s3 = std::move(s1); // 移动构造(std::move 把左值转成右值)
s1.print(); // (empty):s1 已被掏空,处于 valid-but-unspecified 状态
String s4;
s4 = makeString(); // 移动赋值(函数返回值是临时对象)
s4.print(); // hello
s4 = s2; // 拷贝赋值(左值)
s4.print(); // CS106L
}
- 代码做什么:完整实现六大 SMF 的 String 类。
s2 = s1拷贝(深拷贝);s3 = std::move(s1)强制移动(s1 被掏空);s4 = makeString()移动赋值(源是临时对象);s4 = s2拷贝赋值。每次构造/赋值都打印自己的类别。 - 特性机制解说:移动构造的机制:
String(String&& other)的参数是右值引用,other.buf_指向堆上的字符数组;移动构造直接把这个指针复制给自己(O(1)),然后把other.buf_置nullptr、other.size_置 0。于是源对象析构时delete[] nullptr空操作,数据安全转移。移动赋值额外先delete[] buf_释放自己原有的旧缓冲(否则旧字符串泄漏),再偷指针、置空源对象。noexcept的意义:std::vector<String>扩容需要把元素搬到新数组,若移动构造可能抛异常,vector 会退化为拷贝以保证异常安全(旧元素不能处于半搬状态);标了noexcept后 vector 才敢用移动,性能才不会白丢。valid-but-unspecified:被移动的s1仍可析构、可重新赋值、可调用不依赖内容的成员(如print()),但内容未指定——所以移动后不要假设源对象还有原来的值。
示例 5:std::move 只是类型转换 + 移动后慎用源对象(C++11)
// C++11
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int main() {
std::vector<std::string> elems = {"a", "b", "c"};
// 场景:数组平移,旧位置的值不再需要 → 强制移动而不是拷贝
elems[2] = std::move(elems[1]); // std::move 只是把 elems[1] 转成右值
std::cout << elems[2] << '\n'; // b
// 反例:移动后继续使用源对象
std::string a = "treasure";
std::string b = std::move(a);
std::cout << "b = " << b << '\n';
std::cout << "a = " << a << '\n'; // 合法,但内容是"未指定"的(libstdc++ 下通常是空串)
// 千万不要在移动后调用依赖 a 内容的逻辑,例如 a.size() > 0 的假设!
}
- 代码做什么:第一个场景模拟幻灯片中
PhotoCollection::insert的数组平移——elems[i] = std::move(elems[i-1])把”不再使用的旧位置”的内容移走而不是拷贝。第二个场景展示移动后源对象a仍可读取但内容未指定。 - 特性机制解说:
std::move(x)的实现等价于static_cast<T&&>(x)——它不移动任何东西,只是把左值 x 的类型标注为右值引用,使后续的重载决议选中移动版本。移动真正发生的地方是移动构造/移动赋值函数体里。这解释了为什么”std::move 一个没有移动构造的类型”会退化为拷贝。Be wary of std::move:一旦你对左值调用了std::move,就等于向编译器承诺”这个对象我不再需要了”;此后继续使用它(如幻灯片中的whoAmI.get_pixel(21, 24))可能解引用空指针。课程建议:除非性能关键且你确定对象不再被使用,否则不要显式使用std::move。
与旧标准(如C++98)的对比
- 移动语义、右值引用、
std::move全部是 C++11 的新特性:C++98 里没有T&&,没有移动构造/移动赋值。按值返回大对象时只能深拷贝,函数返回临时对象= 拷贝 + 析构,性能损失无法避免。 - C++98 的替代方案:
std::auto_ptr(C++98):标准库曾提供auto_ptr来模拟”转移所有权”,但它的拷贝构造语义是”转移”而非”复制”,实现上把拷贝构造当移动用,行为反直觉且不能放进std::vector等标准容器(容器要求可正常拷贝),因此 C++11 用unique_ptr取代它(详见 Lecture 16)。这也是为什么移动语义必须是语言特性而非库技巧——拷贝/移动的区分需要语言层面的值类别支持。- 裸指针手工管理:想”转移”就手动
p = q; q = nullptr;,容易漏、容易错,全靠自觉。
- 现代优势:语言自动区分左值/右值并选择拷贝或移动;
noexcept让容器放心使用移动;规则清晰(Rule of Five),性能与安全兼得。
关键要点
- 拷贝给持久对象,移动给临时对象:编译器依据左值(能取地址、活到作用域结束)与右值(临时、活到语句结束)自动选择。
- 右值引用
T&&绑定临时量;重载&/&&两个版本即可让编译器按值类别选择。 - 移动操作 = 偷指针 + 源对象置空(valid-but-unspecified),O(1);拷贝是深拷贝,O(n)。移动构造
T(T&&) noexcept、移动赋值T& operator=(T&&) noexcept。 std::move(x)只是static_cast<T&&>(x),它不移动任何东西;使用它等于承诺”x 我不要了”,移动后不要再依赖源对象的内容。- 务必给移动操作加
noexcept,否则容器扩容会回退到拷贝;遵守 Rule of Zero/Three/Five。
常见陷阱与注意事项
- 移动后使用源对象:移动后源对象处于 valid-but-unspecified 状态,继续读它的内容(如
whoAmI.get_pixel(...))可能解引用nullptr。移动后要么立刻析构/重新赋值,要么不再碰它。 - 忘记把源对象置空:移动构造/移动赋值里若只偷指针不置
nullptr,源对象析构时会 delete 掉你刚偷来的数据(幻灯片中的经典错误)。 - 忘记
noexcept:移动操作没标noexcept时,std::vector扩容、std::sort等会回退到拷贝,性能白白损失。 - 滥用
std::move:对还会继续使用的左值乱用std::move,等于主动制造 use-after-move;课程建议只在性能关键且确定对象不再使用时显式使用。 - 移动赋值忘记释放自己的旧资源:
operator=(T&& other)里不先delete[]/释放自己的数据就偷指针,会造成旧资源泄漏。 - 误以为
std::move会”移动”:std::move只是类型转换;若类型没有移动构造,代码静默退化为拷贝,行为正确但性能没有提升。
关联作业提示
本讲与 A7: Unique Pointer(assign7/,实现你自己的 cs106l::unique_ptr)直接相关:
- 用移动语义实现”唯一所有权”:作业要求实现
unique_ptr(unique_ptr&& other)与operator=(unique_ptr&& other)——这正是本讲的”偷指针 + 源对象置空”套路:把other.ptr复制给自己,然后把other.ptr = nullptr(否则两个指针指向同一内存,双双析构造成双重释放;short answer Q2 问的正是这个问题)。 - 用
= delete禁止拷贝:作业要求unique_ptr(const unique_ptr&) = delete;与operator=(const unique_ptr&) = delete;——呼应 Lecture 13 的= delete与std::unique_ptr的设计。 std::move的典型应用:作业 Part 2 的create_list中,node->next = std::move(head);就是本讲的核心用法——head是左值且不再需要,用std::move强制触发移动赋值以转移所有权(short answer Q4)。为什么安全?因为移动后head不再被使用,且移动保持了”同一时刻只有一个 owner”的不变量。- 为什么 unique_ptr 不可拷贝:若有拷贝,两个指针指向同一内存,先析构者 delete 后另一个变成悬垂指针(作业中的示例代码)——这正是本讲”拷贝 vs 移动”的核心动机。
- RAII 自动释放:
unique_ptr的析构自动delete(A7 还问递归释放链表时的栈深度问题),详见 Lecture 16。
