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 也不敢用。它会老老实实走拷贝路径,让你精心设计的移动语义形同虚设。
我个人建议:写自定义类型时,先把移动构造函数和移动赋值运算符写好,然后立刻加上 noexcept。这已经成为我的肌肉记忆了。你也不妨试试——相信我,当你看到 vector 扩容时真的在移动而不是拷贝,那种感觉,嗯,挺爽的。
公众号:蓝海资料掘金营,微信 deep3321