移动构造与移动赋值:把资源偷过来

「std::move 是什么」那一篇已经讲清楚了:它只是个类型转换,真正搬数据的是移动构造函数。这一篇换个视角,从类的实现者出发,亲手写出正确、高效的移动操作。有几个问题只有写实现时才碰得到:noexcept 为什么不是装饰、移动后源对象到底处于什么状态、移动赋值怎么处理自赋值。还有一个更隐蔽的:编译器可能压根没给你生成移动操作。

如果你还没搞清楚 std::move 只是转换、不是搬运工,建议先读同系列的《std::move 其实什么都没搬》,再回来往下看。

你写了移动构造,为什么 vector 扩容还在拷贝

给自己的类加上移动构造,塞进 std::vector,profiling 一看,扩容时居然在做深拷贝。移动构造白写了。原因几乎总是同一个:移动构造没标 noexcept。这个坑足够实在,就从它讲起。

移动操作的正确签名

先把骨架立住。两个移动操作的签名是固定的,少一个限定符语义就变了:

cpp 复制代码
// 片段(无 main,仅展示签名约定)
class Buffer {
public:
    // 移动构造:形参是非 const 右值引用,必须 noexcept
    Buffer(Buffer&& other) noexcept;

    // 移动赋值:同样 noexcept,返回自身的引用
    Buffer& operator=(Buffer&& other) noexcept;

    // 拷贝操作保持 const 左值引用
    Buffer(const Buffer& other);
    Buffer& operator=(const Buffer& other);
};

四条规矩,缺一不可:

  1. 形参是 T&&(非 const)。const T&& 没法「偷」资源,会退化成拷贝;
  2. 必须标 noexcept,后面单独展开;
  3. 把源对象的资源接过来,同时把源对象置空(指针置 nullptr、计数归零);
  4. 移动赋值还要处理自赋值,并释放自己已有的资源。

官方文档:Move constructor --- cppreference · Move assignment --- cppreference

为什么 noexcept 是性能开关(实测)

std::vector 扩容时要把旧元素搬到新内存。搬的过程中如果某个元素的移动构造抛了异常,搬到一半的状态就回不去了。为了保住强异常保证(strong exception guarantee),vector 不会冒险去移动,宁可退回拷贝,因为拷贝失败也不会破坏原对象。

它靠的就是 std::move_if_noexcept:只有当移动构造是 noexcept 时才用移动,否则退回拷贝。下面这段把这件事跑给你看,用计数器数清楚「现有元素到底是移动还是拷贝」:

cpp 复制代码
// move_noexcept_demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 move_noexcept_demo.cpp -o m
#include <cstdio>
#include <utility>
#include <vector>

struct NoExceptMove {
    NoExceptMove() = default;
    NoExceptMove(const NoExceptMove&)  { ++copies; }
    NoExceptMove(NoExceptMove&&) noexcept { ++moves; }
    static int copies, moves;
};
int NoExceptMove::copies = 0;
int NoExceptMove::moves  = 0;

struct ThrowingMove {
    ThrowingMove() = default;
    ThrowingMove(const ThrowingMove&)  { ++copies; }
    ThrowingMove(ThrowingMove&&) { ++moves; }   // 反例,不要这么写:缺 noexcept
    static int copies, moves;
};
int ThrowingMove::copies = 0;
int ThrowingMove::moves  = 0;

template <class T>
void reallocate() {
    std::vector<T> v;
    v.reserve(2);
    v.push_back(T{});   // 放进第 1 个
    v.push_back(T{});   // 放进第 2 个
    v.push_back(T{});   // 超过容量 2,扩容到 4:搬迁旧 2 个
}

int main() {
    reallocate<NoExceptMove>();
    std::printf("NoExceptMove : copies=%d moves=%d\n",
                NoExceptMove::copies, NoExceptMove::moves);

    reallocate<ThrowingMove>();
    std::printf("ThrowingMove : copies=%d moves=%d\n",
                ThrowingMove::copies, ThrowingMove::moves);
}
text 复制代码
NoExceptMove : copies=0 moves=5
ThrowingMove : copies=2 moves=3

