头图

0 序

最近读到了一篇国外老哥写的关于他是如何爱上 Rust 的文章(Go's Errors: How I Learned to Love Rust))。其实从标题就能看出,这篇文章似乎又是在吐槽 Go 的错误处理。实际上也是这样没错,作者从异常错误处理的角度,从 Python 到 Go 再到 Rust,表达了他更喜欢 Rust 的原因。

巧了,我工作生涯的后端主力语言正好是 Python(现在不是了)和 Go,Rust 虽未正式在工作上使用,但也是目前我业余时间用的最多的语言,于是便想着也写一篇我自己对这三门语言异常处理的看法。

由于我对 Rust 的掌握程度远不如前两者,以及 Rust 本身异常处理机制的复杂性也更高,因此文章中难免有些纰漏。如果发现了文章中不对的地方,还请大家不吝指出!

1 异常处理概览

异常处理,或错误处理,是编程语言中对代码出现错误时的处理方式。不同语言的异常机制、异常处理机制都不完全相同。

这一节会简单介绍一下 Python、Go、Rust 三门语言各自的异常处理方式。下一节则会从我个人的角度谈谈使用它们的感受。

1.1 Python

相对于 Go 和 Rust,诞生于上世纪 90 年代初的 Python 无疑是一门老语言了,因此它的异常处理方式也更加传统。案例如下:

import random

def raise_exceptions(num: int):
    if num == 0:
        raise ValueError('Zero is not allowed')
    elif num == 1:
        raise IndexError('Index out of range')
    elif num == 2:
        raise KeyboardInterrupt('Keyboard interrupt')
    elif num == 3:
        raise MemoryError('Memory error')
    else:
        raise KeyError('Unknown error')
    
num = random.randint(0, 5)
print('Random number:', num)
try:
    raise_exceptions(num) 
except ValueError as e:
    print('ValueError:', e)
except Exception as e:
    print('Other Exception:', e)
else:
    print('No exception')
finally:
    print('Finally block')

很好理解的一段代码。代码定义了一个函数,接收一个数字,并根据数字去主动抛出异常。外部通过 try except 语法来捕获函数抛出的异常并打印。

可以看到,这里 Python 处理异常的方式,与 Java、C++ 等传统语言很相似,这也是我说 Python 的异常处理很传统的原因了。

Python 中几乎所有的 Error 都可以被捕获,而如果不捕获,这些 Error 也几乎都会导致程序退出。比较像其他语言中的 panic、crash 的概念。而 Python 里 try except 语法中除了其他语言也会有的 finally 块之外,还有一个很好用的 else ,即没有异常时会走的分支。

1.2 Go

和 Python 不同,Go 将异常分为了 error 和 panic 两种,其中 error 不会导致程序崩溃,而 panic 会。案例如下:

package main

import "fmt"

func errorFunc() error {
    return fmt.Errorf("error func")
}

func panicFunc() {
    panic("panic func")
}

func main() {
    err := errorFunc()
    if err != nil {
        fmt.Println(err)
    }
    // 用 `recover` 来捕获 panic
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    panicFunc()
}

// 运行结果输出:
error func
Recovered from panic: panic func

这段代码用了两个函数分别演示 error 和 panic,并在 main 中展示如何处理它们。可以看到,error 在 Golang 中就是一个普通的返回值,而 panic 抛出的方式很简单,但捕获的方法稍微有些复杂。

这段代码如果我们不读取不处理 error,没有任何问题,但如果不去捕获 panic,则程序会崩溃。

1.3 Rust

Rust 的异常或错误,也被分为了 Result 和 panic 两种。案例如下:

use std::io::Error;
use std::fs::File;

fn result_fn() -> Result<File, Error> {
    let file = File::open("test.txt")?;
    Ok(file)
}

fn panic_fn() {
    panic!("This is a panic!");
}

fn main() {
    let result = result_fn();
    match result {
        Ok(f) => println!("No error!: {}", f.metadata().unwrap().len()),
        Err(e) => println!("Got error: {}", e),
    }

    // Recover the panic
    let result = std::panic::catch_unwind(|| {
        panic_fn();
    });
    match result {
        Ok(_) => println!("No panic!"),
        Err(_) => println!("Got panic!"),
    }
}

// 运行结果输出:
Got error: 系统找不到指定的文件。 (os error 2)
thread 'main' panicked at examples\exception.rs:10:5:
This is a panic!
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Got panic!

Rust 案例同样定义了两个函数,展示了 Result(类似 Go 中的 error)和 panic 的基本用法。嗯,是基本用法,因为相比 Python 和 Go,Rust 的异常处理系统和语法实际上要更加复杂一些。详细的内容到后文再讲。

2 异常处理讨论

这一节会结合上一章节的代码,以及我个人使用不同语言错误处理时的感受,谈谈我对它们各自特性的看法。

2.1 传统的 Python

我个人其实挺喜欢 Python 的这种传统式的异常处理方法。一来是因为,学生时代我曾写过一段时间 Java,因此 Python 的异常处理模式对我来说很容易接受,且在我看来也是符合直觉的。再者,Python 的异常处理用起来很方便。譬如它不像 Go 会区分 error 和 panic,Python 里一视同仁;譬如前文的代码,尽管 raise_exceptions 函数 raise 了好几个 Error,但如果我愿意的话,我完全可以在调用它的地方这么写:

try:
    raise_exceptions()
except:
    # do something
    
# Or

try:
    raise_exceptions()
except Exception as e:
    # do something

这样无论 raise_exceptions 中 raise 了多少种 Error,我都可以在一个 except 语句段中捕获。

当然,这种方式在各种 Python 的规范中(包括面试官)都是不建议这么干的(尽管这样做非常省事),因为这会丢失异常细节,没有针对不同的异常做不同处理。

此外,还有一点。Python 中我们常见的那些个 Error 几乎都是继承自 Exception 类,而 Exception 类又继承自 BaseException 类。这也就意味着,开发者可以随时自定义自己的异常类型并实现,并且这个过程非常简单,就是一个简单的继承:

class MyError(Exception): ...

这种易扩展、可自定义的特性在我们开发一些大型项目时真的非常实用,这也是传统面向对象的优点在 Python 异常机制中的体现。

2.2 一言难尽的 Go

Go 的 error 处理是一直所为人诟病的,甚至有不少人认为 Go 的 error 处理方式是它最臭名昭著的特性之一。我倒是没有如此极端的想法,但这些年使用下来,Go 的错误处理也确实让我有些一言难尽。

2.2.1 error 捕获

作为一个 00 后,Go 的错误处理显然就和它的前辈们不同了。不谈 panic,只看 error,从前面的代码里我们可以看到,Go 的 error 实际上就是作为一个函数的返回值出现的。因此在 Go 中,我们完全可以像下面一样,完全忽略 error:

_ = errorFunc()
// Or
errFunc()

这样一来,代码似乎就变得非常简洁了,这段代码也能正常通过编译,正常执行。不同于 Python,Go 里未被捕获或处理的 error 并不会让我们的程序崩溃。

看上去似乎很美好?一个既能让代码变简洁,能通过编译,又不会引起代码崩溃的东西,为什么会让人讨厌呢?

实际上当我们真的使用 Go 开发项目时,绝大部分的 error 返回我们都应当正确处理,而不是忽略。因为一旦你没有判断函数返回的 error,很多时候你就无法得知这个函数的执行是否真的成功了,而这往往会导致一些更严重的问题,如这段代码:

import (
    "fmt"
    "os"
)

func main() {
    // file 是一个指针,第二个返回值是 error,这里未捕获
    file, _ := os.Open("test.txt")
    data := make([]byte, 1024)
    // count 是整型,第二个返回值是 error,这里也没有捕获
    count, _ := file.Read(data)
    fmt.Println("Read Count: %v", count)
    fmt.Println("Data: %s", string(data))
}

这段代码里两个会返回 error 的函数或方法,我们都没有去捕获 error。于是,对于第一个地方的 file 来说,它本身是一个指针,而如果 os.Open 异常了,意味着这个 file 指针会是个 nil ,那么后面再用 file.Read 时,就会触发 Go 中最常见的 panic 之一:空指针异常,导致程序崩溃。

我们当然可以在代码里用 recover() 捕获 panic,但这样一来,它就像是前面 Python 里的 except Exeption as e 一样了,变成了一个类似全局的整体处理,无法针对具体的问题进行处理。

或者我们可以通过 if file == nil 来判断指针是否为空?嗯,确实可以这么做,但这样一来,它其实和判断 error if err != nil 在代码上就没什么区别了,而 error 还能打印出为什么 file 为空。

另外,类似判断 file 是否为 nil 的方法,即判断返回值本身,在指针返回值上有用,但在这段代码的第二个地方 file.Read() 用就有问题了。file.Read() 返回的第一个结果是本次读取的数据字节长度,类型是 int 。而 file.Read() 在出错时会返回 0,那么这样一来,如果我们只判断 count 是否为 0,我们无法得知这个 0 是因为 Read() 出错了,还是因为目标文件的长度就是 0。这一点如果在某大型项目中造成 bug,它很可能会成为一个非常难以定位的问题。

综上,在实际项目开发过程中,开发者其实不得不大量使用 if err != nil 的判断。这种写法本身并不优雅,很多人觉得丑陋,这一点我个人觉得还好(大概习惯了)。这里主要问题是,error 以函数返回值的方式获取,这种方式将它作为一个 error 的特殊性表现出来,有些时候甚至会和普通返回值混淆。例如,如果某个有意或无意的程序员,把接收 error 的变量改成了与 error 无关的字样,像是 file, desc := os.Open("test.txt") ,接收 error 的变量写成了 desc,那么代码的可读性将会变得非常糟糕,会让其他不明白函数细节的人产生误解,这对于 debug 以及后期的维护都会是噩梦。

2.2.2 error 使用和扩展

前面的 error 捕获如果使用不当,会带来一些难以定位的 bug,而对 error 的一些使用就着实有些不太方便了。

Go 中的 error 类型实际上是一个 interface

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

可以看到,error 只有一个 Error() string 方法。

这就有点难受了,对比前面的 Python,Go 的 error 就相当于,整个 Python 标准库中只提供了一个 Exception 类,其他什么 KeyErrorValueError 的都没有,这就导致我们没办法精确地判断出某个函数到底返回了什么类型错误,因为 error 压根就没有区分类型。于是我们会看到,代码里为了判断错误类型,很多人会解析 Error() 返回的字符串的内容关键字。这可太扯了,想想都知道会有什么风险。

但这种无子类型的 error 在某种程度上,倒是也能逼迫一些开发者将函数的功能尽可能原子化,即遵守单一职责原则,而不是写出一些巨大的函数,返回无数个 error。

有些项目或团队会在代码中对 error 接口进行封装,然后维护一套自己的异常库、包,并规定该项目里产生的异常和错误都必须使用这一套代码。这确实能解决问题,但其实也是一种妥协下的选择。而且,这只能解决自己项目里产生的错误的问题,如果调用的是标准库、第三方库,依然没有办法。

2.2.3 Go 总结

作为返回值的 error 可以不被捕获和处理,也可以随便命名,其实对 Go 比较了解的朋友也许可以感受到,这种高度自由,低门槛的设计其实是有点符合 Go 官方的设计哲学的,即 Go 本身尽可能不干预开发者的行为,把对代码的处理方式交给开发者自己去做,力求让开发者快速上手开发。所以它不强制我们必须捕获处理异常,也未专门针对 error 做特殊语法。包括 error 的可扩展性也是,标准库只提供了最基础的 error 接口,剩下的开发者们自己想办法吧……

但这样一来,代码丑不丑另说,对开发者个人素质和技术水平的要求其实非常高。在 Python 中,包括后文会看到的 Rust 里,如果开发者没有捕获异常,那么就得承担程序崩溃的高风险(Rust 中某些情况下甚至编译都无法通过),但 Go 不会(至少不是所有地方都会因为空指针而 panic),Go 会带来更恶心的,难以定位的隐藏式的 bug。真实的企业项目中,这样的情况其实真的不少,毕竟整个世界都是一个巨大的草台班子(大笑)。

也许 Google 当初发明 Go 语言时,的确是为了要更快上手而选择了这样的设计原则,也许是 Google 程序员作为世界最顶级的开发者,他们的技术水平和素质不会导致犯下很多低级错误,总之,Go 的异常错误处理就以这样一种被人诟病的现状存在着。

2.3 严格且繁杂的 Rust

Rust 和 Go 的设计哲学可以说是两个极端,Rust 会严格约束开发者的一些行为,并且会在编译期间就阻止 Rust 认为不安全、不合法的代码,异常处理就是其中一个。

2.3.1 异常捕获

我们依然先不管 panic,主要考量异常捕获,即 Rust 中的 Result。

Rust 有很多非常特别的语法,会让初次接触该语言的开发者一脸懵逼。譬如前面 Rust 代码中,result_fn() 是这样的:

fn result_fn() -> Result<File, Error> {
    let file = File::open("test.txt")?;
    Ok(file)
}

注意 File::open() 函数最后的问号 ? ,这个问号的意思是说,File::open() 函数也会返回一个 Result,如果 Result 的结果是 Ok ,则绑定结果到 file 变量上,否则,直接向外返回 Error ,也就是 result_fn() 函数直接返回一个 Error 给外部调用者,不再执行后面的代码。改写一下,这个函数其实可以写成这样:

fn result_fn() -> Result<File, Error> {
    let file_result = File::open("test.txt");
    let file: File;
    if file_result.is_ok() {
        file = file_result.unwrap();
    } else {
        return Err(file_result.err().unwrap());
    }
    return Ok(file);
}

// Or

fn result_fn() -> Result<File, Error> {
    let file_result = File::open("test.txt");
    let file = match file_result {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
    return Ok(file);
}

这两种写法里,第一种写虽然很不 Rust,但至少能让不熟悉 Rust 的朋友也大致理解了。

然后就是,为什么正确结果和 error 要用 Ok()Err() 这两个奇怪的东西包裹?这就需要把 Rust 的 Result 源码贴出来了,去掉一堆装饰宏之后是这样的:

/// `Result` is a type that represents either success ([`Ok`]) or failure ([`Err`]).
///
/// See the [module documentation](self) for details.
pub enum Result<T, E> {
    /// Contains the success value
    Ok(T),
    /// Contains the error value
    Err(E),
}

Result 其实就是一个枚举,它的两个成员 Ok(T)Err(E) 分别通过泛型包裹了正确的值和 error。

举上面的例子和源码是想说明两个问题:

  1. Rust 的错误处理有不止一种方式,且有些方式非常简便,能节省不少代码;
  2. Rust 中的异常如果不处理,就无法使用非异常的返回值;

第二点可能有些不好理解。简单说,因为 Rust 会把正确结果和 error 用 Result 枚举给包装起来,因此我们必须获取这个 Result 才能获取我们想要的返回值。而获取到 Result 其实就相当于捕获了异常了,因为异常也是这个枚举对象的一部分。而如果我们没有接收这个 Result 返回值,我们也就得不到一同返回的正确值。这其实很大程度上就避免了上面 Go 会出现的,不判断 error 导致 file 空指针、count 隐形 bug 的问题。

同为返回值方式的异常抛出,Rust 这里就看出它和 Go 的松散约束不同的地方了。Rust 告诉你,如果想得到函数返回值,就必须把 error 一起捕获了。而且,由于枚举类型自身的特性,一旦 Result 是 Err ,那就不会是 Ok ,它俩只能二选一。因此只要有 Err ,就意味着我们不可能得到 Ok 中正确的返回值,也就无法用它来做任何事情。

2.3.2 使用和扩展

前文我们其实已经把 Rust 异常处理的基本使用方式介绍的差不多了,还写了三种不同的处理方法,这也是 Rust 处理 Result 时的灵活之处。尽管处理的选择越多,就意味着初始学习成本会越高,但……反正 Rust 都这么难学了,也不在乎多几个特性(大笑)。

扩展上 Result 本身是一个内置的枚举,而且它接收泛型,因此我们可以基于这个枚举来定制我们自己的 Result。譬如标准库 std::io::Result 就是这么定义的:

pub struct Error {
    repr: Repr,
}

pub type Result<T> = result::Result<T, Error>;

顺便还定义了一个 io 自己的 Error 类型。

多说一句,Rust 中还有另一个与 Result 使用方式几乎一样的枚举,叫 Option

pub enum Option<T> {
    /// No value.
    None,
    /// Some value of type `T`.
    Some(T),
}

很多 Rust 新手会把它和 Result 搞混,甚至不明白为什么要搞两个这么像的东西。其实很简单,它俩的区别在于:Option 是用来告诉用户,某个值是否存在;Result 是用来告诉用户,你获取某个值时有没有出意外。

2.3.3 总结

Rust 异常处理的优点其实在前面都展示了不少了,这里就讲一下我眼中 Rust 异常处理不太好的地方。

首先,Rust 的 Result 可扩展性确实还不错,但没有 Python 那种直接继承的方式来的方便也是真的。而且,我前面有一点一直没提,就是 Rust 也没有 Python 这种传统而简单的 Error 类型判断,所以 Rust 标准库中的那些返回 Result 的地方,很多也都没有区分不同类型的 Error。譬如前面举例的 std::fs::File 的源码里,用的全都是 std::io::Result 这一种 Result(意味着 Error 也是同一种)。这种设计理念和 Go 很类似,不再区分 error 的子类型,但与 Python 的传统设计确是截然相反的。

3 结尾

三个语言的异常处理讲完了,各有优劣。并且由于篇幅的原因,我没有更深入展开更多的知识点,如各个语言异常处理在多线程上的表现等等。但总的来说,这么多年代码写下来,目前我还是更喜欢 Python 的异常处理方式,直观且简单。

对我来说 Rust 好的一点是,它迫使你必须捕获异常(当然也有人认为这种强迫并不好),尽管我会因此多写一些代码,但 Rust 提供的一些语法糖一定程度上减轻了这方面的折磨。所以…… Go 什么时候也搞点语法糖?别管那什么劳什子的向后兼容性了!

这里必须要吐槽一点,Go 社区经常会有一些有意思的提案,但 Go 官方往往会因为兼容性问题拒绝这些提案,这就使得 Go 语言本身的发展其实相对比较缓慢。还是那句话,各有优缺点,Go 官方更看重 Go 语言的稳定性和兼容性,而 Go 发展的决策权其实也主要集中在其团队手中,而非社区,这样的决策一定也有官方自己的考量。

回顾一下本文,Python 的篇幅最短,异常处理使用也最简单,这真的是,人生苦短,快用 Python!


程序员小杜
1.3k 声望37 粉丝

会写 Python,会写 Go,正在学 Rust。