7. 移动语义与异常安全:noexcept 的重要性,std::vector 的移动优化策略
好,咱们今天聊一个实战中特别容易踩坑的话题——移动语义和异常安全。
说实话,我刚学移动语义那会儿,觉得这东西挺简单的:不就是把资源“偷”过来嘛,省一次拷贝。但后来在项目里被坑了几次,才发现事情没那么简单。尤其是跟 std::vector 这种容器打交道时,一个 noexcept 写没写对,性能可能差出几个数量级。
为什么会这样?
嗯,咱们一步步拆开看。
7.1 异常安全的基本概念
先聊两句异常安全。C++ 里有个基本承诺:操作要么成功,要么回滚到操作前的状态。这叫“强异常安全保证”。
移动语义出现后,这个承诺变得微妙了。你想想看:
- 拷贝操作:失败了无所谓,原对象还在。
- 移动操作:资源已经“偷”走了,原对象被掏空了。这时候如果后续操作抛异常,怎么回滚?
说白了,移动操作一旦失败,原对象的状态就不可控了。这就是异常安全在移动语义里的核心矛盾。
核心原则:移动操作如果可能抛异常,容器就不敢用它做“强安全保证”的操作。
7.2 noexcept 的作用
noexcept 这个关键字,说白了就是给编译器一个承诺:“我这个函数绝对不会抛异常。”
对于移动构造函数和移动赋值运算符,这个承诺特别重要。我个人的习惯是:只要你能保证移动操作不抛异常,就一定要加上 noexcept。
来看个例子:
class MyBuffer {
char* data_;
size_t size_;
public:
// 移动构造函数 —— 不抛异常
MyBuffer(MyBuffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr;
other.size_ = 0;
}
// 拷贝构造函数 —— 可能抛异常(内存分配失败)
MyBuffer(const MyBuffer& other)
: data_(new char[other.size_]), size_(other.size_)
{
memcpy(data_, other.data_, size_);
}
};
注意看:移动构造只是交换指针,没有内存分配,所以 noexcept 是安全的。拷贝构造要 new,可能抛 std::bad_alloc,所以不能加 noexcept。
小技巧:如果你不确定移动操作是否真的不抛异常,可以用 static_assert 来验证:
static_assert(std::is_nothrow_move_constructible_v<MyBuffer>,
"MyBuffer 的移动构造必须 noexcept!");
7.3 std::vector 的移动优化策略
好,重点来了。std::vector 在扩容时,会面临一个选择:
- 拷贝元素:安全,但慢。
- 移动元素:快,但可能抛异常。
标准库是怎么选的?
答案是:看你的移动操作是不是 noexcept。
具体来说,std::vector 扩容时内部会调用 std::move_if_noexcept。这个函数的名字已经说得很明白了:
- 如果移动构造函数是
noexcept,就用移动。 - 否则,退化为拷贝。
为什么?因为 vector 要保证强异常安全。如果移动过程中抛异常,已经移动过的元素状态就乱了,没法回滚。拷贝则不同——拷贝失败了,原数组纹丝不动。
我举个例子你就明白了:
#include <vector>
#include <string>
struct SafeMove {
std::string data;
SafeMove(SafeMove&&) noexcept = default;
};
struct UnsafeMove {
std::string data;
UnsafeMove(UnsafeMove&&) /* 没有 noexcept */ = default;
};
int main() {
std::vector<SafeMove> v1;
v1.reserve(1);
v1.emplace_back(); // 扩容时用移动 —— 快
std::vector<UnsafeMove> v2;
v2.reserve(1);
v2.emplace_back(); // 扩容时用拷贝 —— 慢
}
你看,SafeMove 和 UnsafeMove 内部都是 std::string,但一个加了 noexcept,一个没加。结果扩容时的行为完全不同。
我曾经踩过的坑:
有一次我写了一个自定义类型,内部用 std::unique_ptr 管理资源。移动构造我写了,但忘了加 noexcept。结果 std::vector 扩容时全部走拷贝路径,性能直接崩了。排查了半天才发现是 noexcept 的问题。
从那以后,我给自己定了个规矩:所有移动构造函数和移动赋值运算符,默认都加 noexcept,除非你明确知道它会抛异常。
7.4 知识体系总览
下面这张图帮你理清本章的核心逻辑:
7.5 实战建议
最后,给你几条我实战中总结出来的经验:
- 默认加
noexcept:移动构造、移动赋值、swap,只要你能保证不抛异常,就加上。这是最省心的做法。 - 用
static_assert做编译期检查:尤其是模板代码里,确保你的类型满足is_nothrow_move_constructible。 - 注意
std::pair和std::tuple:它们的移动构造是否noexcept,取决于内部所有成员。一个成员没加,整个就退化了。 - 别迷信
std::move:它只是类型转换,真正决定性能的是目标类型有没有noexcept的移动构造。
一句话总结:noexcept 不是可有可无的装饰,它直接决定了 std::vector 是走“快车道”还是“慢车道”。
好了,这一章就聊到这儿。记住:写移动操作时,顺手加个 noexcept,这是成本最低、收益最高的优化之一。