我过去曾经尝试过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提供了两种类型的异常:检查型和非检查型。检查过的异常需要。
-
要么在本地处理,如上所述在
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,2 和3 的含义。
如果有一个专门的结构来存储常规结果或异常,那就更好了。这也是Either<L,R> 类型的目标。
按照惯例,左边存放的是失败,右边是成功。我们可以把上面的片段改写成。
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 | 如果Result 是Ok ,则继续处理其内容 |
| 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的? 操作符,你可以用它来编写既简洁又可读的代码。