专栏目录
11 理解包导入路径的含义 44 利其器!Go常用工具大检阅 22 Go 并发模型和常见并发模式 35 告别乱码!GO语言字符集编码方案间转换 12 go语言 init 函数的妙用 41 与时俱进!使用module管理依赖包 1 参考 Go 项目布局设计你的项目结构 16 方法集合决定接口实现 17 go变长参数函数的妙用 7 go语言定义“零值可用”的类型 30 Go 惯例:将测试依赖的外部数据文件放在 testdata 下面 34 一文告诉你如何在 Go 中实现 HTTPS 通信 24 sync 包的正确使用姿势 6 Go“枚举常量”的惯用实现方法 3 Go 标识符的命名惯例 4 变量声明形式尽量保持一致 2 gofmt:Go代码风格的唯一标准 9 深入理解和高效运用切片 10 Go 字符串是原生类型 8 用复合字面值作初值构造器 13 Go 函数是“一等公民”的理解 14 defer 让你的代码更清晰 23 Go channel 的常见使用模式 18 定义小接口是 Go 的惯例 19 不要在函数参数中使用空接口(interface{}) 15 Go 方法的本质 21 面试必考!掌握 goroutine 的调度原理 29 Go 单元测试惯例:表驱动 26 if err != nil 重复太多可以这么办 31 为被测对象建立性能基准 32 掌握 Go 代码性能剖析神器:pprof 25 别笑!这就是 Go 的错误处理哲学 27 不要让 panic 掺和到正常错误处理中 39 慎用reflect包提供的反射能力 38 小心被kill!不要忽略对系统信号的处理 36 像极!bytes包和strings包的那些相似操作 33 掌握 Go 代码调试利器:delve 43 让你的Go包拥有个性化的导入路径 40 与C互操作不是免费的!疑问了解cgo的使用成本 37 time包,你用对了吗 42 小即是美?构建最小Go程序容器镜像 20 要提高代码可测试性,请使用接口 45 未雨绸缪!Go语言常见“坑”大汇 28 一文告诉你测试包的包名要不要带“\_test”后缀 5 无类型常量让代码更简化

26 if err != nil 重复太多可以这么办

九路
• 阅读 1363

if err != nil 重复太多可以这么办

1. 两种观点

在上一节谈 Go 错误处理的策略时我们就提到过:与 C++、C#、Java 和 Python 等许多支持异常处理的主流编程语言所采用的对隐式结果的隐式错误检查不同,Go 在最初设计时就有意识地选择了使用显式错误结果和显式的错误检查

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

