1. 两种观点

但 Go 在错误处理方面体现出的这种与如今主流语言的格格不入,让很多来自这些主流语言的 Go 初学者感到困惑:Go 代码中反复出现了太多的且方法单一的错误检查if err != nil。比如下面这段摘自“Go2 错误处理概述”中的代码:

  1. func CopyFile(src, dst string) error {
  2. r, err := os.Open(src)
  3. if err != nil {
  4. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  5. }
  6. defer r.Close()
  7. w, err := os.Create(dst)
  8. if err != nil {
  9. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  10. }
  11. if _, err := io.Copy(w, r); err != nil {
  12. w.Close()
  13. os.Remove(dst)
  14. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  15. }
  16. if err := w.Close(); err != nil {
  17. os.Remove(dst)
  18. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  19. }
  20. }

下面是 Go 团队在错误处理改善方面的工作梳理:

2018 年 8 月

2019 年 6 月

2. 我们该怎么办?尽量去优化

另外一名 Go 团队核心开发人员Marcel van Lohuizen也对if err != nil的重复出现情况也进行了研究。他发现代码所在栈帧越低(越接近与 main 函数栈帧),if err != nil就越不常见;反之,代码在栈中的位置越高(更接近于网络 I/O 操作或操作系统 API 调用),if err != nil出现的就越普遍,正如上面CopyFile那个例子中的情况:

image.png

3. 优化思路

改善代码的视觉呈现:这个优化方法就好比给开发人员施加了某种“障眼法”,使得错误处理代码在开发者眼中的视觉呈现更为“优雅”。上面提到的 Go2 关于改善错误处理的几个技术草案本质上就是提供一种改善代码视觉呈现的语法糖。

比如:如果待优化的代码像下面这样:

  1. func SomeFunc() error {
  2. err := doStuff1()
  3. if err != nil {
  4. //handle error...
  5. }
  6. err = doStuff2()
  7. if err != nil {
  8. //handle error...
  9. }
  10. err = doStuff3()
  11. if err != nil {
  12. //handle error...
  13. }
  14. }

那么经由try技术草案优化后的代码将大致变成这样(由于 try 提案被否决,因此我们无法真实实现下面这样的错误处理):

  1. func SomeFunc() error {
  2. defer func() {
  3. if err != nil {
  4. // handle error...
  5. }
  6. }()
  7. try(doStuff1())
  8. try(doStuff2())
  9. try(doStuff3())
  10. }

降低 if err != nil 重复的数量:如果觉得if err != nil重复的次数过多,我们可以降低其出现次数,这其实是将该问题转换为降低函数/方法的复杂度了。

一个函数/方法内部出现多少个if err != nil才需要我们去优化和消除这种代码重复呢?这显然没有标准可言。我这里想到了一个粗略的评估方法:利用圈复杂度(Cyclomatic complexity)。圈复杂度是一种代码复杂度的衡量标准,我们常用它来衡量一个模块判定结构的复杂程度。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系。

圈复杂度可以通过程序控制流图计算,公式为:V(G) = e + 2 - n。其中:e 为控制流图中边的数量;n 为控制流图中节点的数量(包括起点和终点;所有终点只计算一次,多个 return 和 throw 算作一个节点)。下面是不同数量的 if 语句对应的不同圈复杂度:

image.png

我们看到三组 if 语句(不带 else)的圈复杂度已经达到了 4,两组 if 语句的圈复杂度为 3。因此这里给出一个建议值:对圈复杂度为 4 或 4 以上的模块代码进行重构优化,即如果一个函数/方法中的if err != nil数量为 3 个或 3 个以上,我们将尝试对其进行代码优化,以尝试减少或消除过多的if err != nil代码片段。当然这个建议值对于那些有代码洁癖的 gopher 来说可以全不作数_。

现实中的真实优化实施更多是上述两个方向的结合,这里用一幅四象限图来直观展示一下可能的优化思路:

image.png

1) 视觉扁平化

