std::move这四个字母看起来像一条命令实际上它连一条指令都不生成 —— 它只是把类型从T改成T剩下的活全交给重载决议。所以「用了std::move就更快」是个错觉省下来的是资源转移不是字节复制。这篇不靠计时器改用几个可以精确到个位的确定性指标把「什么时候真省、什么时候白忙」量出来。1. 引子source 空了但地址没变先看一个最短的实测它同时回答了「搬走了什么」和「搬了多少」// move_is_a_cast.cpp — 编译: g -stdc17 -Wall -O2 move_is_a_cast.cpp -o demo#includecstdio#includestring#includetype_traits#includeutilitynamespace{constexprconstchar*kLongTexta string long enough to defeat the small-buffer optimization of any standard library implementation;}// namespaceintmain(){// ① std::move 只改类型不改值int 被 move 之后照样能读写inta42;intrstd::move(a);r7;std::printf(a %dmove 之后 a 依然有效\n,a);// 返回类型就是把 T 变成 T纯编译期的事不会生成任何指令static_assert(std::is_same_vdecltype(std::move(a)),int);static_assert(std::is_same_vdecltype(std::move(std::declvalconstint())),constint);// ② 真正省下来的是「资源转移」string 的堆缓冲区被整体交接std::string src{kLongText};constchar*src_bufsrc.data();std::string moved{std::move(src)};std::string copied{src_buf};std::printf(移动后 moved.data() 还在原地址: %s\n,moved.data()src_buf?是:否);std::printf(复制后 copied.data() 还在原地址: %s\n,copied.data()src_buf?是:否);std::printf(长度 moved%zu copied%zu src%zu\n,moved.size(),copied.size(),src.size());// ③ 坑const 对象上的 std::move 退化成拷贝conststd::string frozen{kLongText};std::string from_const{std::move(frozen)};std::printf(const 对象被 move 后frozen.size()%zu from_const.size()%zu\n,frozen.size(),from_const.size());}a 7move 之后 a 依然有效 移动后 moved.data() 还在原地址: 是 复制后 copied.data() 还在原地址: 否 长度 moved99 copied99 src0 const 对象被 move 后frozen.size()99 from_const.size()99四行输出四个结论a 7——std::move之后a完全可以继续读写它没有被「搬空」因为int根本没什么可搬的。移动后目标对象的data()还在原地址复制后换了地址—— 移动做的事情就是「把指针接过去」一个字节的字符数据都没动。src0源对象的长度归零堆缓冲区已经交接出去了。最后一行是坑const对象上调用std::move得到const std::string它绑不到std::string不能把const去掉只能退回去绑const std::string于是调用了拷贝构造 ——frozen.size()仍是 99一次移动都没发生。这条和《const 位置与 const 成员函数》里讲的「const会悄悄改变重载决议结果」是同一类问题。官方文档std::move — cppreference、移动构造函数 — cppreference2. 拷贝 vs 移动代价差在哪把「一次操作要做多少事」摊成表差距的根源就清楚了。设n是元素个数 / 字符个数维度拷贝copy移动move复杂度O(n)与数据量成正比O(1)只改几个指针字段堆分配需要新分配一块n字节的内存零分配直接接管已有内存内存带宽复制n字节写 2~3 个指针约 24 字节源对象状态完全不变有效但未指定实测被置空异常安全可能抛分配失败标了noexcept就不可能抛缓存影响读写两块内存可能驱逐缓存只碰两端对象的头部缓存友好自带条件总是可用只要类型可拷贝类型定义了移动操作且对象不是const同一个 std::string99 个字符数据在堆上被「交给」另一个对象 ① 拷贝新分配一块 100 字节的堆内存把 99 个字符逐个复制过去 调用方 被调方 堆上 ----------- ----------- -------------------------- | ptr ------|-- | ptr ------|---------| NEW: 100 bytes | | size 99 | | | size 99 | -------------------------- ----------- | ----------- ^ 99 个字符被复制了一遍 | 源对象保持原样 一块内存变两块O(n) 带宽 | -- -------------------------- | OLD: 100 bytes | -------------------------- ^ 原来那块内存还在内容不变 ② 移动把指针和大小交过去源对象的指针置空 调用方 被调方 堆上 ----------- ----------- -------------------------- | ptr 0 | | ptr ------|---------| OLD: 100 bytes | | size 0 | | size 99 | -------------------------- ----------- ----------- ^ 一个字符都没被复制 零分配、零复制代价与字符数 n 无关复杂度那一行是全部拷贝是 O(n)移动是 O(1)。所以数据越大移动的优势越明显 —— 移动 1 个字符的字符串和移动 1 亿个字符的字符串代价是一样的都是写几个指针。3. 什么时候移动真的更快3.1 类型持有堆资源只有当类型内部拥有资源堆内存、文件描述符、socket时移动才有东西可「转移」。std::string、std::vector、std::map、std::unique_ptr都属于这一类。反过来如果类型的全部数据都在对象自身内部比如struct { int x; int y; }移动和拷贝都得复制那 8 个字节 —— 没有区别。3.2 数据量越大差距越大这就是上面 O(n) 与 O(1) 的直接推论 —— 关键不是「谁写的字节少」而是拷贝要多复制多少数据、多分配几次内存对象移动的代价拷贝的代价谁赢std::vectorchar8 个元素写 3 个指针约 24 字节零分配一次堆分配 复制 8 字节 写 3 个指针移动略胜省的是一次堆分配std::vectorint1 万个元素同上约 24 字节与 n 无关一次堆分配 复制约 40000 字节移动大胜差距随 n 增长std::string短串命中 SSO复制整个对象libstdc 32 字节复制整个对象32 字节平手—— 字节就在对象内部没有堆资源可转移std::string堆上99 字符约 32 字节零分配一次堆分配 复制 99 字节移动胜省的是一次分配加一段内存带宽表里第二列「与 n 无关」才是移动的核心价值移动的代价是常数拷贝的代价随数据量线性增长。所以数据越大移动的收益越明显 —— 移动 1 个元素的vector和移动 1 亿个元素的vector代价完全一样。反过来说短字符串这种命中小字符串优化SSO的情形数据本身就存在对象内部不走堆没有指针可以交接移动和拷贝都得复制那几十个字节谁都不占便宜。移动的价值是随数据规模增长的不是无条件成立的。3.3 容器扩容还得看noexcept这条最容易被忽略也最容易用确定性指标量出来。std::vector扩容时要保证强异常安全如果移动过程中抛异常原来的元素已经被改坏了一半没法回滚。所以标准要求它用std::move_if_noexcept做决策 ——元素的移动构造标了noexcept才敢用移动否则退回拷贝。官方文档std::move_if_noexcept — cppreference、std::vector::capacity — cppreference4. 用确定性指标把结论钉住计时器会被机器、负载、优化等级干扰换成「数出来的事实」最可靠// vector_growth.cpp — 编译: g -stdc17 -Wall -O2 vector_growth.cpp -o demo#includecstddef#includecstdio#includestring#includeutility#includevectornamespace{// 移动构造标了 noexcept —— 扩容时 vector 敢用移动structMoveNoexcept{staticintcopies;staticintmoves;intvalue{0};MoveNoexcept()default;explicitMoveNoexcept(intv):value(v){}MoveNoexcept(constMoveNoexcepto):value(o.value){copies;}MoveNoexcept(MoveNoexcepto)noexcept:value(o.value){moves;}};intMoveNoexcept::copies0;intMoveNoexcept::moves0;// 移动构造没标 noexcept —— vector 认为移动可能抛退回复制structMoveThrowing{staticintcopies;staticintmoves;intvalue{0};MoveThrowing()default;explicitMoveThrowing(intv):value(v){}MoveThrowing(constMoveThrowingo):value(o.value){copies;}MoveThrowing(MoveThrowingo):value(o.value){moves;}// 少了 noexcept};intMoveThrowing::copies0;intMoveThrowing::moves0;}// namespaceintmain(){constexprintkN100;// ① 扩容次数只看 capacity 变了几次std::vectorstd::stringnames;std::size_t reallocs0;std::size_t capnames.capacity();for(inti0;ikN;i){names.emplace_back(name_std::to_string(i));if(names.capacity()!cap){capnames.capacity();reallocs;}}std::printf(push %d 个 string容量重分配 %zu 次最终 capacity%zu\n,kN,reallocs,names.capacity());// ② 有 noexcept 移动构造std::vectorMoveNoexcepta;for(inti0;ikN;i){a.emplace_back(i);}std::printf(noexcept 移动copies%d moves%d\n,MoveNoexcept::copies,MoveNoexcept::moves);// ③ 移动构造没标 noexceptstd::vectorMoveThrowingb;for(inti0;ikN;i){b.emplace_back(i);}std::printf(普通移动copies%d moves%d\n,MoveThrowing::copies,MoveThrowing::moves);}push 100 个 string容量重分配 8 次最终 capacity128 noexcept 移动copies0 moves127 普通移动copies127 moves0第 2、3 行是同一份逻辑、唯一差别是移动构造函数末尾那个noexcept标了就是copies0 moves127没标就是copies127 moves0。数字完全互补说明vector在两条路径上做了非此即彼的选择。127这个数也能推导出来容量按 1→2→4→8→16→32→64→128 倍增每次扩容都要把已有的元素搬到新内存把这些旧容量加起来正好1248163264 127。结合第 1 行的「重分配 8 次」这两组数字就是vector扩容策略的全部账本。vector 增长push 100 个元素容量重分配 8 次 size: 1 2 4 8 16 32 64 128 ┌──┐ ┌──┐ ┌──┐ ┌───┐ ┌────┐ ┌─────┐ ┌──────┐ ┌────────┐ cap: │1 │ │2 │ │4 │ │ 8 │ │ 16 │ │ 32 │ │ 64 │ │ 128 │ └──┘ └──┘ └──┘ └───┘ └────┘ └─────┘ └──────┘ └────────┘ │ │ │ │ │ │ │ └────┴─────┴──────┴───────┴───────┴────────┘ 每次扩容都要搬走当前所有元素 1 2 4 8 16 32 64 127 次元素搬迁 这 127 次是「拷贝」还是「移动」由移动构造是否 noexcept 决定 移动O(127) 次指针交接拷贝O(127) 次堆分配 内存复制官方文档std::vector — cppreference注意容量增长的「均摊常数」说明、C Core Guidelines — C.66 移动操作标 noexcept5. 什么时候移动没有收益搞清「什么时候省」之后还得知道「什么时候白忙」。三种典型情形// move_no_gain.cpp — 编译: g -stdc17 -Wall -O2 move_no_gain.cpp -o demo#includecstddef#includecstdio#includetype_traits#includeutilitynamespace{structPoint{intx;inty;};// 只写了拷贝构造 → 编译器不再自动生成移动构造structCopyOnly{staticintcopies;CopyOnly()default;CopyOnly(constCopyOnly){copies;}CopyOnlyoperator(constCopyOnly){return*this;}};intCopyOnly::copies0;}// namespaceintmain(){// ① 小型 POD移动就是拷贝同样是搬几个字节Point p{1,2};constPoint q{std::move(p)};std::printf(sizeof(Point)%zu q(%d,%d) p(%d,%d) ← p 一点没变\n,sizeof(Point),q.x,q.y,p.x,p.y);std::printf(trivially_copyable%s移动和拷贝生成的是同一条指令\n,std::is_trivially_copyable_vPoint?true:false);// ② 没有移动操作的类型std::move 只能退化成拷贝CopyOnly a;constCopyOnly b{std::move(a)};std::printf(对 CopyOnly 用 std::move实际触发拷贝 %d 次\n,CopyOnly::copies);std::printf(is_move_constructible%sis_nothrow_move_constructible%s\n,std::is_move_constructible_vCopyOnly?true:false,std::is_nothrow_move_constructible_vCopyOnly?true:false);}sizeof(Point)8 q(1,2) p(1,2) ← p 一点没变 trivially_copyabletrue移动和拷贝生成的是同一条指令 对 CopyOnly 用 std::move实际触发拷贝 1 次 is_move_constructibletrueis_nothrow_move_constructiblefalse小型 PODtrivially_copyabletrue意味着「移动构造」和「拷贝构造」在机器码层面就是同一件事 —— 8 个字节整块搬。写std::move不会多快只会让读代码的人以为这里有什么讲究。const对象回到第 1 节最后那个坑。std::move的结果是const T绑不到T只能退回去拷贝。给const对象加std::move是纯噪音还可能掩盖「这里本来就没有移动」的事实。没有定义移动操作的类型CopyOnly只写了拷贝构造编译器就不会再自动生成移动构造Rule of Five 的连锁反应。最后一行is_move_constructibletrue特别有欺骗性 —— 因为T可以绑到const T所以类型看起来「可移动」实际上调的是拷贝构造copies1就是证据。6. 完整示例把一个带堆缓冲区的对象搬进容器把上面几节合起来。一个持有一块 1KB 堆缓冲区的Frame按 Rule of Five 写全五个操作// move_full.cpp — 编译: g -stdc17 -Wall -O2 move_full.cpp -o demo#includecstddef#includecstdio#includemap#includestring#includeutility#includevectornamespace{// Rule of Five 的典型例子持有一块堆上的缓冲区classFrame{public:staticintcopies;staticintmoves;explicitFrame(std::string name):name_{std::move(name)},bytes_(1024,0){}Frame(constFrameother):name_{other.name_},bytes_{other.bytes_}{copies;}Frame(Frameother)noexcept:name_{std::move(other.name_)},bytes_{std::move(other.bytes_)}{moves;}Frameoperator(constFrame)default;Frameoperator(Frame)noexceptdefault;~Frame()default;conststd::stringname()const{returnname_;}std::size_tbytes()const{returnbytes_.size();}private:std::string name_;std::vectorunsignedcharbytes_;};intFrame::copies0;intFrame::moves0;}// namespaceintmain(){constexprstd::size_t kFrameCount100;// ① emplace_back 触发扩容有 noexcept 移动构造搬元素走 O(1) 的移动std::vectorFrameframes;for(std::size_t i0;ikFrameCount;i){frames.emplace_back(frame_std::to_string(i));}std::printf(emplace_back %zu 次copies%d moves%d\n,kFrameCount,Frame::copies,Frame::moves);// ② 一次拷贝 vs 一次移动Frame original{original};Frame::copies0;Frame::moves0;constFrame duplicate{original};std::printf(一次拷贝copies%d moves%d\n,Frame::copies,Frame::moves);Frame::copies0;Frame::moves0;constFrame stolen{std::move(original)};std::printf(一次移动copies%d moves%d\n,Frame::copies,Frame::moves);std::printf(被移走之后 original.name().size()%zu有效但未指定别依赖\n,original.name().size());// ③ 移动进关联容器不需要任何拷贝Frame movable{movable};Frame::copies0;Frame::moves0;std::mapstd::string,Frametable;table.emplace(key,std::move(movable));std::printf(move 进 mapcopies%d moves%dmap 大小%zu缓冲区%zu 字节\n,Frame::copies,Frame::moves,table.size(),table.at(key).bytes());}emplace_back 100 次copies0 moves127 一次拷贝copies1 moves0 一次移动copies0 moves1 被移走之后 original.name().size()0有效但未指定别依赖 move 进 mapcopies0 moves1map 大小1缓冲区1024 字节注意Frame operator(const Frame) default;这一行 —— 拷贝赋值交给编译器生成是对的成员都是 Rule of Zero 类型但手写拷贝构造的同时用 default补上移动构造就是 Rule of Five 的完整形态只要手写了其中一个另外四个都得显式交代否则编译器会静默地把它们删掉。第 2、3 行的对比最直白同样是Frame对象一次拷贝调 1 次拷贝构造一次移动调 1 次移动构造两者数字互补、绝不同时出现。区别在于这次拷贝背后是1024字节的堆分配加内存复制而移动只是在std::string和std::vector内部换了几个指针。第 4 行要配上标准的规定读移动后的源对象处于有效但未指定valid but unspecified状态。实测libstdc它被置空了但标准不保证这一点 —— 所以永远不要写「移动后判断源对象是否为空」这类逻辑只依赖「可以安全析构、可以重新赋值」这两条保证。7. 延伸阅读std::move — cppreference页面上明确写着「std::move不移动任何东西它等价于一个static_cast到右值引用」这是本文第 1 节所有结论的出处std::move_if_noexcept — cppreference返回const T还是T的判定条件写得一清二楚正是第 4 节那两组互补数字的规则来源std::vector — cppreference看看push_back的「均摊常数」复杂度和扩容相关说明再回看 8 次重分配的账本std::vector::capacity — cppreference想自己量扩容次数时capacity是唯一可靠的观测点size看不出来C Core Guidelines — C.66 让移动操作noexcept这条规则在本文第 4 节被量化成了copies127对moves127的差别不是风格偏好而是性能开关C Core Guidelines — C.64 移动操作应当把源对象置为有效状态解释第 6 节「有效但未指定」的设计意图8. 一句话总结std::move本身零开销它只是把类型变成右值引用、让重载决议选中移动构造真正省下的是资源转移—— 拷贝是 O(n) 的分配加复制移动是 O(1) 的指针交接所以持有堆资源的类型、数据量大的对象、以及有noexcept移动构造的容器扩容才真受益实测copies0 moves127对copies127 moves0而小型 POD移动拷贝、const对象退化成拷贝、以及没定义移动操作的类型is_move_constructible为true但实测走拷贝上写std::move是白忙一场。