ARTICLE DETAIL

资讯详情

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

noexcept 为什么移动构造必须标:从 vector 扩容退化成拷贝说起

noexcept 为什么移动构造必须标:从 vector 扩容退化成拷贝说起 noexcept常被当成「一个爱加不加的函数修饰词」但它是 C 里少数几个直接影响运行时性能的关键字同一个类型移动构造上有没有noexcept决定了std::vector扩容时是搬几个指针还是把所有元素深拷贝一遍。这篇把两份只差一个关键字的类型放在一起真跑一遍看清楚差异是从哪来的。1. 引子日志里全是「拷贝」可我明明写了移动构造给一个自定义类型写了移动构造塞进std::vector以为它会安安静静地搬指针。结果在拷贝构造里打日志一扩容整页都是拷贝记录[Trace] push_back 第 3 个元素触发扩容 [Trace] 拷贝 id1 -- 移动构造明明写了 [Trace] 拷贝 id2编译器没坏std::vector也没反悔。原因只有一个移动构造没有标noexcept。vector在扩容时用的不是无条件std::move而是std::move_if_noexcept—— 一个会「看情况退回拷贝」的函数。2. noexcept 的两种用法修饰函数 vs 当运算符先把这个关键字的两种身份分清楚很多混淆都源于把它们当成了同一个东西。第一种是函数说明符exception specification写在函数声明的参数列表后面向编译器承诺「这个函数不抛异常」。voidmayThrow();// 没承诺voiddeclaredNoThrow()noexcept;// 承诺我不抛第二种是运算符operator写法是noexcept(表达式)它在编译期算出「这个表达式是否被判定为不抛异常」结果是bool可以喂给static_assert。这是编译期自省最顺手的一把刀#includecstdio#includestring#includetype_traits#includeutility#includevectorvoidmayThrow();voiddeclaredNoThrow()noexcept;structWidget{std::string name;~Widget(){}// 没写 noexcept但它本来就是 noexcept};intmain(){// noexcept(expr) 是编译期常量表达式能直接喂给 static_assertstatic_assert(!noexcept(mayThrow()),mayThrow 应该可能抛);static_assert(noexcept(declaredNoThrow()),declaredNoThrow 应该不抛);static_assert(noexcept(std::declvalWidget().~Widget()),析构默认就是 noexcept);static_assert(std::is_nothrow_move_constructiblestd::string::value,string 的移动构造不抛);std::printf(noexcept(mayThrow()) %s\n,noexcept(mayThrow())?true:false);std::printf(noexcept(declaredNoThrow()) %s\n,noexcept(declaredNoThrow())?true:false);std::printf(is_nothrow_move_constructiblestd::vectorint %s\n,std::is_nothrow_move_constructiblestd::vectorint::value?true:false);}noexcept(mayThrow()) false noexcept(declaredNoThrow()) true is_nothrow_move_constructiblestd::vectorint true官方文档noexcept specifier、noexcept operator注意上面两处细节mayThrow()只有声明、没有定义也没关系 ——noexcept运算符只做静态分析不会真的调用它而Widget的析构函数我一个字都没写noexceptstatic_assert依然通过了因为析构函数默认就是noexcept除非它的某个成员或基类的析构可能抛。这个默认值很关键后面会用到。两种用法的区别一张表说清写法名字求值时机结果典型用途void f() noexcept;异常说明符声明时无是个承诺标记移动构造、析构、swapnoexcept(expr)运算符编译期boolstatic_assert、条件式noexcept、类型萃取两者还能合体写条件承诺比如「取决于内部调用的表达式」// 片段条件 noexcept —— 只有当 T 的移动不抛时我这个包装函数才承诺不抛#includeutilitytemplatetypenameTvoidwrapper(Tvalue)noexcept(noexcept(std::forwardT(value))){// ...}3. noexcept 不是「不会抛」而是「抛了就直接 terminate」这是最容易误解的一点。noexcept不是「我保证不抛异常」这种君子协定也不是「编译器会帮你拦住异常」它的真实语义是一旦异常从noexcept函数里逃出来直接调用std::terminate()。std::terminate()会调用当前注册的 terminate handler默认就是std::abort不做栈展开stack unwinding也不会去匹配任何catch。也就是说外层的try/catch救不了它局部对象的析构也不保证执行。// 片段noexcept 里抛异常 —— 不需要 main()因为它会把进程直接干掉// 反例不要这么写给会抛的函数标 noexcept#includestdexceptvoidbad()noexcept{throwstd::runtime_error(boom);// 编译只给个警告运行时直接 terminate}voidcaller(){try{bad();// 这里的 catch 永远等不到异常}catch(conststd::runtime_error){// 永远进不来}}上面这段在 gcc 下能编过只报一条 warning但真跑起来会打印terminate called after throwing an instance of std::runtime_error然后abort()掉进程 —— 退出码是信号量而不是 0。这就是「标了就不能反悔」的含义加了noexcept之后再遇到异常路径程序不再是「上层处理异常」而是「当场死给你看」。官方文档std::terminate所以有一条实用判据只有当这个函数真的、在所有路径上都不会抛时才给它标noexcept。反过来如果你写了noexcept函数却出现了throw编译器通常只给警告 —— 这个警告一定要当错误看它是真的会崩。第 2 节里Widget的析构为什么默认noexcept现在能答了析构函数在栈展开过程中必然会被调用如果它自己再抛异常就会在「处理异常的途中又抛一个异常」标准的选择就是直接terminate。所以标准干脆默认所有析构都是noexcept—— 写析构函数时千万别让它抛异常Core Guidelines 里 E.16 专门讲了这条。4. 关键机制move_if_noexcept 让 vector 扩容退化成拷贝现在回到引子里的现象。std::vector扩容时要做三件事vector 扩容reallocation三步 ────────────────────────────────────────────── 1. 申请一块更大的新缓冲区 - 这一步可能抛 std::bad_alloc 2. 把旧缓冲区里的元素「搬」到新缓冲区 - 这里决定用移动还是拷贝 3. 释放旧缓冲区 为什么第 2 步不敢无条件用 move 假设搬到第 3 个元素时移动构造抛了异常 · 新缓冲区里已有 2 个「半成品」 · 旧缓冲区里的第 3 个元素已经被掏空moved-from · 两边都不完整 —— 无法回滚到扩容前的状态 → 强异常安全strong guarantee被破坏 如果第 2 步用「拷贝」 第 3 个元素拷贝失败时旧缓冲区的数据一个都没动 直接释放新缓冲区就能完全回滚 —— 强保证成立。 ──────────────────────────────────────────────这段权衡的标准名字叫std::move_if_noexcept它的判定规则是std::move_if_noexcept(elem) 的决策 ────────────────────────────────────────────── T 的移动构造标了 noexcept 吗 │ ┌────────┴─────────┐ 是│ │否 ▼ ▼ 返回 T T 可以拷贝吗 走移动构造 │ ┌───────┴───────┐ 是│ │否 ▼ ▼ 返回 const T 只能移动 走拷贝构造 别无选择 返回 T 判定式libstdc 里的写法 is_nothrow_move_constructibleT::value || !is_copy_constructibleT::value → 返回右值引用移动 否则 → 返回常量左值引用拷贝 ──────────────────────────────────────────────官方文档std::move_if_noexcept注意最后一格如果类型既不可拷贝、移动又会抛vector依然只能移动—— 因为没得选。这也解释了为什么「移动构造标了noexcept」不是锦上添花而是保住性能的必需品。下面这段代码把决策跑出来。两个结构体长得一模一样只差SafeMove的移动构造后面多了一个noexcept#includecstdio#includeutility#includevectorstructSafeMove{intid;explicitSafeMove(intv):id(v){}SafeMove(constSafeMoveo):id(o.id){std::printf( 拷贝 SafeMove id%d\n,o.id);}SafeMove(SafeMoveo)noexcept:id(o.id){std::printf( 移动 SafeMove id%d\n,o.id);}};structRiskyMove{intid;explicitRiskyMove(intv):id(v){}RiskyMove(constRiskyMoveo):id(o.id){std::printf( 拷贝 RiskyMove id%d\n,o.id);}RiskyMove(RiskyMoveo):id(o.id){std::printf( 移动 RiskyMove id%d\n,o.id);}};templatetypenameTvoidgrow(constchar*name){std::printf(%s 放进 vector 扩容\n,name);std::vectorTv;for(inti1;i4;i){v.push_back(T{i});std::printf( push_back(%d) 后 size%zu capacity%zu\n,i,v.size(),v.capacity());}}intmain(){growSafeMove(SafeMove);growRiskyMove(RiskyMove);}SafeMove 放进 vector 扩容 移动 SafeMove id1 push_back(1) 后 size1 capacity1 移动 SafeMove id2 移动 SafeMove id1 push_back(2) 后 size2 capacity2 移动 SafeMove id3 移动 SafeMove id1 移动 SafeMove id2 push_back(3) 后 size3 capacity4 移动 SafeMove id4 push_back(4) 后 size4 capacity4 RiskyMove 放进 vector 扩容 移动 RiskyMove id1 push_back(1) 后 size1 capacity1 移动 RiskyMove id2 拷贝 RiskyMove id1 push_back(2) 后 size2 capacity2 移动 RiskyMove id3 拷贝 RiskyMove id1 拷贝 RiskyMove id2 push_back(3) 后 size3 capacity4 移动 RiskyMove id4 push_back(4) 后 size4 capacity4怎么读这份输出每个push_back都会有一条「移动」记录 —— 那是形参到元素的入位push_back(T)绑定临时对象无论标不标noexcept都有不是扩容开销。真正的差异藏在扩容那几次容量 1→2 时SafeMove移动 1 个旧元素RiskyMove拷贝1 个容量 2→4 时SafeMove移动 2 个RiskyMove拷贝2 个。也就是说仅仅漏写一个noexcept扩容就从「搬 N 个指针」变成「N 次深拷贝 N 次旧对象析构」。元素越重比如内部是std::string、std::vector、大数组差距越大vector扩容总拷贝次数是 O(N)退化成拷贝后整体复杂度不变但常数项能差几十倍。5. 静态自检手上的类型会不会退化成拷贝不用等运行时打日志noexcept运算符 类型萃取能在编译期就给出答案。下面把move_if_noexcept的判定式原样抄了一遍#includecstdio#includestring#includetype_traits#includeutility#includevector// 复刻 std::move_if_noexcept 的判定规则返回「移动」还是「拷贝」templatetypenameTconstchar*relocateStrategy(){return(std::is_nothrow_move_constructibleT::value||!std::is_copy_constructibleT::value)?移动:拷贝;}structRiskyMove{// 移动可能抛且可拷贝RiskyMove()default;RiskyMove(RiskyMove){}RiskyMove(constRiskyMove)default;};structMoveOnlyNoexcept{// 不可拷贝只能移动MoveOnlyNoexcept()default;MoveOnlyNoexcept(MoveOnlyNoexcept)noexcept{}MoveOnlyNoexcept(constMoveOnlyNoexcept)delete;};intmain(){std::printf(vectorint 扩容时: %s\n,relocateStrategyint());std::printf(vectorstd::string 扩容时: %s\n,relocateStrategystd::string());std::printf(vectorstd::vectorint 扩容时: %s\n,relocateStrategystd::vectorint());std::printf(vectorRiskyMove 扩容时: %s\n,relocateStrategyRiskyMove());std::printf(vectorMoveOnlyNoexcept 扩容时: %s\n,relocateStrategyMoveOnlyNoexcept());}vectorint 扩容时: 移动 vectorstd::string 扩容时: 移动 vectorstd::vectorint 扩容时: 移动 vectorRiskyMove 扩容时: 拷贝 vectorMoveOnlyNoexcept 扩容时: 移动标准库类型绝大多数都达标int是平凡类型std::string、std::vector、std::unique_ptr的移动构造都是noexceptRiskyMove被判定为拷贝MoveOnlyNoexcept虽然没标也能编但它是靠「不可拷贝」这条兜底规则拿到移动的 ——安全网不是设计。自检清单可以缩成一行自己写的每个类型移动构造和移动赋值都应该标noexcept除非它的某个成员确实会抛。6. 完整示例一个永远不会退化成拷贝的 Buffer把前面的点串起来 —— 一个自己管理std::vectorint的类型按 Rule of Five 写全五个特殊成员函数移动操作全部标noexcept。为了让「移动只搬指针」这件事可验证我在main里加了两个static_assert一旦以后有人把noexcept删掉编译期就会直接拦下#includecstddef#includecstdio#includetype_traits#includeutility#includevectorclassBuffer{public:explicitBuffer(std::size_t n):data_(n,0){}Buffer(constBufferother):data_(other.data_){}// 拷贝会分配可能抛Buffer(Bufferother)noexcept:data_(std::move(other.data_)){}// 移动只搬指针Bufferoperator(constBufferother){// copy-and-swap天然强异常安全if(this!other){Buffertmp(other);swap(tmp);}return*this;}Bufferoperator(Bufferother)noexcept{if(this!other)data_std::move(other.data_);return*this;}~Buffer()default;voidswap(Bufferother)noexcept{data_.swap(other.data_);}std::size_tsize()constnoexcept{returndata_.size();}intat(std::size_t i){returndata_.at(i);}intat(std::size_t i)const{returndata_.at(i);}private:std::vectorintdata_;};// 非成员 swapADL 只找得到非成员函数成员函数不参与 ADL// 所以配套提供这个转发函数using std::swap; swap(a, b); 才会命中它。voidswap(Buffera,Bufferb)noexcept{a.swap(b);}intmain(){// 编译期体检谁把 noexcept 删了这里就编不过static_assert(std::is_nothrow_move_constructibleBuffer::value,移动构造要 noexcept);static_assert(std::is_nothrow_swappableBuffer::value,swap 要 noexcept);std::vectorBufferpool;for(intn1;n3;n)pool.push_back(Buffer(static_caststd::size_t(n)*4));std::printf(pool.size() %zu各元素大小,pool.size());for(constBufferb:pool)std::printf(%zu ,b.size());std::printf(\n);Buffera(4);Bufferb(8);a.at(0)7;b.at(0)9;usingstd::swap;// 让 ADL 有机会找到 Buffer::swap它更快且 noexceptswap(a,b);std::printf(swap 后 a.size()%zu b.size()%zu a[0]%d b[0]%d\n,a.size(),b.size(),a.at(0),b.at(0));}pool.size() 3各元素大小4 8 12 swap 后 a.size()8 b.size()4 a[0]9 b[0]7两处值得注意using std::swap;然后写不带限定的swap(a, b)这是惯用法这样写的好处是给 ADL 留了机会 —— 只要类所在的命名空间里有一个非成员swap示例里就是Buffer, Buffer的那个转发函数它就会被优先选中。注意成员Buffer::swap本身不会被 ADL 找到ADL 只搜非成员函数所以成员版必须再包一层非成员转发否则swap(a, b)只能退回std::swap—— 那要靠两次移动构造 一次临时对象多一次构造/析构而且noexcept与否取决于移动操作。非成员swap声明成noexcept的意义在《异常安全三级保证》会体现copy-and-swap 里swap是唯一的提交动作它必须不可能失败否则「提交」也能半途崩掉强异常安全就无从谈起。7. 哪些函数该标 noexcept哪些千万别标noexcept的坑在于不可撤回标了以后任何一条异常路径都会从「上层可以 catch」变成「进程直接 terminate」。所以判定标准只有一条 —— 这个函数在所有可能的执行路径上都不会让异常逃出来。对「函数类型」的影响也值得记住C17 起noexcept是函数类型的组成部分void(*)() noexcept和void(*)()是两种不同的类型前者的函数指针只能指向noexcept函数。所以 lambda 想赋给noexcept函数指针就必须显式标// 片段noexcept 是函数类型的一部分C17 起void(*plain)()[]{};// OKvoid(*strict)()noexcept[]()noexcept{};// OK必须显式标// void (*bad)() noexcept [] {}; // ✗ 编译失败类型不兼容官方文档Function declarations — exception specification 是类型的一部分场景建议理由移动构造 / 移动赋值必须标不标则vector/string扩容走move_if_noexcept退化成拷贝swap成员版标copy-and-swap 的「提交」一步必须不可能失败析构函数默认就是别加throw栈展开中再抛 →std::terminateCG E.16简单 getter、size()、纯计算标明确表达意图且真的不抛会分配内存的函数别标std::bad_alloc一旦发生就直接 terminate失去处理机会调用未知代码的包装函数别标你无法保证被调方不抛可能throw的业务校验函数别标标了等于把「异常」这一信息渠道掐死虚函数 / 回调谨慎派生类实现可能抛且noexcept参与类型覆盖时不能放宽8. 延伸阅读noexcept specifier — cppreference异常说明符的完整规则包括「析构函数默认 noexcept」的确切条件noexcept operator — cppreference运算符形式写编译期自省时的语法参考std::move_if_noexcept — cppreference本文核心机制的官方定义页面上明确写了「保证强异常安全」这一动机C Core Guidelines · C.66 Make move operations noexcept为什么移动操作必须noexcept的规范性依据C Core Guidelines · E.16 Destructors and swap must never fail析构与swap的失败语义9. 一句话总结noexcept有两种用法修饰函数 / 当运算符两种语义都不能含糊它是「抛了就直接std::terminate」的硬承诺同时也是给标准容器的性能开关 —— 移动构造漏标一个noexceptstd::vector扩容就会用std::move_if_noexcept静默退化成逐元素深拷贝所以移动构造、移动赋值、swap一律标上会分配内存或可能真抛的地方一个都别标。
返回列表