本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将讨论的是主流的异常处理的方法。
首先,我们要知道,异常处理通常分为两种:一种是比如把0作为分母,或者把NULL
解引用,这类都属于操作系统或底层硬件告诉你这不该做的;而另一种则是开发者自己编写程序的时候,发现了不符合程序需求的地方,比如说从数据库查询一条数据,结果却返回了两条记录,这种是由开发者自己提醒自己做了不该做的事,程序出了问题。我这篇文章主要讨论的是后者。
在这篇文章中,我举的例子将继续之前的快餐店。现在,我们有了一系列正常的函数
fn get_tomato() -> Tomato;
fn slice_tomato(tomato: Tomato) -> SlicedTomato;
fn fry_tomato(sliced_tomato: SlicedTomato) -> Chips;
用来制作薯条。但是,我们的薯条工艺良品率不高,在每一步中都有可能会产生报废的薯条。这时候,应该怎样设计这个炸薯条的API呢?
通过返回值处理异常
对于最原始的语言,比如说C,或者大道至简的某些语言,比如说Go,并没有很完善的异常处理机制。这时候,就需要开发者自己约定该怎么处理异常。通常情况下,这是通过返回值进行的。
单返回值异常处理
比如说在C标准库里,我们经常能看到这样的代码:
// If the call to uname() is not successful, -1 is returned and errno is set appropriately.
int uname(struct utsname *buf);
这个函数是用来获取当前操作系统版本相关信息的函数,但是它却不是通过返回值来给我们相关的信息,而是需要我们先自己创建一个struct utsname
类型的对象,然后把对象的地址传进去,由函数内部来填充这个对象。为什么不直接返回这个对象呢,其中很重要的一点原因就是大家约定好了要在返回值里处理异常。就像上方的注释所说,如果没能成功获取相关的信息,那么这个函数将返回-1, 并且将全局变量errno
设置为相应的错误代码。
因此,我们可以这样处理错误:
struct utsname buf;
if (uname(&buf) == -1) {
// do something with `errno`
}
类似地,我们也可以这样设计我们的炸薯条的错误:
int get_tomato(struct Tomato *tomato);
int slice_tomato(struct Tomato *tomato, struct SlicedTomato *sliced_tomato);
int fry_tomato(struct SlicedTomato *sliced_tomato, struct Chips *chips);
这种方案是在最窘迫的情况下,语言啥也不提供时这么做。
多返回值异常处理
在Go语言中,虽然也没有错误处理的机制,但是Go语言提供了多返回值这一功能,我们可以把上面的代码写的更优雅一些:
func GetTomato() (Tomato, Error)
func SliceTomato(Tomato) (SlicedTomato, Error)
func FryTomato(SlicedTomato) (Chips, Error)
这个函数返回了两个值,一个Chips
类型的我们炸制的薯条,一个Error
类型的错误。如果我们炸制成功,那么Error
类型的错误就是nil
, 否则就包含了相应的错误信息。我们可以这样处理错误:
tomato, err := GetTomato()
if err != nil {
// do something with `err`
}
将异常通过返回值告诉开发者,这种做法是最原始的做法,在上面这种简单的例子中看上去就很麻烦,在有些情况下更是灾难性的。就像是我们刚刚的炸薯条的例子,如果要写一个总的函数把这三个过程都串起来,用Go语言应该这么写:
func MakeChips() (Chips, Error) {
tomato, getTomatoErr := GetTomato()
if getTomatoErr != nil {
// do something with `getTomatoErr` and return
}
slicedTomato, sliceTomatoErr := SliceTomato(tomato)
if sliceTomatoErr != nil {
// do something with `sliceTomatoErr` and return
}
chips, fryTomatoErr := FryTomato(slicedTomato)
if fryTomatoErr != nil {
// do something with `fryTomatoErr` and return
}
return chips, nil
}
每一步都要判断,满眼都是if err != nil
。虽然说这种方案很麻烦,但还是可以完成错误处理的过程的。
返回枚举值进行异常处理
返回枚举值来进行异常处理是通过返回值进行异常处理的最终阶段,Rust就使用了这种方案。要实现这种方案,首先需要语言的枚举类型enum
是一等公民,并且有能力做值的包装。Rust标准库中提供了一个异常处理返回值类型Result
,它的定义如下:
pub enum Result<T, E> {
Ok(T),
Err(E),
}
这个枚举值有两个分支:Ok
或Err
。我们可以使用Result
来进行错误处理:当函数正常返回value
的时候,那么就返回一个Result::Ok(value)
,也就是把正常的返回值包装在Result
里面;如果函数发生错误error
的时候,那么就返回一个Result::Err(error)
。
利用Result
,我们可以这样设计炸薯条的API:
fn get_tomato() -> Result<Tomato, ChipsError>;
fn slice_tomato(tomato: Tomato) -> Result<SlicedTomato, ChipsError>;
fn fry_tomato(sliced_tomato: SlicedTomato) -> Result<Chips, ChipsError>;
如果要进行异常处理,我们可以
match get_tomato() {
Ok(tomato) => { /* do something */ },
Err(chips_error) => { /* handling error */ }
}
这似乎和Go语言的方案差不多。然而,我们往往并不是收到错误就立刻处理,而是把错误向上转发,就像Go语言的例子中那样。这时候,Rust为我们提供了?
运算符,专门处理这种过程。用?
运算符之后,我们可以这样完成make_chips
函数:
fn make_chips() -> Result<Chips, ChipsError> {
fry_tomato(slice_tomato(get_tomato()?)?)
}
?
运算符究竟干了什么呢?对于chips_result?
来说,如果chips_result
是Result::Ok(value)
,它输出value
,供后面的使用;当chips_result
是Result::Err(chips_error)
时,它直接令当前函数返回,并返回Result::Err(chips_error)
。
try
…catch
方案
我们通过上面Go语言的解决方案发现了,异常处理通常是这样的:在函数内部依次调用多个可能抛出异常的函数,如果其中一个抛出了异常,那么就返回,不继续执行。因此,我们可以把这个流程抽象出来,就变成了try
…catch
块。许多流行的语言,如C++、Java、Python、JavaScript、Swift都实现了这种方案。下面我就以Swift为例解释这种方案:
我们可以这样设计炸薯条的API:
func getTomato() throws -> Tomato
func slice(tomato: Tomato) throws -> SlicedTomato
func fry(slicedTomato: SlicedTomato) throws -> Chips
这里和一般的函数不同的是,它在签名里包含了throws
关键词。这个关键词的作用是什么呢?它的作用是表示当前函数可能会抛出异常,就像是Go语言我们发现函数值返回了err
, Rust中发现函数返回是Result
类型一样,是一种约定。同时,编程语言也对这种约定做了一些支持。
如果要处理这样的错误,我们可以:
do {
let tomato = try getTomato()
// do something else
} catch SomeError(someError) {
// deal with someError
}
当我们执行这样的函数时,执行顺序是:先执行getTomato
,如果没有抛出异常,那么继续执行do
块的内容;如果抛出了异常,那么直接终止当前do
块的内容,而是跳到对应的catch
块执行,在Swift 5.3之后,catch
后可以直接进行模式匹配。
如果不需要处理这样的错误,而是向上转发,我们可以:
func makeChips() throws -> Chips {
let tomato = try getTomato()
let slicedTomato = try slice(tomato: tomato)
let chips = try fry(slicedTomato: slicedTomato)
return chips
}
这里try
的作用就和Rust中的?
运算符相同了。如果函数没有抛出异常,那么返回正常的值,并继续执行;如果抛出了异常,那么中止执行,并在上层函数中抛出同样的异常。
当然,在try
…catch
块(Swift中就是do
…catch
块)中,还有一点需要注意的,那就是清理工作。比方说,我们打开一个数据库连接connection
,正常情况下是要关闭它的,我们的逻辑可以这么写:
let connection = createConnection()
doSomethingWith(connection: connection)
connection.close()
但是,如果doSomethingWith(connection:)
需要进行异常处理,事情就变得不那么简单了。我们的代码如果写成:
do {
let connection = createConnection()
try doSomethingWith(connection: connection)
connection.close()
} catch /* ... */
这样的话,一旦doSomethingWith(connection:)
抛出了异常,那么connection.close()
这个语句就不会被执行到,这个连接就没办法关闭,有可能造成严重的后果。我们可以在do
最后写一个connection.close()
,再在catch
块里写一个connection.close()
来解决这样的问题,但这种解决方法一是太麻烦,二是有的开发者一不注意就忘写了。因此,语言又提供了一种总会被执行到的语句块,在Swift中叫做defer
,我们可以这样写:
do {
let connection = createConnection()
defer {
connection.close()
}
try doSomethingWith(connection: connection)
} catch /* */
这里的defer
块,一旦在作用域结束时就会被执行,无论是正常执行的结束,还是因为抛出异常而终止,defer
块始终能被执行,也就完善了清理工作。