两份代码只差一个 noexcept,结果天差地别:

  • NoExceptMove:扩容时旧元素全部移动(2 次),加上 3 个临时对象入容器(3 次移动),共 5 次移动、0 次拷贝;
  • ThrowingMove:移动构造可能抛异常,vector 对那 2 个旧元素改用拷贝,于是出现了 2 次拷贝。临时对象入容器仍走移动(它们是即将消亡的纯右值,不涉及回滚风险)。
移动构造 扩容时旧元素 临时对象 后果
noexcept 移动(O(1)) 移动 理想,零深拷贝
非 noexcept 退回拷贝(O(n)) 移动 你以为在移动,其实在深拷贝

结论很硬。只要你的类会被放进 std::vector 并经历扩容(绝大多数资源管理类都会),移动操作就必须标 noexcept ,否则性能优化全部落空。这也是 C++ Core Guidelines C.66 的硬要求。

官方文档:std::move_if_noexcept --- cppreference

知道了「为什么」,再看 vector 内部的决策流程就更清楚了:它自己并不逐个判断,而是把这件事整个委托给 std::move_if_noexcept。

text 复制代码
std::vector 扩容:把旧缓冲区里的 n 个元素搬到新缓冲区

  vector 准备搬旧元素
        │
        ▼
  对每个元素调用 std::move_if_noexcept(elem)
        │
        ├── Q1: T 的移动构造是 noexcept 吗?
        │        │
        │        ├── 是 ──► 返回 T&&  ──► 调移动构造(每个 O(1),不分配)
        │        │                          承诺不抛,vector 敢用
        │        │
        │        └── 否 ──► 继续 Q2
        │
        └── Q2: T 可以拷贝吗?
                 │
                 ├── 能拷贝 ──► 返回 const T& ──► 调拷贝构造(每个 O(n),深拷贝)
                 │                               强异常保证:搬到一半抛了,旧缓冲区原样还在
                 │
                 └── 不能拷贝 ──► 只能硬着头皮移动(例如 std::unique_ptr)
                                  此时只提供基本异常保证:搬一半抛了,状态由实现决定

  结论:noexcept 是递给 vector 的一张「请放心移动我」的担保书

一句话概括:没标 noexcept,vector 为保住强异常保证,宁可多花 O(n) 去拷贝。你的移动构造被静默绕过,一行报错都不会有。

移动后源对象的状态:有效但未指定

标准保证被移动的对象处于「有效但未指定(valid but unspecified)」状态。翻译过来就是:它还能安全析构、还能被重新赋值,但它的值是什么,标准不保证。

所以实践上的约定是:移动构造把源对象置空(指针 = nullptr、长度 = 0)。这样一来,源对象析构时 delete[] nullptr 是安全的(C++ 规定对空指针 delete 什么也不做),重新赋值也毫无歧义。

cpp 复制代码
#include <cstdio>
#include <utility>

struct Holder {
    int* p = nullptr;
    Holder() : p(new int(1)) {}
    Holder(Holder&& other) noexcept : p(other.p) { other.p = nullptr; }  // 置空
    Holder(const Holder&) = delete;
    Holder& operator=(Holder&&) = delete;
    ~Holder() { delete p; }
};

int main() {
    Holder a;
    Holder b = std::move(a);   // a.p 被置空
    // a 现在处于 valid-but-unspecified 状态:析构安全,但不要再读 *a.p
    std::printf("移动后 a.p 是否为空指针: %s\n", a.p == nullptr ? "是" : "否");
}
text 复制代码
移动后 a.p 是否为空指针: 是

记住一条:移动后的对象,要么让它安静地析构,要么给它重新赋值,不要去读它「原来的值」 。不同标准库实现下,被移动后的 std::string 可能是空串也可能不是,依赖这个值是不可移植的。

官方文档:Moved-from state of library types

把「移动前」和「移动后」两边的状态画出来,指针归属的变化一眼可见:

