不同语言的错误处理

155 阅读7分钟

我过去曾经尝试过Go,至少可以说我对它并不热衷。我最担心的是该语言如何处理错误,或者更准确地说,它为开发者提供了什么机制来管理这些错误。在这篇文章中,我想描述几种流行的语言是如何处理错误的。

我们时代之前的时代

我也许可以追溯到很久之前,但我需要在某个时候选择一个基线。在这篇文章中,基线是C。

如果你在网上搜索 "错误处理C",你可能会经常绊倒在以下内容上。

C语言不提供对错误处理的直接支持

由于缺乏这种支持,开发人员创造了应对机制。其中一种方法是让函数返回一个代表错误的值。这个值是数字,而且文档中描述了这个问题。

如果函数需要返回一个值,你需要替代方法。比如说。

  • 定义一个指针参数,如果发生错误将被设置。如果调用成功,它将被null
  • 另一种方法是使用一个专门的结构,有一个字段专门用来存储错误。

最后的解决方案是使用一个全局错误变量--errno

每一种替代方案都有优点和缺点。然而,由于没有现成的方法,最大的问题是缺乏一致性。

异常

我不知道哪种语言首先实现了异常,但我很确定Java是普及异常的语言。异常解决了一个常见的问题:简单的错误检查代码将名义路径和错误处理路径交织在一起。

int foo;
int bar;
int slice;
foo = get_foo();
if (foo < 0)
    {
        return foo;
    }
bar = slice_the_bar(foo);
if (bar < 0)
    {
        return bar;
    }
slice = check_bar_slice(bar);
if (slice < 0)
    {
        return slice;
    }

异常的好处是把它们干净地分开在不同的块中,以方便阅读。

try {
    int foo = getFoo();             (1) (4)
    int bar = sliceTheBar(foo);     (2) (4)
    checkBarSlice(bar);             (3) (4)
} catch (FooException e) {
    // Do something with e          (1)
} catch (BarException e) {
    // Do something with e          (2)
} catch (SliceException e) {
    // Do something with e          (3)
} finally {
    // Will be executed in all cases
}
1如果调用抛出一个FooException ,则短路并直接执行相关的catch
2同样,对于BarException
3同上SliceException
4名义路径

Java的异常是在其类型系统中烘托出来的。

Java Exception class diagram

Java提供了两种类型的异常:检查型非检查型。检查过的异常需要。

  • 要么在本地处理,如上所述在try/catch 块中处理

  • 或者 "向上 "传播,通过在方法签名中定义异常,例如:

    Foo getFoo() throws FooException {
        // return a Foo or throw a new FooException
    }
    

编译器会强制执行这一要求。未检查的异常不需要遵循这个规则,但可以。

后来设计的一些语言也确实实现了异常。Scala和Kotlin,因为它们共享Java的JVM根源,但也有Python和Ruby。

Try容器

虽然异常是对普通返回值的一种改进,但它们也不能免于批评。大部分的批评都是针对检查过的异常,因为它们所基于的机制使代码变得混乱。此外,有些人认为所有的异常都是一种GOTO ,因为它具有短路的性质。

随着近年来功能编程的兴起,开发人员提供了库,将其引入主流语言。异常是FP从业者的大忌,因为它们为部分定义的函数开辟了道路。部分定义的函数是一个只对特定范围的参数值有效的函数。例如,divide() ,对除0以外的所有参数都有效。在FP中,人们应该返回调用的结果,无论它是成功还是失败。

在Java中,Vavr库用Try类型弥合了异常和FP之间的差距。我们可以用Vavr重写上面的代码段为。

Try.of(() -> getFoo())                      (1)
   .mapTry(foo -> sliceTheBar(foo))         (1)
   .andThenTry(bar -> checkBarSlice(bar))   (1)
   .recover(FooException.class, e -> 1)     (2)
   .recover(BarException.class, e -> 2)     (2)
   .recover(SliceException.class, e -> 3)   (2)
   .andFinally(() -> {})                    (3)
   .getOrElse(() -> 5);                     (4)
1名义路径
2在相关异常被抛出的情况下设置返回值
3在所有情况下都要执行的块,无论是名义路径还是异常情况
4如果有结果,则获取结果,或者返回供应商的执行结果

无论哪种容器

虽然上面的片段可能对你的FP端有吸引力,但我们的编程端可能不高兴。我们不得不给异常指定唯一的返回值。我们必须知道1,23 的含义。