Go 提供了将触发错误处理的语句与错误处理代码放在一行的支持,比如上面的 SomeFunc 函数,我们可以将之等价重写为下面代码:

  1. func SomeFunc() error {
  2. if err := doStuff1(); err != nil { // handle error... }
  3. if err := doStuff2(); err != nil { // handle error... }
  4. if err := doStuff3(); err != nil { // handle error... }
  5. }

不过这种优化显然是有约束的,如果错误处理分支的语句不是简单的return err,而是复杂如下面代码中这样:

  1. if _, err = io.Copy(w, r); err != nil {
  2. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  3. }

那么”扁平化”会导致代码行过长,反倒降低了视觉呈现的“优雅度”。另外如果你使用goimports或gofmt工具对代码进行自动格式化,那么这些格式化工具会自动展开上述代码,这会让你困惑不已

2) 重构:减少if err != nil的重复次数

我们沿着降低复杂度的方向对待优化代码进行重构,以减少if err != nil代码片段的重复次数。我们以上面的CopyFile为优化对象。 原 CopyFile 函数有 4 个重复出现的if err != nil代码段,这里我们将其减至 2 个。下面是一种优化方案的代码实现:

  1. // go-if-error-check-optimize-1.go
  2. func openBoth(src, dst string) (*os.File, *os.File, error) {
  3. var r, w *os.File
  4. var err error
  5. if r, err = os.Open(src); err != nil {
  6. return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
  7. }
  8. if w, err = os.Create(dst); err != nil {
  9. r.Close()
  10. return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
  11. }
  12. return r, w, nil
  13. }
  14. func CopyFile(src, dst string) error {
  15. var err error
  16. var r, w *os.File
  17. if r, w, err = openBoth(src, dst); err != nil {
  18. return err
  19. }
  20. defer func() {
  21. r.Close()
  22. w.Close()
  23. if err != nil {
  24. os.Remove(dst)
  25. }
  26. }()
  27. if _, err = io.Copy(w, r); err != nil {
  28. return fmt.Errorf("copy %s %s: %v", src, dst, err)
  29. }
  30. return nil
  31. }

感觉是强行减少, 我比较在意返回参数的个数.

3) check/handle 风格化

上面的位于第四象限的重构之法虽然减少了if err != nil代码片段的重复次数,但其视觉呈现依旧欠佳。Go2 的check/handle 技术草案的思路给了我们一些启发,我们可利用 panic 和 recover 封装一套跳转机制,模拟实现一套 check/handle 机制,在降低复杂度的同时,也能在视觉呈现上有所改善。我们仍然以CopyFile为例进行优化:

  1. // go-if-error-check-optimize-2.go
  2. func check(err error) {
  3. if err != nil {
  4. panic(err)
  5. }
  6. }
  7. func CopyFile(src, dst string) (err error) {
  8. var r, w *os.File
  9. // error handler
  10. defer func() {
  11. if r != nil {
  12. r.Close()
  13. }
  14. if w != nil {
  15. w.Close()
  16. }
  17. if e := recover(); e != nil {
  18. if w != nil {
  19. os.Remove(dst)
  20. }
  21. err = fmt.Errorf("copy %s %s: %v", src, dst, err)
  22. }
  23. }()
  24. r, err = os.Open(src)
  25. check(err)
  26. w, err = os.Create(dst)
  27. check(err)
  28. _, err = io.Copy(w, r)
  29. check(err)
  30. return nil
  31. }

不过这一优化方案也具有一定约束,比如:函数必须使用具名的 error 返回值、defer 性能(在 Go 1.14 版本中,与不使用 defer 的性能差异微乎其微,可忽略不计)、panic 和 recover 的性能等。尤其是 panic 和 recover 的性能要比正常函数返回的性能相差好多,下面是一个简单的性能基准对比测试:

  1. // panic_recover_performance_test.go
  2. package main
  3. import (
  4. "errors"
  5. "testing"
  6. )
  7. func check(err error) {
  8. if err != nil {
  9. panic(err)
  10. }
  11. }
  12. func FooWithoutDefer() error {
  13. return errors.New("foo demo error")
  14. }
  15. func FooWithDefer() (err error) {
  16. defer func() {
  17. err = errors.New("foo demo error")
  18. }()
  19. return
  20. }
  21. func FooWithPanicAndRecover() (err error) {
  22. // error handler
  23. defer func() {
  24. if e := recover(); e != nil {
  25. err = errors.New("foowithpanic demo error")
  26. }
  27. }()
  28. check(FooWithoutDefer())
  29. return nil
  30. }
  31. func FooWithoutPanicAndRecover() error {
  32. return FooWithDefer()
  33. }
  34. func BenchmarkFuncWithoutPanicAndRecover(b *testing.B) {
  35. for i := 0; i < b.N; i++ {
  36. FooWithoutPanicAndRecover()
  37. }
  38. }
  39. func BenchmarkFuncWithPanicAndRecover(b *testing.B) {
  40. for i := 0; i < b.N; i++ {
  41. FooWithPanicAndRecover()
  42. }
  43. }

运行上述性能基准测试:

  1. $ go test -bench . panic_recover_performance_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkFuncWithoutPanicAndRecover-8 39020437 28.8 ns/op
  5. BenchmarkFuncWithPanicAndRecover-8 4442336 271 ns/op
  6. PASS
  7. ok command-line-arguments 2.639s

4) 封装:内置 error 状态

