作者:Andrea Bergia
译者:豌豆花下猫 @Python 猫
英文:Error handling patterns
转载请保留作者及译者信息!
错误处理是编程的一个基本要素。除非你写的是“hello world”,否则就必须解决代码中的谬误。在本文中,我将探讨各种编程语言在处理错误时应用的最常见的四种办法,并剖析它们的优缺点。
关注不同设计方案的语法、代码可读性、演变过程、运行效率,将有助于咱们写出更为优雅和强壮的代码。
返回错误代码
这是最古老的策略之一——如果一个函数可能会出错,它能够简略地返回一个错误代码——通常是正数或者null
。例如,C 语言中常常应用:
FILE* fp = fopen("file.txt" , "w");
if (!fp) {// 产生了谬误}
这种办法非常简单,既易于实现,也易于了解。它的执行效率也十分高,因为它只须要进行规范的函数调用,并返回一个值,不须要有运行时反对或分配内存。然而,它也有一些毛病:
- 用户很容易遗记处理函数的谬误。例如,在 C 中,printf 可能会出错,但我简直没有见过程序查看它的返回值!
- 如果代码必须解决多个不同的谬误(关上文件,写入文件,从另一个文件读取等),那么传递谬误到调用堆栈会很麻烦。
- 除非你的编程语言反对多个返回值,否则如果必须返回一个有效值或一个谬误,就很麻烦。这导致 C 和 C++ 中的许多函数必须通过指针来传递存储了“胜利”返回值的地址空间,再由函数填充,相似于:
my_struct *success_result;
int error_code = my_function(&success_result);
if (!error_code) {// can use success_result}
家喻户晓,Go 抉择了这种办法来处理错误,而且,因为它容许一个函数返回多个值,因而这种模式变得更加人性化,并且十分常见:
user, err = FindUser(username)
if err != nil {return err}
Go 采纳的形式简略而无效,会将谬误传递到调用方。然而,我感觉它会造成很多反复,而且影响到了理论的业务逻辑。不过,我写的 Go 还不够多,不晓得这种印象当前会不会改观!😅
异样
异样可能是最罕用的错误处理模式。try/catch/finally
办法相当无效,而且应用简略。异样在上世纪 90 年代到 2000 年间十分风行,被许多语言所采纳(例如 Java、C# 和 Python)。
与错误处理相比,异样具备以下长处:
- 它们天然地区分了“高兴门路”和错误处理门路
- 它们会主动从调用堆栈中冒泡进去
- 你不会遗记处理错误!
然而,它们也有一些毛病:须要一些特定的运行时反对,通常会带来相当大的性能开销。
此外,更重要的是,它们具备“深远”的影响——某些代码可能会抛出异样,但被调用堆栈中十分远的异样处理程序捕捉,这会影响代码的可读性。
此外,仅凭查看函数的签名,无奈确定它是否会抛出异样。
C++ 试图通过throws
关键字来解决这个问题,但它很少被应用,因而在 C++ 17 中已被弃用,并在 C++ 20 中被删除。尔后,它始终试图引入 noexcept 关键字,但我较少写古代 C++,不晓得它的风行水平。
(译者注:throws 关键字很少应用,因为应用过于繁琐,须要在函数签名中指定抛出的异样类型,并且这种办法不能解决运行时产生的异样,有因为“未知异样”而导致程序退出的危险)
Java 曾试图应用“受检的异样(checked exceptions)”,即你必须将异样申明为函数签名的一部分——然而这种办法被认为是失败的,因而像 Spring 这种古代框架只应用“运行时异样”,而有些 JVM 语言(如 Kotlin)则齐全摈弃了这个概念。这造成的后果是,你根本无法确定一个函数是否会抛出什么异样,最终只失去了一片凌乱。
(译者注:Spring 不应用“受检的异样”,因为这须要在函数签名及调用函数中显式解决,会使得代码过于简短而且造成不必要的耦合。应用“运行时异样”,代码间的依赖性升高了,也便于重构,但也造成了“异样源头”的凌乱)
回调函数
另一种办法是在 JavaScript 畛域十分常见的办法——应用回调,回调函数会在一个函数胜利或失败时调用。这通常会与异步编程联合应用,其中 I/O 操作在后盾进行,不会阻塞执行流。
例如,Node.JS 的 I/O 函数通常加上一个回调函数,后者应用两个参数(error,result),例如:
const fs = require('fs');
fs.readFile('some_file.txt', (err, result) => {if (err) {console.error(err);
return;
}
console.log(result);
});
然而,这种办法常常会导致所谓的“回调天堂”问题,因为一个回调可能须要调用其它的异步 I/O,这可能又须要更多的回调,最终导致凌乱且难以跟踪的代码。
古代的 JavaScript 版本试图通过引入 promise 来晋升代码的可读性:
fetch("https://example.com/profile", {method: "POST", // or 'PUT'})
.then(response => response.json())
.then(data => data['some_key'])
.catch(error => console.error("Error:", error));
promise 模式并不是最终计划,JavaScript 最初采纳了由 C#推广开的 async/await 模式,它使异步 I/O 看起来十分像带有经典异样的同步代码:
async function fetchData() {
try {const response = await fetch("my-url");
if (!response.ok) {throw new Error("Network response was not OK");
}
return response.json()['some_property'];
} catch (error) {console.error("There has been a problem with your fetch operation:", error);
}
}
应用回调进行错误处理是一种值得理解的重要模式,不仅仅在 JavaScript 中如此,人们在 C 语言中也应用了很多年。然而,它当初曾经不太常见了,你很可能会用的是某种模式的 async/await。
函数式语言的 Result
我最初想要探讨的一种模式起源于函数式语言,比方 Haskell,然而因为 Rust 的风行,它曾经变得十分支流了。
它的创意是提供一个 Result
类型,例如:
enum Result<S, E> {Ok(S),
Err(E)
}
这是一个具备两种后果的类型,一种示意胜利,另一种示意失败。返回后果的函数要么返回一个Ok
对象(可能蕴含有一些数据),要么返回一个Err
对象(蕴含一些谬误详情)。函数的调用者通常会应用模式匹配来解决这两种状况。
为了在调用堆栈中抛出谬误,通常会编写如下的代码:
let result = match my_fallible_function() {Err(e) => return Err(e),
Ok(some_data) => some_data,
};
因为这种模式十分常见,Rust 专门引入了一个操作符(即问号 ?
)来简化下面的代码:
let result = my_fallible_function()?; // 留神有个 "?" 号
这种办法的长处是它使错误处理既显著又类型平安,因为编译器会确保解决每个可能的后果。
在反对这种模式的编程语言中,Result 通常是一个 monad),它容许将可能失败的函数组合起来,而无需应用 try/catch 块或嵌套的 if 语句。
(译者注:函数式编程认为函数的输出和输入应该是纯正的,不应该有任何副作用或状态变动。monad 是一个函数式编程的概念,它通过隔离副作用和状态来进步代码的可读性和可维护性,并容许组合多个操作来构建更简单的操作)
依据你应用的编程语言和我的项目,你可能次要或仅仅应用其中一种错误处理的模式。
不过,我最喜爱的还是 Result 模式。当然,不仅是函数式语言采纳了它,例如,在我的雇主 lastminute.com 中,咱们在 Kotlin 中应用了 Arrow 库,它蕴含一个受 Haskell 强烈影响的类型Either
。我有打算写一篇对于它的文章,最初感激你浏览这篇文章,敬请放弃关注😊。
译注:还有一篇《Musings about error handling mechanisms in programming languages》文章,同样剖析了不同编程语言在错误处理时的计划。它还介绍了 Zig 编程语言的做法、Go 语言的 defer 关键字等内容,能够丰盛大家对这个话题的了解,举荐一读。