异常处理与STL:异常安全、STL异常保证、自定义异常类
异常处理这个话题,说实话,在C++社区里争议一直挺大的。有人觉得异常是洪水猛兽,有人觉得不用异常C++就白学了。我个人属于中间派——异常要用,但得用得明白。
STL作为C++标准库的基石,它对异常安全有自己的一套承诺。你如果不了解这些承诺,写出来的代码可能表面上跑得挺好,一遇到异常就崩得莫名其妙。今天我们就来聊聊这个话题。
什么是异常安全?
说白了,异常安全就是你的代码在抛出异常后,还能保持一个合理的状态。不会出现内存泄漏、资源没释放、数据半修改这种烂摊子。
我见过太多新手写的代码,异常一抛,整个程序就处于一种「半死不活」的状态。嗯,这其实就是异常安全没做好。
异常安全通常分三个等级:
| 等级 | 含义 | 通俗理解 |
|---|---|---|
| 基本保证 | 异常发生后,资源不泄漏,对象处于有效但不确定的状态 | 程序还能继续跑,但数据可能变了 |
| 强保证 | 操作要么完全成功,要么回滚到操作前的状态 | 像数据库事务一样,要么全做,要么不做 |
| 不抛异常 | 保证操作永远不会抛出异常 | 最安全,但实现难度也最大 |
核心观点:写STL代码时,我习惯默认追求「强保证」。实在做不到,至少保证「基本保证」。绝不写连基本保证都没有的代码。
STL的异常保证承诺
STL容器和算法对异常安全有明确的承诺。你想想看,如果std::vector::push_back在扩容时抛异常,原来的数据丢了怎么办?STL的设计者早就考虑到了。
我总结了一下STL常见的异常保证:
- 容器操作:大多数修改操作提供基本保证。比如
vector::insert、list::push_back等。 - 智能指针:
std::shared_ptr和std::unique_ptr提供不抛异常的保证(除了构造时可能内存不足)。 - 算法:STL算法通常不提供异常安全保证。如果比较函数或迭代器操作抛异常,结果未定义。
- 交换操作:大多数STL容器的
swap是不抛异常的。这是实现强保证的关键。
实战技巧:我在项目中经常用「copy-and-swap」手法。先拷贝一份数据,在拷贝上操作,最后用不抛异常的swap替换原数据。这样就能轻松实现强保证。
自定义异常类
STL自带的异常类(std::runtime_error、std::logic_error等)够用吗?说实话,在真实项目中往往不够。你需要携带更多上下文信息,比如出错的容器、具体的键值、当前的容量等。
我习惯这样设计自定义异常类:
#include <stdexcept>
#include <string>
#include <sstream>
class ContainerException : public std::runtime_error {
public:
ContainerException(const std::string& msg,
size_t container_id,
const std::string& operation)
: std::runtime_error(buildMessage(msg, container_id, operation))
, container_id_(container_id)
, operation_(operation) {}
size_t containerId() const { return container_id_; }
const std::string& operation() const { return operation_; }
private:
static std::string buildMessage(const std::string& msg,
size_t id,
const std::string& op) {
std::ostringstream oss;
oss << "[Container #" << id
<< "][" << op << "] " << msg;
return oss.str();
}
size_t container_id_;
std::string operation_;
};
你看,这样设计的好处是:捕获异常时,你能直接拿到容器ID和操作名,调试起来方便多了。
注意:自定义异常类一定要继承自std::exception或其子类。否则catch(std::exception& e)就抓不到你的异常了。我曾经踩过这个坑,排查了半天才发现是自己忘了继承。
实战:异常安全的栈
我们来写一个简单的栈,用STL容器做底层存储,同时保证异常安全。你想想看,如果push时内存分配失败,栈的状态应该是什么?
#include <vector>
#include <stdexcept>
#include <utility>
template <typename T>
class SafeStack {
public:
SafeStack() = default;
// 强保证:要么成功push,要么栈不变
void push(const T& value) {
// 先拷贝一份,避免影响原数据
auto temp = data_;
temp.push_back(value);
// 用不抛异常的swap替换
data_.swap(temp);
}
void push(T&& value) {
auto temp = data_;
temp.push_back(std::move(value));
data_.swap(temp);
}
// 基本保证:pop后栈大小减1,但不会泄漏
void pop() {
if (data_.empty()) {
throw std::underflow_error("pop from empty stack");
}
data_.pop_back();
}
const T& top() const {
if (data_.empty()) {
throw std::underflow_error("top on empty stack");
}
return data_.back();
}
bool empty() const { return data_.empty(); }
size_t size() const { return data_.size(); }
private:
std::vector<T> data_;
};
关键点:注意push方法。我用了临时变量temp,在它上面操作。如果push_back抛异常,temp会被析构,原data_纹丝不动。这就是copy-and-swap的精髓。
异常安全的核心逻辑
为了让你更直观地理解异常安全的决策流程,我画了一张图:
避坑指南
我在实际项目中踩过不少异常相关的坑,分享几个给你:
- 析构函数不要抛异常。如果析构函数抛异常,而异常正在传播中,程序会直接终止。STL容器在析构时也会调用元素的析构函数,所以你的元素析构函数必须是不抛异常的。
- 移动操作要标记noexcept。STL容器在重新分配内存时,如果移动构造函数是noexcept的,它会优先用移动而不是拷贝。否则,为了安全,它会用拷贝。性能差距很大。
- catch(...)要谨慎使用。我曾经在项目里看到有人用catch(...)吞掉了所有异常,结果程序出了bug根本找不到原因。除非你真的知道自己在做什么,否则别这么干。
我的习惯:在写模板代码时,我会用static_assert检查类型是否满足要求。比如要求拷贝构造函数不抛异常,就加上static_assert(std::is_nothrow_copy_constructible_v<T>)。这样编译期就能发现问题,不用等到运行时。
总结一下
异常安全不是锦上添花,而是C++工程的基本功。STL已经给我们做了很好的示范——它明确承诺了各种操作的异常保证等级。我们在使用STL时,也要遵循同样的思路。
记住三个要点:
- 了解STL容器和算法的异常保证承诺
- 用copy-and-swap实现强保证
- 自定义异常类要继承自
std::exception
做到这三点,你的代码在异常面前就能站得稳了。
公众号:蓝海资料掘金营,微信deep3321