Lecture 14 (Week 7 - Thursday): 移动语义 (Move Semantics)

目录 · ← l13 · l15 →

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 会自动调用 Photostd::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[] datanullptr 是空操作,于是被偷走的内存安全存活。若不置空,临时对象的析构会把刚偷来的数据 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_nullptrother.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 Pointerassign7/,实现你自己的 cs106l::unique_ptr)直接相关:

  1. 用移动语义实现”唯一所有权”:作业要求实现 unique_ptr(unique_ptr&& other)operator=(unique_ptr&& other)——这正是本讲的”偷指针 + 源对象置空”套路:把 other.ptr 复制给自己,然后把 other.ptr = nullptr(否则两个指针指向同一内存,双双析构造成双重释放;short answer Q2 问的正是这个问题)。
  2. = delete 禁止拷贝:作业要求 unique_ptr(const unique_ptr&) = delete;operator=(const unique_ptr&) = delete;——呼应 Lecture 13 的 = deletestd::unique_ptr 的设计。
  3. std::move 的典型应用:作业 Part 2 的 create_list 中,node->next = std::move(head); 就是本讲的核心用法——head 是左值且不再需要,用 std::move 强制触发移动赋值以转移所有权(short answer Q4)。为什么安全?因为移动后 head 不再被使用,且移动保持了”同一时刻只有一个 owner”的不变量。
  4. 为什么 unique_ptr 不可拷贝:若有拷贝,两个指针指向同一内存,先析构者 delete 后另一个变成悬垂指针(作业中的示例代码)——这正是本讲”拷贝 vs 移动”的核心动机。
  5. RAII 自动释放unique_ptr 的析构自动 delete(A7 还问递归释放链表时的栈深度问题),详见 Lecture 16。