ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

移动构造与移动赋值:把资源偷过来 「std::move是什么」那一篇已经讲清楚了它只是个类型转换真正搬数据的是移动构造函数。这一篇换个视角从类的实现者出发亲手写出正确、高效的移动操作。有几个问题只有写实现时才碰得到noexcept为什么不是装饰、移动后源对象到底处于什么状态、移动赋值怎么处理自赋值。还有一个更隐蔽的编译器可能压根没给你生成移动操作。如果你还没搞清楚std::move只是转换、不是搬运工建议先读同系列的《std::move 其实什么都没搬》再回来往下看。你写了移动构造为什么 vector 扩容还在拷贝给自己的类加上移动构造塞进std::vectorprofiling 一看扩容时居然在做深拷贝。移动构造白写了。原因几乎总是同一个移动构造没标noexcept。这个坑足够实在就从它讲起。移动操作的正确签名先把骨架立住。两个移动操作的签名是固定的少一个限定符语义就变了// 片段无 main仅展示签名约定classBuffer{public:// 移动构造形参是非 const 右值引用必须 noexceptBuffer(Bufferother)noexcept;// 移动赋值同样 noexcept返回自身的引用Bufferoperator(Bufferother)noexcept;// 拷贝操作保持 const 左值引用Buffer(constBufferother);Bufferoperator(constBufferother);};四条规矩缺一不可形参是T非const。const T没法「偷」资源会退化成拷贝必须标noexcept后面单独展开把源对象的资源接过来同时把源对象置空指针置nullptr、计数归零移动赋值还要处理自赋值并释放自己已有的资源。官方文档Move constructor — cppreference · Move assignment — cppreference为什么 noexcept 是性能开关实测std::vector扩容时要把旧元素搬到新内存。搬的过程中如果某个元素的移动构造抛了异常搬到一半的状态就回不去了。为了保住强异常保证strong exception guaranteevector不会冒险去移动宁可退回拷贝因为拷贝失败也不会破坏原对象。它靠的就是std::move_if_noexcept只有当移动构造是noexcept时才用移动否则退回拷贝。下面这段把这件事跑给你看用计数器数清楚「现有元素到底是移动还是拷贝」// move_noexcept_demo.cpp — 编译: g -stdc17 -Wall -O2 move_noexcept_demo.cpp -o m#includecstdio#includeutility#includevectorstructNoExceptMove{NoExceptMove()default;NoExceptMove(constNoExceptMove){copies;}NoExceptMove(NoExceptMove)noexcept{moves;}staticintcopies,moves;};intNoExceptMove::copies0;intNoExceptMove::moves0;structThrowingMove{ThrowingMove()default;ThrowingMove(constThrowingMove){copies;}ThrowingMove(ThrowingMove){moves;}// 反例不要这么写缺 noexceptstaticintcopies,moves;};intThrowingMove::copies0;intThrowingMove::moves0;templateclassTvoidreallocate(){std::vectorTv;v.reserve(2);v.push_back(T{});// 放进第 1 个v.push_back(T{});// 放进第 2 个v.push_back(T{});// 超过容量 2扩容到 4搬迁旧 2 个}intmain(){reallocateNoExceptMove();std::printf(NoExceptMove : copies%d moves%d\n,NoExceptMove::copies,NoExceptMove::moves);reallocateThrowingMove();std::printf(ThrowingMove : copies%d moves%d\n,ThrowingMove::copies,ThrowingMove::moves);}NoExceptMove : copies0 moves5 ThrowingMove : copies2 moves3两份代码只差一个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。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 的一张「请放心移动我」的担保书一句话概括没标noexceptvector为保住强异常保证宁可多花 O(n) 去拷贝。你的移动构造被静默绕过一行报错都不会有。移动后源对象的状态有效但未指定标准保证被移动的对象处于「有效但未指定valid but unspecified」状态。翻译过来就是它还能安全析构、还能被重新赋值但它的值是什么标准不保证。所以实践上的约定是移动构造把源对象置空指针 nullptr、长度 0。这样一来源对象析构时delete[] nullptr是安全的C 规定对空指针delete什么也不做重新赋值也毫无歧义。#includecstdio#includeutilitystructHolder{int*pnullptr;Holder():p(newint(1)){}Holder(Holderother)noexcept:p(other.p){other.pnullptr;}// 置空Holder(constHolder)delete;Holderoperator(Holder)delete;~Holder(){deletep;}};intmain(){Holder a;Holder bstd::move(a);// a.p 被置空// a 现在处于 valid-but-unspecified 状态析构安全但不要再读 *a.pstd::printf(移动后 a.p 是否为空指针: %s\n,a.pnullptr?是:否);}移动后 a.p 是否为空指针: 是记住一条移动后的对象要么让它安静地析构要么给它重新赋值不要去读它「原来的值」。不同标准库实现下被移动后的std::string可能是空串也可能不是依赖这个值是不可移植的。官方文档Moved-from state of library types把「移动前」和「移动后」两边的状态画出来指针归属的变化一眼可见移动前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 挪到 bO(1) 如果移动构造忘了把 a 置空 a [ size_ 4 data_ 0x1000 ] ──┐ ├──► 0x1000 被两个对象都认为「是我的」 b [ size_ 4 data_ 0x1000 ] ──┘ → 析构时 double free未定义行为移动赋值自赋值 清理已有资源移动赋值比移动构造多两件事先释放自己已有的资源以及防自赋值。顺序很重要。要先delete自己的旧资源再接管对方的自赋值时直接跳过否则会把自己刚delete的指针又去读。// 片段无 main展示移动赋值的关键逻辑BufferBuffer::operator(Bufferother)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)就静默回退成拷贝构造没有任何报错性能悄悄没了。下面这段直接验证// move_suppress_demo.cpp — 编译: g -stdc17 -Wall -O2 move_suppress_demo.cpp -o s#includecstdio#includeutilitystructWithDtor{WithDtor()default;WithDtor(constWithDtor){std::puts( 拷贝构造);}~WithDtor(){}// 反例不要这么写无意义的用户析构却抑制了隐式移动};structWithoutDtor{WithoutDtor()default;WithoutDtor(constWithoutDtor){std::puts( 拷贝构造);}WithoutDtor(WithoutDtor)noexcept{std::puts( 移动构造);}};intmain(){std::puts(--- 声明了析构std::move 退化为拷贝 ---);WithDtor a;WithDtor bstd::move(a);std::puts(--- 没声明析构或显式写了移动走移动 ---);WithoutDtor c;WithoutDtor dstd::move(c);}--- 声明了析构std::move 退化为拷贝 --- 拷贝构造 --- 没声明析构或显式写了移动走移动 --- 移动构造你声明了……隐式移动构造/赋值std::move实际走什么都不声明自动生成移动析构函数被抑制拷贝拷贝构造被抑制拷贝拷贝赋值被抑制拷贝移动赋值被抑制拷贝显式写/default移动存在移动这就是为什么 Rule of Five 强调「要么全交给编译器要么五个都写齐」你一旦手写了析构哪怕空的就必须把移动操作也显式写出来或default否则就掉进上面的坑。注这篇以 C17 为准。C20 对这条规则略有放宽用户声明的析构不再抑制移动但 Rule of Five 的最佳实践在任何版本都成立不受标准改动影响。一个正确且高效的 Buffer把前面所有要点合起来这是一个能整体编译运行的Buffer移动noexcept、移动后置空、移动赋值防自赋值清理、Rule of Five 写齐。// buffer_full.cpp — 编译: g -stdc17 -Wall -O2 buffer_full.cpp -o buf#includecstdio#includeutilityclassBuffer{public:explicitBuffer(std::size_t n):size_(n),data_(newint[n]){std::printf( [构造] 分配 %zu 个 int\n,n);}// 拷贝构造深拷贝Buffer(constBufferother):size_(other.size_),data_(newint[other.size_]){for(std::size_t i0;isize_;i)data_[i]other.data_[i];std::printf( [拷贝构造] 深拷贝 %zu 个 int\n,size_);}// 移动构造noexcept只偷指针Buffer(Bufferother)noexcept:size_(other.size_),data_(other.data_){other.size_0;other.data_nullptr;std::printf( [移动构造] 只偷指针\n);}// 移动赋值noexcept自赋值安全 先清理自己Bufferoperator(Bufferother)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_tsize()const{returnsize_;}private:std::size_t size_0;int*data_nullptr;};intmain(){Buffera(4);Buffer bstd::move(a);// 移动构造Bufferc(2);cstd::move(b);// 移动赋值b 先被清理再接管std::printf(a.size()%zu b.size()%zu c.size()%zu\n,a.size(),b.size(),c.size());}[构造] 分配 4 个 int [移动构造] 只偷指针 [构造] 分配 2 个 int [移动赋值] a.size()0 b.size()0 c.size()4注意第 7 行的移动赋值原来c持有的 2 个 int 被delete[]释放c接管了b的 4 个 inta、b都被置空最终size()都是 0。全程无深拷贝、无泄漏。延伸阅读std::move_if_noexcept — cppreference ——vector扩容时「移动还是拷贝」的决策依据C Core Guidelines · C.66 —— 移动操作必须noexceptMove constructor / Move assignment — cppreference —— 隐式声明的抑制规则Compiler Explorer —— 对比noexcept有无时vector扩容生成的汇编差异收个尾移动操作标noexcept移动后把源对象置空移动赋值防自赋值并先清理自己。这三件事做对移动才算真的省下了拷贝。真正阴的是最后那条只要声明了析构或拷贝操作中的任意一个编译器就不再自动生成移动std::move会安静地退回拷贝。所以 Rule of Five 那句话值得记住要么全交给编译器要么五个都写齐。
返回列表