43. noexcept:移动操作与异常安全
移动语义是C++11引入的利器,但很多人忽略了一个关键点——移动操作到底该不该抛异常?
我个人习惯是:能noexcept就noexcept。这不是教条,而是血的教训换来的经验。
为什么移动操作要noexcept?
先看一个场景。你写了个vector,里面存了一堆对象。当vector扩容时,它会怎么做?
嗯,标准做法是:申请新内存,把旧元素搬过去,销毁旧元素。但这里有个选择——用拷贝还是用移动?
如果移动操作可能抛异常,vector会怎么选?它会选拷贝。因为拷贝失败时,旧数据还在,可以回滚。移动如果中途挂了,旧数据已经被破坏,神仙难救。
说白了,noexcept就是告诉编译器:「你放心用移动,不会出事。」
核心原则:如果移动操作不抛异常,一定要标记noexcept。这能让标准库容器选择更高效的移动而非拷贝。
标准库的「信任投票」
我查过C++标准库的实现,比如std::vector的push_back。它的逻辑大致是:
// 伪代码,展示核心逻辑
if constexpr (is_nothrow_move_constructible_v<T>) {
// 放心用移动
new (dest) T(std::move(old));
} else {
// 只能拷贝,保证异常安全
new (dest) T(old);
}
你看,编译器在编译期就做了决策。如果你的移动构造函数没标noexcept,它就被当成「可能抛异常」处理,从而选择更慢的拷贝路径。
我在项目中遇到过一件事:一个自定义类,移动构造明明不会抛异常,但忘了加noexcept。结果vector扩容时性能骤降,排查了半天才发现是这个问题。
什么时候可以noexcept?
不是所有移动操作都能保证不抛异常。我总结了几种常见情况:
| 移动操作类型 | 能否noexcept | 说明 |
|---|---|---|
| 基本类型(int, double等) | ✅ 可以 | 纯内存拷贝,不可能抛异常 |
| 指针/智能指针 | ✅ 可以 | 只是指针值的转移 |
| std::string | ✅ 可以 | SSO优化下也是noexcept |
| std::vector等容器 | ✅ 可以 | 只转移内部指针 |
| 自定义类(成员都是noexcept) | ✅ 可以 | 组合起来也是noexcept |
| 自定义类(含动态资源分配) | ⚠️ 视情况 | 如果移动后源对象状态简单,可以noexcept |
| 自定义类(含锁、文件句柄等) | ❌ 谨慎 | 资源转移可能失败 |
避坑指南:移动后源对象的状态
我曾经犯过一个错误:移动构造函数标了noexcept,但移动后源对象的状态没处理好。
class Buffer {
int* data_;
size_t size_;
public:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
// 忘记重置源对象!
// other.data_ 仍然指向旧内存
// other.size_ 仍然是旧大小
}
~Buffer() { delete[] data_; }
};
问题在哪?源对象析构时会delete掉同一块内存,导致悬空指针。正确的做法是:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr; // 源对象不再拥有资源
other.size_ = 0;
}
嗯,这里要注意:noexcept只保证不抛异常,但不保证逻辑正确。移动后源对象必须处于「有效但未指定」的状态——可以析构,可以赋值,但不能假设它还有用。
noexcept与异常安全的博弈
你可能会问:如果移动操作确实可能抛异常,硬标noexcept会怎样?
答案是:程序会直接终止。C++规定,如果noexcept函数抛了异常,会调用std::terminate。这不是你想要的。
警告:不要为了性能而谎报noexcept。如果移动操作确实可能抛异常,就别标。否则异常发生时,程序直接崩溃,连catch的机会都没有。
那如果移动操作可能抛异常,怎么办?我一般这么处理:
- 优先保证移动操作不抛异常。设计类时,让移动操作只转移指针/句柄,不涉及新资源分配。
- 实在不行就用拷贝。性能差一点,但安全第一。
- 或者提供强异常保证。比如移动前先备份,失败时回滚。
知识体系图
下面这张图展示了noexcept在移动操作中的核心作用:
实际项目中的经验
我在做高性能计算库时,所有移动操作都强制要求noexcept。做法很简单:
- 移动构造只转移指针,不分配新内存。
- 移动赋值先检查自赋值,然后交换指针。
- 源对象置为可析构状态,通常是nullptr。
举个例子:
class ImageBuffer {
uint8_t* pixels_;
size_t width_, height_;
public:
// 移动构造:只转移指针,不分配
ImageBuffer(ImageBuffer&& other) noexcept
: pixels_(other.pixels_)
, width_(other.width_)
, height_(other.height_)
{
other.pixels_ = nullptr;
other.width_ = 0;
other.height_ = 0;
}
// 移动赋值:交换指针
ImageBuffer& operator=(ImageBuffer&& other) noexcept {
if (this != &other) {
delete[] pixels_; // 释放旧资源
pixels_ = other.pixels_;
width_ = other.width_;
height_ = other.height_;
other.pixels_ = nullptr;
other.width_ = 0;
other.height_ = 0;
}
return *this;
}
~ImageBuffer() {
delete[] pixels_;
}
};
小技巧:可以用static_assert在编译期检查你的类是否满足noexcept移动:
static_assert(std::is_nothrow_move_constructible_v<ImageBuffer>,
"ImageBuffer 的移动构造必须 noexcept!");
总结
noexcept对移动操作来说,不是锦上添花,而是雪中送炭。它让标准库敢用移动,让容器敢扩容,让性能能起飞。
但记住:诚实比性能更重要。能noexcept就标,不能就别硬标。设计类时尽量让移动操作简单到不可能失败,这才是正道。
我见过太多人为了那点性能提升,在移动操作里搞复杂逻辑,结果异常安全一塌糊涂。嗯,别学他们。
公众号:蓝海资料掘金营,微信deep3321