Go语言错误处理全攻略:从基础到优雅实践

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())
  }
}

注意

  • 当错误发生时,函数返回的其它值通常就不再可信。

小测试

  1. 修改例子,让它读取一个虚构的文件夹,如"unicorns",看看会打印出什么错误信息?
  2. 如果我们使用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编程之旅增添一份信心与灵感,快去动手试试吧!

相关推荐
星辰大海的精灵19 分钟前
分布式系统中使用OTEL、Jaeger和Prometheus监控服务
后端·架构·自动化运维
Roc-xb1 小时前
Cargo, the Rust package manager, is not installed or is not on PATH.
开发语言·后端·rust
m0_748240021 小时前
【SpringAOP】Spring AOP 底层逻辑:切点表达式与原理简明阐述
java·后端·spring
飞升不如收破烂~1 小时前
单例模式---是 Spring 容器的核心特性之一
java·后端·spring
是一只派大鑫2 小时前
从头开始学SpringBoot—02ssmp整合及案例
java·spring boot·后端
用户99045017780092 小时前
postgresql默认密码和如何修改默认密码
后端
油泼辣子多加2 小时前
2025年02月26日Github流行趋势
github
多多*3 小时前
MyBatis-Plus 元对象处理器 @TableField注解 反射动态赋值 实现字段自动填充
java·开发语言·数据库·windows·github·mybatis
乔大将军3 小时前
项目准备(flask+pyhon+MachineLearning)- 2
后端·python·flask
m0_748238783 小时前
从零创建一个 Django 项目
后端·python·django