18. noexcept关键字在移动语义中扮演什么角色?

说实话,noexcept 这个关键字,在 C++11 刚出来那会儿,我并没太当回事。觉得不就是告诉编译器「这函数不会抛异常」嘛,优化能有多大?直到有一次,我在项目中写了一个自定义容器,里面存了大量 string 对象。结果每次扩容时,性能都惨不忍睹。我查了半天,最后发现——问题就出在 noexcept 的缺失上。

嗯,今天我们就来聊聊,noexcept 在移动语义里到底扮演了什么角色。

一、noexcept 的本质:给编译器一个承诺

noexcept 说白了,就是你对编译器说:「我这个函数,保证不抛异常。」

编译器听到这个承诺后,会做两件事:

  • 如果函数真的抛了异常,程序直接 terminate(很严重,别乱承诺)
  • 允许编译器做更激进的优化——尤其是在移动语义中

你想想看,如果编译器不确定你的移动构造函数会不会抛异常,它就不敢放心大胆地用它。为什么?因为异常安全需要兜底。

二、移动语义中的关键场景:std::vector 扩容

这是最经典的例子。我当年踩的坑就在这里。

当 vector 需要扩容时,它要把旧内存里的对象搬到新内存。这时候有两种策略:

策略 条件 行为
移动元素 移动操作是 noexcept 直接移动,高效
拷贝元素 移动操作可能抛异常 退化为拷贝,慢

为什么会这样?

假设 vector 正在扩容,它已经移动了 5 个元素到新内存。这时第 6 个元素的移动构造函数抛异常了。怎么办?旧内存里的前 5 个元素已经被移走了,状态不确定。新内存里的 5 个元素也构造到一半。整个容器就处于「半死不活」的状态。

所以标准库的决策是:如果移动操作可能抛异常,我就用拷贝。拷贝失败时,旧元素还在,可以安全回滚。

核心结论:std::vector 等容器在扩容时,会通过 std::move_if_noexcept 来判断使用移动还是拷贝。而判断的依据,就是移动构造函数/移动赋值运算符是否标记为 noexcept。

三、代码示例:有 noexcept 和没有的区别

#include <vector>
#include <string>

struct MyType {
    std::string data;
    
    // 移动构造函数 — 没有 noexcept
    MyType(MyType&& other) 
        : data(std::move(other.data)) {
        // 可能抛异常吗?string 的移动是 noexcept 的,所以这里其实安全
        // 但编译器不会自动推导,需要你手动标记
    }
    
    MyType& operator=(MyType&& other) {
        data = std::move(other.data);
        return *this;
    }
};

struct MyTypeNoexcept {
    std::string data;
    
    // 移动构造函数 — 显式标记 noexcept
    MyTypeNoexcept(MyTypeNoexcept&& other) noexcept
        : data(std::move(other.data)) {
    }
    
    MyTypeNoexcept& operator=(MyTypeNoexcept&& other) noexcept {
        data = std::move(other.data);
        return *this;
    }
};

int main() {
    std::vector<MyType> v1;
    v1.reserve(1);
    v1.push_back(MyType{});  // 扩容时:拷贝!因为移动不是 noexcept
    
    std::vector<MyTypeNoexcept> v2;
    v2.reserve(1);
    v2.push_back(MyTypeNoexcept{});  // 扩容时:移动!高效
}

你看,MyType 明明所有操作都不会抛异常,但因为没写 noexcept,vector 就不敢用它的移动构造函数。性能差距在元素数量大时非常明显。

我的习惯:只要移动构造函数和移动赋值运算符不抛异常,就一定要加 noexcept。这不仅是给编译器看的,也是给调用者看的——你的类型是「可信任的」。

四、noexcept 对完美转发的影响

完美转发里,noexcept 同样重要。我记得有一次写泛型代码,用 std::forward 转发参数,然后调用某个函数。如果被调函数是 noexcept 的,我希望外层函数也能推导出 noexcept。

C++17 引入了 noexcept 作为类型系统的一部分。你可以这样写:

template <typename F, typename... Args>
auto invoke_with_log(F&& f, Args&&... args) 
    noexcept(noexcept(std::forward<F>(f)(std::forward<Args>(args)...)))
{
    // 记录日志...
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

这个 noexcept(noexcept(...)) 的写法,就是让编译器根据内部表达式是否 noexcept,来决定外层函数是否 noexcept。完美转发时,异常说明也要「完美传递」。

五、避坑指南:我曾经犯过的错

我曾经在一个项目里,给移动构造函数加了 noexcept,但内部调用了某个可能抛异常的函数。结果测试时程序直接 terminate 了。嗯,noexcept 不是装饰品,它是严肃的承诺。

几个原则:

  • 移动构造函数和移动赋值运算符:只要可能,就标记 noexcept
  • 析构函数:C++11 默认是 noexcept,不要改
  • swap 函数:通常应该是 noexcept
  • 其他函数:只有确定不会抛异常时才加

警告:不要为了性能优化而虚假标记 noexcept。如果函数真的抛了异常,程序会直接终止,连栈展开都不会做。这比异常本身更可怕。

六、知识体系图

下面这张图总结了 noexcept 在移动语义中的角色和影响链路:

noexcept 在移动语义中的角色 noexcept 声明 编译器信任,允许移动 vector 扩容时使用移动构造 编译器保守,退化为拷贝 vector 扩容时使用拷贝构造 ✅ 性能好,无额外拷贝开销 适合存储大量元素 ❌ 性能差,拷贝开销大 元素多时性能急剧下降 结论:移动操作务必标记 noexcept

七、总结

noexcept 在移动语义中的角色,可以用一句话概括:它是移动操作被标准库信任的通行证

没有 noexcept,你的移动构造函数写得再漂亮,vector 也不敢用。它会老老实实走拷贝路径,让你精心设计的移动语义形同虚设。

我个人建议:写自定义类型时,先把移动构造函数和移动赋值运算符写好,然后立刻加上 noexcept。这已经成为我的肌肉记忆了。你也不妨试试——相信我,当你看到 vector 扩容时真的在移动而不是拷贝,那种感觉,嗯,挺爽的。


公众号:蓝海资料掘金营,微信 deep3321