现代化程序开发笔记(9)——异常处理

本系列文章以我的个人博客的搭建为线索(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),
}

这个枚举值有两个分支:OkErr。我们可以使用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_resultResult::Ok(value),它输出value,供后面的使用;当chips_resultResult::Err(chips_error)时,它直接令当前函数返回,并返回Result::Err(chips_error)

trycatch方案

我们通过上面Go语言的解决方案发现了,异常处理通常是这样的:在函数内部依次调用多个可能抛出异常的函数,如果其中一个抛出了异常,那么就返回,不继续执行。因此,我们可以把这个流程抽象出来,就变成了trycatch块。许多流行的语言,如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中的?运算符相同了。如果函数没有抛出异常,那么返回正常的值,并继续执行;如果抛出了异常,那么中止执行,并在上层函数中抛出同样的异常。

当然,在trycatch块(Swift中就是docatch块)中,还有一点需要注意的,那就是清理工作。比方说,我们打开一个数据库连接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块始终能被执行,也就完善了清理工作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值