本节摘要:移动语义把"拿走一笔账"从深拷贝(新建缓冲、逐字节抄写、销旧账)变成过户(接管指针、原账作废),成本从与数据量成正比降到常数级。右值引用与移动构造是它的接口形式,noexcept 标注决定 vector 扩容敢不敢走移动。std::move 不移动任何东西,它只是过户资格证明。
本章主线的流水线改造走到最后一站:落库站要"接管"请求体。接管在默认语义下是抄账——深拷贝整份请求体再让原件走析构;移动语义给出第二选项:过户。本节是全章的主峰,也是现代 C++ 性能叙事的核心:搞懂它,容器扩容、按值返回、unique_ptr 的行为全都串成一条线。
写一个自己管缓冲区的类,把抄账与过户两套流程都实现出来,流水并排看:
#include <cstdio> #include <cstring> #include <utility> class Buffer { public: explicit Buffer(std::size_t n) : size_(n), data_(new char[n]) { std::printf(" 开户:分配 %zu 字节\n", n); } ~Buffer() { std::printf(" 销账:%p\n", reinterpret_cast<void*>(data_)); delete[] data_; } // 抄账:深拷贝——新开户,逐字节复制 Buffer(const Buffer& other) : size_(other.size_), data_(new char[other.size_]) { std::memcpy(data_, other.data_, size_); std::printf(" 拷贝构造:新开户并抄写 %zu 字节\n", size_); } Buffer& operator=(const Buffer& other) { if (this != &other) { delete[] data_; data_ = new char[other.size_]; size_ = other.size_; std::memcpy(data_, other.data_, size_); std::printf(" 拷贝赋值:重开户并抄写 %zu 字节\n", size_); } return *this; } // 过户:接管指针,源账户作废 Buffer(Buffer&& other) noexcept // noexcept:容器敢用它的前提 : size_(other.size_), data_(other.data_) { other.data_ = nullptr; // 源置空,防止销账时双重释放 other.size_ = 0; std::printf(" 移动构造:过户,抄写 0 字节\n"); } Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data_; // 先销掉自己名下旧账 size_ = other.size_; data_ = other.data_; other.data_ = nullptr; other.size_ = 0; std::printf(" 移动赋值:过户并销旧账\n"); } return *this; } private: std::size_t size_; char* data_; }; int main() { std::printf("步骤 1 开户\n"); Buffer a(1024); std::printf("步骤 2 抄账\n"); Buffer b = a; // 拷贝构造 std::printf("步骤 3 过户\n"); Buffer c = std::move(a); // 移动构造:a 从此为空壳 std::printf("步骤 4 结束,三笔账依次析构\n"); return 0; }
输出确定(地址每次运行不同):
步骤 1 开户 开户:分配 1024 字节 步骤 2 抄账 开户:分配 1024 字节 拷贝构造:新开户并抄写 1024 字节 步骤 3 过户 移动构造:过户,抄写 0 字节 步骤 4 结束,三笔账依次析构 销账:0x…(b 的缓冲) 销账:0x…(原 a 的缓冲,现由 c 持有) 销账:00000000(a 已是空壳,delete 空指针无害)
两条流水线的对比一目了然:拷贝构造多一笔"新开户加抄写",开销随字节量线性涨;移动构造只交接指针、把源置空,开销与数据量无关。置空那一步是过户制度的命门——源账作废必须体现在源对象里,否则三个对象析构时同一块缓冲销两次,双重释放当场发作。空壳析构(delete 空指针)是语言定义的无害操作,这正是"源置空"设计的巧妙处。

移动构造写完了还有一个细节决定它能不能被标准库用上:noexcept。vector 扩容要把旧元素搬到新账页,如果搬运用的移动操作可能抛异常,搬到一半抛出就会留下"一部分元素搬走了、一部分还在原地"的破账,强异常安全保证破产。所以 vector 的策略是:移动操作标了 noexcept 才走移动,否则退回拷贝——安全但慢。
把上面 Buffer 的移动构造去掉 noexcept 再观察 vector 扩容,你会发现拷贝构造被调用而不是移动构造;补回 noexcept,扩容立刻全走移动。对管理大量资源的类,这一个关键字在扩容密集场景下就是数倍的性能差。规则记成一句话:移动构造与移动赋值,能 noexcept 就 noexcept。
顺带把 moved-from(已过户)状态说清楚:被 move 走的对象处于"有效但未指定"状态——可以析构、可以重新赋值(等于接新账),但不要假设它的内容(不要假设 data_ 是 null 还是旧值,除非你的类文档自己承诺)。上面代码里源置空是 Buffer 自己的实现选择,不是语言强制。审计守则:move 之后的对象只做两件事——析构或赋新值,别的读取都是埋雷。
背景:本章主线的落库站原先签名是 void store(Request r),调用方写 store(req)——按值抄账,请求体被完整复制一份,原件随即析构。
操作:两步。第一步,调用点改为 store(std::move(req)),把请求标记为可过户;第二步,Request 类补齐移动构造与移动赋值并标 noexcept(它管理着缓冲区与元数据表)。若无这两步里的第二步,move 只是空标记,照样走拷贝。
结果:落库站的请求体从"整份复制"变为"指针交接",单站开销从与请求体大小成正比降到常数级;高峰期该站平均耗时从约 40 微秒降到 1 微秒以内。
解读:移动语义的收益要三个条件凑齐:类自己实现了移动操作(或成员全部可移动)、调用点显式 move、移动标 noexcept 让容器与算法敢用。缺任何一环,优化都悄悄退回拷贝,而且编译器不会提醒你。这就是"成本是设计出来的"的又一例证。
变式:构造函数的"按值加 move"惯用法值得单独记——成员初始化用 Request(std::string body) : body_(std::move(body)) {},调用方传左值就拷贝一次、传右值就零拷贝过户,一份签名同时覆盖两种情形,比左值引用加右值引用两个重载更省心。全册末尾的 FAQ 还会从审计角度对比它和转发引用的取舍。
💡 关键直觉:看到深拷贝先问"原件还要吗"。不要——过户;要——才抄账。第 1 章的 new/delete 管的是账本上有没有幽灵账,本节的 move 管的是账本上不必要的重复劳动,两章合起来才是完整的堆内存审计。
下一节补流转的最后一环:中转函数怎么把右值属性原样送达终点。转发丢一次属性,过户就断一程——完美转发(万能引用加 std::forward)是让 3.3 的优化穿透封装层的通行证。