text 复制代码
移动前:Buffer a(4);  Buffer b = std::move(a);

  a  [ size_ = 4  data_ = 0x1000 ] ──┐
                                     ├──► 0x1000: [1][2][3][4]   ← 只有 a 指着它
  b  [ size_ = 0  data_ = nullptr ] ─┘

                       │  执行移动构造
                       ▼

移动后:b 接管了那块内存,a 被置空

  a  [ size_ = 0  data_ = nullptr ]      ← valid-but-unspecified
                                           还能安全析构(delete[] nullptr 是空操作)
                                           也能被重新赋值,但不要再读 *a.data_

  b  [ size_ = 4  data_ = 0x1000 ] ──► 0x1000: [1][2][3][4]

  注意:0x1000 这块内存自始至终没有重新分配,也没有被复制
        「移动」= 只把所有权标签从 a 挪到 b,O(1)

如果移动构造忘了把 a 置空:

  a  [ size_ = 4  data_ = 0x1000 ] ──┐
                                     ├──► 0x1000 被两个对象都认为「是我的」
  b  [ size_ = 4  data_ = 0x1000 ] ──┘     → 析构时 double free,未定义行为

移动赋值:自赋值 + 清理已有资源

移动赋值比移动构造多两件事:先释放自己已有的资源,以及防自赋值。顺序很重要。要先 delete 自己的旧资源,再接管对方的,自赋值时直接跳过,否则会把自己刚 delete 的指针又去读。

cpp 复制代码
// 片段(无 main,展示移动赋值的关键逻辑)
Buffer& Buffer::operator=(Buffer&& other) noexcept {
    if (this != &other) {        // 防自赋值
        delete[] data_;          // 先释放自己已有的资源
        data_     = other.data_; // 接管对方的
        len_      = other.len_;
        other.data_ = nullptr;   // 把源对象置空
        other.len_  = 0;
    }
    return *this;
}

if (this != &other) 这一行不能省:如果写 a = std::move(a),没有自赋值检查就会先把 a.data_ 释放,紧接着又去读它,这是未定义行为。

编译器何时自动生成移动操作(最容易踩的坑)

很多人以为「我不写移动构造,编译器会自动给我生成一个」。这句话只对了一半,而错的那半正好最容易出事。规则是:

只要用户声明了析构函数、拷贝构造、拷贝赋值或移动赋值中的任意一个,编译器就不再隐式生成移动构造/移动赋值。

于是 std::move(x) 就静默回退成拷贝构造,没有任何报错,性能悄悄没了。下面这段直接验证:

cpp 复制代码
// move_suppress_demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 move_suppress_demo.cpp -o s
#include <cstdio>
#include <utility>

struct WithDtor {
    WithDtor() = default;
    WithDtor(const WithDtor&) { std::puts("  拷贝构造"); }
    ~WithDtor() {}     // 反例,不要这么写:无意义的用户析构,却抑制了隐式移动
};

struct WithoutDtor {
    WithoutDtor() = default;
    WithoutDtor(const WithoutDtor&) { std::puts("  拷贝构造"); }
    WithoutDtor(WithoutDtor&&) noexcept { std::puts("  移动构造"); }
};

int main() {
    std::puts("--- 声明了析构:std::move 退化为拷贝 ---");
    WithDtor a;
    WithDtor b = std::move(a);

    std::puts("--- 没声明析构(或显式写了移动):走移动 ---");
    WithoutDtor c;
    WithoutDtor d = std::move(c);
}
text 复制代码
--- 声明了析构:std::move 退化为拷贝 ---
  拷贝构造
--- 没声明析构(或显式写了移动):走移动 ---
  移动构造
你声明了...... 隐式移动构造/赋值 std::move 实际走
什么都不声明 自动生成 移动
析构函数 被抑制 拷贝
拷贝构造 被抑制 拷贝
拷贝赋值 被抑制 拷贝
移动赋值 被抑制 拷贝
显式写/ =default 移动 存在 移动

这就是为什么 Rule of Five 强调「要么全交给编译器,要么五个都写齐」:你一旦手写了析构(哪怕空的),就必须把移动操作也显式写出来或 =default,否则就掉进上面的坑。

