被误解的error,被滥用的异常
Go error从诞生之初就招人诟病,人们总是调侃写Go代码有一半都是if err != error {}
。但其实大家都没有正确的去理解和看待error这个东西,这也是我写本篇文章的目的。
其实大家诟病Go error的核心矛盾在于error不能自动结束函数流程。我们期望有一种机制能在err != nil
的时候自动返回,而不是手动return
。你之所以会这样想是因为你习惯了使用异常,你希望向使用异常那样使用error。但是在Go语言中,异常和error显然是两个有着明显区别的概念。我们似乎很少去思考error和异常之间的区别,甚至很多人习惯了无脑抛异常,导致异常被滥用。
在Go语言中,error是一个值,一个很普通的值,和2,"abc"没有任何区别的值,而且还是可以被忽略的。它仅仅表示一种函数执行后的状态,没有任何特殊,Go语言希望我们手动的去处理它,决定程序的流程是返回还是继续,决定是忽略error还是返回它。从某种意义上来说,error应该是一种正常状态,异常才是不正常的状态,程序必须马上终止执行。
Go语言的error是一个值!实际上任何语言的error都是值,不同的是对error的处理机制。Go是一门支持多返回值的语言,这使得它可以将真正的返回值和error分开返回,这同样也使得error的处理很难自动化进行,我们必须在err != nil
的时候手动结束函数流程。同样,Go语言的error也是非常灵活和自然的。你会发现在Go语言中处理error不需要任何额外的语法,而且不需要扩展if
的语义。至于错误应该何时传播,何时终止传播,何时处理,这并不是Go语言的问题,而是所有语言的问题。
其他语言对error的处理
Rust
鼎鼎大名,它的核心思想是如果一片内存在任何时候都只被一个变量引用,那么我们就可以在变量退出作用域时安全的释放这片内存。所有权和借用的概念都是从这一思想来的。《Rust程序语言设计》的错误处理一章有一段话,大家可以好好理解以下,它说的就是error和异常。
Rust 将错误分为两大类:可恢复的(recoverable)和 不可恢复的(unrecoverable)错误。对于一个可恢复的错误,比如文件未找到的错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是 bug 出现的征兆,比如试图访问一个超过数组末端的位置,因此我们要立即停止程序。
Rust对错误的处理方式是将返回值和error用枚举类型结合起来。
enum Result<T, E> {
Ok(T),
Err(E),
}
使用?
传递错误:
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}
使用match
手动处理错误:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
Rust提供了很多错误处理相关的函数,这里仅举两个基本的例子。
V
V语言很像是Go和Rust的合体。它算是一门比较年轻的语言,目前最新的是版本是0.3。它的吉祥物总感觉很熟悉,但又叫不上名字。
在Go语言中,我们通常会返回一个值和一个error,比如:
func string2int(s string) (int, error) {
...
}
而V语言则将两者结合,形成了一种可选类型,就是在具体类型前加上一个?
,上面的函数在V语言中会有如下定义:
fn string2int(s string) ?int {
return error("未实现")
}
对于?Type
这样的类型,我们可以直接返回Type
类型的值,也可以直接返回error
,但是不支持多返回值。
如果在函数调用后面加上一个?
,那么如果函数返回error,error就会向上传递,知道传递到main
函数,程序就会panic。
import net.http
fn f(url string) ?string {
resp := http.get(url)?
return resp.text
}
如果需要手动处理error,可以在函数调用后加上一个or
代码块。在or
代码块中,既可以结束程序,也可以给一个默认值,在or
代码块中,可以直接访问err
变量。
结束流程的例子:
user := repo.find_user_by_id(7) or {
println(err) // "User 7 not found"
return
}
提供默认值的例子:
fn do_something(s string) ?string {
if s == 'foo' {
return 'foo'
}
return error('invalid string') // Could be `return none` as well
}
a := do_something('foo') or { 'default' } // a will be 'foo'
b := do_something('bar') or { 'default' } // b will be 'default'
println(a)
println(b)
其实我们可以发现,使用?
调用函数和or {return err}
是等价的。
此外,还也可以使用if-else
处理可选值:
import net.http
if resp := http.get('https://google.com') {
println(resp.text) // resp is a http.Response, not an optional
} else {
println(err)
}
仔细对比就会发现,这不就是Go语言中if v,err := xxx(); err != nil {} else {}
的简化形式吗。
V语言中error也是值,但是它提供了更自动化的机制来处理error,使得error有了一点异常的影子。
Zig
Zig可能听过的人比较少,但它是励志想要替换C语言的一门语言。它的宣言是专注于调试你的程序,而不是编程语言知识。
Zig也提供了一种机制将返回值和error组合在一起,称为错误联合类型(Error Union Type)。其语法是errorSetType!Type
,其中errorSetType
可以省略,也就是说可以简写为!Type
。
pub fn parseU64(buf: []const u8, radix: u8) !u64 {
...
}
能够处理错误联合类型的有catch
,try
,switch
,errdefer
。
使用cache
提供默认值:
fn doAThing(str: []u8) void {
const number = parseU64(str, 10) catch 13;
...
}
使用try
传递error:
fn doAThing(str: []u8) !void {
const number = try parseU64(str, 10);
...
}
它实际上等价于下面的代码:
fn doAThing(str: []u8) !void {
const number = parseU64(str, 10) catch |err| return err;
...
}
使用switch
处理不同错误,奇奇怪怪的语法🤣:
fn doAThing(str: []u8) void {
if (parseU64(str, 10)) |number| {
doSomethingWithNumber(number);
} else |err| switch (err) {
error.Overflow => {
// handle overflow...
},
// we promise that InvalidChar won't happen (or crash in debug mode if it does)
error.InvalidChar => unreachable,
}
}
errdefer
,发生错误时的延迟执行:
fn createFoo(param: i32) !Foo {
const foo = try tryToAllocateFoo();
// now we have allocated foo. we need to free it if the function fails.
// but we want to return it if the function succeeds.
errdefer deallocateFoo(foo);
const tmp_buf = allocateTmpBuffer() orelse return error.OutOfMemory;
// tmp_buf is truly a temporary resource, and we for sure want to clean it up
// before this block leaves scope
defer deallocateTmpBuffer(tmp_buf);
if (param > 1337) return error.InvalidParam;
// here the errdefer will not run since we're returning success from the function.
// but the defer will run!
return foo;
}
Zig不支持多返回值,返回值和error的组合算是一个不错的选择,而且提供了一些自动化的机制,但是你需要明白什么时候会结束程序流程,什么时候不会。
总结
Go语言error的设计是不是最优秀的?我认为不是,但也不足以让我诟病它。当你学过几门编程语言之后就会深刻体会到Go是多么的简洁明了,它既没有奇奇怪怪的语法,也没有大篇大篇的关键字,绝大多数表述都是很自然的,它与我们的经验相符,因此,我很容易接受它。试想以下,我买个锤子,结果你给我一本两百页的说明书,并叮嘱我看完说明书以后才能使用,我一定会脱口而出WTF。对于习惯了996的我们,恨不得一行代码跑出10行的功能,有没有一种可能是我们真的太卷了呢。