1. 项目概述:为什么C++异常处理值得你花时间
写C++代码,尤其是规模稍大一点的项目,最头疼的往往不是实现功能,而是处理那些“万一”。文件打不开、内存申请失败、网络连接超时、输入数据格式错误……这些意外情况如果处理不好,轻则程序崩溃,用户体验糟糕;重则数据丢失,甚至引发安全漏洞。很多从C语言转过来的朋友,习惯了用返回值(比如返回-1、NULL)和全局错误码(errno)来报错,但在C++的面向对象和多资源管理场景下,这套机制就显得力不从心,代码里会充斥着大量的if (ret < 0)检查,逻辑支离破碎。
C++异常机制就是为了优雅地解决这个问题而生的。它把正常的业务逻辑和错误处理逻辑分离开,当函数遇到无法处理的错误时,可以“抛出”(throw)一个异常对象,然后程序的控制流会沿着调用栈向上“回溯”,直到找到能“捕获”(catch)并处理这个异常的代码块。这个过程就像在一个部门里出了问题,一线员工(深层函数)解决不了,就上报给经理(上层函数),经理再决定是自己处理还是继续上报给总监。
但说实话,C++异常的名声有点两极分化。喜欢的人觉得它让代码更清晰、更安全;讨厌的人则抱怨它性能有开销、让控制流难以追踪,甚至在一些嵌入式或高性能场景中被禁用。这种争议恰恰说明了它的重要性——用好了是神器,用错了是灾难。因此,搞清楚“怎么用”以及“什么时候用”,是每个C++开发者从入门到精通的必修课。这篇文章,我就结合自己这些年踩过的坑和项目里的实战经验,从最基础的语法开始,一直聊到大型项目里如何安全、高效地驾驭异常,目标是让你看完后,不仅能写出正确的异常处理代码,更能做出合理的架构设计决策。
2. 异常处理的核心机制与基础语法拆解
要玩转异常,首先得把它的三条核心语法——throw、try、catch——以及背后的栈展开(Stack Unwinding)机制吃透。这部分是地基,地基不牢,后面所有的高级技巧都是空中楼阁。
2.1throw、try、catch:异常处理的“三板斧”
throw表达式:这是异常的发起者。当检测到错误时,使用throw抛出一个异常对象。这个对象可以是任何类型(内置类型、字符串、自定义类对象),但最佳实践是抛出一个派生自std::exception(或其子类,如std::runtime_error、std::logic_error)的对象。这样做的好处是,所有异常都能通过std::exception的what()方法获取错误描述,便于统一处理。
// 不好的做法:抛出基本类型,信息量少 if (file.open() fails) { throw -1; // 调用者看到-1,一脸茫然 } // 好的做法:抛出标准异常或自定义异常 if (!file.open("data.txt")) { throw std::runtime_error("无法打开文件: data.txt"); } // 更好的做法:使用更具体的异常或自定义异常类 class FileOpenError : public std::runtime_error { public: explicit FileOpenError(const std::string& filename) : std::runtime_error("文件打开失败: " + filename) {} }; throw FileOpenError("data.txt");try代码块:这是异常的“监控区”。你把可能抛出异常的代码用try{}包裹起来。try块本身不处理异常,它只是标定了一个范围,告诉编译器:“我这里的代码可能会出问题,请做好回溯准备。”
catch子句:这是异常的“处理区”。紧跟在try块后面,可以有一个或多个catch块。每个catch块声明它能捕获的异常类型。当try块中抛出异常时,程序会按顺序匹配catch块的类型。匹配成功,则执行该catch块内的代码,异常在此被处理,程序继续执行catch块之后的代码(如果还有的话)。
try { // 可能抛出异常的代码 loadConfig("config.json"); connectToDatabase(); startBusinessLogic(); } catch (const FileOpenError& e) { // 专门处理文件打开错误 std::cerr << "配置文件错误: " << e.what() << std::endl; // 可以尝试使用默认配置继续运行 useDefaultConfig(); } catch (const std::runtime_error& e) { // 处理其他运行时错误 std::cerr << "运行时错误: " << e.what() << std::endl; // 可能需要进行一些清理工作 cleanupResources(); // 决定是退出还是向上抛 throw; // 重新抛出当前异常,让更外层处理 } catch (...) { // 捕获所有其他未知类型的异常 std::cerr << "发生未知异常!" << std::endl; // 通常在这里记录日志并终止程序,因为不知道如何恢复 std::terminate(); }注意:
catch (...)是“兜底”条款,能捕获任何类型的异常,包括非std::exception派生的异常(比如throw “error”)。但在生产代码中,要慎用。因为它隐藏了异常的具体类型,让你无法进行有针对性的恢复。通常只用于在程序最外层记录日志并安全退出。
2.2 栈展开(Stack Unwinding)与RAII的生死同盟
抛出异常后,最关键的过程就是栈展开。编译器会沿着函数调用链从抛出点开始,逆向回溯,逐个退出(销毁)当前作用域内的局部对象,直到找到一个匹配的catch块。这个过程是自动的。
栈展开的成功与否,直接关系到资源泄漏。这就是为什么RAII(Resource Acquisition Is Initialization)是C++异常安全性的基石。RAII的核心思想是:将资源(内存、文件句柄、锁、网络连接等)的生命周期绑定到一个局部对象的生命周期上。对象构造时获取资源,对象析构时释放资源。由于栈展开时会自动调用局部对象的析构函数,资源也就被自动、正确地释放了。
// 不使用RAII:异常导致资源泄漏 void riskyFunction() { int* ptr = new int[100]; someOperation(); // 如果这里抛出异常 delete[] ptr; // 这行永远不会执行,内存泄漏! } // 使用RAII(智能指针):异常安全 void safeFunction() { std::unique_ptr<int[]> ptr(new int[100]); // 资源获取即初始化 someOperation(); // 如果这里抛出异常 // 栈展开时,ptr作为局部对象会被销毁,其析构函数自动调用 delete[] // 内存被安全释放,无泄漏 }关键点:如果你在代码中手动管理资源(new/delete,open/close),那么必须在每个可能抛出异常的分支都考虑资源的释放,极易出错。而使用RAII包装器(如智能指针std::unique_ptr,std::shared_ptr, 文件流std::fstream, 锁std::lock_guard),资源管理就交给了对象的生命周期,异常安全几乎免费获得。在写任何可能抛出异常的代码前,先问问自己:我的资源都用RAII管理好了吗?
2.3 异常规格(Exception Specification)与noexcept的现代用法
早期C++有动态异常规格(throw(type)),用于声明函数可能抛出的异常类型。但这套机制在实践中被证明是糟糕的设计(检查发生在运行时,且影响优化),在C++11中已被弃用。
现代C++的旗帜是**noexcept**。它有两个主要作用:
- 性能提示:告诉编译器该函数不会抛出任何异常。编译器可以基于此进行更激进的优化(例如,避免生成栈展开所需的额外代码)。
- 契约保证:作为函数接口的一部分,向调用者承诺“我不会抛异常”。如果
noexcept函数内部抛出了异常,程序会直接调用std::terminate()终止,而不是展开栈。
何时使用noexcept?
- 移动构造函数/移动赋值运算符:标准库容器(如
std::vector)在重新分配内存时,为了提供强异常安全保证,会优先使用noexcept的移动操作。如果你的移动操作不会抛异常,务必加上noexcept,这能显著提升容器操作的性能。class MyType { public: MyType(MyType&& other) noexcept { /* 移动资源 */ } MyType& operator=(MyType&& other) noexcept { /* 移动赋值 */ return *this; } }; - 析构函数:析构函数默认就是
noexcept的。绝对不要让析构函数抛出异常!如果析构函数中的操作可能失败,请吞掉异常或记录日志,但不要让它传播出去。否则,在栈展开过程中如果析构函数又抛出异常,程序会立即终止。 - 简单、确定性的函数:如
getter、setter、数学计算函数等,明确知道不会失败或失败的后果仅是返回错误码而非抛异常的函数。
一个重要的实战技巧:对于小型、频繁调用的函数,使用noexcept是好的。但对于复杂的、可能失败的业务函数(如连接数据库、解析文件),不要轻易加noexcept。保留抛异常的权利,是保证调用者能得知错误并妥善处理的必要条件。
3. 项目实战中的异常处理策略与设计模式
理解了基础语法,我们进入项目实战环节。在真实的、尤其是大型C++项目中,异常处理不是简单的try-catch,而是一种涉及架构设计的策略。你需要决定在哪里抛出、在哪里捕获、如何传递错误信息、如何保证异常安全。
3.1 异常安全保证的三个级别
在设计和评审函数时,我们常讨论其异常安全性,通常分为三个级别,从弱到强:
- 基本保证(Basic Guarantee):如果抛出异常,程序仍处于有效状态(无资源泄漏、所有对象仍可析构)。这是最低要求,任何代码都应满足。
- 强保证(Strong Guarantee):如果抛出异常,程序状态会回滚到函数调用前的样子。就像事务操作一样,要么完全成功,要么完全失败。这通常通过“拷贝-交换”(copy-and-swap)惯用法实现。
- 不抛异常保证(Nothrow Guarantee):函数承诺绝不抛出任何异常。这通常由
noexcept声明。
如何实现强保证?一个经典的例子是std::vector::push_back。在C++11前,它提供的是强保证:如果插入元素时拷贝构造函数抛出异常,vector的状态保持不变。实现方式通常是在插入前分配新内存、拷贝构造新元素,所有操作成功后再与旧内存交换。如果中间任何一步失败,旧数据完好无损。
在你的代码中,对于关键的状态修改操作,应力求提供强保证。例如,一个更新用户配置的函数:
class UserSettings { std::map<std::string, std::string> data; public: void updateSetting(const std::string& key, const std::string& newValue) { auto oldData = data; // 1. 拷贝旧状态(可能抛异常,但旧data不变) oldData[key] = newValue; // 2. 在副本上修改(可能抛异常) // 3. 使用不抛异常的swap完成提交 std::swap(data, oldData); // noexcept } };3.2 边界划分:在模块/层接口处集中处理异常
这是大型项目中最重要的一条原则:不要到处catch,也不要从不catch。异常应该有一个明确的传播和处理边界。
底层库/工具函数:专注于检测错误并抛出含义明确、信息丰富的异常。它们通常不处理业务逻辑,也不知道在具体业务场景下该如何恢复。所以,除了用RAII保证自身资源安全外,它们应该让异常自由传播。
// 在某个网络工具库中 Socket connectToHost(const std::string& host, int port) { Socket sock; if (!sock.connect(host, port)) { // 抛出技术性异常,包含足够调试信息 throw NetworkError("连接失败", host, port, sock.lastError()); } return sock; }业务逻辑层:这是处理异常的核心地带。业务层了解业务语义,知道某种错误是否可恢复、该如何恢复。例如,用户登录时网络超时,业务层可以决定是重试、提示用户检查网络,还是切换到离线模式。
- 可恢复异常:在业务层被捕获,并执行恢复逻辑(重试、降级、使用默认值等),然后程序继续正常运行。
- 不可恢复异常:通常是严重的逻辑错误或系统错误(如内存耗尽、关键配置文件损坏)。业务层捕获后,进行必要的资源清理和日志记录,然后可以选择重新抛出(让上层决定),或者以可控的方式终止当前业务单元(如结束当前用户会话),但尽量保证进程不崩溃。
用户界面/最外层主循环:这是最后的防线。它的任务是捕获所有未被处理的异常,防止程序崩溃,并以友好的方式告知用户(例如,弹出一个错误对话框,并自动保存当前工作进度)。在这里,
catch (...)可能是合适的,因为你的目标是“不让程序死得难看”。int main() { try { Application app; app.run(); } catch (const std::exception& e) { // 记录详细的错误日志,包括调用栈(如果有工具支持) logFatalError(e.what()); // 向用户显示友好的错误信息 showErrorMessageBox(“程序遇到错误,已保存工作进度。详情请查看日志。”); // 尝试安全退出 saveRecoveryData(); return EXIT_FAILURE; } catch (...) { logFatalError(“未知异常”); showErrorMessageBox(“发生未知严重错误。”); return EXIT_FAILURE; } return EXIT_SUCCESS; }
3.3 自定义异常类:传递丰富的错误上下文
标准异常类(如std::runtime_error)的what()信息往往不够。在项目中,定义一套自己的异常类体系非常有用。你可以继承std::exception,添加错误码、模块名、时间戳、相关对象ID等上下文信息。
class MyAppException : public std::exception { protected: std::string message_; int errorCode_; std::string module_; std::chrono::system_clock::time_point timestamp_; public: MyAppException(int code, const std::string& module, const std::string& msg) : errorCode_(code), module_(module), timestamp_(std::chrono::system_clock::now()) { message_ = fmt::format("[{}][{}][{}] {}", module_, errorCode_, std::chrono::system_clock::to_time_t(timestamp_), msg); } const char* what() const noexcept override { return message_.c_str(); } int getErrorCode() const { return errorCode_; } // ... 其他getter }; // 业务异常子类 class DatabaseException : public MyAppException { public: enum class Reason { ConnectionFailed, QueryError, Timeout }; DatabaseException(Reason reason, const std::string& details) : MyAppException(static_cast<int>(reason), "Database", details) {} };这样,在日志系统或错误处理器中,你可以解析这些异常,获取结构化的错误信息,便于监控和排查。
3.4 异常 vs. 错误码 vs. 预期类型(std::expected)的选型
C++社区关于错误处理的争论,焦点往往是异常和错误码(包括返回布尔值、错误枚举等)的取舍。近年来,类似RustResult的std::expected(C++23引入,之前可通过第三方库如tl::expected使用)也加入了战局。
简单对比:
| 特性 | 异常 (Exceptions) | 错误码 (Error Codes) | std::expected<T, E> |
|---|---|---|---|
| 控制流 | 非局部跳转,自动传播 | 通过返回值手动检查 | 通过返回值携带,需手动检查(类似optional) |
| 性能(无错时) | 可能有极小开销(取决于实现) | 零开销 | 零开销(与返回结构体相同) |
| 性能(有错时) | 栈展开开销大 | 开销极小 | 开销极小 |
| 是否强制处理 | 否(可能被忽略导致崩溃) | 否(容易被忽略) | 是(必须解包才能获取值,编译器可能警告) |
| 错误信息携带 | 丰富,可携带任意对象 | 通常只是一个整数或简单对象 | 可携带任意类型的错误对象E |
| 与构造函数兼容 | 是(构造函数无法返回错误码) | 否 | 是(但需通过工厂函数) |
| 代码清晰度 | 主逻辑清晰,错误处理分离 | 主逻辑与错误检查混杂 | 主逻辑清晰,错误需显式处理 |
选型建议(个人经验):
使用异常的场景:
- 构造函数和操作符重载中报告错误(它们没有返回值)。
- 错误是真正的“异常”,即发生频率很低,且通常无法在本地立即恢复的情况(如内存耗尽、硬件故障、关键服务连接失败)。
- 错误需要跨多层调用栈传播才能被处理的情况。用错误码需要每一层都检查并传递,非常繁琐。
- 你所在的项目或团队已经确立了以异常为主的错误处理规范。
使用错误码或
std::expected的场景:- 性能极其敏感的代码,如高频交易引擎、游戏渲染循环、嵌入式实时系统。异常的栈展开开销在错误路径上是不可接受的。
- 错误是预期内的、频繁发生的,是业务逻辑的一部分(如“用户名已存在”、“解析失败但可跳过”)。用异常处理这类情况就像用大炮打蚊子。
- 需要与C语言接口或没有异常机制的代码(如某些内核代码、外部C库)交互。
- 你希望强制调用者立即处理错误,而不是任由它传播。
std::expected通过类型系统做到了这一点。
一个混合策略:在很多大型项目中,我看到一种混合模式:在模块内部和跨模块的“硬”错误使用异常;在模块接口处或对性能有要求的局部,使用错误码或std::expected。例如,一个网络库内部连接失败会抛异常,但它的异步回调接口可能返回一个包含错误码的std::expected<Data, Error>,以避免在回调中抛异常带来的复杂性。
4. 高级技巧、常见陷阱与性能调优
掌握了基础和策略,我们来看看一些能让你代码更健壮、更高效的高级技巧和必须避开的“坑”。
4.1 异常安全与STL容器、智能指针
STL容器和智能指针是RAII的典范,它们自身提供了很强的异常安全保证。但你的用法会影响最终的安全性。
emplace_backvspush_back:在C++11后,对于容器,优先使用emplace系列函数(如emplace_back)。它直接在容器内构造对象,避免了先构造临时对象再移动或拷贝可能带来的额外异常风险(尽管移动操作通常是noexcept的)。- 智能指针的构造:
std::make_unique和std::make_shared不仅语法简洁,更重要的是它们提供了更强的异常安全性。考虑以下代码:
编译器可能先processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // 危险!new Widget,然后调用computePriority(),最后构造shared_ptr。如果computePriority()抛出异常,那么new Widget分配的内存就泄漏了。而std::make_shared<Widget>()将内存分配和对象构造合并为一个原子操作,避免了这个问题。 - 注意“指针的指针”:
vector<unique_ptr<T>>在重新分配(resize)时,需要移动其中的unique_ptr。确保你的T的移动构造函数是noexcept的,否则vector将被迫使用拷贝,而unique_ptr是不可拷贝的,这会导致编译错误或运行时异常。
4.2 构造函数和析构函数中的异常
这是两个需要特别小心的地方。
- 构造函数:如果构造函数抛出异常,那么该对象的析构函数不会被调用(因为对象被认为没有完全构造成功)。但是,所有已经构造完成的成员子对象和基类子对象,它们的析构函数会被调用(按与构造相反的顺序)。因此,在构造函数中,要用RAII管理每一个可能失败的资源初始化步骤。如果初始化失败,让异常抛出,RAII成员会自动清理。
- 析构函数:如前所述,析构函数默认
noexcept。绝对不要在析构函数中抛出异常。如果析构函数中调用的某个操作可能失败(比如关闭文件、提交日志),必须用try-catch块在内部消化掉这个异常,最多记录一条日志。~MyClass() { try { if (file_.is_open()) { file_.close(); // close可能失败 } } catch (const std::exception& e) { // 只能记录日志,绝不能重新抛出! logError("Failed to close file in destructor: ", e.what()); } }
4.3 异常与多线程
在多线程环境中,异常不能跨线程传播。如果一个线程中抛出的异常没有被该线程自身捕获,标准行为是调用std::terminate()终止整个程序。
处理方式:
- 线程入口函数包装:每个线程的入口函数(或
std::async返回的future)应该用try-catch块包裹,捕获所有异常,并将其转换为线程间可传递的信息(如设置一个共享的std::exception_ptr,或通过promise.set_exception)。void threadFunc(std::promise<int>& prom) { try { int result = doHeavyWork(); prom.set_value(result); } catch (...) { prom.set_exception(std::current_exception()); } } - 使用
std::future:std::async或std::packaged_task返回的std::future对象,在其get()方法被调用时,如果异步操作中抛出了异常,该异常会在调用get()的线程中重新抛出。这是跨线程传递异常的标准方式。auto future = std::async(std::launch::async, [](){ // ... 可能抛异常 throw std::runtime_error("Oops from another thread!"); }); try { future.get(); } catch (const std::runtime_error& e) { // 在这里捕获到另一个线程抛出的异常 }
4.4 性能考量与最佳实践
关于异常的性能,误解很多。关键在于理解“零开销原则”在异常中的体现:在未发生异常的正常执行路径上,现代编译器的异常机制开销极低(接近零)。开销主要发生在抛出和捕获异常的路径上(栈展开、类型匹配等)。
优化建议:
- 不要将异常用于常规控制流:比如用抛异常来代替
break或return。这会让性能分析工具失效,也让代码难以理解。 - 减少
try块的范围:只包裹真正可能抛出异常的语句。大的try块会增加栈展开时需要检查的数据,理论上(虽然影响很小)也会增加一些簿记开销。 - 对于频繁调用且失败率高的函数,考虑使用错误码。例如,在一个解析大量可能格式错误的数据的循环中,如果“解析失败”是常见情况,用错误码或
std::optional会比抛异常高效得多。 - 使用编译器的异常优化选项:如GCC/Clang的
-fno-exceptions会完全禁用异常(所有throw、try、catch变成编译错误)。这通常只在极端性能要求或特定环境(如内核、部分嵌入式系统)下使用。更常见的是使用-fvisibility-hidden等选项来优化异常处理表的大小。
5. 调试与排查:当异常“失控”时怎么办
即使设计得再好,异常相关的bug依然难以调试,因为调用栈在抛出点就中断了。下面是一些实战调试技巧。
5.1 获取并打印完整的异常调用栈
标准异常只告诉你“哪里错了”(what()),但不知道“怎么走到这一步的”。在Linux/macOS下,你可以利用backtrace系列函数;在Windows下,可以使用DbgHelp库。这里给出一个Linux下的简单示例:
#include <execinfo.h> #include <signal.h> #include <iostream> #include <sstream> void printStackTrace() { const int maxFrames = 100; void* buffer[maxFrames]; int numFrames = backtrace(buffer, maxFrames); char** symbols = backtrace_symbols(buffer, numFrames); if (symbols) { std::ostringstream oss; oss << "Stack trace:\n"; for (int i = 0; i < numFrames; ++i) { oss << symbols[i] << "\n"; } // 可以输出到std::cerr或你的日志系统 std::cerr << oss.str() << std::flush; free(symbols); } } // 自定义异常类,在构造时捕获栈信息 class TracedException : public std::exception { std::string stackTrace_; std::string msg_; public: TracedException(const std::string& msg) : msg_(msg) { std::ostringstream oss; // ... 调用printStackTrace并将结果存入oss stackTrace_ = oss.str(); } const char* what() const noexcept override { // 可以将栈信息也整合进what()返回的字符串 static std::string fullMsg = msg_ + "\n" + stackTrace_; return fullMsg.c_str(); } };更成熟的做法是集成像libunwind、boost::stacktrace(C++17后可用std::stacktrace,但编译器支持不一)这样的库。
5.2 使用IDE和调试器
现代IDE(如Visual Studio、CLion、Qt Creator)和GDB/LLDB调试器对异常有很好的支持。
- 设置异常断点:你可以在调试器中设置“在抛出异常时中断”(Break When Thrown),而不是等到捕获时才中断。这能让你立刻看到异常发生的现场。
- 查看异常对象:在捕获异常的
catch块处中断后,可以查看异常对象的内容,包括其继承层次和成员变量。 - 条件断点:如果只想在特定类型的异常或满足特定条件时才中断,可以设置条件断点。
5.3 记录详细的异常日志
在项目的关键入口点(如main函数、线程入口、网络请求处理器)和最外层的catch块中,记录异常信息时,不要只记录e.what()。尽可能记录:
- 异常类型(通过
typeid(e).name(),注意需要解构)。 - 时间戳。
- 线程ID。
- 相关的业务上下文(如用户ID、请求ID、操作名称)。
- 如果集成了栈追踪,将栈信息也记录下来。
这能极大提升线上问题排查的效率。可以使用像spdlog、glog这样功能强大的日志库来简化这项工作。
5.4 常见异常问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
程序调用std::terminate()崩溃 | 1. 异常未被捕获(noexcept函数内抛异常、线程未捕获)2. 栈展开期间析构函数又抛异常 3. 未定义 std::terminate_handler时bad_alloc等未被捕获 | 1. 检查最外层和线程函数是否有catch(...)2. 检查所有析构函数,确保它们不会抛异常 3. 设置自定义 terminate_handler记录信息 |
| 内存泄漏伴随异常抛出 | 资源未用RAII管理,异常导致delete/close等未执行 | 1. 将原始指针/句柄替换为智能指针/RAII包装器 2. 使用 valgrind或AddressSanitizer检查 |
| 捕获到的异常信息模糊 | 抛出的异常对象信息不足(如基本类型)或what()信息不完整 | 1. 统一使用或继承std::exception2. 在自定义异常构造函数中填充详细上下文 3. 实现栈追踪 |
| 异常导致对象状态不一致 | 函数未提供强异常安全保证,部分操作成功,部分失败 | 1. 使用“拷贝-交换”惯用法 2. 先修改副本,确认成功后再 swap3. 将可能失败的操作前置 |
| 多线程中异常“消失” | 子线程异常未被捕获,导致std::terminate | 1. 线程函数用try-catch包裹2. 通过 std::promise/std::future传递异常3. 使用 std::async并检查future |
6. 实战案例:一个简单网络客户端中的异常处理设计
让我们用一个简化的网络客户端例子,把上面的策略串联起来。这个客户端需要从服务器获取配置,然后根据配置处理数据。
// ------------------- 自定义异常体系 ------------------- class AppException : public std::exception { /* 同上文,略 */ }; class NetworkException : public AppException { /* 略 */ }; class ConfigException : public AppException { /* 略 */ }; class DataProcessingException : public AppException { /* 略 */ }; // ------------------- 底层网络模块 ------------------- class HttpClient { public: std::string get(const std::string& url) { // 模拟网络操作,可能抛出 NetworkException if (/* 连接失败 */) throw NetworkException(/* ... */); if (/* 超时 */) throw NetworkException(/* ... */); // ... 发送请求,接收响应 if (response.status != 200) { throw NetworkException(/* 包含状态码和URL */); } return response.body; } // 提供不抛异常的版本,用于性能敏感或必须处理错误的场景 bool tryGet(const std::string& url, std::string& outBody, std::string& outError) noexcept { try { outBody = get(url); return true; } catch (const NetworkException& e) { outError = e.what(); return false; } } }; // ------------------- 配置解析模块 ------------------- class ConfigParser { public: Config parse(const std::string& jsonStr) { // 使用第三方JSON库解析,可能抛出 ConfigException // 如果解析失败,抛出包含行号、具体错误的 ConfigException // 提供强异常安全保证:要么返回有效Config,要么抛出异常且不改变任何状态 } }; // ------------------- 业务逻辑层 ------------------- class DataProcessor { Config config_; public: // 主业务函数:获取配置并处理 void runProcessingCycle() { HttpClient client; ConfigParser parser; try { // 1. 获取配置(可能失败,网络错误) std::string configJson = client.get("http://config-server/app-config"); // 2. 解析配置(可能失败,配置格式错误) config_ = parser.parse(configJson); // 如果失败,config_保持原状(强保证) // 3. 根据配置处理数据(可能失败,业务逻辑错误) processDataInternal(); } catch (const NetworkException& e) { // 网络错误:可能是暂时的,可以重试或使用缓存配置 logWarning("网络错误,尝试使用缓存配置", e); if (!loadConfigFromCache()) { // 缓存也没有,这是严重错误,需要上报并可能停止服务 throw; // 重新抛出,让上层(如看门狗)决定是否重启 } // 使用缓存配置继续处理 processDataInternal(); } catch (const ConfigException& e) { // 配置错误:通常是永久性的,需要人工干预 logError("配置解析失败,停止处理", e); notifyAdmin(e.what()); // 无法恢复,让程序以错误状态停止当前循环 return; // 不抛出,安静地结束本次循环 } catch (const DataProcessingException& e) { // 数据处理错误:可能是数据问题,记录并跳过当前数据项 logError("数据处理失败,跳过此项", e); // 可以尝试清理并继续处理下一项数据 cleanupCurrentItem(); // 注意:这里没有重新抛出,错误在业务层被消化 } catch (const std::exception& e) { // 捕获其他未知的std::exception派生异常 logError("未预期的标准异常", e); throw; // 重新抛出,视为不可恢复错误 } catch (...) { // 捕获所有其他异常(非std::exception派生) logError("未知类型的异常"); std::terminate(); // 对于完全未知的错误,安全终止是稳妥的选择 } } private: void processDataInternal() { // 使用config_处理数据... // 如果遇到业务逻辑错误,抛出 DataProcessingException // 所有资源都用RAII管理(智能指针、文件流等) } }; // ------------------- 程序入口 ------------------- int main() { DataProcessor processor; // 外层捕获,防止整个进程崩溃 while (keepRunning) { try { processor.runProcessingCycle(); std::this_thread::sleep_for(std::chrono::seconds(60)); // 每分钟运行一次 } catch (const AppException& e) { // 这是我们从业务层重新抛上来的严重错误 logFatal("业务严重错误,循环终止", e); // 可以等待一段时间后重启循环,或者直接退出进程 std::this_thread::sleep_for(std::chrono::seconds(5)); } catch (const std::exception& e) { logFatal("标准库异常逃逸,程序退出", e); return EXIT_FAILURE; } catch (...) { logFatal("未知异常逃逸,程序退出"); return EXIT_FAILURE; } } return EXIT_SUCCESS; }这个案例展示了分层处理的思想:底层(HttpClient)只负责抛出技术异常;业务层(DataProcessor::runProcessingCycle)根据业务语义决定哪些错误可恢复(网络错误用缓存)、哪些需上报(配置错误)、哪些可忽略(单条数据处理错误);最外层(main)则保证进程不会因为一个未处理的异常而突然消失,给了我们记录日志和优雅退出的机会。整个过程中,RAII(虽然没有显式写出)确保了在任何异常路径上,文件、内存、网络连接等资源都能被正确释放。