Go语言之异常处理
在编写Go语言代码的时候,我们应该习惯使用error类型值来表明非正常的状态。作为惯用法,在Go语言标准库代码包中的很多函数和方法也会以返回error类型值来表明错误状态及其详细信息。
error是一个预定义标识符,它代表了一个Go语言內建的接口类型。这个接口的类型声明如下:
type error interface{ Error() string }
其中的Error方法声明的意义就在于为方法调用方提供当前错误状态的详细信息。任何数据类型只要实现了这个可以返回string类型值的Error方法就可以成为一个error接口类型的实现。不过在通常情况下,我们并不需要自己去编写一个error的实现类型。Go语言的标准库代码包errors为我们提供了一个用于创建errors类型值的函数New。该方法的声明如下:
func New(text string) error { return &errorString{text} }
errors.New函数接受一个string类型的参数值并可以返回一个error类型值。这个error类型值的动态类型就是errors.errorString类型。New函数的唯一参数被用于初始化那个errors.errorString类型的值。从代表这个实现类型的名称上可以看出,该类型是一个包级私有的类型。它只是errors包的内部实现的一部分,而非公开的API。errors.errorString类型及其方法的声明如下:
type errorString struct { s string } func (e *errorString) Error() string { return e.s }
传递给errors.New函数的参数值就是当我们调用它的Error方法的时候返回的那个结果值。
我们可以使用代码包fmt中的打印函数打印出error类型值所代表的错误的详细信息,就像这样:
var err error = errors.New("A normal error.")
这些打印函数在发现打印的内容是一个error类型值的时候都会调用该值的Error方法并将结果值作为该值的字符串表示形式。因此,我们传递给errors.New的参数值即是其返回的error类型值的字符串表示形式。
另一个可以生成error类型值的方法是调用fmt包中的Errorf函数。调用它的代码类似于:
err2 := fmt.Errorf("%s\n", "A normal error.")
与fmt.Printf函数相同,fmt.Errorf函数可以根据格式说明符和后续参数生成一个字符串类型值。但与fmt.Printf函数不同的是,fmt.Errorf函数并不会在标准输出上打印这个生成的字符串类型值,而是用它来初始化一个error类型值并作为该函数的结果值返回给调用方。在fmt.Errorf函数的内部,创建和初始化error类型值的操作正是通过调用errors.New函数来完成。
在大多数情况下,errors.New函数和fmt.Errorf函数足以满足我们创建error类型值的要求。但是,接口类型error使得我们拥有了很大的扩展空间。我们可以根据需要定义自己的error类型。例如,我们可以使用额外的字段和方法让程序使用方能够获取更多的错误信息。例如,结构体类型os.PathError是一个error接口类型的实现类型。它的声明中包含了3个字段,这使得我们能够从它的Error方法的结果值当中获取到更多的信息。os.PathError类型及其方法的声明如下:
// PathError records an error and the operation and file path that caused it. type PathError struct { Op string Path string Err error } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
从os.PathError类型的声明上我们可以获知,它的3个字段都是公开的。因此,在任何位置上我们都可以直接通过选择符访问到它们。但是,在通常情况下,函数或方法中的相关结果声明的类型应该是error类型,而不应该是某一个error类型的实现类型。这也是为了遵循面向接口编程的原则。在这种情况下,我们常常需要先判定获取到的error类型值的动态类型,再依此来进行必要的类型转换和后续操作。例如:
file, err3 := os.Open("/etc/profile") if err3 != nil { if pe, ok := err3.(*os.PathError); ok { fmt.Printf("Path Error: %s (op=%s, path=%s)\n", pe.Err, pe.Op, pe.Path) } else { fmt.Printf("Unknown Error: %s\n", err3) } }
我们通过类型断言表达式和if语句来对os.Open函数返回的error类型值进行处理。这与把error类型值作为结果值来表达函数执行的错误状态的做法一样,也属于Go语言中的异常处理的惯用法之一。
如果os.Open函数在执行过程中没有发生任何错误,那么我们就可以对变量file所代表的文件的内容进行读取了。相关代码如下:
r := bufio.NewReader(file) var buf bytes.Buffer for { byteArray, _, err4 := r.ReadLine() if err4 != nil { if err4 == io.EOF { break } else { fmt.Printf("Read Error: %s\n", err4) break } } else { buf.Write(byteArray) } }
io.EOF变量正是由errors.New函数的结果值来初始化的。EOF是文件结束符(End Of File)的缩写。对于文件读取操作来说,它意味着读取器已经读到了文件的末尾。因此,严格来说,EOF并不应该算作一个真正的错误,而仅仅属于一种“错误信号”。
变量r代表了一个读取器。它的ReadLine方法返回3个结果值。第三个结果值的类型就是error类型的。当读取器读到file所代表的文件的末尾时,ReadLine方法会直接将变量io.EOF的值作为它的第三个结果值返回。如果判断的结果为true,那么我们就可以直接终止那个用于连续读取文件内容的for语句的执行。否则,我们就应该意识到在读取文件内容的过程中有真正的错误发生了,并采取相应的措施。
注意,只有当两个error类型的变量的值确实为同一个值的时候,使用比较操作符==进行判断时才会得到true。从另一个角度看,我们可以预先声明一些error类型的变量,并把它们作为特殊的“错误信号”来使用。任何需要返回同一类“错误信号”的函数或方法都可以直接把这类预先声明的值拿来使用。这样我们就可以很便捷的使用==来识别这些“错误信号”并进行相应的操作了。
不过,需要注意的是,这类变量的值必须是不可变的。也就是说,它们的实际类型的声明中不应该包含任何公开的字段,并且附属于这些类型的方法也不应该包含对其字段进行赋值的语句。例如,我们前面提到的os.PathError类型就不适合作为这类变量的值的动态类型,否则很可能会造成不可预知的后果。
这种通过预先声明error类型的变量为程序使用方提供便利的做法在Go语言标准库代码包中非常常见。
关于实现error接口类型的另一个技巧是,我们还可以通过把error接口类型嵌入到新的接口类型中对它进行扩展。例如,标准库代码包net中的Error接口类型,其声明如下:
// An Error represents a network error. type Error interface { error Timeout() bool // Is the error a timeout? Temporary() bool // Is the error temporary? }
一些在net包中声明的函数会返回动态类型为net.Error的error类型值。在使用方,对这种error类型值的动态类型的判定方法与前面提及的基本一致。
如果变量err的动态类型是net.Error,那么就我们可以根据它的Temporary方法的结果值来判断当前的错误状态是否临时的:
if netErr, ok := err.(net.Error); ok && netErr.Temporary() { }
如果是临时的,那么就可以间隔一段时间之后再进行对之前的操作进行重试,否则就记录错误状态的信息并退出。假如我们没有对这个error类型值进行类型断言,也就无法获取到当前错误状态的那个额外属性,更无法决定是否应该进行重试操作了。这种对error类型的无缝扩展方式所带来的益处是显而易见的。
在Go语言中,对错误的正确处理是非常重要的。语言本身的设计和标准库代码中展示的惯用法鼓励我们对发生的错误进行显式的检查。虽然这会使Go语言代码看起来稍显冗长,但是我们可以使用一些技巧来简化它们。这些技巧大都与通用的编程最佳实践大同小异,或者已经或将要包含在我们所讲的内容(自定义错误类型、单一职责函数等)中,所以这并不是问题。况且,这一点点代价比传统的try-catch方式带来的弊端要小得多。