1. 从返回值到异常:C++错误处理真正的转折点
我最早写C++的时候,心里一直有个坎:错误到底该怎么报?如果是简单的库函数,返回一个-1或者nullptr,调用方检查一下也就完事了。但一旦程序结构复杂起来——如果深层函数内部发生了一个错误,这个错误需要穿透五六层调用才能被真正关心它的地方处理,返回值方案就开始变得非常难看。
举个例子,你写一个图形界面程序,用户点击按钮触发一个文件解析操作。解析过程调用了文件读取模块,文件读取模块又调用了内存分配,内存分配失败。如果用返回值一层层上报,你得在每一层都写:
cpp复制int parse_file(const std::string& path) {
int err = read_file(path);
if (err != 0) {
return err;
}
// ...
}
这还不是最难受的。更难受的是,返回值方案非常容易漏掉。你少写一个检查,错误就无声无息地吞掉了;你把返回值用在其他用途上,错误就彻底丢了。遇到那种“函数内部某个分支出错,但当前环境根本没法处理”的情况,你只能在出错的地方退出进程,或者打一段日志然后返回一个不痛不痒的默认值——这两种方案本质上都是在掩盖问题。
C++的异常机制解决的正是这件事:检测到错误的代码不用关心谁来处理、在哪一层处理,它只要把错误打包成异常对象向上抛,中间的所有层都可以完全不用改代码。 只有真正有能力处理异常的调用方,才需要通过try/catch来接住它。
这套思想的转变,对工程架构的影响是根本性的。
打个比方:返回码设计就像一个小区物业。水管漏水了,你不能直接打电话给自来水厂,你得一层层向上汇报,每一层管家都得记下来,最后看谁有空去处理。结果要么汇报链断了,要么处理的人根本不在。异常机制就不一样,它像警报器——楼下漏水,警报直接打到消防队/物业/你自己三个绑定的号码上,谁接了谁来处理,中间那几层住户根本不用管。
作为一个写C++写了十来年的人,我的观点很明确:异常不是“处理错误”的一种语法糖,而是C++错误传播模型的基础设施。 很多C++项目把它当作可有可无的附加特性,甚至为了性能强制关闭异常,我总觉得这种做法是把错误处理问题往后推了,并没有真正解决错误传播的复杂度。
当然,异常处理也不是没有成本,它在性能、代码体积和心智负担上都有代价。后面我会详细聊这些,但在说代价之前,你至少得搞明白一件事:异常机制本身是一个设计得非常精巧的控制流机制,它让错误从产生点直接跳跃到处理点,中间的栈帧全部自动展开,局部对象全部自动析构。 光是这一点,返回值方案就永远追不上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 异常机制的底层逻辑:栈展开与对象析构的顺序真相
理解C++异常,必须从“栈展开”和“对象生命周期”入手。这两件事搞明白了,你对异常的所有疑惑会瞬间消失一大半。
2.1 一次throw之后,程序到底做了什么
当你写下throw MyException("failed")时,编译器会在当前执行点构建一个异常对象,然后开始沿着调用栈向上寻找匹配的catch子句。这个过程叫栈展开,英文 stack unwinding。
栈展开做了什么?每一层栈帧在被跳过的过程中,该层所有已经构造完成且还未析构的局部对象,都会按照构造的逆序被析构。 这个行为非常重要——它保证了异常安全的基础。看代码:
cpp复制void inner() {
FileHandle f("data.txt"); // 构造
std::vector<int> v(100); // 构造
throw std::runtime_error("boom");
// 无论中间抛没抛,f和v都会在异常穿出前被自动析构
// 析构顺序:v先析构,f后析构(逆序)
}
很多人初学异常时有个误解,以为throw之后程序直接“炸掉”了。实际不是,栈展开的过程非常有序,资源会一层一层被释放,就像函数正常return一样。这正是C++能依靠RAII管理资源的原因——异常路径上的资源释放交给编译器,写代码的人只需要保证对象是“局部对象”就行。
如果某个局部对象是指针裸指针呢?那就没有自动析构这回事了。裸指针的析构是平凡的,它不会帮你delete。所以异常路径上裸指针指向的内存就泄漏了。这也是为什么在支持异常的C++代码里,强烈推荐使用智能指针和容器类替代裸指针和手动new/delete。不是个人好恶,是异常机制天然依赖RAII。
2.2 catch匹配的规则与常见直觉偏差
catch子句的匹配规则,实际上比很多人的直觉要更“宽容”。最典型的就是基类catch能接住派生类异常:
cpp复制class BaseErr : public std::exception {};
class DerivedErr : public BaseErr {};
try {
throw DerivedErr();
} catch (const BaseErr& e) {
// 能接住,因为DerivedErr继承自BaseErr,且是引用捕获
}
这个规则和函数重载不一样。函数重载必须精确匹配或可隐式转换,但catch的匹配本质上是“类型兼容性检查”——派生类异常对象可以被基类引用捕获。这带来了一个好消息和一个坏消息。
好消息是,你可以定义一个统一的异常基类,然后在顶层用一个catch兜底,给用户友好的错误提示。坏消息是,如果你不按引用捕获,就会发生对象切割:
cpp复制try {
throw DerivedErr();
} catch (BaseErr e) {
// e是一个新构造的BaseErr对象,DerivedErr的派生部分被切掉了
// 如果DerivedErr里有多余字段,这里全丢了
// 而且这里还会产生一份新的拷贝
}
这种写法在小型程序里或许看不出问题,但在大型项目里,一旦异常类型里携带着错误码、堆栈信息、上下文快照等关键字段,切割一次就全没了。所以业界有一个铁律:异常捕获一定要用引用,最好是const引用。 这不仅仅是风格问题,是避免切割和额外拷贝的工程常识。
2.3 匹配顺序与兜底捕获的边界感
还有一个极其重要的点:catch子句是按代码中出现顺序依次比较的。 一旦匹配到第一个符合条件的catch块,后面所有的catch都会被跳过。注意,这和switch的穿透逻辑类似——你写的第一个catch是std::exception,那这个catch之后的所有更具体的catch全部不会被执行。
cpp复制try {
// ...
} catch (const std::bad_alloc& e) {
// 如果这段代码放在后面,它永远不会被触发
} catch (const std::exception& e) {
// 基类在前,派生类在后就废了
}
所以正确的排列顺序是:派生程度高的catch写在前面,基类catch写在后面,catch(...)永远放最后。 这真的不是“细节”,我在实际项目里不止一次见过有人把catch(...)放在中间,后面跟着一个更具体的catch,结果具体catch变成了不可达代码,编译器连警告都不给,运行时出了问题非常难排查。
catch(...)本身我多说一句。它的本意是捕获所有异常,保证程序不因未捕获异常而终止。但如果你只是想让它吞掉所有错误,那它就是个“错误粉碎机”。正确的用法是:在catch(...)里记录日志、做一些清理工作,然后重新抛出:
cpp复制try {
// ...
} catch (...) {
log("unknown exception occurred");
throw; // 重新抛出,不修改原异常对象
}
这里的throw;是C++语法里的一个特殊存在——它只在catch块内部合法,表示“把当前正在处理的异常对象原样重新抛出去”。你可以在中间加日志、做清理,但异常的类型和信息不会丢失。这是所有专业C++工程都在用的模式。
3. 异常安全承诺:你的代码到底能在异常下撑多久
很多C++初学者写了几个try/catch,以为自己已经掌握了异常处理。实际上,真正把异常处理用到工程级别的标志,是你开始思考异常安全问题。
什么叫异常安全?简单的定义是:当你的函数抛出异常时,程序不会进入一个不稳定的状态。 C++标准里定义了三个等级的保证:
- 基本保证:抛出异常后,程序处于一个有效但不确定的状态,所有不变量仍然成立,资源不泄漏。比如一个
std::vector的插入操作抛异常了,vector的内容可能是旧的也可能是新的,但它仍然是一个合法的vector,可以安全析构。 - 强保证:抛出异常后,程序状态完全回滚到函数调用之前,就像函数根本没执行过。比如
std::vector::push_back如果保证不抛异常,那就没有回滚问题;如果抛了,标准库容器在多数情况下提供强保证。 - 不抛保证:函数承诺在任何情况下都不抛异常。析构函数、swap函数通常必须满足这个保证。
这三个等级不是学术概念,它直接影响你怎么写函数。
3.1 基本保证:防范资源泄漏的底线
最基本的一点:你的函数无论在哪一行抛异常,之前分配的所有资源都必须被释放。 这件事靠手工写清理代码是不可能的,因为你没法枚举所有抛异常的时机。正确做法就是靠前文说的RAII:
cpp复制void process() {
std::shared_ptr<Connection> conn = std::make_shared<Connection>();
auto lock = std::lock_guard<std::mutex>(mutex_);
do_something(); // 如果这行抛异常
// conn 和 lock 依然会析构,资源不会泄漏
}
如果你非要手动new,那你有两条路:要么承诺这个函数永远不会在new和delete之间抛异常,要么把所有需要手动清理的资源包在try/catch里一堆,可读性和可靠性直线下滑。我见过太多这类代码了,每次review都想叹气。
3.2 强保证:copy-and-swap 模式
强保证的经典实现就是copy-and-swap。核心思路是:先在一个不影响当前状态的对象上做操作,全部操作成功之后,再把它通过swap替换到正式位置。
cpp复制class Widget {
public:
void setData(const std::string& data) {
Widget temp(*this); // 先拷贝一份当前状态
temp.data_ = data; // 在临时对象上修改
swap(temp); // 如果以上任意一步抛异常,this不受任何影响
}
void swap(Widget& other) noexcept {
data_.swap(other.data_);
}
private:
std::string data_;
};
这里的swap标记成noexcept,保证swap本身不抛异常,这样整个setData函数就能做到:要么成功,要么保持原状。这个模式在实现存储类、配置类等对一致性敏感的类时非常实用。
强保证不是所有函数都需要,很多函数能提供基本保证就够了。但你要能说清楚——你的函数到底提供了哪级保证,这很重要。因为你写的函数会被别人调用,调用方设计自己的异常安全策略时,必须知道你函数的行为。
3.3 析构函数里为什么禁止抛异常
这是一个老生长谈但必须强调的规则:析构函数绝对不能抛出异常。 原因很简单,如果栈展开过程中,一个局部对象的析构函数又抛出了异常,而此时栈上已经有一个正在处理的异常了,两个异常同时存在,C++标准直接调用std::terminate结束进程。
想象你正在处理一个连接错误,catch块里准备写日志,结果日志对象的析构抛了一个异常——程序直接崩了。连catch块里的代码都没机会执行完。所以析构函数要么什么都不做,要么把所有可能抛异常的操作包在try/catch里再吞掉:
cpp复制~FileHandle() {
try {
close();
} catch (...) {
// 析构函数中不允许异常逃逸
// 记录日志或者忽略
}
}
我不觉得“在析构函数里处理异常”是一种妥协,它实际上反映了C++的一个工程哲学:析构函数的职责是释放资源,而不是报告错误。 真正的错误报告必须在析构之前就完成。如果你需要在对象生命周期结束时检查某个操作的结果,请单独写一个close()、flush()之类的公有函数,让调用方显式调用并捕获异常。析构函数只是最后一道保险。
4. 设计可靠的异常类型:从零散throw到工程化的异常体系
很多人写异常,直接throw std::runtime_error("xxx")完事。这在小型程序里没问题,但在一个中大型项目里,你会遇到几个麻烦:第一,异常消息是字符串,没法承载结构化信息;第二,顶层无法根据异常类型做分类处理;第三,无法区分“预期内业务异常”和“系统性致命错误”。
4.1 派生自std::exception的通用异常基类
一个比较常见的工程化做法是:定义一个项目级的异常基类,所有业务异常都从它派生。
cpp复制class AppError : public std::runtime_error {
public:
explicit AppError(const std::string& msg, int error_code = 0)
: std::runtime_error(msg), error_code_(error_code) {}
int code() const noexcept { return error_code_; }
private:
int error_code_;
};
class ConfigError : public AppError {
public:
explicit ConfigError(const std::string& msg)
: AppError(msg, E_CONFIG) {}
};
class NetworkError : public AppError {
public:
explicit NetworkError(const std::string& msg, int socket_errno)
: AppError(msg, E_NETWORK), socket_errno_(socket_errno) {}
int socket_errno() const noexcept { return socket_errno_; }
private:
int socket_errno_;
};
这样设计的好处非常明显:顶层捕获时,只需要接const AppError&就能统一处理业务错误;而具体的派生异常类型保持了各自的结构化字段,错误处理代码可以根据需要提取这些信息。
4.2 用what()携带精确可读的信息
std::exception::what()返回一个const char*,很多人的第一反应是“直接放一个字符串字面量进去”。这在简单场景下没问题,但在日志体系里,你几乎总是想看到更多上下文信息——函数名、行号、参数值、输入数据大小等。
但这里有一个比较隐蔽的坑:what()返回的是const char*,这个指针指向的内存什么时候失效? 如果异常对象被捕获后又被拷贝、移动,或者你构造异常对象时传入了std::string的临时值,那么这个指针的指向就是悬空的。
我见过有人写出这样的代码:
cpp复制throw std::runtime_error(std::string("file: ") + path + " not found");
这段代码在绝大多数编译器上能跑,因为std::runtime_error的构造函数会拷贝字符串内容到内部存储。但有些老旧的自定义异常类,可能只能保存const char*指针,那上述代码就是定时炸弹。尽可能避免自己直接存储const char*,统一用std::string做内部存储。在构造函数里取msg.c_str()之前,务必确保msg的生命周期覆盖异常对象的生命周期。
4.3 错误码与异常的取舍:什么场景该用哪种
异常不是银弹,错误码也不是过时产物。我的实践经验是,两者并行不悖,用在不同的层次和场景。
适合用异常的场景:
- 构造函数中失败(构造函数没有返回值,你只能用异常报告失败)
- 运算符重载中失败(同理,返回值被占用了)
- 配置文件中某个字段缺失或格式错误(这通常是程序bug或配置错误,应该立即失败而不是默默降级)
- 深层模块的错误需要跨多层传播,且中间层不应被污染
适合用错误码/可选值的场景:
- 极高频、且错误属于“正常业务分支”的情况,比如查找不存在的键、网络请求返回404等
- 实时性要求极高的循环体内,异常开销难以忍受
- 系统底层边界,比如驱动层、嵌入式环境,异常支持不完整或性能代价过大
- 与C语言代码互操作的层
我见过最漂亮的做法,是把错误码作为“内部通讯协议”,把异常作为“对外承诺”。内部函数高频调用返回错误码,但如果错误码需要传播穿透多个模块,就在模块边界把它转换成一个带上下文的异常对象,向上抛。这样既避免了高层状态混乱,又保证了性能敏感的内部循环不被异常控制流拖累。
5. try/catch之外的异常处理词汇表:noexcept、函数异常规格和现代演进
C++从C++98到C++20,异常语法本身没怎么变,但周边机制一直在演进。其中最值得关注的是noexcept以及它对异常体系的影响。
5.1 noexcept到底改变了什么
C++11引入了noexcept关键字,取代了C++98里的throw()动态异常规格。一个函数如果声明了noexcept,它在语义上有两个作用:
- 告诉编译器:这个函数保证不会抛出异常。
- 告诉编译器:如果这个函数在运行时真的抛了异常,不需要进行栈展开,直接调用
std::terminate终止程序。
第二个作用听起来像是在“惩罚”,但它帮助编译器生成了更优的代码。因为编译器知道,函数的调用不会触发栈展开,就不需要保存额外的恢复信息。这就是为什么move构造函数、swap函数、析构函数等高频调用的函数,加上noexcept后性能会有实实在在的提升。
cpp复制class Buffer {
public:
Buffer(Buffer&& other) noexcept
: ptr_(other.ptr_), size_(other.size_) {
other.ptr_ = nullptr;
other.size_ = 0;
}
private:
int* ptr_;
size_t size_;
};
为什么移动构造函数特别需要noexcept?因为std::vector扩容时,如果移动构造函数可能抛出异常,vector就不敢用移动,只能退回去做拷贝——拷贝可能慢一个数量级。你声明了noexcept,vector才敢放心地移动元素。这是一个真实的性能差异,我实测过,对存储了复杂对象的超大vector,noexcept能让扩容性能提升数倍。
5.2 异常规格的旧时代遗产
如果你在维护老代码,可能会碰到C++98的throw()写法。标准里throw()在C++17已经不再作为异常规格使用,C++20里直接删除了。碰到这种代码,建议升级成noexcept。
比较麻烦的还有一种写法:函数声明里写throw(A, B),表示只能抛A和B类型的异常。这个机制在实际工程中几乎没什么应用价值——因为一个函数调用的深层函数可能抛任何异常,而编译器并不帮你静态检查。所以C++17直接废除了动态异常规格,只保留noexcept。
5.3 constexpr函数和异常:一个很小的兼容点
C++14放宽了constexpr函数的要求,但C++14的constexpr函数里依然不能抛异常。到了C++20,constexpr函数里可以使用try/catch和throw了。这个话题比较新,实际项目里用到的还不多。但如果你在写编译期计算的代码,需要意识到:constexpr函数一旦抛异常,编译期计算就编译失败。 这种代码最好还是保持纯函数式风格,避免用异常做控制流。
5.4 编译器选项与异常开关
在嵌入式、实时系统、或者追求极致性能的领域,很多项目会用一个很“粗暴”的手段:编译时全局禁用异常。
bash复制g++ -fno-exceptions main.cpp
这样写的后果是:你在代码里写try、catch、throw会直接编译错误,所有标准库函数里会抛异常的操作(比如std::vector的at()在越界时)都会变成未定义行为。这种方案确实能省去异常的处理开销,但它把整个错误处理负担转移回给程序员。
我的态度是:除非你的运行环境实在不支持异常(比如某些MCU编译工具链),否则不要全局禁用。 异常处理在现代C++编译器的零成本模型下,未抛异常路径的开销几乎为零,只有实际抛异常才会触发昂贵的栈展开。也就是说,正常业务路径的源码不需要为异常付出明显的性能代价。全局禁用异常,等于亲手把错误传播的便捷工具扔掉,换来一个你又用不上的优化。
6. 实战中的异常处理陷阱:多线程、智能指针与顶层兜底
理论知识铺垫得差不多了,这块聊聊我在真实项目里反复踩过的坑。很多坑是经验主义的东西,文档里不写,但你在生产环境一定会遇到。
6.1 多线程环境里,异常该抛向哪里
多线程程序中最容易出错的地方是:一个工作线程的异常如果没有被捕获,会导致整个进程直接终止。 这听起来像小事,但真的会炸——用户点了一下后台保存任务,结果保存线程抛了个没被接住的异常,整个程序挂了,前面的未保存数据全没了。
所以我的铁律是:每个线程入口函数,都要包一层完整的try/catch。 你可以把顶层捕获的逻辑封装成一个辅助函数:
cpp复制void thread_safe_entry(std::function<void()> fn) {
try {
fn();
} catch (const std::exception& e) {
log_error("thread exited with exception: {}", e.what());
} catch (...) {
log_error("thread exited with unknown exception");
}
}
但这里有个微妙之处:异常被捕获之后,这个线程就结束了。如果你期望线程继续运行(比如线程池),那要在线程循环内继续处理,而不是在循环外一次性兜底。在线程池环境里,我常看到把异常捕获写在run()外层,结果抛一次异常线程就死了,下次任务再也没人处理。正确做法是:任务级捕获:
cpp复制void worker() {
while (running_) {
auto task = queue_.pop();
try {
task();
} catch (const std::exception& e) {
log_error("task failed: {}", e.what());
// 任务失败不影响线程继续取下一个任务
}
}
}
6.2 智能指针与异常交互的特殊情况
智能指针本身是RAII的,异常传播时能正确释放。但std::make_shared和直接new在异常场景下有一个隐藏差异。
cpp复制process(std::shared_ptr<A>(new A), std::shared_ptr<B>(new B));
C++标准允许执行顺序是:先new A,再new A的shared_ptr构造,再new B,再B的shared_ptr构造。如果new B抛异常,前两个步骤已经完成了,但new A的裸指针被夹在中间,shared_ptr还没来得及接管,内存就泄漏了。这是经典的异常安全漏洞。
解决办法很简单:永远使用std::make_shared。
cpp复制process(std::make_shared<A>(), std::make_shared<B>());
这样每个shared_ptr在函数参数求值完成后立刻掌握所有权,任何一步抛异常都只泄漏正在构造的那个对象,其他已完成构造的对象能被正确释放。
6.3 顶层兜底:让未捕获异常变成友好错误而不是崩溃
在服务端程序里,主循环的顶层捕获能避免整个服务因为一个小任务崩溃。在GUI程序里,顶层捕获能让你弹出一个友好错误框而不是直接闪退。
顶层捕获的代码位置非常有讲究:
cpp复制int main() {
try {
run_app();
} catch (const ConfigError& e) {
std::cerr << "配置错误: " << e.what() << "\n";
return EXIT_FAILURE;
} catch (const NetworkError& e) {
std::cerr << "网络错误: " << e.what() << "\n";
return EXIT_FAILURE;
} catch (const std::exception& e) {
std::cerr << "系统错误: " << e.what() << "\n";
return EXIT_FAILURE;
} catch (...) {
std::cerr << "未知错误\n";
return EXIT_FAILURE;
}
}
这是不是一定正确?不一定。因为main返回前,局部静态对象的析构在main退出之后才执行,如果那里发生异常,上层还有没有别的兜底?主函数顶层捕获只是最后一道网,真正要做的还是在恰当的边界做精准捕获,不要把所有希望寄托在顶层。
6.4 日志与异常信息的搭配:别只顾着catch不记录
我review代码时,看到最多的问题是:catch到异常后,就只写一行// log,实际代码里什么都没有。等线上出问题了,日志只有一行“exception occurred”,完全看不到现场信息。
正确的做法是:在异常被抛出的源头就附带足够的上下文。 比如:
cpp复制throw NetworkError("failed to connect to " + host + ":" + std::to_string(port));
然后在捕获层,日志里带上当前的任务ID、模块名和异常信息:
cpp复制catch (const NetworkError& e) {
logger.error("[task {}] network error: {} (socket errno={})",
task_id, e.what(), e.socket_errno());
}
记录日志不是副作用,它是异常处理链中不可分割的一部分。没有足够信息的日志,不如不记,因为排障时它只会给你制造更多困惑。
7. 异常与性能的真相:什么才是真正该担心的开销
最后一章,聊聊性能。这是初学者问得最多、误解也最多的话题。
7.1 零成本模型:不抛异常时开销几乎为零
现代C++的异常实现普遍采用零成本模型。意思是:在没有异常抛出的正常执行路径上,不需要额外的运行时检查。 编译器通过静态分析,把异常处理所需的元数据放到代码段的一个单独区域,正常执行流完全不会碰到它们。代价是异常分发(栈展开过程)相对较慢——因为要查表、匹配清理操作,还要通过展开描述符重建栈帧。
所以如果你写了一个函数,理论上它不会抛异常,但你还没能力证明这一点,那就不用加noexcept;如果这个函数是性能关键路径,而且你能保证它不会抛,那就果断加noexcept。但别把常数级的性能优化当作全局禁用异常的借口。我实测过正常路径上前者几乎无差别,后者(异常分发)确实慢,大概是几十到几百纳秒级别的开销,但这类情况本身就不是高频路径该出现的。
7.2 异常安全与代码体积的权衡
异常机制会引入二进制体积增加,原因是编译器需要额外生成展开表、类型表等元数据。对热更新、嵌入式、或者某些对二进制大小敏感的模块,这个体积膨胀可能是个问题。
一个折中方案是按模块隔离异常边界:在模块内部,禁用异常,用手写错误码传递;在模块接口处,把错误码转成异常抛给上层。这样做保留了性能敏感内部代码的零异常开销,同时让模块外部能享受异常机制的错误传播便利。代价是模块边界多了一层转换逻辑,需要额外维护。
7.3 一个更现代的替代:std::expected与错误码
C++23引入了std::expected,提供了一种“要么有值,要么有错误”的返回类型。它是C++标准库对错误码/可选值模型的一次补强:
cpp复制std::expected<int, std::error_code> divide(int a, int b) {
if (b == 0) {
return std::unexpected(std::make_error_code(std::errc::invalid_argument));
}
return a / b;
}
auto result = divide(10, 2);
if (result) {
use(*result);
} else {
handle_error(result.error());
}
这种方案在性能上比异常有优势,在代码可读性上也比返回码好,因为它必须显式检查结果,不存在“忘记检查返回值”的问题。但它也有缺点:错误传播路径上的每一层都必须显式处理expected,中间层不想处理就要手动转发,这又回到“污染层”的老问题上。
所以我的建议是:性能关键路径、错误频率高的业务分支,用std::expected或错误码;传播距离远、中间层需要透明跳过的情况,用异常。 两者不是竞争关系,是互补关系。工程上你完全可以定义一个架构策略:模块内部用expected,模块边界转成异常。
8. 我的异常工程实践清单
最后用我在实际项目中沉淀下来的几条纪律收尾,也是我团队代码规范里逐字写进去的内容:
- 捕获一律用引用,最好const引用。 这条没得商量。
- catch(...)里必须写日志或至少写注释,不能空着吞异常。
- 析构函数、swap函数、move构造函数,默认加noexcept。
- 不要用异常做正常控制流。 如果你发现异常在正常业务中频繁出现,说明你还没把错误分支设计成正常分支,回去改代码。
- 异常基类尽量私有继承或者统一命名空间,避免多个库之间异常的交叉污染。 一个模块抛出的异常被另一个模块捕获后,错误边界就变得模糊了。
- 在公共接口的文档里写明函数可能抛出哪些异常,以及对应哪些层级的安全保证。 这比写一百行注释都有用。
C++的异常处理不是一道填空题,也不是“写几个try/catch交差”。它是一整套错误传播和错误恢复的工程哲学,从栈展开、RAII、对象生命周期,到异常安全等级、noexcept优化、错误模型选型,每一步都值得认真对待。希望这篇基于大量实操经验的梳理,能帮你把异常从“语法知识”变成“工程能力”。