func CopyFile(src, dst string) error {
    r, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

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

    if err := w.Close(); err != nil {
        os.Remove(dst)
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
} 

对 Go 错误处理的“诟病”也体现在了 Go 官方用户调查数据当中。下图来自 2018 年Go 官方用户调查的结果

26 if err != nil 重复太多可以这么办

图7-2-1: 2018年Go官方用户调查的结果节选

我们看到在**“你使用 Go 面临的最大的挑战”这一项调查中,参与调查的用户将“错误处理(error handling)”列在了第五位**。在之前2016 年2017 年两次官方用户调查中,错误处理也名列前茅。这也促使 Go 团队在Go2的演进和开发计划中,将 Go 错误处理的改善列为了一项重点工作。下面是 Go 团队在错误处理改善方面的工作梳理:

不过一些知名 Go 程序员却给出了和调查结果不一样的观点。比如:Go 社区的知名 Go 程序员 Dave Cheney 就在其博客文章“Go 语言之禅”中直言不讳地阐述了自己的观点:

Go的成功很大程度上要归功于显式的处理错误的方式,因为它让Go程序员首先考虑失败情况,这将引导Go程序员专注于在编写代码时处理故障,而不是在程序部署并运行在生产环境后再进行故障的处理。而反复出现的代码片段:

if err != nil {
    return err
}

所付出的成本已基本被在故障发生时刻去处理故障的成本超过了。 

Go 语言之父 Rob Pike 在 2019 年的Go Sydney 聚会的演讲中谈及 Go2 的变化时也认为if err != nil在代码库中的使用远没有少数人所建议的那么普遍。

著名 Go 培训师、《Go 语言实战》联合作者之一的威廉·肯尼迪(William Kennedy)更是在 Go 团队try 提案公示之后,发表了对Go 社区的公开信。他认为上面的 Go 用户调查结果(2018 年)可能夸大了人们对错误处理的抱怨,而这些变化可能并不是大多数 Go 开发人员真正想要或真正需要的。同时他希望 Go 团队不要接受try 这个提案,因为它引入了两种方法来完成相同的事情,这与 Go完成一种事情仅有一种方法的原则有背离,这样会导致代码库中出现严重的不一致(有些人使用新引入的 try,有些人则依旧喜欢使用传统的if err != nil的错误检查)。威廉姆甚至认为 Go 团队应该重新评估错误处理改善在 Go2 演进中的优先级,毕竟它在用户调查中仅排名第五位,并希望 Go 团队对调查数据进行重新评估。

以上两种观点交锋的结果是 Go 团队否决了大部分之前编写的关于 Go 错误处理改善的设计草案,仅“Go2 错误检查(error inspecting)设计草案”中的部分内容最终在Go 1.13 版本中被接纳和实现。

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

神仙打架,作为吃瓜群众的我们究竟该怎么办呢?也许 Go 用户调查结果(2018 年)的确夸大了人们对错误处理的抱怨,但现实中大量反复出现的if err != nil的现象也的确是存在的。就连 Go 团队的技术负责人Russ Cox也承认当前的 Go 错误处理机制对于 Go 开发人员来说的确会有一定的心智负担。如果像上面例子(CopyFile)中那样编写错误处理代码,虽然功能正确,但显然错误处理不够简洁和优雅。

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

26 if err != nil 重复太多可以这么办

图7-2-2: if err != nil 反复出现在函数栈中的分布特点

不过该开发人员也认为:可以通过良好的设计减少或消除这类反复出现的错误检查。

好了!到这里我们可以确定一定以及肯定要对if err != nil的反复出现进行尽可能的优化,但是在未引入trycheck/handle这些新语法的情况下,我们要怎么做呢?

3. 优化思路

优化反复出现的if err != nil代码块的本质目的是让错误检查和处理较少或不要干扰到正常业务代码,让正常业务代码看起来更具视觉连续性。我们大致有两个努力的方向:

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

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

func SomeFunc() error {
    err := doStuff1()
    if err != nil {
        //handle error...
    }

    err = doStuff2()
    if err != nil {
        //handle error...
    }

    err = doStuff3()
    if err != nil {
        //handle error...
    }
} 

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

func SomeFunc() error {
    defer func() {
        if err != nil {
            // handle error...
        }
    }()
    try(doStuff1())
    try(doStuff2())
    try(doStuff3())
} 
  • 降低 if err != nil 重复的数量:如果觉得if err != nil重复的次数过多,我们可以降低其出现次数,这其实是将该问题转换为降低函数/方法的复杂度了。

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

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

26 if err != nil 重复太多可以这么办

图7-2-3:if语句个数对函数圈复杂度的影响

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

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

26 if err != nil 重复太多可以这么办

图7-2-4:if err != nil优化思路的四象限图

下面我们分别来详细说明一下各个象限的优化思路(注:第三象限显然表示的是待优化的代码)。

1) 视觉扁平化

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

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

这虽然并未从本质上消除if err != nil代码块过多的问题,也没有降低 SomeFunc 的圈复杂度,但经过这种视觉呈现上的优化,多数 Gopher 会觉得代码看起来更舒服了。

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

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

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

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

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

// go-if-error-check-optimize-1.go