如果有一个专门的结构来存储常规结果或异常,那就更好了。这也是Either<L,R> 类型的目标。

Vavr’s Either type

按照惯例,左边存放的是失败,右边是成功。我们可以把上面的片段改写成。

Try.of(() -> getFoo())
   .mapTry(foo -> sliceTheBar(foo))
   .andThenTry(bar -> checkBarSlice(bar))
   .andFinally(() -> {})
   .toEither()                             (1)
1持有一个Throwable 一个Integer

正如我在上面提到的,Try 是一个很好的桥梁,可以从抛出异常的设计到FP方法。随着时间的推移,你可能会发展设计,将Either 纳入方法签名中。下面是它们的比较。

异常功能性
int getFoo() throws FooException

|

Either<FooFailure, Integer> getFoo()

| |

int sliceTheBar(Foo foo) throws BarException

|

Either<Failure, Integer> sliceTheBar(int foo)

| |

void checkBarSlice(Bar bar) throws SliceException

|

Either<Failure, Integer> checkBarSlice(int bar)

|

现在的用户代码要简单得多。

var result = getFoo()
    .flatMap(foo -> sliceTheBar(foo))
    .flatMap(bar -> checkBarSlice(bar));

注意,之前的andFinally() 块不需要特殊处理。

两者都是类固醇

Java通过一个库提供Either ,其他语言也是如此。然而,其中有几个语言将其整合在他们的标准库中。

  • Kotlin提供了Result。与普通的Either 相比,它强迫左边的人成为一个例外,而且它不是模板化的,也就是说,类型是 Exception
  • Scala提供了一个常规的Either<L, R>

然而,在这两种情况下,它都 "只是 "一个类型。Rust把Either 带到了另一个层次;它也把它称为结果。Rust的Result ,是在语言的语法中烘托出来的。

下面是Rust编程语言在线书中的一个函数示例。

fn read_username_from_file() -> Result<String, io::Error> {
    let f = File::open("hello.txt");                         (1)
    let mut f = match f {                                    (2)
        Ok(file) => file,                                      (3)
        Err(e) => return Err(e),                             (4)
    };
    let mut s = String::new();
    match f.read_to_string(&mut s) {                         (2) (5)
        Ok(_) => Ok(s),                                      (3)
        Err(e) => Err(e),                                    (4)
    }
}
1读取一个文件。File::open ,返回一个Result ,因为它可能失败。
2评估Result
3如果ResultOk ,则继续处理其内容
4如果不是,则返回一个新的错误Result ,包住原来的错误
5在Rust中,如果一个函数的最后一行是一个表达式(没有分号),你可以隐式返回

Rust引入了传播错误的快捷方式?? ,意思如下。

  • 如果Result 包含Err ,立即用它返回
  • 如果它包含Ok ,则解开其值并继续进行

有了它,我们可以把上面的片段改写成。

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();
    File::open("hello.txt")?                                 (1)
         .read_to_string(&mut s)?;                           (1)
    Ok(s)                                                    (2)
}
1如果Ok, 解除该值,否则返回Err
2返还Result

Go的奇特情况

纵观历史,编程语言提供了越来越强大的结构来处理错误:从简单的返回值到通过异常Either 。这让我们看到了Go编程语言。相对来说,它是最近才被提出来的,它迫使开发者通过......多个返回值来处理错误。

varFoo, err := GetFoo()                   (1)
if err != nil {                           (2)
    return err
}
sliceBar, err := SliceTheBar(varFoo)      (1)
if err != nil {                           (2)
    return err
}
err := CheckBarSlice(sliceBar)            (1)
if err != nil {                           (2)
    return err
}
1返回错误引用
2检查该引用是否指向一个错误

开发者必须检查每一个潜在的错误,使代码中的错误处理代码在名义路径上变得混乱。我不知道为什么Go的设计者会选择这样的方法。

总结

我不是函数式编程的专家,也不是一个死忠的粉丝。我只是承认它的好处。例如,你可以围绕不变性设计你的面向对象模型。

作为一个JVM开发者,我从一开始就在使用异常。然而,对于错误处理来说,Either 的方法更有优势。通过适当的语法,比如Rust的? 操作符,你可以用它来编写既简洁又可读的代码。

更进一步。

关注@nicolas_frankel