Rust函数中多类型返回错误的最优处理方案探讨
我在思考错误处理的多种可行方案时写下了这个问题。
我认为最初的版本对他人帮助不大,但如果你感兴趣,可以查看编辑历史了解详情。
我于2024年3月重新梳理了这个问题,使其更简洁,希望能更具实用性。
Rust中的一个函数可能需要调用多个来自不同库的函数,这些函数会通过Result类型返回错误或成功状态。
正如你所知,Result对象接受两个泛型参数(类型)。
这使得处理调用栈任意层级中函数调用树可能返回的多种类型变得复杂。
具体来说:一个函数可能返回某种类型表示错误状态,它还可能调用其他同样会返回错误状态类型的函数。因此,任何调用其他函数的函数都存在一组可能的返回类型,这在错误处理中尤为重要。
在其他语言中,这些错误返回类型可能会被动态处理。Rust也支持这种方式,但开发者并非必须采用。
由于我们必须处理一组类型,因此必须采取措施协调不同的错误类型。
以下伪代码演示了这个问题:函数调用两个返回不同错误类型的函数。
fn someNewLibraryFunction() -> Result<OkType, Undecided> { let first_result: Result<Something, ErrorType1> = tryToDoThis(); match first_result // ... // etc --> return Undecided; let second_result: Result<SomethingElse, ErrorType2> = tryToDoThat(first_result); match second_result // ... // etc --> return Undecided; // If we got to here, compute result and return Ok return Ok(SomeOkType); }
我不再像之前那样详述所有方案,而是总结最合理的几种。
为清晰理解现状,回顾解决该问题的可行工程策略会有所帮助。
记录所有错误,不返回任何信息:
这是最简单的方案,实现起来非常容易。
我们通过记录错误并丢弃的方式处理所有返回的错误,可以使用println!或其他更复杂的日志方法。
除原型开发阶段外,我们通常不会选择这种方案。
由于没有返回错误类型,该方案解决了问题。或者,我们可以返回Err(())来指示发生了错误,但不包含更多信息。将所有错误消息转换为统一类型,例如String:
基于上一种方案,我们可以将所有错误强制转换为字符串类型并返回Err(message),而非不返回错误信息。
该方案通过返回统一类型解决问题,与第一种方案不同,我们可以保留错误的部分信息。
该方案有以下优缺点:
- 优点: 调用者可以决定错误发生时的处理方式
- 优点: 我们仍可根据错误情况实现分支逻辑
- 缺点: 分支逻辑依赖字符串比较,这种解决方案低效且不够优雅,不具备可扩展性和健壮性
- 缺点: 我们没有类型信息,而类型信息是分支逻辑的更优方案
- 缺点: 返回
String作为错误类型无法向调用者明确表明接收的是错误,因此容易被误用
注:分支指if或match之类的逻辑
- 将预期接收的所有错误类型转换为单一类型:
该方案有两种实现方向:
- 将所有错误转换为单一类型
- 或者创建一个新的
enum,为每个可能的错误来源定义变体 - 错误处理的程度,以及将哪些错误分组为新的表示形式可能有所不同
这是更具Rust风格的方案。
假设你使用多个库构建系统,库的结构呈金字塔层级。
你正在开发位于另外两层之间的某个库层,该库调用一个或多个底层库的函数,同时被上层库调用。
该库调用的函数可能返回多种不同的错误类型。
尽可能处理这些错误。
当无法处理时,将现有错误类型转换为你的库层内的统一错误类型,即所有可能发生的错误都映射到单一类型。
如上所述,这个新类型可以是单一类型,也可以是包含多个变体的enum类型,后者的变体集合对应我们希望向上层代码报告的错误类型。
无论选择哪种方式,你都将多个来源的多种错误类型简化为一种更简单的类型。
与上一种方案类似,如果选择将所有错误映射到单一类型,我们可能会丢失错误确切来源的特定类型信息。
如果选择使用包含多个变体的enum,则不一定会丢失错误来源的信息。
请注意:不应简单创建一个包裹所有可能接收的错误类型的新enum。例如,如果底层代码返回SpecialMathLibraryError,上层代码无需知晓SpecialMathLibraryError类型,而是应将SpecialMathLibraryError转换为与你编写的库层相关的新类型。
虽然简单地将底层错误类型包裹到不断膨胀的enum中看似更容易,但我强烈建议不要这样做。这会因依赖底层代码抛出的错误类型信息,导致代码各层级间产生强耦合。
该方案存在一个显著缺点:与采用更动态方案的其他语言相比,这种方法会产生大量维护开销。如果需要调整软件架构,所需的修改耗时很长。由于API本质上发生了变化,所有调用函数都必须适配这种破坏性API变更。修改表示错误的enum的可能返回类型集合也是一项耗时的工作,会带来维护开销。
- 优点: 我们可以在类型中存储额外数据。如果是enum类型,我们可以根据其多个变体实现分支逻辑
- 缺点: 需要实现将现有错误类型转换为新类型的逻辑,可以使用
Fromtrait - 缺点: 可能会将一些不相关的错误类型合并为单一新类型,可能是enum的一个变体
- 缺点: 维护开销显著增加,我们必须管理一个全新的
enum类型 - 优点: 如果选择保留,我们可以维护错误来源的原始信息
这还会造成维护混乱,因为如果函数发生变化,我们需要处理的错误类型也可能变化。这意味着我们需要修改整个enum、函数逻辑以及转换的样板代码。
- 使用动态分发:
以下示例展示了如何实现类似try-catch的功能,它根据向下转换为原始错误类型是否成功进行运行时分支,也可以使用error.is::<T>()实现相同功能。
fn someNewLibraryFunction() -> Result<(), Box<dyn Error>> { return Err(Box::new(MyError1{})); } fn main() { let result = someNewLibraryFunction(); match result { Err(error) => { if let Some(downcasted) = error.downcast_ref::<MyError1>() { println!("It is MyError1 type"); } else if let Some(downcasted) = error.downcast_ref::<MyError2>() { println!("It is MyError2 type"); } else { println!("Could not downcast the error"); } }, Ok(_) => { }, } }
与其他语言中的try-catch类似,调用者有责任了解被调用函数可能“抛出”的错误类型,并选择为每个错误类型实现处理逻辑或不处理。最后的else可以作为catch-all(捕获所有)或catch-any(捕获任意)语句。
遗憾的是,Rust中没有明确的最佳错误处理代码编写方式。
面向对象语言的经验会建议运行时动态方案是最优的,它的优点是维护相对轻松,实现速度较快。所谓的“缺点”是你必须主动去了解可能的错误类型——编译器不会主动告知你(而使用enum方案时编译器会)。
考虑到动态分发方法在几乎所有主流工业级语言(C#、C++、Python、Java等)中都很通用,我并不认为这是一个严重的缺点。
如果有其他替代方案,欢迎补充。
内容的提问来源于stack exchange,提问作者user2138149

