Go语言错误处理全攻略:从基础到优雅实践
在编程世界中,错误处理是不可避免的话题。Go语言以其简洁和高效著称,其错误处理机制更是别具一格。与其他语言的异常机制不同,Go通过返回值显式处理错误,这种设计不仅提高了代码的可读性,还赋予了开发者更多的控制权。本文将带你从Go错误处理的基础知识入手,逐步深入到优雅实践,解锁编写健壮代码的秘密。无论你是Go新手还是老手,这里都有值得一读的干货
本文全面剖析了Go语言的错误处理机制,从基础的多返回值约定、使用内置error类型,到defer关键字的资源管理,再到自定义错误类型和类型断言的高级用法。文中通过代码示例和互动小测试,展示了如何优雅地处理文件操作、输入验证等场景中的错误。此外,还对比了Go错误值与异常机制的差异,探讨了panic和recover的使用场景,帮助读者掌握从简单检查到复杂处理的完整技能树。
错误
处理错误
- Go语言允许函数和方法同时返回多个值
- 按照惯例,函数在返回错误时,最后边的返回值应用来表示错误。
- 调用函数后,应立即检查是否发生错误。
- 如果没有错误发生,那么返回的错误值为nil。
go
package main
import (
"fmt"
"io/ioutil"
"os"
)
func main() {
files, err := ioutil.ReadDir(".")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, file := range files {
fmt.Println(file.Name())
}
}
注意
- 当错误发生时,函数返回的其它值通常就不再可信。
小测试
- 修改例子,让它读取一个虚构的文件夹,如"unicorns",看看会打印出什么错误信息?
- 如果我们使用ReadDir来读取诸如"/etc/hosts"这样的文件而不是文件夹的时候,程序会打印出什么错误信息?
优雅的错误处理
- 减少错误处理代码的一种策略是:将程序中不会出错的部分和包含潜在错误隐患的部分隔离开来。
- 对于不得不返回错误的代码,应尽力简化相应的错误处理代码。
- Errors are values.
- Don't just check errors, handle them gracefully.
- Don't panic.
- Make the zero value useful.
- The bigger the interface, the weaker the abstraction.
- interface{} says nothing.
- Gofmt's style is no one's favorite, yet gofmt is everyone's favorite.
- Documentation is for users.
- A little copying is better than a little dependency.
- Clear is better than clever.
- Concurrency is not parallelism.
- Don't communicate by sharing memory, share memory bycommunicating.
- Channels orchestrate; mutexes serialize.
文件写入
-
写入文件的时候可能出错:
- 路径不正确
- 权限不够
- 磁盘空间不足
- ...
-
文件写入完毕后,必须被关闭,确保文件被刷到磁盘上,避免资源的泄露。
go
package main
import (
"fmt"
"os"
)
func proverbs(name string) error {
f, err := os.Create(name)
if err != nil {
return err
}
_, err = fmt.Fprintln(f, "Errors are values.")
if err != nil {
f.Close()
return err
}
_, err = fmt.Fprintln(f, "Don't just check errors, handle them gracefully.")
f.Close()
return err
}
func main() {
err := proverbs("proverbs.txt")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
内置类型error
- 内置类型error用来表示错误。
小测试
- 为什么函数应返回错误而不是退出程序?
- 答:在 Go 语言中,函数返回错误而不是直接退出程序,提供了更大的灵活性和健壮性。这种方式使得错误处理更加集中和清晰,避免了全局状态的改变,并且符合 Go 的设计哲学。通过合理使用错误返回,可以编写出更加模块化和可测试的代码。
defer关键字
- 使用defer关键字,Go可以确保所有deferred的动作可以在函数返回前执行。
go
package main
import (
"fmt"
"os"
)
func proverbs(name string) error {
f, err := os.Create(name)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintln(f, "Errors are values.")
if err != nil {
return err
}
_, err = fmt.Fprintln(f, "Don't just check errors, handle them gracefully.")
return err
}
func main() {
err := proverbs("proverbs.txt")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
- 可以defer任意的函数和方法。
- defer并不是专门做错误处理的。
- defer可以消除必须时刻惦记执行资源释放的负担
小测试
- defer的动作什么时候会被执行?
- 答:defer 语句在函数返回之前执行,无论是正常返回还是因为错误返回。 多个 defer 语句按照 后进先出(LIFO) 的顺序执行。 defer 语句可以在返回值确定之后访问和修改返回值(通过返回值的地址)。 defer 语句在 panic 之前执行,可以捕获 panic 并通过 recover 避免程序退出。
有创意的错误处理
go
package main
import (
"fmt"
"io"
)
type safeWriter struct {
w io.Writer
err error
}
func (sw *safeWriter) writeln(s string) {
if sw.err != nil {
return
}
_, sw.err = fmt.Fprintln(sw.w, s)
}
func proverbs(name string) error {
f, err := os.Create(name)
if err != nil {
return err
}
defer f.Close()
sw := safeWriter{w: f}
sw.writeln("Errors are values.")
sw.writeln("Don't just check errors, handle them gracefully.")
sw.writeln("Don't panic.")
sw.writeln("Make the zero value useful.")
return sw.err
}
func main() {
}
小测试
- 在例子中,如果在将 "Clear is better than clever."写入到文件的过程中出错了,那么接下来会发生哪些事件?
New error
- errors包里有一个构造用New函数,它接收string作为参数用来表示错误信息。该函数返回error类型。
go
package main
import (
"errors"
"fmt"
"os"
)
const rows, columns = 9, 9
// Grid is a Sudoku grid
type Grid [rows][columns]int8
// Set ...
func (g *Grid) Set(row, column int, digit int8) error {
if !inBounds(row, column) {
return errors.New("out of bounds")
}
g[row][column] = digit
return nil
}
func inBounds(row, column int) bool {
if row < 0 || row >= rows {
return false
}
if column < 0 || column >= columns {
return false
}
return true
}
func main() {
var g Grid
err := g.Set(10, 0, 5)
if err != nil {
fmt.Printf("An error occurred: %v.\n", err)
os.Exit(1)
}
}
提示
- 错误信息应具有信息性
- 可以把错误信息当作用户界面的一部分,无论对最终用户还是开发者。
小测试
- 在函数里,首先编写对输入参数的防卫性代码有什么好处?
- 答: 在函数中首先编写对输入参数的防卫性代码是一种良好的编程实践,它能够提高代码的健壮性、可维护性和可读性,同时提供清晰的错误信息,避免不必要的计算,并确保函数的行为一致。通过合理使用防卫性编程,可以编写出更加可靠和高效的代码。
按需返回错误
- 按照惯例,包含错误信息的变量名应以Err开头。
go
package main
import (
"errors"
"fmt"
"os"
)
const rows, columns = 9, 9
// Grid is a Sudoku grid
type Grid [rows][columns]int8
var (
// ErrBounds ...
ErrBounds = errors.New("out of bounds")
// ErrDigit ...
ErrDigit = errors.New("invalid digit")
)
// Set ...
func (g *Grid) Set(row, column int, digit int8) error {
if !inBounds(row, column) {
return ErrBounds
}
g[row][column] = digit
return nil
}
func inBounds(row, column int) bool {
if row < 0 || row >= rows {
return false
}
if column < 0 || column >= columns {
return false
}
return true
}
func main() {
var g Grid
err := g.Set(10, 0, 5)
if err != nil {
switch err { // 指针 内存地址
case ErrBounds, ErrDigit:
fmt.Println("Les erreurs de parametres hors limites.")
default:
fmt.Println(err)
}
os.Exit(1)
}
}
- errors.New这个构造函数是使用指针实现的,所以上例中的switch语句比较的是内存地址,而不是错误包含的文字信息。
小测试
- 编写一个validDigit函数,用它来保证Set函数只接受1到9之间的数值。
自定义错误类型
- error类型是一个内置的接口:任何类型只要实现了返回string的Error()方法就满足了该接口。
- 可以创建新的错误类型。
go
package main
import (
"errors"
"fmt"
"os"
)
const rows, columns = 9, 9
// Grid is a Sudoku grid
type Grid [rows][columns]int8
var (
// ErrBounds ...
ErrBounds = errors.New("out of bounds")
// ErrDigit ...
ErrDigit = errors.New("invalid digit")
)
// SudokuError ...
type SudokuError []error
// Error returns one or more errors separated by commas.
func (se SudokuError) Error() string {
var s []string
for _, err := range se {
s = append(s, err.Error())
}
return strings.Join(s, ", ")
}
// Set ...
func (g *Grid) Set(row, column int, digit int8) error {
var errs SudokuError
if !inBounds(row, column) {
errs = append(errs, ErrBounds)
}
if !validDight(digit) {
errs = append(errs, ErrDigit)
}
if len(errs) > 0 {
return errs
}
g[row][column] = digit
return nil
}
func inBounds(row, column int) bool {
if row < 0 || row >= rows {
return false
}
if column < 0 || column >= columns {
return false
}
return true
}
func main() {
var g Grid
err := g.Set(12, 0, 15)
if err != nil {
switch err { // 指针 内存地址
case ErrBounds, ErrDigit:
fmt.Println("Les erreurs de parametres hors limites.")
default:
fmt.Println(err)
}
os.Exit(1)
}
}
- 按照惯例,自定义错误类型的名字应以Error结尾。
- 有时候名字就是Error,例如url.Error
小测试
- 上例中,如果操作成功时,Set方法返回空的error slice,那么会发生什么?
类型断言
-
上例中,我们可以使用类型断言来访问每一种错误。
-
使用类型断言,你可以把接口类型转化成底层的具体类型。
- 例如:err.(SudokuError)
go
package main
import (
"errors"
"fmt"
"os"
)
const rows, columns = 9, 9
// Grid is a Sudoku grid
type Grid [rows][columns]int8
var (
// ErrBounds ...
ErrBounds = errors.New("out of bounds")
// ErrDigit ...
ErrDigit = errors.New("invalid digit")
)
// SudokuError ...
type SudokuError []error
// Error returns one or more errors separated by commas.
func (se SudokuError) Error() string {
var s []string
for _, err := range se {
s = append(s, err.Error())
}
return strings.Join(s, ", ")
}
// Set ...
func (g *Grid) Set(row, column int, digit int8) error {
var errs SudokuError
if !inBounds(row, column) {
errs = append(errs, ErrBounds)
}
if !validDight(digit) {
errs = append(errs, ErrDigit)
}
if len(errs) > 0 {
return errs
}
g[row][column] = digit
return nil
}
func inBounds(row, column int) bool {
if row < 0 || row >= rows {
return false
}
if column < 0 || column >= columns {
return false
}
return true
}
func main() {
var g Grid
err := g.Set(12, 0, 15)
if err != nil {
if errs, ok := err.(SudokuError); ok {
fmt.Printf("%d error(s) occurred:\n", len(errs))
for _, e := range errs {
fmt.Printf("- %v\n", e)
}
}
os.Exit(1)
}
}
- 如果类型满足多个接口,那么类型断言可使它从一个接口类型转化为另一个接口类型。
小测试
- 这个类型断言err.(SudokuError)做了什么?
不要恐慌(don't panic)
- Go没有异常,它有个类似机制panic。
- 当panic发生,那么程序就会崩溃。
其它语言的异常vs Go的错误值
-
其它语言的异常在行为和实现上与Go语言的错误值有很大的不同:
-
如果函数抛出异常,并且附近没人捕获它,那么它就会"冒泡"到函数的调用者那里,如果还没有人进行捕获,那么就继续"冒泡"到更上层的调用者...直到达到栈(Stack)的顶部(例如main函数)。
-
异常这种错误处理方式可被看作是可选的:
- 不处理异常,就不需要加入其它代码。
- 想要处理异常,就需要加入相当数量的专用代码。
-
Go语言中的错误值更简单灵活:
- 忽略错误是有意识的决定,从代码上看也是显而易见的。
-
小测试
- 和异常相比,Go的错误值有哪两个好处?
如何panic
-
Go里有一个和其他语言异常类似的机制:panic。
-
实际上,panic很少出现。
-
创建panic:
-
panic("I forgot my towel")
- panic的参数可以是任意类型
-
错误值、panic、os.Exit ?
- 通常,更推荐使用错误值,其次才是panic。
- panic比os.Exit更好:panic后会执行所有defer的动作,而os.Exit则不会。
- 有时候Go程序会panic而不是返回错误值
go
package main
import "fmt"
func main() {
var zero int
_ = 42 / zero // panic
}
小测试
- 你的程序应该在什么时候panic?
保持冷静并继续
- 为了防止panic导致程序崩溃,Go提供了 recover 函数。
- defer的动作会在函数返回前执行,即使发生了panic。
- 但如果defer的函数调用了recover,panic就会停止,程序将继续运行。
go
package main
import "fmt"
func main() {
defer func() {
if e := recover(); e != nil {
fmt.Println(e)
}
}()
panic("I forgot my towel")
}
小测试
- 在哪里可以使用内置的recover函数?
- 答:recover 是一个非常强大的工具,用于捕获和处理运行时的 panic。通过在 defer 函数中使用 recover,可以避免程序崩溃,恢复正常的执行流程,并执行必要的资源清理和日志记录。合理使用 recover 可以使程序更加健壮和可靠。
作业题
-
编写一个程序:
- 在Go标准库里,有个函数可以解析网址(golang.org/pkg/net/url/#Parse):
- 使用一个非法网址传递到url.Parse函数,把发生的错误显示出来。
- 使用%#v和Printf打印错误,看看都显示什么。
- 然后执行*url.Error类型断言,来访问和打印底层结构体的字段和内容。
总结
Go语言的错误处理看似简单,却蕴含深意。通过显式返回错误,开发者能够清晰地掌控程序流程,避免隐藏的异常"冒泡"风险。从基础的nil检查到defer释放资源,再到自定义错误类型和recover挽救panic,Go提供了一套灵活而强大的工具链。实践证明,优雅的错误处理不仅让代码更健壮,也让调试和维护变得更轻松。希望本文的分享能为你的Go编程之旅增添一份信心与灵感,快去动手试试吧!