错误处理是编程的基本方面。除非你只是在编撰“HelloWorld”,否则你须要在代码中处理错误。在这篇文章中,我将讨论一下各类编程语言常用的几种方式。
返回错误代码
这是最古老的策略之一-假如一个函数可能失败,它可以简单地返回一个错误代码-一般是一个正数,或则。这在C语言中十分常见,比如:
#3:8:9:6:3:4:9:3:0:c:5:d:b:5:d:d:5:8:1:8:7:7:4:e:9:c:1:9:2:9:9:7#
这些方式十分简单,无论是实现还是理解都很容易。它执行上去也十分高效,由于它只涉及到一个标准的函数调用,有一个返回值-不须要运行时支持或分配。但是,它有一些缺点:
#c:1:3:5:2:e:0:f:b:a:9:1:c:5:d:3:c:6:d:5:3:2:8:5:d:6:0:1:5:0:f:b#
Go语言以这些方法处理错误十分出名。但是,因为Go容许函数返回多个值,这些模式显得更加人性化-而且十分常见:
#7:0:d:e:d:4:c:a:0:f:5:2:6:2:0:d:4:0:e:3:d:a:c:f:7:f:0:0:3:6:9:8#
Go语言的这些模式简单、有效,而且可以将错误传播给调用者。另一方面,我认为它相当重复,并且对实际的业务逻辑有点分散注意力。我还没有写足够的Go代码来晓得这些印象是否会随着时间的推移消失!
详尽剖析
在C语言中,错误处理一般通过返回错误代码或设置全局变量(如errno)来完成。这些方法的优点是简单且执行效率高,但缺点是可能会忽视错误处理,或则在须要传播错误时显得复杂。
比如,文章中提及的fopen函数,假若打开文件失败,它会返回,并设置全局变量errno。你可以检测fopen的返回值,假如它是,这么就发生了错误。之后,你可以查看errno的值,以获取更多关于错误的信息。
#a:5:6:f:c:4:1:1:9:a:b:d:6:a:4:b:6:7:6:9:3:1:1:f:3:4:f:3:b:d:e:1#
在这个事例中,perror函数用于复印描述错误的消息。它会依照errno的当前值生成一个消息。
对于自定义错误,你可以定义自己的错误代码,并在函数返回时返回那些代码。诸如:
#b:d:0:4:8:c:9:8:f:7:8:4:7:2:8:3:b:f:9:7:0:c:e:8:f:2:9:7:d:9:6:1#
在这个事例中,my_function在错误发生时返回MY_ERROR。之后,你可以检测my_function的返回值,假如它是MY_ERROR,这么就发生了错误。
请注意,那些都是基本的错误处理策略,实际的错误处理可能会更复杂,取决于你的具体需求。
详尽剖析
在Go语言中,错误处理一般是通过在函数中返回一个错误对象来实现的。假如函数执行成功,这个错误对象一般是nil;假如函数执行失败,这个错误对象会包含错误的详尽信息。这些模式在Go中十分常见,由于Go支持函数返回多个值。
但是,这些模式的一个缺点是,它可能会使代码显得重复和冗余。每次调用可能会失败的函数时,你都须要检测返回的错误对象是否为nil。若果不为nil,你一般须要立刻返回错误,便于将错误传播给调用者。这可能会造成你的代码中饱含了类似的错误检测和处理代码,如下所示:
#6:b:6:b:c:b:a:1:6:e:8:7:1:4:2:f:8:5:3:7:0:7:b:3:1:c:7:c:2:7:5:1#
这些重复的错误检测和处理代码可能会分散开发者对实际业务逻辑的注意力。在前面的事例中,实际的业务逻辑是“找到用户,获取用户的数据,处理数据”,但这种逻辑被错误检测和处理代码所掩藏。
据悉,这些模式可能会使代码的结构显得复杂。假如你须要在检测错误后执行一些清除操作,你可能须要使用defer句子或其他复杂的结构。
总的来说,尽管Go的错误处理模式简单有效,但它可能会使代码显得重复和冗余,因而分散开发者对实际业务逻辑的注意力。
异常
异常可能是最常用的错误处理模式。try/catch/finally的方式工作得相争当,使用上去也很简单。异常在90年代和2000年代十分流行,被许多语言如Java、C#或Python采用。
与错误代码相比,异常有一些优点:
但是,它们也有一些缺点:它们须要一些特定的运行时支持,但是一般会形成相当大的性能开支。更重要的是,它们有一个“远-reaching”的疗效-一个异常可能由一些代码抛出,并由在调用栈中很远的地方的异常处理器捕获,这会影响清晰度。
据悉,仅仅通过查看其签名,就不显著晓得一个函数是否会抛出任何异常。
C++企图通过引入throwscause来解决这个问题,但这个方式使用得太少,以至于在C++17中被弃用,并在C++20中被移除。从那时起,它企图引入noexcept,但我还没有写足够的现代C++来晓得它有多流行。
Java以“检查异常”(checkedexceptions)的方法企图使用,即你必须申明为签名部份的异常-但这些技巧被觉得是失败的,现代框架如Spring只使用“运行时异常”,JVM语言如Kotlin完全摈弃了这个概念。最后,没有好的方式晓得一个方式调用是否会抛出任何异常,因而你最终会有点混乱。
详尽剖析
与错误代码相比,异常有一些优点:
好的,让我们来详尽剖析一下。
它们自然地将“正常路径”和错误处理路径分开
当你使用错误代码时,你的代码可能会饱含了检测错误的句子。比如,在C语言中:
#2:6:8:8:8:2:4:f:4:a:3:2:1:7:9:c:8:f:8:9:8:a:5:1:4:8:f:a:8:2:5:d#
在这个事例中,你须要在每次调用可能失败的函数后检测错误。这可能会使你的代码显得混乱,无法阅读和维护。
与此相反,异常容许你将正常的业务逻辑和错误处理逻辑分开。比如,在Java中:
#c:2:e:9:c:9:8:f:c:a:1:b:5:f:e:d:3:4:7:b:e:a:6:8:0:2:e:9:6:0:2:6#
在这个事例中,你的正常业务逻辑(写入文件)和错误处理逻辑(catch块)是分开的。这促使代码更便于阅读和维护。
它们会手动通过调用栈冒泡
当你使用错误代码时,你须要在每位函数中检测错误,并决定怎样处理它。假如你想将错误传播到调用栈,你须要显式地返回错误代码。
然而,当你使用异常时,假如你不在函数中捕获异常,它会手动传播到调用栈。这促使错误处理愈发简单,由于你不须要在每位函数中显式地检测和传播错误。
当你使用错误代码时,假若一个函数调用了另一个可能失败的函数,这么你须要在每位函数中检测错误,并决定怎样处理它。比如,在C语言中:
#6:f:b:e:f:4:8:6:6:8:c:5:5:6:5:b:a:0:2:5:c:e:5:f:f:7:4:7:8:d:1:5#
在这个事例中,do_something函数调用了fopen,而且必须复查fopen是否成功。假如fopen失败,do_something必须返回一个错误代码,之后main函数必须检测这个错误代码。
与此相反,当你使用异常时,假若一个函数调用了另一个可能抛出异常的函数,这么你不须要在每位函数中捕获异常。假如你不捕获异常,它会手动传播到调用栈。比如,在Java中:
#3:6:5:1:b:d:a:c:5:e:9:0:1:f:6:f:9:7:7:b:5:b:5:e:3:f:7:f:b:b:0:5#
在这个事例中,doSomething函数调用了FileWriter构造函数,这可能会抛出IOException。并且,doSomething不须要捕获这个异常。假如FileWriter构造函数抛出一个异常,这么这个异常会手动传播到main函数,main函数可以选择捕获并处理这个异常。
你不能忘掉处理错误
当你使用错误代码时,你可能会忘掉检测错误。比如,你可能会忘掉检测fopen的返回值。
然而,当你使用异常时,假如你不捕获异常,程序会中止。这逼迫你处理所有的错误,因而避开了可能的错误和不确定的行为。
当你使用错误代码时,你可能会忘掉检测错误。比如,在C语言中:
#4:8:8:f:9:a:7:3:9:b:6:b:f:0:e:7:f:7:0:a:a:0:3:d:0:3:c:5:6:9:b:4#
在这个事例中,假如fopen失败,这么fp将是,但是后续的文件操作可能会造成程序崩溃。但是,因为忘掉检测fp是否为,这个错误可能会被忽略。
然而,当你使用异常时,假如你不捕获异常,程序会中止。这逼迫你处理所有的错误。比如,在Java中:
#c:e:5:9:2:4:0:8:0:c:a:0:6:8:c:f:a:7:c:3:f:7:a:4:a:6:f:8:8:d:e:7#
在这个事例中,假如doSomething函数抛出一个IOException,这么这个异常会手动传播到main函数。因为main函数没有捕获这个异常,程序会中止,这促使你处理所有可能的错误。
总的来说,尽管错误代码在个别情况下(比如,性能关键的代码或嵌入式系统)可能是更好的选择,但在许多情况下,异常提供了一种更清晰、更便于管理的错误处理机制。
详尽剖析
Java的“检查异常”(CheckedExceptions)机制被觉得是失败的,因而现代框架如Spring只使用“运行时异常”(RuntimeExceptions),而JVM语言如Kotlin则完全摈弃了检测异常的概念。
在Java中,检测异常是这些在编译时必须被处理(通过try-catch块或则throws关键字)的异常。这些设计的本意是强制程序员处理可能出现的错误情况。但是,这些机制在实践中常常造成代码过分复杂,而且饱含了大量的空异常处理代码,由于程序员们常常只是简单地在catch块中复印异常,而没有进行任何实质性的错误处理。
比如,以下是一个典型的Java方式,它可能会抛出一个检测异常:
#e:8:2:e:c:6:7:7:f:b:f:f:a:d:0:1:c:f:9:e:b:c:b:e:0:1:c:f:a:7:1:1#
调用这个方式的代码必须处理可能抛出的IOException:
#9:8:d:2:4:1:4:9:9:7:d:0:d:8:1:4:4:8:4:f:6:9:8:1:b:a:7:1:8:8:7:b#
但是,这些机制在实践中常常造成代码过分复杂,而且饱含了大量的空异常处理代码,由于程序员们常常只是简单地在catch块中复印异常,而没有进行任何实质性的错误处理。
因而,现代的框架(如Spring)和JVM语言(如Kotlin)一般只使用运行时异常。这样,程序员可以选择在那里处理异常,而不是被强制在每位可能抛出检测异常的方式中处理异常。
运行时异常是这些不须要在方式签名中申明或则在方式体中捕获的异常。这种异常一般表示编程错误,如空表针异常(PointerException)或字段越界异常(ArrayIndexOutOfBoundsException)。
比如,以下是一个Spring方式,它可能会抛出一个运行时异常:
#5:1:c:b:9:6:e:5:5:c:3:8:7:c:1:7:7:2:e:c:2:0:d:5:1:f:b:6:f:c:9:b#
调用这个方式的代码可以选择是否处理可能抛出的运行时异常:
#3:7:1:a:6:2:5:5:e:f:c:2:b:8:1:c:f:1:e:c:1:4:a:7:6:b:3:3:3:a:9:e#
在Kotlin中,所有的异常都是运行时异常,因而你可以选择在那里处理异常,而不是被强制在每位可能抛出异常的方式中处理异常。诸如:
#b:f:2:8:6:0:b:0:1:b:c:1:6:2:c:f:4:b:e:1:4:5:f:1:5:e:a:d:4:b:1:1#
在这个反例中,saveUser函数可能会抛出一个运行时异常,但你可以选择在那里处理这个异常。
错误反弹
另一种方式,特别常见于JavaScript,是使用反弹,当函数成功或失败时会被调用。这一般与异步编程结合使用,其中I/O在后台进行,不会阻塞执行流。
比如,Node.JS的I/O函数一般接受一个带有两个参数(错误,结果)的反弹,比如:
#f:d:b:0:6:4:0:2:2:6:3:1:9:6:3:9:3:a:3:8:3:9:1:e:d:a:6:4:d:7:d:a#
但是,这些方式一般会造成所谓的“回调地狱”问题,由于一个反弹可能须要调用更多的异步I/O,这又须要更多的反弹,等等,最终造成代码混乱且无法跟踪。
JavaScript的现代版本企图通过引入promises来使代码更易读:
#2:a:f:0:7:a:4:b:7:1:7:a:b:4:0:1:8:3:e:1:f:1:0:6:1:3:8:e:9:6:c:c#
promises模式的最后一步是JavaScript采用了由C#普及的async/await模式,这促使异步I/O最终看上去很像同步代码,带有精典的异常:
#7:2:0:4:3:7:c:2:3:a:a:f:d:8:f:9:9:a:4:a:3:7:e:7:0:a:f:b:4:4:6:3#
使用反弹进行错误处理是一个重要的模式,除了在JavaScript中-比如,人们在C语言中使用它早已有很长时间了。但是,它如今并不常见-你可能会使用一些方式的async/await。
详尽剖析
JavaScript中确实有异常(Exception)的概念。当在代码中发生错误时,JavaScript会停止执行,并抛出一个异常。假如这个异常没有被捕获和处理,这么程序都会中断。
在JavaScript中,你可以使用throw句子来抛出一个异常。你可以抛出一个字符串、数字、布尔值或对象。一般,我们会抛出一个Error对象或Error对象的泛型。Error对象有一个message属性,用于储存关于错误的文本信息。
以下是怎样在JavaScript中创建和抛出自定义异常的示例:
#f:f:9:3:7:4:b:4:6:6:0:7:9:5:5:7:6:a:2:0:3:3:8:3:f:f:b:9:3:7:7:4#
在里面的代码中,我们首先创建了一个名为MyCustomError的自定义错误类,它承继自外置的Error类。之后,我们使用throw句子抛出了一个MyCustomError实例。
你可以在try...catch块中捕获并处理这个自定义异常:
#e:e:5:2:0:c:8:a:0:4:d:b:6:d:c:4:5:c:b:7:b:e:4:1:0:1:2:4:f:8:2:0#
在里面的代码中,我们首先尝试抛出一个MyCustomError实例。之后,我们在catch块中捕获这个异常,并检测它是否是MyCustomError类型的实例。假如是,我们复印一条特定的错误消息;假如不是,我们复印一条通用的错误消息。
详尽剖析
在异步编程中,错误处理确实与同步编程有所不同。这是由于在异步操作中,当错误发生时,原始的执行上下文可能早已消失了,因而不能简单地在同一个调用栈中向下抛出错误。因而,我们须要一些特殊的机制来处理异步错误。
以下是一些不同语言中异步错误处理的反例:
JavaScript:在JavaScript中,异步错误一般通过反弹函数、Promise或async/await来处理。
#6:3:c:2:8:a:f:7:f:8:d:e:3:3:5:a:9:1:9:2:9:6:5:0:3:d:e:9:b:c:1:f#
#b:6:5:0:1:6:8:6:7:7:f:0:3:1:d:3:3:c:6:c:9:6:1:2:f:4:3:d:b:a:e:9#
#d:0:9:d:f:9:b:f:6:6:a:2:5:a:8:7:9:9:4:9:9:b:3:c:6:f:b:3:c:a:8:9#
Java:在Java中,Future和CompletableFuture提供了处理异步错误的机制。
#5:b:a:2:d:8:e:6:3:0:e:b:0:9:d:7:b:8:e:1:9:6:d:e:3:5:3:4:d:8:9:1#
C#:在C#中,可以使用async/await和try/catch来处理异步错误。
#5:b:2:5:f:4:3:4:4:d:e:0:b:e:6:d:f:d:0:c:f:e:8:7:9:a:9:f:9:4:e:6#
Go:在Go中,错误作为函数的返回值处理,这也适用于异步操作。
#7:0:4:4:2:b:7:4:2:d:5:1:7:6:1:c:0:3:7:7:c:b:7:5:6:3:9:3:7:2:6:1#
Rust:在Rust中,可以使用Result或Option类型来处理错误,包括异步错误。
#8:4:2:0:5:7:b:8:4:a:4:d:0:8:8:6:d:3:5:3:1:f:2:d:2:6:f:8:b:f:a:7#
在上述所有反例中,你可以听到异步错误处理的一个共同主题:错误不是通过调用栈向下抛出,而是通过某种方式的错误处理机制(如反弹函数、Promise、Future或Result)显式地传递和处理。
详尽剖析
在异步编程中,错误一般被封装在一个特殊的对象中,比如JavaScript的Error对象、Promise,或则Rust的Result类型。这个错误对象一般会包含关于错误的信息,比如错误的类型、错误的消息等。这个错误对象一般会被储存在堆显存中,由于它的生命周期可能超过了创建它的函数的生命周期。
错误处理器是一种特殊的函数或方式,它的任务是处理这种错误。错误处理器可能是一个反弹函数、一个Promise的catch方式、一个Future的exceptionally方式,等等。错误处理器一般会接收一个错误对象作为参数,并按照这个错误对象来执行相应的错误处理逻辑。
错误处理器的储存位置取决于它的具体类型和它被使用的上下文。诸如,倘若错误处理器是一个反弹函数,这么它可能会被储存在堆显存中,由于它的生命周期可能超过了创建它的函数的生命周期。倘若错误处理器是一个对象的方式(比如,一个Promise的catch方式),这么它可能会被储存在这个对象的显存空间中。
当一个异步操作失败时,错误对象会被传递给相应的错误处理器。这个过程可能会涉及到跨线程或跨机器的通讯,取决于异步操作和错误处理器的具体运行环境。诸如,假如异步操作和错误处理器在同一个线程中运行,这么错误对象可能会通过线程的显存空间来传递。假如它们在不同的线程或不同的机器上运行,这么错误对象可能须要通过某种方式的进程间通讯或网路通讯来传递。
总的来说,异步错误处理的工作原理是:当一个异步操作失败时,它会创建一个包含错误信息的错误对象,并将这个错误对象传递给一个错误处理器,这个错误处理器会按照这个错误对象来执行相应的错误处理逻辑。这个过程可能会涉及到跨线程或跨机器的通讯,取决于异步操作和错误处理器的具体运行环境。
函数式语言中的结果
我想讨论的最后一个模式起源于函数式语言,如Haskell,但因为Rust的流行,它早已显得愈发主流。
这些技巧的思想是有一个类型Result,它有两个变体-一个表示成功,另一个表示失败。一个返回结果的函数将返回Ok变体,可能带有一些数据,或则返回Err变体,带有一些错误细节。函数的调用者一般会使用模式匹配来处理这两种情况。
为了在调用栈中冒泡错误,你一般会写像这样的代码:
#9:5:8:4:d:a:1:1:b:3:1:e:7:b:c:b:c:d:0:4:1:4:3:d:2:d:c:d:2:3:a:6#
这些模式十分常见,以至于Rust在语言中引入了一个整个操作符(问号?)来简化前面的代码:
#d:6:e:2:1:f:2:9:f:6:c:3:d:7:d:5:2:2:e:e:5:0:8:2:9:7:e:b:0:7:6:8#
这些方式的优点是它使错误处理既显式又类型安全,由于编译器确保处理了每一个可能的结果。
在支持它的语言中,Result一般是一个monad,这容许组合可能失败的函数,而毋须使用try/catch块或嵌套的if词句。
详尽剖析
Go和Rust都选择了显式的错误处理模式,而不是手动的异常冒泡,主要是出于以下几个诱因:
可预测性和控制性:在Go和Rust中,错误被视为普通的值,可以像处理其他值一样处理错误。这些方法促使错误处理愈发可预测,由于你总是晓得在那里和怎样处理错误。相比之下,手动的异常冒泡可能会造成错误在你预期之外的地方被捕获,这可能会促使代码的行为更难预测。
错误处理的显性:在Go和Rust中,假如一个函数可能会出错,这么这个函数的签名都会明晰地表示出这一点,由于它会返回一个错误值。这促使程序员在编撰代码时就考虑到错误处理,而不是在后期调试阶段才发觉错误。
性能:手动的异常冒泡和处理可能会引入额外的运行时开支,由于须要维护一个异常栈,并在异常发生时进行栈展开。而在Go和Rust中,错误只是普通的值,处理错误不会引入额外的性能开支。
简约性和易读性:手动的异常冒泡可能会造成大量的try-catch代码块,这可能会促使代码更难读和理解。而在Go和Rust中,错误处理一般只须要一个if句子,这促使代码愈发简练和易读。
以上都是设计语言时的权衡。手动的异常冒泡在个别情况下可能更有用,比如在Java中,但在Go和Rust这样的语言中,显式的错误处理被觉得是更好的选择。
详尽剖析
手动的异常冒泡和处理可能会引入额外的运行时开支,这与堆栈回溯(stackunwinding)技术有关。
当一个异常被抛出时,运行时系统须要进行一系列的操作来找到适当的异常处理程序。这个过程被称为堆栈回溯。运行时系统须要回溯调用堆栈,查找可以处理当前异常的catch块。这个过程可能涉及到大量的估算和显存操作,因而可能会引入额外的性能花销。
据悉,假若一个函数有可能抛出异常,这么编译器可能须要生成额外的代码来处理可能的异常情况。这可能会致使生成的代码更大,也可能会影响性能。
相比之下,假若一个语言选择了显式的错误处理模式,比如Go和Rust,这么错误就是普通的值,处理错误不须要额外的运行时支持,也不会引入额外的性能花销。
详尽剖析
Rust和Go的错误处理模式都指出显式错误处理,但她们的处理方法有所不同。
Rust的错误处理模式:
Rust使用Result类型来处理可能出错的函数。Result是一个枚举,有两个变体:Ok和Err。假如函数执行成功,它返回Ok变体,可能带有一些数据;假如函数执行失败,它返回Err变体,带有一些错误细节。
#1:d:d:3:6:0:9:b:3:3:c:0:0:5:0:d:c:7:2:3:f:5:3:3:b:5:4:c:4:3:0:5#
优点:
以下是一个使用?操作符的Rust代码示例:
#5:1:b:0:e:a:1:c:2:9:c:4:9:e:a:0:5:3:0:2:9:c:8:2:d:0:5:5:0:1:b:2#
在这个事例中,print_division_result函数调用了divide函数,并使用?操作符处理了可能的错误。假如divide函数返回一个错误,?会立刻结束print_division_result函数,并返回这个错误。假如divide函数成功,?会返回Ok中的值,之后我们复印这个结果。
在print_division_result函数中,Ok(())是函数的返回值。这个函数的返回类型是Result