func openBoth(src, dst string) (*os.File, *os.File, error) {
    var r, w *os.File
    var err error
    if r, err = os.Open(src); err != nil {
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    if w, err = os.Create(dst); err != nil {
        r.Close()
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    return r, w, nil
}

func CopyFile(src, dst string) error {
    var err error
    var r, w *os.File
    if r, w, err = openBoth(src, dst); err != nil {
        return err
    }
    defer func() {
        r.Close()
        w.Close()
        if err != nil {
            os.Remove(dst)
        }
    }()

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

我们看到:为了减少 CopyFile 函数中的 if 检查的重复次数,我们引入一个中间层:openBoth函数。我们将打开源文件和创建目的文件的工作转移到了openBoth函数中。这样优化下来,CopyFile 的圈复杂度下降到我们可以接受的范围内,而新增的 openBoth 函数的圈复杂度也在可接受范围内。

3) check/handle 风格化

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

// go-if-error-check-optimize-2.go
func check(err error) {
    if err != nil {
        panic(err)
    }
}

func CopyFile(src, dst string) (err error) {
    var r, w *os.File

    // error handler
    defer func() {
        if r != nil {
            r.Close()
        }
        if w != nil {
            w.Close()
        }
        if e := recover(); e != nil {
            if w != nil {
                os.Remove(dst)
            }
            err = fmt.Errorf("copy %s %s: %v", src, dst, err)
        }
    }()

    r, err = os.Open(src)
    check(err)

    w, err = os.Create(dst)
    check(err)

    _, err = io.Copy(w, r)
    check(err)

    return nil
} 

看一下这段 check/handle 风格的CopyFile代码,无论是从业务代码(Open -> Create -> Copy)的视觉连续性来看,还是从 CopyFile 的圈复杂度来看,这次优化显然都要好于前面的优化。这也再一次证实了现实中的真正好的优化更多是上述两个方向的结合。

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

// panic_recover_performance_test.go 
package main

import (
    "errors"
    "testing"
)

func check(err error) {
    if err != nil {
        panic(err)
    }
}
func FooWithoutDefer() error {
    return errors.New("foo demo error")
}

func FooWithDefer() (err error) {
    defer func() {
        err = errors.New("foo demo error")
    }()
    return
}

func FooWithPanicAndRecover() (err error) {
    // error handler
    defer func() {
        if e := recover(); e != nil {
            err = errors.New("foowithpanic demo error")
        }
    }()

    check(FooWithoutDefer())
    return nil
}

func FooWithoutPanicAndRecover() error {
    return FooWithDefer()
}

func BenchmarkFuncWithoutPanicAndRecover(b *testing.B) {
    for i := 0; i < b.N; i++ {
        FooWithoutPanicAndRecover()
    }
}

func BenchmarkFuncWithPanicAndRecover(b *testing.B) {
    for i := 0; i < b.N; i++ {
        FooWithPanicAndRecover()
    }
} 

运行上述性能基准测试:

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

我们看到 panic 和 recover 让函数调用的性能慢了近 10 倍。因此,我们在使用这种方案优化重复代码前,需要全面了解这些约束。

4) 封装:内置 error 状态

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

b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
        return b.Flush()
    }
} 

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

// $GOROOT/src/bufio/bufio.go
type Writer struct {
    err error
    buf []byte
    n   int
    wr  io.Writer
}

func (b *Writer) Write(p []byte) (nn int, err error) {
    for len(p) > b.Available() && b.err == nil {
        ... ...
    }
    if b.err != nil {
        return nn, b.err
    }
    ......
    return nn, nil
} 

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

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

// go-if-error-check-optimize-3.go
package main

import (
    "fmt"
    "io"
    "os"
)

type FileCopier struct {
    w   *os.File
    r   *os.File
    err error
}

func (f *FileCopier) open(path string) (*os.File, error) {
    if f.err != nil {
        return nil, f.err
    }

    h, err := os.Open(path)
    if err != nil {
        f.err = err
        return nil, err
    }
    return h, nil
}

func (f *FileCopier) openSrc(path string) {
    if f.err != nil {
        return
    }

    f.r, f.err = f.open(path)
    return
}

func (f *FileCopier) createDst(path string) {
    if f.err != nil {
        return
    }

    f.w, f.err = os.Create(path)
    return
}

func (f *FileCopier) copy() {
    if f.err != nil {
        return
    }

    if _, err := io.Copy(f.w, f.r); err != nil {
        f.err = err
    }
}

func (f *FileCopier) CopyFile(src, dst string) error {
    if f.err != nil {
        return f.err
    }

    defer func() {
        if f.r != nil {
            f.r.Close()
        }
        if f.w != nil {
            f.w.Close()
        }
        if f.err != nil {
            if f.w != nil {
                os.Remove(dst)
            }
        }
    }()

    f.openSrc(src)
    f.createDst(dst)
    f.copy()
    return f.err
}

func main() {
    var fc FileCopier
    err := fc.CopyFile("foo.txt", "bar.txt")
    if err != nil {
        fmt.Println("copy file error:", err)
        return
    }
    fmt.Println("copy file ok")
} 

这次的重构很是彻底。我们将原 CopyFile 函数彻底抛弃,而重新将其逻辑封装到一个名为FileCopier结构的 CopyFile 方法中。FileCopier 结构内置了一个 err 字段用于保存内部的错误状态,这样在其 CopyFile 方法中,我们只需按照正常业务逻辑,顺序执行 openSrc、createDst 和 copy 即可,正常业务逻辑的视觉连续性就是这样被很好地实现的。同时该 CopyFile 方法的复杂度因 if 检查的“大量缺席”而变得很小。

4. 小结

本节要点:

  • Go 使用显式错误结果和显式的错误检查是 Go 语言成功的重要因素,同时也是if err != nil反复出现的根本原因;
  • 了解关于 Go 错误处理改善的两种观点;
  • 了解减少和消除if err != nil代码片段的两个优化方向:改善视觉呈现与降低复杂度;
  • 掌握错误处理代码优化的四种常见方法(位于三个不同象限中),并根据所处场景与约束灵活使用。
点赞
收藏
评论区
推荐文章

暂无数据