注:这篇以 C++17 为准。C++20 对这条规则略有放宽(用户声明的析构不再抑制移动),但 Rule of Five 的最佳实践在任何版本都成立,不受标准改动影响。

一个正确且高效的 Buffer

把前面所有要点合起来,这是一个能整体编译运行的 Buffer:移动 noexcept、移动后置空、移动赋值防自赋值+清理、Rule of Five 写齐。

cpp 复制代码
// buffer_full.cpp --- 编译: g++ -std=c++17 -Wall -O2 buffer_full.cpp -o buf
#include <cstdio>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : size_(n), data_(new int[n]) {
        std::printf("  [构造] 分配 %zu 个 int\n", n);
    }

    // 拷贝构造:深拷贝
    Buffer(const Buffer& other)
        : size_(other.size_), data_(new int[other.size_]) {
        for (std::size_t i = 0; i < size_; ++i) data_[i] = other.data_[i];
        std::printf("  [拷贝构造] 深拷贝 %zu 个 int\n", size_);
    }

    // 移动构造:noexcept,只偷指针
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
        std::printf("  [移动构造] 只偷指针\n");
    }

    // 移动赋值:noexcept,自赋值安全 + 先清理自己
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            size_     = other.size_;
            data_     = other.data_;
            other.size_ = 0;
            other.data_ = nullptr;
        }
        std::printf("  [移动赋值]\n");
        return *this;
    }

    ~Buffer() { delete[] data_; }

    std::size_t size() const { return size_; }

private:
    std::size_t size_ = 0;
    int*       data_ = nullptr;
};

int main() {
    Buffer a(4);
    Buffer b = std::move(a);     // 移动构造
    Buffer c(2);
    c = std::move(b);            // 移动赋值(b 先被清理,再接管)
    std::printf("a.size()=%zu b.size()=%zu c.size()=%zu\n",
                a.size(), b.size(), c.size());
}
text 复制代码
  [构造] 分配 4 个 int
  [移动构造] 只偷指针
  [构造] 分配 2 个 int
  [移动赋值]
a.size()=0 b.size()=0 c.size()=4

注意第 7 行的移动赋值:原来 c 持有的 2 个 int 被 delete[] 释放,c 接管了 b 的 4 个 int;a、b 都被置空,最终 size() 都是 0。全程无深拷贝、无泄漏。

延伸阅读

收个尾

移动操作标 noexcept,移动后把源对象置空,移动赋值防自赋值并先清理自己。这三件事做对,移动才算真的省下了拷贝。

真正阴的是最后那条:只要声明了析构或拷贝操作中的任意一个,编译器就不再自动生成移动,std::move 会安静地退回拷贝。所以 Rule of Five 那句话值得记住,要么全交给编译器,要么五个都写齐。

相关推荐
All for pursuit.1 小时前
【回溯-7】494.目标和
数据结构·c++·算法·leetcode
白杨尚青1 小时前
C++入门篇(十三):vector(上)——动态数组:构造、空间增长与迭代器失效
开发语言·c++·笔记·算法·stl
yunwei372 小时前
eBPF 开发实践:使用 eBPF 隐藏进程或文件信息
linux·后端·性能优化
(Charon)3 小时前
【C++面试】RAII到底是什么:从资源管理理解C++对象生命周期
c++·算法·面试
无名猿3 小时前
成员初始化列表与初始化顺序陷阱:按声明顺序,不是书写顺序
c++·现代c++·语法基础
91刘仁德3 小时前
传输层协议深度拆解:端口寻址、UDP 报文与 TCP 可靠性机制全解析
linux·网络·c++·网络协议·tcp/ip·udp·c
Liuchang09113 小时前
洛谷 P2670 [NOIP 2015 普及组] 扫雷游戏
c++·算法·游戏
顶点多余3 小时前
C/C++常见面试题
开发语言·c++
HugoStudio_SWAN3 小时前
洛谷 B4575 [GESP202609 二级] 直角三角形——临场定义理解与边界质疑
c++·学习·程序人生·算法