17. 什么是异常安全的移动操作?如何保证移动构造函数不抛异常?
异常安全,说白了就是你的代码在遇到异常时,能不能体面地收场。不会把程序搞崩,也不会让资源泄漏。移动操作里的异常安全,是个特别有意思的话题。
我刚开始接触移动语义时,觉得这玩意儿不就是把指针换了个手吗?能出什么问题?后来在项目里真遇到了,才知道坑有多深。
移动操作为什么可能抛异常?
你想想看,移动操作的本质是「偷资源」。把源对象的资源指针、句柄、文件描述符这些,直接转给目标对象。按理说,这应该是个轻量操作,不该抛异常。
但现实没那么理想。我遇到过的情况包括:
- 内存分配:有些容器在移动时,需要为新对象分配内部缓冲区
- 引用计数操作:shared_ptr 移动时,需要原子操作更新计数
- 自定义资源管理:比如移动一个持有锁的对象,需要重新获取锁
这些操作都可能抛出异常。一旦异常发生,源对象和目标对象的状态就变得不可预测了。
异常安全的三个级别
我习惯把异常安全分成三个级别,这样好记:
| 级别 | 含义 | 移动操作中的表现 |
|---|---|---|
| 基本保证 | 异常发生后,不泄漏资源 | 移动失败时,源对象处于有效但未指定的状态 |
| 强保证 | 操作要么成功,要么回滚到原始状态 | 移动失败时,源对象完全不变 |
| 不抛异常保证 | 操作绝对不会抛出异常 | 移动操作总是成功,这是最理想的情况 |
对于移动操作,我们追求的是第三级——不抛异常保证。为什么?因为标准库的容器在重新分配内存时,会优先选择不抛异常的移动构造函数。如果移动可能抛异常,容器就会退回到拷贝操作,性能就没了。
如何保证移动构造函数不抛异常?
嗯,这里有几个实用的方法,都是我在项目里验证过的:
1. 使用 noexcept 声明
这是第一步,也是最简单的一步。告诉编译器:我这个移动构造函数不会抛异常。
class MyBuffer {
public:
MyBuffer(MyBuffer&& other) noexcept
: data_(other.data_)
, size_(other.size_)
{
other.data_ = nullptr;
other.size_ = 0;
}
private:
char* data_;
size_t size_;
};
注意,光写 noexcept 不够,你得确保函数体里真的不会抛异常。
2. 只移动基本类型和 POD 类型
说白了,移动操作里只做指针赋值、整数拷贝这些操作。这些操作在 C++ 里是天生不抛异常的。
class ResourceHolder {
public:
ResourceHolder(ResourceHolder&& other) noexcept
: handle_(other.handle_)
, ref_count_(other.ref_count_)
{
other.handle_ = nullptr;
other.ref_count_ = 0;
}
private:
void* handle_; // 指针赋值,不抛异常
int ref_count_; // 整数拷贝,不抛异常
};
3. 避免在移动操作中分配内存
我见过有人这么写:
// ❌ 错误示范
class BadExample {
public:
BadExample(BadExample&& other) {
// 这里分配内存,可能抛异常!
buffer_ = new char[other.size_];
memcpy(buffer_, other.buffer_, other.size_);
// ...
}
};
这其实是个深拷贝,不是移动。真正的移动应该直接偷资源,不需要新分配。
4. 使用 std::move_if_noexcept
这个工具函数很有意思。它会在移动构造函数是 noexcept 时执行移动,否则执行拷贝。标准库的容器就靠它来做决策。
template<typename T>
void reallocate(std::vector<T>& vec) {
// 如果 T 的移动构造函数是 noexcept,就用移动
// 否则用拷贝
new_buffer[i] = std::move_if_noexcept(old_buffer[i]);
}
一个完整的例子
我在项目中写过一个简单的字符串类,它的移动操作是这样的:
class FastString {
public:
// 移动构造函数
FastString(FastString&& other) noexcept
: data_(other.data_)
, length_(other.length_)
, capacity_(other.capacity_)
{
// 把源对象置为空状态
other.data_ = nullptr;
other.length_ = 0;
other.capacity_ = 0;
}
// 移动赋值运算符
FastString& operator=(FastString&& other) noexcept {
if (this != &other) {
// 先释放自己的资源
delete[] data_;
// 偷取对方的资源
data_ = other.data_;
length_ = other.length_;
capacity_ = other.capacity_;
// 把源对象置为空
other.data_ = nullptr;
other.length_ = 0;
other.capacity_ = 0;
}
return *this;
}
private:
char* data_;
size_t length_;
size_t capacity_;
};
你看,整个移动过程就是指针赋值和整数拷贝,没有任何内存分配或复杂操作。这就是不抛异常的关键。
标准库的约定
标准库对移动操作有个不成文的约定:移动后的源对象应该处于「有效但未指定」的状态。什么意思呢?就是你可以继续使用这个对象,比如给它赋值、析构它,但不能假设它还有原来的值。
我举个例子:
std::string a = "hello";
std::string b = std::move(a);
// 此时 a 是空字符串,但这是实现定义的
// 你不能假设 a 一定是空的,只能说它是有效的
这个约定给了实现者灵活性,也让异常安全更容易保证。因为移动后源对象的状态不需要精确回滚,只要不泄漏资源就行。
SVG:异常安全移动操作决策流程
避坑指南
我曾经在项目里犯过一个错误:移动构造函数里调用了日志函数,结果日志函数在特定配置下会抛异常。那个 bug 查了我整整两天。
所以我的建议是:
- 移动操作里不要写复杂逻辑。就做资源转移,别干别的。
- 所有成员变量都应该是可简单移动的。如果某个成员不能简单移动,考虑用指针包装一下。
- 写单元测试验证 noexcept。用 static_assert 在编译期检查:
static_assert(std::is_nothrow_move_constructible_v<MyType>);
总结一下:异常安全的移动操作,核心就一句话——只做不抛异常的事。指针赋值、整数拷贝、句柄转移,这些都可以。内存分配、锁操作、I/O 操作,这些就别放进来。记住这个原则,你的移动操作就能既安全又高效。