以Rust和Go为例编程语言的错误处理

柏舟   新冠5年 01-23

编写程序的流程就像一棵树,越往树梢走,分支越多。然而讨厌的是只有特定的分支才能到达终点,而剩余的绝大多数的分支都属于异常情况需要处理。在异常中分为两类,一类是程序员考虑过的,另一类是程序员没有想到的。通常我们希望能够处理第一类错误,及时暴露第二类错误的同时,不影响正常程序处理。为了达成这个目标有一系列的技术:

  1. 编程语言内置了错误/异常系统,有C语言返回错误码的形式,Go和Rust返回错误接口的形式,和C++、Java等异常控制流的形式。
  2. 错误恢复技术,除了try catch,recover,最有名的就是Erlang的任其崩溃的思想了。
  3. 测试驱动开发思想(TDD),在代码开发层面通过开发单元测试提高代码质量。
  4. 系统设计上使用容错技术,比如批处理的持久化,同一个软件开发几套、运行时投票。

还有形式化等等各种工具。错误处理涵盖了编程的各个方面,涉及多种多样的需求。

编程语言错误处理概述

编程语言提供的错误处理功能十分有限,最强大的是C++类的抛出异常,提供了异常控制流的技术,可以自由的托管异常的处理位置。但相应的问题是如果函数内部的异常特别多,注释又没有写,程序员必须看源代码才能了解存在哪些异常。所以,这套体系不适合异常很多且需要处理的情况,对于复杂的业务需要仔细设计。但是总的来说,对于异常大部分都是无法处理的,任其崩溃就好了。

Go和Rust这类错误处理方式需要显式处理错误。优点是错误类型都是显式定义的,缺点是会污染所有后续的函数,并且影响调用栈,很难调试。

Rust的错误处理:各种毫无意义的复杂度

我认为Rust的最大痛点是错误处理。我当时学Rust非常顺利,因为函数式我很熟悉,C++我也会,但是我遇到最大的坑就是Box<dyn Error+Send+Sync>。这玩意太离谱,我知道多线程变量传递确实存在拷贝的问题,但是我一点都不关心。因为这玩意儿连转换成Box<dyn Error>都做不到,真的极其难用。

Rust原生的方法是使用enumType,每个错误定义一个enum值,在上层进行判断。但这个最大的问题就是当函数处理过程中需要增加额外的错误的时候,就需要一层层错误往上面包,最后成一个树的情况。

flowchart TD; EnumError1 --> EnumError2 & Error3; EnumError2 --> DbError & NumberParseError; DbError --> RecordNotFound & SyntaxError;

Rust并不支持多重继承,所以注定了你编写的Error不能展成一个平面,你和使用你的库的人不得不写成树的形式,在能够处理Error的位置一层层展开处理。那个括号叠括号的代码,写起来和读起来真的痛不欲生,而且根本没有替代方案(只能不停拆分子函数)。

此外,由于你的Error类型和你使用的底层库的Error类型不同,你不得不编写一些Convertor代码,比如:

impl From<DbErr> for DAOErr {
    fn from(err: DbErr) -> Self {
        match err {
            DbErr::RecordNotFound(s) => Self::RecordNotFound(s),
            _ => Self::Custom(err.to_string()),
        }
    }
}

这些代码根本没有意义,你还不得不写,最讨厌的是你还需要处理内部信息和Send+Sync的问题。所以我特别好奇,那些吹Rust的人到底被Rust的错误处理折磨过没有?

Go的错误处理:简单得处理不了问题

Go的错误处理特别简单,就是一个error的interface,返回字符串。它最大的痛点就是把所有错误处理的缺点都集齐了:

  1. 经常使用fmt.Errorf导致没有类型信息。
  2. 函数报错退出时,它返回的堆栈信息是panic的位置,而不是实际error发生的位置,这导致你无法直接找到error发生的位置。
  3. 随处可见的if err != nil。
  4. 虽然有defer,但是还是需要仔细检查释放资源。

当程序因为异常退出了,一般的处理方式有两种:

  1. 如果程序比较简单,用Debug模式复现一下,步进一步一步找;
  2. 如果程序比较复杂,就全局搜索error的字符串信息,看在哪里定义了。 如果你的程序用了go程,那么Debug真的是一段痛不欲生的经历。

Go的错误处理就是error的字符串信息硬编码,对于一些OsError和IOError,官方库还有定义,但是你想要处理TCP/UDP缓存区溢出,不好意思,我在官方库源代码找了半天,没有显示定义,最后用的string contains硬编码。

if err != nil {
    info := err.Error()
    switch {
        case strings.HasSuffix(info, "connection refused"):
        case strings.HasSuffix(info, "no route to host"):
        case strings.HasSuffix(info, "no buffer space available"):
        default:
            obj.MsgWriter.Error(err)
            return err
    }
}

如果是多人合作编写Go代码,其它人代码写得丑陋,错误信息不好好写,出了错全局搜索出来一大片,或者根本无法搜索,那就彻底完蛋。

虽然Rust也有错误栈的问题,但是Rust显示定义的错误的类型(只要你不用anyhow),并且内建了stackstrace,追踪起来还是比较容易的。

错误处理通常解决方案:类型系统约束,源头上解决问题

其实我在以前的文章提过了。静态类型系统最大的优势就是约束你的输入输出类型。只要你的函数定义域不同,就需要定义不同的类型。也许静态检查并不能真的检查出值的不同,但是可以提醒你这两个变量类型是不同的,转换时需要进行检查。不要用同一个int表示很多含义的数据,很容易写着就写混了,定义域对不上了。把经常同时出现的放在一个struct下,有约束的单独定义类型以示区别。

此外,异常处理一定是一个系统的问题,需要结合语言特点、需求、位置综合考虑:

  1. 哪些是设计时就考虑到的;
  2. 哪些是未知的异常,但是可以处理的;
  3. 哪些是未知的异常,但是需要修复的。