1. 项目概述:为什么C++异常处理是“安全气囊”而非“装饰品”
干了这么多年C++,从桌面应用到后台服务,我见过太多因为错误处理不当而导致的“血案”。程序在测试环境跑得好好的,一到线上就莫名其妙崩溃,日志里留下一句“Segmentation fault”就再无下文,排查起来像大海捞针。早期我也习惯用返回错误码,或者在函数里写一堆if (ptr == nullptr)的判断,代码很快就变得臃肿不堪,逻辑主线被淹没在各种错误检查中。直到被异常处理“教做人”,才明白它不是一个可选的语法糖,而是构建健壮、清晰、可维护的C++程序的核心机制。你可以把它想象成汽车的安全气囊,平时感觉不到它的存在,但一旦发生意外(运行时错误),它能接管程序,以一种可控的方式“软着陆”,而不是直接车毁人亡(进程崩溃)。
今天要聊的day09(C++)异常处理,就是带你从“知道有这回事”到“真正敢用、会用、用好”。很多初学者对异常望而却步,觉得它复杂、有开销、会打乱流程。这些顾虑部分正确,但更多是源于不了解其设计哲学和最佳实践。异常处理的本质是“将错误检测与错误处理分离”。写业务逻辑时,你只需要关注“快乐路径”(happy path),假设一切顺利;而将各种“不快乐”的可能性(文件打不开、内存不足、网络超时、无效输入)统统抛给专门的异常处理块。这极大地提升了代码的可读性和模块化程度。接下来,我会拆解异常从抛出到捕获的完整生命周期,分享实战中如何设计异常类型、编写异常安全的代码,以及如何平衡性能与安全。无论你是正在啃《C++ Primer》的新手,还是被祖传代码里混乱的错误处理折磨的老手,相信都能找到有用的东西。
2. 异常处理的核心机制与生命周期全解析
理解异常,首先要把它看作一个特殊的控制流。它打破了函数逐级返回的正常顺序,沿着调用栈向上“跳跃”,直到找到能处理它的代码块。
2.1 抛出异常:不仅仅是throw something
抛出异常使用throw关键字。关键不在于语法,而在于你抛出什么。最佳实践是抛出对象,而非内置类型或指针。
// 不推荐:信息量少,类型不安全 throw -1; // 抛出一个整数,代表什么错误? throw “Something went wrong”; // 抛出一个字符串字面量,难以扩展 // 推荐:使用标准异常或自定义异常类 #include <stdexcept> throw std::runtime_error(“无法打开配置文件”); // 标准库异常,包含字符串信息 // 更推荐:自定义异常类,携带更丰富的上下文 class DatabaseConnectionException : public std::runtime_error { public: DatabaseConnectionException(const std::string& msg, const std::string& host, int port) : std::runtime_error(msg), m_host(host), m_port(port) {} std::string getHost() const { return m_host; } int getPort() const { return m_port; } private: std::string m_host; int m_port; }; // 使用时 throw DatabaseConnectionException(“连接失败”, “192.168.1.100”, 3306);为什么推荐自定义异常类?
- 类型安全:捕获时可以使用
catch (const DatabaseConnectionException& e)精确捕获,避免误捕。 - 携带上下文:除了错误信息,还能携带错误码、时间戳、操作ID、相关对象状态等,对后期调试和日志记录至关重要。
- 继承体系:可以从
std::exception或它的子类(如std::runtime_error)派生,这样既可以用具体类型捕获,也可以用基类std::exception兜底。
实操心得:throw意味着资源承诺当你写throw时,心里必须清楚:从throw语句开始,到异常被捕获并处理完毕之前,当前函数栈会展开(stack unwinding),局部对象会按照构造相反的顺序析构。这意味着,如果你的资源管理(如内存、文件句柄、锁)依赖于局部对象的析构函数(即RAII,资源获取即初始化),那么即使抛出异常,资源也会被正确释放。反之,如果你用new分配了内存但没有用智能指针管理,或者在throw前手动acquire了一个锁但没有用lock_guard,那么这些资源就会泄漏。所以,throw之前,请确保你的代码是“异常安全”的。
2.2 捕获异常:精准打击与兜底策略
捕获异常使用try-catch块。try块内是可能抛出异常的代码,catch块则像一个个针对不同异常类型的过滤器。
try { openFile(“config.json”); connectToDatabase(); processUserRequest(); } catch (const DatabaseConnectionException& e) { // 精准捕获数据库连接异常 std::cerr << “数据库连接失败 [” << e.getHost() << “:” << e.getPort() << “]: ” << e.what() << std::endl; // 可能的重试逻辑或降级处理 useBackupDatabase(); } catch (const std::ios_base::failure& e) { // 捕获所有IO相关异常(文件打开失败等) std::cerr << “IO错误: ” << e.what() << std::endl; loadDefaultConfiguration(); } catch (const std::exception& e) { // 兜底:捕获所有标准异常 std::cerr << “标准异常: ” << e.what() << std::endl; // 记录日志,优雅退出或上报 logFatalError(e.what()); } catch (...) { // 终极兜底:捕获所有未被前述catch块捕获的异常,包括非std::exception派生的 std::cerr << “发生未知异常!” << std::endl; // 这里通常只能做最少的清理,然后重新抛出或终止 std::terminate(); // 或 throw; }捕获顺序至关重要catch子句的匹配是按照书写顺序进行的。因此,必须将最具体(派生类)的异常放在前面,最通用(基类)的放在后面。如果把catch (const std::exception& e)放在第一个,那么后面所有的catch块都将永远不会被执行,因为所有标准异常都被它截胡了。
catch (...)的使用场景与禁忌catch (...)能捕获任何异常,包括你自定义的、非从std::exception派生的、甚至是其他语言(如C)抛出的。正因如此强大,使用需极度谨慎:
- 适用场景:在程序的最外层(如
main函数),作为一个最后的安全网,防止程序因未捕获异常而立即崩溃,以便有机会记录日志、保存用户数据或执行必要的清理。 - 禁忌:在中间层次的函数中滥用
catch (...)。因为你不知道捕获了什么,也就无法进行有意义的恢复。通常在这里只能做两件事:1) 执行一些不依赖异常类型的资源清理;2) 重新抛出(throw;)让上层处理,或者调用std::terminate终止程序。绝对不要在catch (...)里默默地“吞掉”异常然后继续执行,这会导致程序处于不可预测的状态,是调试的噩梦。
2.3 栈展开与资源管理:RAII是守护神
当异常被抛出,程序控制流离开try块,开始“栈展开”:它会逆向遍历调用栈,离开每个函数的作用域,并析构该作用域内的所有局部对象。
void processTransaction() { std::lock_guard<std::mutex> lock(g_transactionMutex); // RAII:锁在构造时获取,在析构时释放 std::vector<Data> buffer(1024); // RAII:内存由vector管理,析构时自动释放 std::ofstream logFile(“transaction.log”); // RAII:文件流,析构时会关闭文件 if (!validateInput()) { throw std::invalid_argument(“无效输入”); // 从这里抛出异常 } // ... 其他可能抛出异常的操作 } // 无论是因为正常返回还是异常离开,lock、buffer、logFile都会在这里被正确析构,资源被释放。如果没有RAII会怎样?假设我们手动管理资源:
void badExample() { MyResource* res = new MyResource(); // 手动new acquireLock(&g_lock); // 手动加锁 if (somethingBadHappens) { throw std::runtime_error(“error”); // 抛出异常! // 问题:下面的delete和releaseLock永远不会执行! // delete res; // 内存泄漏! // releaseLock(&g_lock); // 死锁! } // 正常路径 releaseLock(&g_lock); delete res; }这就是为什么C++社区极力推崇RAII。通过将资源生命周期绑定到对象生命周期,让析构函数成为资源的释放者,无论函数以何种方式退出(正常返回、异常),资源都能得到保障。标准库中的智能指针(std::unique_ptr,std::shared_ptr)、容器、文件流、锁守卫(std::lock_guard)都是RAII的典范。
注意事项:构造函数中的异常构造函数没有返回值,所以报告构造失败的最佳方式就是抛出异常。但这里有个关键点:如果一个对象的构造函数因异常而退出,那么它的析构函数将不会被调用。然而,对于这个构造函数内已经构造完毕的成员子对象和基类子对象,它们的析构函数是会被调用的(按构造的逆序)。这要求我们在设计类时,要确保成员变量本身是异常安全的(例如,使用智能指针而非原生指针),避免构造函数中途失败导致部分资源泄漏。
3. 标准库异常体系与自定义异常设计
C++标准库提供了一套基础的异常类型,它们都继承自std::exception。理解这个体系有助于我们选择合适的异常类型,并构建自己的异常层次。
3.1 标准异常家族巡礼
<stdexcept>头文件定义了两大类逻辑错误和运行时错误:
- 逻辑错误(
std::logic_error):通常表示程序内部的逻辑bug,在代码部署前理论上可以被检测和避免。std::invalid_argument:参数值不被接受。std::out_of_range:访问越界,如vector::at。std::length_error:试图创建超出最大大小的对象,如vector的reserve。
- 运行时错误(
std::runtime_error):表示仅在运行时才能检测到的错误,通常与外部环境或资源有关。std::overflow_error/std::underflow_error:算术运算溢出/下溢(现在较少用)。std::system_error:封装了操作系统错误码,非常有用。std::ios_base::failure:由I/O流操作抛出。
此外,还有其他头文件定义的异常,如std::bad_alloc(内存不足时由new抛出),std::bad_cast(dynamic_cast失败)等。
如何选择?
- 如果你写的函数参数无效,抛
std::invalid_argument。 - 如果你访问容器越界,抛
std::out_of_range(虽然标准库容器会替你抛)。 - 如果操作失败是因为外部系统(文件、网络、数据库),抛
std::runtime_error或其子类(如std::system_error)。 - 如果错误类型比较特殊,标准库没有对应,就自定义。
3.2 设计一个实用的自定义异常类
自定义异常类不仅仅是继承std::exception那么简单。一个好的设计应该考虑可扩展性、信息丰富性和易用性。
#include <exception> #include <string> #include <chrono> #include <sstream> class MyAppException : public std::runtime_error { public: // 基础构造函数 explicit MyAppException(const std::string& message, const std::string& file = “”, int line = 0, const std::string& func = “”) : std::runtime_error(formatMessage(message, file, line, func)), m_timestamp(std::chrono::system_clock::now()), m_file(file), m_line(line), m_function(func) {} // 获取详细信息 std::chrono::system_clock::time_point getTimestamp() const { return m_timestamp; } std::string getFile() const { return m_file; } int getLine() const { return m_line; } std::string getFunction() const { return m_function; } // 可以添加错误码、模块名等更多字段 void setErrorCode(int code) { m_errorCode = code; } int getErrorCode() const { return m_errorCode; } private: static std::string formatMessage(const std::string& msg, const std::string& file, int line, const std::string& func) { std::ostringstream oss; if (!file.empty()) { oss << “[” << file; if (line > 0) oss << “:” << line; if (!func.empty()) oss << “ in ” << func; oss << “] ”; } oss << msg; return oss.str(); } private: std::chrono::system_clock::time_point m_timestamp; std::string m_file; int m_line; std::string m_function; int m_errorCode = 0; }; // 使用宏简化抛出,自动填充文件名、行号、函数名(非常实用!) #define THROW_MYAPP_EXCEPTION(msg) \ throw MyAppException((msg), __FILE__, __LINE__, __FUNCTION__) // 使用示例 void loadConfig(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { THROW_MYAPP_EXCEPTION(“无法打开配置文件: ” + path); } // ... }这样设计的好处:
- 自动记录上下文:通过宏,异常自动捕获了抛出点的文件名、行号和函数名,这在查看日志时价值连城。
- 时间戳:便于追踪错误发生的时间序列。
- 可扩展:可以轻松添加错误码、严重级别、线程ID等字段。
- 易于集成:仍然可以通过
std::exception的what()方法获取格式化后的信息,兼容现有只处理标准异常的代码。
实操心得:异常与日志的协作异常对象本身携带了丰富的诊断信息,但异常处理块不应该只负责把错误信息打印到控制台(std::cerr)。在生产环境中,应该将异常信息(包括what()、自定义字段如时间戳、错误码等)通过日志库(如spdlog、glog)记录到文件或日志系统中,并设置合适的日志级别(如ERROR或FATAL)。这样既不会干扰正常的程序输出,也便于后续的集中分析和监控告警。
4. 异常安全保证:编写“地震”中也不垮的代码
异常安全是指当异常被抛出时,程序状态(特别是数据结构和资源)所表现出的行为。它通常分为几个级别,从弱到强:
4.1 四级异常安全保证
- 无保证(No guarantee):如果抛出异常,程序可能处于任何状态——对象被破坏,资源泄漏,数据损坏。这是最糟糕的情况,应绝对避免。
- 基本保证(Basic guarantee):如果抛出异常,程序状态保持不变。这意味着所有对象都处于有效状态(尽管内容可能改变了),没有资源泄漏。这是最低的合理要求。
- 强保证(Strong guarantee):如果抛出异常,程序状态完全回滚到操作调用前的状态。就像这个操作从来没发生过一样。这通常通过“拷贝-交换”(copy-and-swap)惯用法或事务语义来实现。
- 不抛异常保证(Nothrow guarantee):操作保证永远不会抛出异常。例如,析构函数、移动操作、
swap函数通常被期望提供这个级别的保证。
4.2 实现强保证的经典模式:“拷贝-交换”
假设我们有一个管理动态数组的类,我们想实现一个可能失败的update操作,并希望它是强异常安全的。
class MyArray { public: // ... 构造函数、析构函数、拷贝控制成员 ... void update(const std::vector<int>& newData) { // 方案一(弱保证):直接修改,如果中途异常,m_data可能损坏。 // m_data.clear(); // for (auto& elem : newData) { m_data.push_back(elem); } // push_back可能抛bad_alloc // 方案二(强保证):拷贝-交换 MyArray temp(*this); // 1. 拷贝构造一个副本。如果失败,原对象*this完全不变。 temp.m_data.assign(newData.begin(), newData.end()); // 2. 在副本上执行所有可能失败的操作。 swap(*this, temp); // 3. 与副本交换。swap通常应提供nothrow保证。 } // 4. 离开时temp析构,清理旧数据。 friend void swap(MyArray& a, MyArray& b) noexcept { // noexcept 关键! using std::swap; swap(a.m_data, b.m_data); // 交换其他成员... } private: std::vector<int> m_data; };原理:所有可能抛出异常的操作都在一个临时副本上进行。只有所有操作都成功了,我们才通过一个不会失败的swap操作,将修改“提交”到原对象。如果任何一步失败,异常会传播出去,而原对象*this始终保持不变。
注意事项:swap函数必须标记为noexcept。这是C++11后移动语义和标准库容器(如std::vector)能够高效工作的关键前提之一。如果一个swap可能抛出异常,那么很多标准库操作(如vector的重新分配)将无法提供强异常安全保证。
4.3 实战中的异常安全策略
不是所有函数都需要强保证,这需要权衡实现的复杂性和性能开销。
- 对于关键的数据结构操作(如账户转账、配置更新),应力求强保证。使用“拷贝-交换”或借助第三方库实现事务。
- 对于资源管理类(如智能指针、文件句柄包装类),其核心操作(析构、资源释放)必须提供不抛异常保证,并且自身应该是异常中立的(即不吞掉内部操作抛出的异常,除非能完全处理)。
- 对于性能敏感的底层操作,可能只提供基本保证,但必须在文档中明确说明。调用者需要知晓风险。
- 一个通用技巧:先修改,后清理。对于某些操作,可以先在“新的地方”完成所有工作(可能分配新资源),然后再释放旧资源。这样即使新操作失败,旧状态依然完整。
void appendData(std::vector<int>& vec, const std::vector<int>& newData) { size_t oldSize = vec.size(); vec.reserve(oldSize + newData.size()); // reserve可能抛异常,但此时vec内容未变。 try { for (auto val : newData) { vec.push_back(val); // push_back在reserve后通常不会重新分配,但拷贝构造可能抛异常。 } } catch (...) { // 如果发生异常,vec.size()可能已经部分增加。 // 我们需要将vec恢复到调用前的状态。 vec.resize(oldSize); // 假设resize不抛异常(对于标准容器,基本保证) throw; // 重新抛出异常 } }5. 现代C++中的异常规范:noexcept的正确打开方式
C++11引入了noexcept说明符和运算符,它取代了旧的、行为复杂的throw()动态异常规范。noexcept是编译时信息,主要影响编译器优化和标准库行为。
5.1 何时使用noexcept
移动构造函数和移动赋值运算符:这是最重要的使用场景。标准库容器(如
std::vector)在重新分配内存时,如果元素的移动操作是noexcept的,它会使用更高效的移动而非拷贝。这能显著提升性能。class MyType { public: MyType(MyType&& other) noexcept // 标记为noexcept : data(std::move(other.data)) {} MyType& operator=(MyType&& other) noexcept { data = std::move(other.data); return *this; } private: std::vector<int> data; };交换函数
swap:如前所述,为了实现强异常安全,swap通常应该是noexcept的。析构函数:析构函数默认就是
noexcept的。除非你明确知道它可能抛出异常(这通常是个坏设计),否则不要改变它。如果析构函数抛出异常且未被捕获,程序会直接调用std::terminate。确实不会失败的操作:例如,获取一个对象的当前大小、返回一个内置类型的值等。
5.2noexcept运算符与条件性noexcept
noexcept还可以作为一个运算符,在编译期判断一个表达式是否可能抛出异常。
void myFunc() noexcept(noexcept(otherFunc())) { // 这个函数是否是noexcept的,取决于otherFunc()是否是noexcept的。 otherFunc(); }这在编写泛型代码时非常有用,可以让你根据模板参数的属性来决定函数的异常规范。
重要警告:如果你将一个函数声明为noexcept,但它实际上抛出了异常,程序会直接调用std::terminate()终止运行,而不是去栈展开和寻找catch块。因此,不要轻易对可能失败的操作使用noexcept。只有当你能从逻辑上百分之百确定操作不会失败,或者失败的成本(程序终止)可以接受时,才使用它。
5.3 异常与性能开销的迷思
很多人担心异常处理会带来巨大的运行时开销。这个观点在二十年前可能更成立。现代C++编译器和运行时对异常处理做了大量优化。
- 无异常抛出时的开销(零成本抽象):在正常执行路径(不抛出异常)上,主流编译器(如GCC、Clang、MSVC)的实现通常接近零开销。它们使用一种叫做“表格驱动”的机制,异常处理信息存储在程序单独的段中,不影响正常代码的执行速度。
- 抛出异常时的开销:这确实比返回一个错误码要昂贵得多,因为它涉及栈展开和查找匹配的
catch块。但这正是关键所在:异常是为“异常”情况设计的,是那些发生频率很低、但一旦发生就需要跨多层函数进行处理的错误。对于这种场景,性能开销是次要的,程序的健壮性和代码的清晰度才是首要的。 - 平衡之道:在性能极度敏感的循环内部(比如每帧调用数万次的图形渲染函数),或者与不支持异常的语言(如C)交互的边界上,可以考虑禁用异常(编译器选项如
-fno-exceptions)或使用错误码。但在大部分业务逻辑、模块边界、资源初始化等场景,异常是更优的选择。
我的经验是:不要因为对性能的模糊恐惧而拒绝异常。首先用异常写出清晰、安全的代码。然后,当性能分析(Profiling)工具明确告诉你异常处理是瓶颈时,再去考虑优化那部分特定代码。
6. 异常处理实战:从项目配置到调试技巧
6.1 在VSCode等IDE中配置异常感知的调试环境
以VSCode配合GCC/Clang或MSVC为例,要让调试器在抛出异常时自动中断,便于你第一时间定位问题。
对于GDB(Linux/macOS下的GCC/Clang):在项目的.vscode/launch.json文件中,配置setupCommands:
{ “version”: “0.2.0”, “configurations”: [ { “name”: “(gdb) Launch”, “type”: “cppdbg”, “request”: “launch”, “program”: “${workspaceFolder}/build/your_program”, “args”: [], “stopAtEntry”: false, “cwd”: “${workspaceFolder}”, “environment”: [], “externalConsole”: false, “MIMode”: “gdb”, “setupCommands”: [ { “description”: “为 gdb 启用整齐打印”, “text”: “-enable-pretty-printing”, “ignoreFailures”: true }, { “description”: “在抛出异常时中断”, “text”: “catch throw” // 这行是关键! } ] } ] }这样,当程序执行throw语句时,调试器就会自动暂停,你可以查看完整的调用栈和变量状态。
对于Windows下的MSVC:在VSCode中使用MSVC调试器时,可以在launch.json中配置:
{ “name”: “(Windows) Launch”, “type”: “cppvsdbg”, “request”: “launch”, “program”: “${workspaceFolder}/out/your_program.exe”, “args”: [], “stopAtEntry”: false, “cwd”: “${workspaceFolder}”, “environment”: [], “externalConsole”: false, “visualizerFile”: “${workspaceFolder}/my.natvis” // 可选,自定义可视化工具 }MSVC调试器通常默认就会在未捕获的异常处中断。你可以在VSCode的“调用栈”视图附近找到“异常设置”按钮,可以更精细地配置中断条件(如特定异常类型)。
6.2 常见异常问题排查与解决实录
即使理解了原理,实战中还是会遇到各种诡异问题。下面是我踩过的一些坑和解决方法。
问题1:异常被莫名其妙地“吞掉”,程序无声无息地崩溃或行为异常。
可能原因A:线程函数未捕获异常。C++标准规定,如果异常从线程函数的初始函数(如
std::thread构造时传递的函数)抛出且未被捕获,程序会调用std::terminate()。这通常表现为程序突然崩溃,日志里没有异常信息。- 解决:在线程函数的顶层用
try-catch(...)包裹所有代码,并在catch块中记录日志。
void threadWorker() { try { // 所有工作逻辑 } catch (const std::exception& e) { std::cerr << “线程异常: ” << e.what() << std::endl; } catch (...) { std::cerr << “线程未知异常” << std::endl; } }或者使用
std::async并获取其返回的std::future,异常会存储在future中,当调用future.get()时会重新抛出。- 解决:在线程函数的顶层用
可能原因B:析构函数中抛出了异常。如果栈展开过程中,某个局部对象的析构函数又抛出了异常,而此时已有异常在传播,程序会直接调用
std::terminate()。- 解决:确保析构函数绝不抛出异常。如果析构函数中的操作可能失败(如关闭文件失败、网络连接断开通知失败),请用
try-catch(...)在析构函数内部处理掉并记录日志,不要让它传播出去。
- 解决:确保析构函数绝不抛出异常。如果析构函数中的操作可能失败(如关闭文件失败、网络连接断开通知失败),请用
问题2:捕获异常后,程序状态似乎不对,某些对象看起来被部分修改了。
- 可能原因:异常安全级别不足。函数只提供了基本保证或无保证,异常发生在操作中途。
- 排查:仔细审查抛出异常的那个函数,以及它调用的所有函数。确认它们是否提供了你期望的异常安全保证(基本、强或无)。查看关键数据结构的修改是否在异常发生后仍保持一致。
- 解决:重构代码以提供更强的异常安全保证。使用RAII管理所有资源。对于复杂操作,考虑使用“拷贝-交换”模式或将其拆分为多个提供强保证的原子操作。
问题3:使用第三方库或系统API时,它们使用错误码,如何与我的异常体系集成?
- 解决:在边界层进行转换。创建一个薄薄的包装层,将错误码转换为异常。
这样,你的核心业务逻辑就可以完全基于异常来编写,享受其带来的清晰度,而将底层的错误码处理隔离在边界模块中。// 假设有一个C风格的数据库库 extern “C” { int db_query(DBHandle* handle, const char* sql, DBResult** result); const char* db_error_message(DBHandle* handle); } class Database { public: DBResult query(const std::string& sql) { DBResult* rawResult = nullptr; int error = db_query(m_handle, sql.c_str(), &rawResult); if (error != DB_SUCCESS) { std::string errMsg = db_error_message(m_handle); // 将错误码和消息转换为自定义异常 throw DatabaseException(“查询失败”, error, errMsg, sql); } return DBResult(rawResult); // 用RAII包装 } };
问题4:异常导致性能下降,特别是在频繁调用的关键路径上。
- 排查:使用性能分析工具(如perf, VTune, 各种Profiler)确认瓶颈确实在于异常处理。通常,真正的瓶颈在于算法复杂度、不必要的拷贝、缓存不友好等处。
- 优化:如果证实异常是瓶颈,考虑以下方法:
- 将异常改为错误码:仅限于此函数及其直接调用者之间。确保调用者会立即检查错误码。
- 使用
std::optional或std::expected(C++23):作为返回可能失败结果的另一种方式。std::optional可以表示“有值”或“无值”,适合简单的失败情况。std::expected则能同时携带结果值和错误信息,功能更强大。 - 重新设计接口:也许这个函数不应该失败,或者失败的情况可以通过前置检查来避免。例如,在尝试分配大块内存之前,先检查可用内存量(虽然这不完全可靠)。
6.3 异常处理的最佳实践清单
最后,把我个人总结的几条核心原则列出来,方便大家自查:
- 能用异常,就用异常:对于大多数应用层和库的边界错误,异常比错误码更清晰、更安全。
- 用RAII管理一切资源:这是写出异常安全代码的基石。内存用智能指针,文件用
ifstream/ofstream,锁用lock_guard,网络连接用自定义的RAII包装类。 - 异常对象按值抛出,按const引用捕获:
throw MyException();和catch (const MyException& e)。 - 自定义异常,丰富信息:继承
std::exception,添加时间、地点、错误码等上下文。使用宏简化抛出。 catch块从具体到抽象:先捕获派生类异常,最后用std::exception&和...兜底。- 不要在析构函数中抛出异常。
- 为移动操作、
swap、确实不失败的操作标记noexcept。 - 在模块/线程边界处理所有异常:防止异常逃逸到你不了解的上下文中。
- 异常是给程序员看的,不是给用户看的:
what()返回的信息应该有助于调试。给用户的错误信息应该是友好、本地化的,可能在捕获异常后重新生成。 - 记录,记录,再记录:在关键的
catch块中,一定要将异常信息记录到日志系统,这是线上问题排查的生命线。