|
|
讨论现代 C++ 的异常安全与 RAII,绕不开一个共识:资源管理应该与对象生命周期绑死,而不是靠人品。异常只是让控制流绕路,不能让资源泄漏、状态破坏、或者让接口语义变得暧昧。RAII 的意义正在这里:把“必须做的事”放进析构,把“可能失败的事”放进构造或工厂。
先说异常安全的三个级别:基本保证(Basic)、强保证(Strong)、无抛出保证(No-throw)。大部分库代码至少要做到基本保证,即使抛异常也不破坏 invariants;修改型操作尽量争取强保证,比如先在临时对象上完成,再 commit;而析构、swap、移动操作等关键路径争取 no-throw。一个经验法则:只要对象一旦处于被管理状态,其析构就不应再抛异常,否则你会在栈展开时撞车。
RAII 的落点,不只是内存,还有文件句柄、互斥锁、事务、socket、临时状态切换等。比如用 std::unique_ptr 管理裸指针,用 std::lock_guard 保证加锁-解锁成对,用 gsl::finally 或自写小工具处理“临时关闭日志、结束后恢复”这种细粒度状态。记住:new/delete、lock/unlock、open/close 这种成对 API,统统交给对象。现代 C++ 已经给出丰富拼装件:unique_ptr、shared_ptr、scoped_lock、std::jthread、std::span、std::filesystem::path 等,把它们组合起来比手搓 try/catch 稳得多。
有人会问:那我是不是应该在业务层“到处抛异常、底层负责兜底”?我的看法是分层处理:边界明确的错误(比如用户输入、网络超时)可以用 expected 风格的返回值,让调用方明显地看到分支;真正违反假设的情况(比如不变量破坏、内存不足)才用异常跨越边界。可惜标准库里的 std::expected 还没进正式发布之前,很多团队用 tl::expected 或自研类型替代,这种“显式错误通道”与 RAII 并不冲突,反而让异常只承担“罕见且需要非局部跳转”的职责。相关讨论可以看 https://wg21.link/P0323R 最终提案脉络。
实现层面有几个易踩坑。第一,构造函数里做复杂操作要么保证强异常安全,要么把重活放到工厂函数里,这样能用返回值做错误分支;第二,移动构造和移动赋值尽量 noexcept,这能解锁容器的强异常保证及更优路径;第三,写自定义资源守卫时遵循单一职责,只管理一种资源,并提供显式的 release/commit,避免“析构里做可失败操作”,可失败的动作应提前完成,把析构退化为纯清理;第四,异常中立性:模板和通用库不要吞异常,保持异常按原路传播。
代码结构上,我常用“三段式变更”:prepare -> operate -> commit。准备阶段申请资源、构建临时对象;操作阶段在临时上完成所有可失败步骤;最后一次性提交,把对象交换到目标状态。比如对容器做批量更新,先在本地副本上完成,全部成功后用无抛出的 swap 替换,这天然提供强保证。配合小而专的 RAII 类(比如 FileGuard、TxnGuard)能把失败路径压缩成“自动回滚”的语义。
最后谈团队实践:统一策略,规定哪些模块允许抛异常、哪些返回 expected;规定“析构不得抛”“移动应 noexcept”;为资源写最小可用守卫类,禁止直接裸用系统句柄;配一套静态分析和 sanitizers,辅以故障注入测试验证异常路径。异常安全不是语言魔法,而是约定、抽象和纪律的组合。当你把资源绑定到对象、生效绑定到作用域、失败绑定到类型,你的 C++ 代码就从“能跑”迈向“可证”。 |
|