深入 Ruby 18:异常、ensure 与 throw/catch
清理完成,为什么失败却消失了
任务导入函数打开文件、解析记录,最后释放资源。给函数加上 ensure 看似只改变了清理过程,下面的写法却改变了调用者看到的结果:
1 | |
ArgumentError 没有到达调用者。清理子句里的显式 return 产生新的返回动作,取代了原本向外传播的异常。这个例子说明,审阅异常处理不能只问“有没有执行清理”,还要追踪离开当前方法时携带的是正常值、异常,还是其他控制转移。
本篇前置是方法返回、block 的非局部控制流和对象调用。实验使用 CRuby 3.4.11,完整脚本在 examples/ruby/labs/18/run.rb。核心问题限于同一线程内的栈展开;线程异步异常与取消策略属于后续并发章节。
一个表达式的四条出口
把各子句记录成事件,比只打印一句“捕获成功”更容易检查顺序:
1 | |
成功路径先执行主体,再执行 else,最后执行 ensure。异常路径中匹配到的 rescue 处理错误,跳过 else,同样执行 ensure。这里的返回值分别是 :accepted 与 :recovered,:ignored 只是清理子句计算出的普通值。
else 适合放“主体成功之后才允许做”的工作。它还有一个容易忽略的作用:把成功后的处理从被保护主体中分离出来。如果主体读取输入,else 更新统计,那么统计实现抛出的错误不应被同一个 rescue ArgumentError 当成输入错误处理。缩小捕获范围可以保留这一区别。Ruby 的 异常语法说明 定义了这些子句的关系;事件断言则让具体调用的路径可见。
如果主体执行了 return,else 不承担成功回调的职责;控制已经准备离开方法。资源释放应放在 ensure 或资源 API 的 block 中,不能靠 else 猜测“没有报错就一定执行到这里”。同样,ensure 中再次 raise 会使新的异常成为当前失败,通常可以通过原因链追溯旧异常,却改变了调用者最先收到的异常类型。
捕获范围由异常类决定
无类型的 rescue 默认捕获 StandardError 及其子类。ArgumentError、TypeError、NoMethodError 都在这个范围内。Exception 是更上层的基类;SystemExit 不属于 StandardError。因此把捕获范围从默认范围扩到 Exception,可能拦截原本应终止程序的控制事件。
实验通过 SystemExit 对象观察这个区别,不终止整个验证程序:
1 | |
外层显式捕获是测试隔离手段。它证明内层裸 rescue 没有吞掉退出请求,不意味着应用应该随处捕获退出。实际 CLI 最外层可以把明确的输入异常转换成退出码;库代码则继续抛出错误,让调用方决定如何展示。
多个 rescue 从上到下寻找第一个匹配项。把 StandardError 放在 ArgumentError 前面,会使后面的窄分支失去机会。继承层次表达的是分类,捕获顺序表达的是处理优先级,两者需要一起审查。
默认范围也不等于理想范围。导入器若把整个方法包在 rescue StandardError 下,内部拼错方法名引发的 NoMethodError 会被伪装成“用户输入错误”。具体的解析器异常应在解析边界转换,其他程序缺陷保留原始栈。异常处理越靠近可恢复动作,越容易解释恢复成立的条件。
原因链保存失败的上下文
底层整数转换失败,上层可以补充任务导入的语义,同时保留底层原因:
1 | |
这里的两个异常对象承担不同的信息:外层说明哪个操作失败,cause 说明这个失败由什么触发。只把 error.message 拼接进新字符串,会把结构化关系压成文本;只返回 nil,则连失败是否发生都需要调用方猜测。Exception 文档 描述了原因对象和回溯信息。
需要原样传播时,rescue 中的无参数 raise 比新造一个相同消息的异常更直接。需要更换抽象层次时,转换异常是合理选择,但应明确外层异常的分类、保留原因,并避免把机密输入写进对外消息。Taskbook 的库层主要采用 ArgumentError 表示不满足输入契约;CLI 负责把这类错误映射为用户可读错误和非零退出。
retry 会重新进入被保护主体,绝不保证主体先前的副作用被撤销。如果主体已经写入一条记录,再在下一条记录失败,直接重试可能重复写入。一个可重试过程至少需要失败条件可能改变、次数有上限、已经发生的动作允许重复。这些条件无法从一个 rescue 关键字推导出来。
throw 与异常分类没有继承关系
Ruby 的 throw 可以向动态调用链上的匹配 catch 传递一个值。它适合已有明确成功条件的多层遍历退出:
1 | |
这里得到 2 是一次约定好的控制转移,不需要构造错误对象。名称和其他语言的异常语法相似,不能据此把它等同于 raise。catch 匹配的是标签,rescue 匹配的是异常类;它们服务于不同协议。
没有匹配 catch 时,throw 会产生 UncaughtThrowError。实验也断言了这个失败,避免把漏写标签接收端误认为正常搜索结果。复杂调用图中如果反复使用通用符号标签,还要避免互不相关的代码碰巧约定了相同标签。局部唯一对象作为标签可以缩小误匹配机会,但普通的一层循环直接使用 find 往往更清楚。
清理过程会参与这类栈展开。也就是说,throw 并非绕过 ensure 的捷径。然而不能把 Ruby 层的清理语义扩大成“任何终止都保证清理”:进程被不可捕获信号终止、调用绕开正常退出流程的操作,或机器掉电,都超出了普通栈展开可以完成的范围。
从失败路径反推方法的职责
资源拥有者负责释放资源,业务方法负责保持业务结果,程序入口负责决定进程退出。这三个职责可以出现在相邻调用层,却不应通过一个巨大的捕获块混在一起。文件在库里打开,库就用 block 管住其生命周期;调用者传入流,库一般不擅自关闭。业务方法应传播可分类错误,CLI 才打印消息。
返回 nil、返回结果对象、抛异常都可以成为公共契约,关键是调用方能否区分“任务不存在”“输入非法”“程序缺陷”。本系列对输入非法采用异常,空筛选结果仍是合法空数组。这样,一个失败的解析不会和“没有符合条件的任务”产生相同结果。
恢复动作必须真的建立可继续的状态
异常处理分支返回了一个值,不代表原操作已经恢复。如果读取任务失败后返回空数组,统计函数仍会生成一个结构正确的零任务报告。调用链没有崩溃,但数据含义已经改变。只有业务允许“读取失败等同于空清单”时,这种转换才成立;通常应该继续传播失败,让调用方决定是否使用旧报告或结束命令。
恢复还可能需要补偿。向两个输出依次写入时,第二次失败不会自动撤销第一次写入。捕获异常之后重试整个方法,可能在第一个输出中重复记录。异常机制负责控制流,事务或幂等协议负责状态,这两个问题不能交给同一个关键字。设计恢复分支时,应列出异常发生前哪些动作可能已经完成,再逐一判断它们是否可重复。
异常消息也不该成为程序分支的唯一依据。通过字符串包含“timeout”来决定重试,容易受文案、版本或底层语言改变影响。公开异常类或明确结果代码更适合稳定分类。消息用于解释,类别用于控制,原因链用于追溯,回溯用于定位;把它们各自保留下来,调用方才有足够信息选择策略。
清理失败则存在双重错误:业务主体失败,关闭也失败。如果关闭错误覆盖主体,调用方可能只看到磁盘关闭问题;如果关闭错误被无声忽略,又丢失资源异常证据。小型工具可以保留主体异常并将清理失败记录到单独诊断通道,大型系统可以定义聚合错误。采用哪种方式取决于公共合同,但不应在 ensure 里随意返回“成功”来结束讨论。
对调用方来说,最有用的文档不是罗列所有可能异常类,而是说明哪些失败可以修正输入后重试、哪些可能已经产生副作用、哪些属于程序缺陷。Taskbook 的整批校验在导出前完成,因此字段错误不会先产生半份 stdout;这个顺序约束比一句笼统的“具有异常处理”更具体。
运行与练习
在 examples/ruby 下运行:
1 | |
实验判定包含正常路径、捕获路径、清理顺序、清理覆盖、异常层次、原因链和未匹配标签。任一条件变化都会抛异常并返回非零退出码;成功行以 PASS 18 开头。原始运行记录在 examples/ruby/evidence/18/run.txt,该记录只证明上述受控场景。
练习一:把 path 的成功主体改为显式 return :body_value,先预测 else 是否执行,再修改事件断言验证预测。练习二:让清理过程抛出另一个异常,检查调用方得到的异常类与 cause,随后把清理错误处理改成既不丢失业务失败、又能记录关闭问题的明确策略。
系列导航
导读 · 上一篇:17:DSL、instance_eval 与 class_eval:配置怎样获得执行上下文 · 下一篇:19:文件、IO 与路径的资源边界 · 完整源码包
