3.4 移动语义与对象搬家


3.4 移动语义与对象搬家

本节摘要:拷贝是"整屋家具再买一套",移动是"房产证过户、原主搬空"——右值引用 T&& 标记"这个对象可被掏空",移动构造把堆指针偷过来而不逐字节复制。std::move 本身不搬任何东西,它只是授权;完美转发则让模板把这份授权原样递给下一层。理解移动语义,容器扩容、函数返回、智能指针过户的性能账全部豁然开朗。

学习目标

阅读完本节,你应当能够:

  1. 用字节级视角说出拷贝构造与移动构造各搬了什么;
  2. 解释左值、右值与 std::move 的真实含义;
  3. 在自己的类上实现 noexcept 移动构造;
  4. 用万能引用与完美转发写出零损耗的工厂函数。

一、一次拷贝与一次移动的内存账单

#include <iostream> #include <string> #include <utility> struct Widget { Widget(std::size_t n) : data_(new char[n]{}) {} ~Widget() { delete[] data_; } Widget(const Widget& o) : size_(o.size_), data_(new char[o.size_]) { std::copy(o.data_, o.data_ + size_, data_); std::cout << "拷贝构造:分配并复制 " << size_ << " 字节\n"; } Widget(Widget&& o) noexcept : size_(o.size_), data_(o.data_) { o.data_ = nullptr; o.size_ = 0; std::cout << "移动构造:只偷了两个指针值\n"; } std::size_t size_ = 0; char* data_ = nullptr; }; int main() { Widget a(1000000); Widget b = a; // 拷贝:百万字节逐个复制 Widget c = std::move(a); // 移动:两个整数赋值完事 } // 输出:拷贝构造:分配并复制 1000000 字节 // 移动构造:只偷了两个指针值

拷贝构造要 new 一块同尺寸堆内存、逐字节复制、保持来源完好;移动构造只把"指针与长度"两个标量接过户,再把来源置空防止双重释放。百万字节对八个字节,这就是移动语义的物理本质:不搬货物,只换货主。

图:拷贝与移动的字节级对比

图:拷贝与移动的字节级对比

二、左值右值与 std::move 的真面目

左值有名字、能取地址(变量、数组元素、*p);右值是临时的、即将消亡的(字面量、函数按值返回的临时对象、std::move 的产物)。T&&(右值引用)绑定右值,等于给临时对象发了"器官捐献卡"——重载决议时,临时对象优先匹配 T&& 版本的构造/赋值,于是"反正要死,掏空它"的交易达成。

std::move 的名字有极大误导性:它一个字节也不搬,只是把表达式强制标记成右值(一次类型转换),真正干活的是随后被选中的移动构造函数。记住等价式:

Widget c = std::move(a); // 等价理解:请按移动方式从 a 构造 c

被移动后的对象处于"有效但未指明"状态——可以析构、可以赋新值,但别假设它还有原来的内容。std::string s2 = std::move(s1); 之后 s1 通常是空串,但这不保证,写依赖它非空的代码是错的。

三个高频疑问一次说清:

**函数按值返回要不要 move?**不用。return local; 触发返回值优化或隐式移动(C++11 起对局部变量自动按移动处理),写 return std::move(local); 反而禁用拷贝省略、画蛇添足。

**什么时候主动 move?**来源确实即将丢弃:把成员交给新主、把容器内容转交、把"要修改的按值参数"搬进容器(v.push_back(std::move(name));)。来源还要用就绝不能 move。

为什么移动构造要 noexcept?std::vector 扩容要把旧元素搬到新内存。若移动可能抛异常,搬到一半炸了,旧数据已残、新数据不全,无法恢复;于是标准库退回安全的逐个拷贝。不标 noexcept 的移动构造,在容器扩容时根本不会被使用——3.2 节 Buffer 已示范正确写法。

三、完美转发:把授权原样传递

模板里把参数转发给下一层时,希望保持它原本的左值/右值身份——右值继续走移动,左值继续走拷贝。这就是万能引用(T&& 在模板推导语境)加 std::forward

#include <memory> #include <utility> #include <string> template <typename T, typename... Args> std::unique_ptr<T> make(Args&&... args) { // 万能引用:左右通吃 return std::unique_ptr<T>( new T(std::forward<Args>(args)...)); // 原样转发:不丢身份 // 若直接写 new T(args...):右值退化成左值 全部走拷贝 性能白丢 } struct Big { explicit Big(std::string s) : s_(std::move(s)) {} std::string s_; }; auto p = make<Big>(std::string(1000000, 'x')); // 参数是右值 整条链路走移动

std::forward<Arg>(arg) 按模板推导记录的原身份还原类别。日常业务代码直接用 std::make_unique(内部正是这么写的),自己动手的机会主要在泛型工厂、emplace 系列封装等场合。记一条判断口诀:有名字的是左值——即便它的类型是右值引用,所以要显式 forward/move 才能恢复右值身份。

四、移动语义的连锁红利

移动语义落地后,一串旧经验作废、一串新模式成立:

  • 按值传参复活:"sink 参数"(函数要保存副本)直接按值收 std::string,函数体内 move 进目标——调用方给右值就全程移动,给左值也只拷贝一次,代码比 const 引用加分支更简洁;
  • 容器大方了vector<Widget> 扩容从"逐个深拷贝"变成"逐个过户",存大对象不再心疼;
  • unique_ptr 能进容器:只移不拷的类型,C++98 容器根本装不下(拷贝是硬要求),移动语义让"容器里的独占所有权"成为现实——第 2.3 节的 vector<unique_ptr<Shape>> 正是产物;
  • 接口语义变清晰:参数按值收 = "我要占有";按 const 引用收 = "我只看";按右值引用收 = "我要掏空你"。签名本身就在讲所有权的故事。

💡 关键直觉:拷贝保护的是"来源的完整性",移动利用的是"来源的临终"。凡是你确知对象将死的地方(临时值、return、最后一次使用),都该把移动的路让出来——语言已铺好轨道,别让旧习惯把货车开回拷贝的旧路。

本节要点回顾

  • 拷贝搬货物 移动换货主:偷指针置空来源,百万字节变两个标量。
  • move 不移动:只是授权标记;被移对象处于有效但未指明状态。
  • noexcept 是入场券:容器扩容只信不抛的移动构造。
  • 完美转发保身份:万能引用收单,forward 原样递送;make_unique 是现成范本。
  • 签名即所有权声明:按值、const 引用、右值引用各有含义,接口设计当成语言的一部分使用。

下一节从运行期回到编译期:模板如何按类型批量生成代码,让 RAII 与移动语义的成果自动覆盖所有类型。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U