19. 为什么移动构造函数和移动赋值运算符通常标记为noexcept?
这个问题,我当年刚接触C++11时也困惑过。移动操作不就是“偷”资源吗?跟异常有什么关系?
后来在一次线上事故中,我才真正理解了——noexcept不是锦上添花,而是移动语义的基石。今天咱们就把它彻底说透。
一、先说结论:noexcept决定了容器是否敢用你的移动操作
标准库容器(比如std::vector)在扩容时,需要把旧元素搬到新内存。它面临一个选择:
- 用拷贝:安全,但慢(尤其对大对象)
- 用移动:快,但如果移动操作抛异常,旧数据已经被破坏,无法回滚
标准库的策略很简单:如果移动构造函数是noexcept,就用移动;否则,老老实实拷贝。
核心原则:noexcept是移动操作的“安全许可证”。没有它,标准库宁可慢,也不敢用。
二、一个活生生的例子
我写个代码,你一看就明白:
#include <vector>
#include <string>
struct MyType {
std::string data;
// 移动构造函数——没有noexcept
MyType(MyType&& other) : data(std::move(other.data)) {
// 可能抛异常吗?std::string的移动是noexcept的,所以这里不会
// 但编译器不知道!它只看你的声明
}
};
int main() {
std::vector<MyType> v;
v.reserve(1); // 先预留1个位置
v.push_back(MyType{}); // 第一次插入,没问题
v.push_back(MyType{}); // 第二次插入,需要扩容!
// 这里:因为MyType的移动构造函数没有noexcept
// vector会选择拷贝!而不是移动!
return 0;
}
你看,明明移动操作不会抛异常,就因为没写noexcept,vector就选择了拷贝。性能损失可能高达几十倍。
我的习惯:只要我能保证移动操作不抛异常,就一定加上noexcept。这不仅是承诺,更是给标准库的通行证。
三、为什么移动操作通常不会抛异常?
移动操作的本质是“资源窃取”。说白了,就是把指针、句柄这些轻量级的东西从源对象转移到目标对象。
- 不涉及内存分配:移动不申请新内存,只是转移所有权
- 不涉及深拷贝:不需要复制大量数据
- 操作简单:就是几个指针赋值,或者句柄交换
所以,一个设计良好的移动操作,几乎不可能抛异常。我甚至可以说:如果你的移动构造函数抛异常了,那八成是设计出了问题。
四、标准库的“强异常安全保证”
这里有个关键概念——强异常安全保证。意思是:如果操作失败,程序状态必须回滚到操作之前的样子。
对于std::vector::push_back来说:
- 如果扩容时移动元素,中途抛异常——旧元素已经被移动走了,无法恢复
- 如果扩容时拷贝元素,中途抛异常——旧元素还在,可以安全回滚
所以标准库必须保守:除非你明确承诺移动操作不会抛异常,否则我就当它会抛。
我曾经踩过的坑:有一次我写了一个自定义容器,移动构造函数里调用了第三方库的接口。那个接口在极端情况下会抛异常。我没加noexcept,结果性能比拷贝还差。后来我重构了设计,把可能抛异常的逻辑移到别处,加上noexcept,性能直接翻倍。
五、什么时候可以不加noexcept?
说实话,场景很少。但确实有:
- 移动操作确实可能抛异常:比如移动一个包含文件句柄的对象,关闭旧句柄时可能失败
- 你明确知道这个对象不会被放入标准库容器:比如只用于局部变量传递
- 你正在实现一个“移动但不安全”的语义:这种情况极少见
但我的建议是:99%的情况下,都加上noexcept。哪怕你只是怀疑,也先加上。如果后来发现真的会抛异常,编译器会告诉你(虽然是以运行时行为异常的方式)。
六、一张图总结核心逻辑
下面这张图,展示了标准库容器在扩容时的决策流程:
七、实际编码中的最佳实践
说了这么多,落实到代码上其实很简单:
class Buffer {
int* data_;
size_t size_;
public:
// 移动构造函数——必须加noexcept
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值运算符——也必须加noexcept
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
// 析构函数——默认就是noexcept,不用写
~Buffer() {
delete[] data_;
}
};
我的建议:写移动操作时,先问自己三个问题:
- 这个操作会申请内存吗?——如果会,可能抛异常
- 这个操作会调用可能抛异常的函数吗?——如果会,考虑重构
- 如果以上都是“否”,果断加noexcept
八、总结
noexcept对于移动操作,就像安全带对于开车。平时你可能觉得它多余,但关键时刻能救命——这里的“命”就是性能。
记住一句话:noexcept不是写给编译器看的,是写给标准库看的。它告诉容器:“放心用我的移动操作,不会出事。”
你想想看,如果你是一个容器,面对两个选择:一个快但可能翻车,一个慢但绝对安全。你会选哪个?
嗯,现在你知道为什么移动操作要加noexcept了。
公众号:蓝海资料掘金营,微信deep3321