C++错误处理经历了从C风格错误码到异常,再到混合模式的演进。这场“战争”没有赢家——两种方式各有适用场景,C++哲学是提供选择而非强制统一。理解异常和错误码的权衡,对于设计健壮的API至关重要。
参考:https://detxg.cn/category/medicinal-recipes.html
C风格错误码:函数返回错误码(如-1、EINVAL),通过全局变量errno传递详细信息。调用者必须检查返回值,否则错误被忽略。错误码的优点是明确、无运行时开销、控制流清晰。缺点是错误处理代码充斥主逻辑,容易遗漏检查,返回值无法用于计算结果(需要输出参数)。
C++异常:异常将错误处理与正常逻辑分离。函数抛出异常,调用者可以捕获。异常携带类型和信息,可以沿调用栈向上传播。异常的好处是错误处理集中化、错误类型丰富、构造函数可以报告失败。缺点是运行时开销(即使不抛出)、控制流隐式化、以及异常安全问题。
性能比较:异常在未抛出时几乎零开销(现代实现使用零开销异常表)。抛出时的成本较高(几百到几千CPU周期),因为需要栈展开和查找catch块。错误码的成本始终存在(每次调用检查返回值)。因此,异常适用于错误率低的场景,错误码适用于高频错误场景(如循环中的分配失败)。
异常安全:异常安全代码必须保证抛出异常时不泄漏资源、不破坏不变量。RAII是异常安全的基础——智能指针、锁守卫在栈展开时自动释放资源。异常安全级别:不抛出保证(noexcept)、强保证(操作回滚)、基本保证(资源不泄漏,对象有效)、以及无保证(可能崩溃)。
noexcept承诺:noexcept函数承诺不抛出异常。违反承诺时调用std::terminate。移动构造函数、析构函数、swap等函数应该noexcept,以便标准库使用更高效的实现(如vector重新分配时移动而非拷贝)。
参考:https://detxg.cn/category/seasonal-health.html
错误码的现代化:C++17引入std::optional,表示值或空。C++23引入std::expected,表示值或错误码。expected是错误码的改进版本——强制检查错误(通过error()或value()),支持链式操作(and_then、or_else)。expected与异常互斥,适合需要高性能、显式错误处理的场景。
混合策略:在大型系统中,常见模式是:内部使用异常,边界处转换为错误码。例如,库内部使用异常简化逻辑,API边界捕获异常返回错误码,避免异常跨越模块边界(因为异常在不同编译器/运行时可能不兼容)。
异常与构造函数:构造函数不能返回错误码,失败时只能抛出异常(或设置标志位,但这是反模式)。工厂函数可以返回optional或expected,但无法避免两阶段初始化。
异常与析构函数:析构函数不应抛出异常。如果析构中需要清理资源且可能失败,提供close或flush函数让用户显式调用,析构函数忽略或记录错误。
异常与性能关键代码:在游戏、金融、嵌入式等低延迟领域,异常通常被禁用。Google的C++风格指南禁止使用异常,LLVM也禁用。这些项目使用错误码和断言。
参考:https://detxg.cn/category/food-therapy-basics.html
异常的类型:标准库异常层次:std::exception(基类)、std::runtime_error(运行时错误)、std::logic_error(编程错误)。自定义异常应该继承自std::runtime_error或std::logic_error。what()返回描述字符串,但不建议用于控制流。
线程中的异常:线程函数抛出异常会调用std::terminate。使用std::promise和std::future可以将异常从工作线程传递到主线程。
异常与模板:异常与模板兼容,但异常类型在实例化时才确定。模板代码通常传播异常,不捕获。
最佳实践:
对于可能失败的构造函数,使用异常。
对于析构函数、swap、移动操作,标记noexcept。
在性能关键路径且错误频繁时,使用expected。
在模块边界(如动态库API),使用错误码。
使用RAII管理资源,避免裸new/delete。
捕获异常时使用引用(catch(conststd::exception&e)),避免对象切片。
C++的错误处理没有银弹。异常和错误码共存,开发者需要根据上下文选择。理解两者的权衡,是编写健壮C++代码的关键。
参考:https://detxg.cn