在 Go 语言之父 Rob Pike 的”Errors are values”一文中,Rob Pike 为我们呈现了 Go 标准库中使用了避免if err != nil反复出现的一种代码设计思路,bufio 包的 Writer 就是使用了这个思路实现的,因此它可以可以像下面这样使用:

  1. b := bufio.NewWriter(fd)
  2. b.Write(p0[a:b])
  3. b.Write(p1[c:d])
  4. b.Write(p2[e:f])
  5. // and so on
  6. if b.Flush() != nil {
  7. return b.Flush()
  8. }
  9. }

我们看到上述代码中并没有判断三个 b.Write 的返回错误值,错误处理放在哪里了呢?我们打开一下$GOROOT/src/bufio/bufio.go,我们看到下面代码:

  1. // $GOROOT/src/bufio/bufio.go
  2. type Writer struct {
  3. err error
  4. buf []byte
  5. n int
  6. wr io.Writer
  7. }
  8. func (b *Writer) Write(p []byte) (nn int, err error) {
  9. for len(p) > b.Available() && b.err == nil {
  10. ... ...
  11. }
  12. if b.err != nil {
  13. return nn, b.err
  14. }
  15. ......
  16. return nn, nil
  17. }

我们可以看到,错误状态被封装在 bufio.Writer 结构的内部了,Writer 定义了一个 err 字段作为一个内部错误状态值,它与 Writer 的实例绑定在了一起,并且在每次 Write 入口判断是否为 nil。一旦不为 nil,Write 其实什么都没做就返回了

先一股脑写代码, 最后只检查一次 err 的状态.

这种方法显然是消除if err != nil代码片段重复出现的理想方法。我们还以CopyFile为例,看看使用这种“内置 error 状态”的新封装方法后,我们能得到什么样的代码:

  1. // go-if-error-check-optimize-3.go
  2. package main
  3. import (
  4. "fmt"
  5. "io"
  6. "os"
  7. )
  8. type FileCopier struct {
  9. w *os.File
  10. r *os.File
  11. err error
  12. }
  13. func (f *FileCopier) open(path string) (*os.File, error) {
  14. if f.err != nil {
  15. return nil, f.err
  16. }
  17. h, err := os.Open(path)
  18. if err != nil {
  19. f.err = err
  20. return nil, err
  21. }
  22. return h, nil
  23. }
  24. func (f *FileCopier) openSrc(path string) {
  25. if f.err != nil {
  26. return
  27. }
  28. f.r, f.err = f.open(path)
  29. return
  30. }
  31. func (f *FileCopier) createDst(path string) {
  32. if f.err != nil {
  33. return
  34. }
  35. f.w, f.err = os.Create(path)
  36. return
  37. }
  38. func (f *FileCopier) copy() {
  39. if f.err != nil {
  40. return
  41. }
  42. if _, err := io.Copy(f.w, f.r); err != nil {
  43. f.err = err
  44. }
  45. }
  46. func (f *FileCopier) CopyFile(src, dst string) error {
  47. if f.err != nil {
  48. return f.err
  49. }
  50. defer func() {
  51. if f.r != nil {
  52. f.r.Close()
  53. }
  54. if f.w != nil {
  55. f.w.Close()
  56. }
  57. if f.err != nil {
  58. if f.w != nil {
  59. os.Remove(dst)
  60. }
  61. }
  62. }()
  63. // 流畅的 Go!
  64. f.openSrc(src)
  65. f.createDst(dst)
  66. f.copy()
  67. return f.err
  68. }
  69. func main() {
  70. var fc FileCopier
  71. err := fc.CopyFile("foo.txt", "bar.txt")
  72. if err != nil {
  73. fmt.Println("copy file error:", err)
  74. return
  75. }
  76. fmt.Println("copy file ok")
  77. }