为什么具体错误类型优于哨兵错误:性能、安全性与可扩展性解析

本文深入探讨Go语言中具体错误类型相比哨兵错误的三大优势:更好的性能表现、防止变量被篡改的安全性以及支持向后兼容的扩展能力,并通过基准测试和实际代码示例展示errutil.Find函数的强大替代方案。

为什么具体错误类型优于哨兵错误

TL;DR

导出的具体错误类型优于哨兵错误。它们具有更好的性能、不可被篡改,并支持扩展性。第三方函数errutil.Find是标准库函数errors.As的强大替代方案。

场景设置

假设您正在编写一个名为bluesky的包,其目的是检查新兴社交媒体平台Bluesky上的用户名可用性:

1
2
3
4
5
6
package bluesky

func IsAvailable(username string) (bool, error) {
  // 实际实现省略
  return false, nil
}

调用IsAvailable可能因各种原因失败(即返回非nil错误值):用户名在Bluesky上可能无效;或者可能存在技术困难阻止函数确定用户名的可用性。您预计包的客户端希望以编程方式响应函数IsAvailable的各种失败情况,并打算设计包以允许他们这样做。

哨兵错误

最流行和最直接的方法是导出不同的错误变量,也称为哨兵错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package bluesky

import (
  "errors"
  "math/rand/v2"
)

var ErrInvalidUsername = errors.New("invalid username")

var ErrUnknownAvailability = errors.New("unknown availability")

func IsAvailable(username string) (bool, error) {
  // 实际实现省略
  switch rand.IntN(3) {
  case 0:
    return false, ErrInvalidUsername
  case 1:
    return false, ErrUnknownAvailability
  default:
    return false, nil
  }
}

以下是客户端代码响应此类哨兵错误的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
  "errors"
  "fmt"
  "os"
  "strconv"

  "example.com/bluesky"
)

func main() {
  if len(os.Args) < 2 {
    fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
    os.Exit(1)
  }
  username := os.Args[1]
  avail, err := bluesky.IsAvailable(username)
  if errors.Is(err, bluesky.ErrInvalidUsername) {
    const tmpl = "%q is not valid on Bluesky.\n"
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if errors.Is(err, bluesky.ErrUnknownAvailability) {
    const tmpl = "The availability of %q on Bluesky could not be checked.\n"
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
  fmt.Println(strconv.FormatBool(avail))
}

具体错误类型

这是另一种可能的方法:对于每个不同的失败情况,导出一个基于空结构体的具体错误类型,并配备使用指针接收器的Error方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package bluesky

import "math/rand/v2"

type InvalidUsernameError struct{}

func (*InvalidUsernameError) Error() string {
  return "invalid username"
}

type UnknownAvailabilityError struct{}

func (*UnknownAvailabilityError) Error() string {
  return "unknown availability"
}

func IsAvailable(username string) (bool, error) {
  // 实际实现省略
  switch rand.IntN(3) {
  case 0:
    return false, new(InvalidUsernameError)
  case 1:
    return false, new(UnknownAvailabilityError)
  default:
    return false, nil
  }
}

以下是客户端代码响应此类具体错误类型的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
  "errors"
  "fmt"
  "os"
  "strconv"

  "example.com/bluesky"
)

func main() {
  if len(os.Args) < 2 {
    fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
    os.Exit(1)
  }
  username := os.Args[1]
  avail, err := bluesky.IsAvailable(username)
  var iuerr *bluesky.InvalidUsernameError
  if errors.As(err, &iuerr) {
    const tmpl = "%q is not valid on Bluesky.\n"
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  var uaerr *bluesky.UnknownAvailabilityError
  if errors.As(err, &uaerr) {
    const tmpl = "The availability of %q on Bluesky could not be checked.\n"
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
  fmt.Println(strconv.FormatBool(avail))
}

我认为这样的错误类型通常比哨兵错误更可取,原因有三:

  1. 性能:检查错误类型可能比检查哨兵错误值更快
  2. 不可重新赋值性:与导出的变量不同,类型不能被篡改
  3. 可扩展性:这些类型可以通过附加字段进行丰富,以向后兼容的方式携带有关失败的上下文信息

在本文的其余部分,我将更详细地证实这些主张。

性能

errors.Is和errors.As速度慢

Go 1.13的发布标志着标准库中开始引入函数errors.Iserrors.As

1
2
3
4
5
package errors

func Is(err, target error) bool { /* ... */ }

func As(err error, target any) bool { /* ... */ }

从那时起,errors.Is作为将错误值与哨兵错误直接比较(使用==或!=)的更好替代方案而广受欢迎;类似地,调用errors.As逐渐取代了将错误值对目标类型进行类型断言。

不幸的是,尽管它们具有强大的树遍历语义,但errors.Is对性能造成了影响,errors.As也是如此。这两个函数确实依赖反射来执行安全检查并防止panic;反射可能很慢,有时甚至成为性能瓶颈,尤其是在CPU密集型工作负载(如解析X.509证书)中。您可能想完全否定我对errors.Iserrors.As的性能担忧,也许认为在典型程序中只有快乐路径需要高性能;但请记住,至少在某些程序中,快乐路径恰好比不快乐路径使用得更少。

函数errors.As实际上比函数errors.Is更依赖反射;此外,它的签名使得对它的典型调用会产生分配。因此,errors.As通常比errors.Is慢得多。以下是一些基准测试,将它们相互对比,以及与更强大的替代方案(errutil.Find)进行对比,我稍后将介绍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package bluesky_test

import (
  "errors"
  "testing"

  "example.com/bluesky"
  "github.com/jub0bs/errutil"
)

var sink bool

func BenchmarkErrorChecking(b *testing.B) {
  b.Run("k=errors.Is", func(b *testing.B) {
    for b.Loop() {
      sink = errors.Is(bluesky.ErrInvalidUsername, bluesky.ErrInvalidUsername)
    }
  })
  b.Run("k=errors.As", func(b *testing.B) {
    var err error = new(bluesky.UnknownAvailabilityError)
    for b.Loop() {
      var target *bluesky.UnknownAvailabilityError
      sink = errors.As(err, &target)
    }
  })
  b.Run("k=errutil.Find", func(b *testing.B) {
    var err error = new(bluesky.UnknownAvailabilityError)
    for b.Loop() {
      _, sink = errutil.Find[*bluesky.UnknownAvailabilityError](err)
    }
  })
}

以下是比较errors.Iserrors.As的一些基准测试结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ benchstat -col '/k@(errors.Is errors.As)' bench.out
goos: darwin
goarch: amd64
pkg: example.com/bluesky
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
                │  errors.Is  │               errors.As               │
                │   sec/op    │    sec/op     vs base                 │
ErrorChecking-8   8.657n ± 6%   92.160n ± 9%  +964.51% (p=0.000 n=20)

                │ errors.Is  │          errors.As           │
                │    B/op    │    B/op     vs base          │
ErrorChecking-8   0.000 ± 0%   8.000 ± 0%  ? (p=0.000 n=20)

                │ errors.Is  │          errors.As           │
                │ allocs/op  │ allocs/op   vs base          │
ErrorChecking-8   0.000 ± 0%   1.000 ± 0%  ? (p=0.000 n=20)

乍一看,天平更倾向于哨兵错误而不是具体错误类型,因为errors.Iserrors.As快10倍以上,至少在我可靠的2016款Macbook Pro上是这样。但是等等;还有更多。

泛型来救援

幸运的是,由于泛型,errors.As的更好替代方案是可能的,并且它非常接近地遵循errors.As的语义。我最近将这样的替代方案作为我的github.com/jub0bs/errutil库的一部分发布:

1
2
3
4
5
6
7
8
9
package errutil

// Find在err的树中查找与类型T匹配的第一个错误,
// 如果找到,返回相应的值和true。
// 否则,返回零值和false。
//
// 文档其余部分省略
//
func Find[T error](err error) (T, bool)

通常,对errors.As的调用可以有利地重构为对errutil.Find的调用,如下面的diff所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
 package main

 import (
-  "errors"
   "fmt"
   "os"
   "strconv"

   "example.com/bluesky"
+  "github.com/jub0bs/errutil"
 )

 func main() {
   if len(os.Args) < 2 {
     fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
     os.Exit(1)
   }
   username := os.Args[1]
   avail, err := bluesky.IsAvailable(username)
-  var iuerr *bluesky.InvalidUsernameError
-  if errors.As(err, &iuerr) {
+  if _, ok := errutil.Find[*bluesky.InvalidUsernameError](err); ok {
     const tmpl = "%q is not valid on Bluesky.\n"
     fmt.Fprintf(os.Stderr, tmpl, username)
     os.Exit(1)
   }
-  var uaerr *bluesky.UnknownAvailabilityError
-  if errors.As(err, &uaerr) {
+  if _, ok := errutil.Find[*bluesky.UnknownAvailabilityError](err); ok {
     const tmpl = "The availability of %q on Bluesky could not be checked.\n"
     fmt.Fprintf(os.Stderr, tmpl, username)
     os.Exit(1)
   }
   if err != nil {
     fmt.Fprintln(os.Stderr, err)
     os.Exit(1)
   }
   fmt.Println(strconv.FormatBool(avail))
 }

在我看来,errutil.Find至少在三个方面优于errors.As

  1. 更符合人体工程学:不需要调用者预先声明目标动态类型的变量
  2. 更类型安全:由于其泛型类型约束,保证不会panic
  3. 更高效:因为它避免了反射,所以速度更快且分配更少,基准测试结果可以证明

顺便说一句,错误检查草案设计提案建议,如果Go团队及时破解了参数多态性难题(Go 1.18),以便errors.As在标准库中开始使用(Go 1.13),那么errors.As将与我的errutil.Find函数非常相似。

如果您想在项目中采用errutil.Find但不愿意仅为一个小函数添加依赖项,请随意在需要的地方复制errutil.Find的源代码;毕竟,正如Rob Pike所说:

少量复制优于少量依赖。

关键的是,我的基准测试还表明,选择具体错误类型并使用errutil.Find检查它们比坚持使用哨兵错误并使用errors.Is检查它们快大约两倍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ benchstat -col '/k@(errors.Is errutil.Find)' bench.out
goos: darwin
goarch: amd64
pkg: example.com/bluesky
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
                │  errors.Is  │            errutil.Find             │
                │   sec/op    │   sec/op     vs base                │
ErrorChecking-8   8.657n ± 6%   3.822n ± 9%  -55.85% (p=0.000 n=20)

                │ errors.Is  │          errutil.Find          │
                │    B/op    │    B/op     vs base            │
ErrorChecking-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=20) ¹
¹ all samples are equal

                │ errors.Is  │          errutil.Find          │
                │ allocs/op  │ allocs/op   vs base            │
ErrorChecking-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=20) ¹

好吧,但我知道,在不快乐路径上轻微的性能提升可能不足以让您偏爱具体错误类型而不是哨兵值。还有什么?

不可重新赋值性

尽管从一个次要版本到下一个版本不断改进,但Go编程语言仍然有缺陷。一个特别不幸的功能是,任何包都可以篡改它导入的包导出的变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

import (
  "fmt"
  "os"
)

func main() {
  os.Stdout = nil // 🙄
  fmt.Println("Hello, 世界") // 不输出任何内容
}

并且由于跨包重新分配导出的变量是可能的,至少有些人可能会依赖它。请记住Hyrum Wright的永恒之言,这也适用于语言设计:

有了足够多的API用户, 你在合同中承诺什么并不重要: 你的系统的所有可观察行为 都会被某人依赖。

尽管跨包重新分配导出的变量在测试中可能很方便,但它充满了危险:

  1. 它是可变全局状态的载体,最好避免;
  2. 它不能(通常)以并发安全的方式执行。

哨兵错误作为导出的变量,也不能免于跨包重新分配,这可能导致可怕的结果。例如,将nil分配给"伪错误"io.EOF会破坏大多数io.Reader的实现:

1
2
3
4
5
6
7
package p

import "io"

func init() {
  io.EOF = nil // 😱
}

您可能会争辩说,没有理智的人会这样做,或者可以编写代码分析器来检测跨包篡改,您基本上是对的。可悲的是,在撰写本文时,staticcheck和capslock都没有实现对这种滥用的检查。此外,在编译时禁止这种滥用会更令人满意。

Dave Cheney的"常量错误"方法

在我解释为什么具体错误类型也符合要求之前,我必须提到先前的技术。在他的dotGo 2019演讲及其配套博客文章(两者都早于Go 1.13)中,Dave Cheney重新审视了他的一个想法:一种巧妙(但最终有缺陷)的替代技术,用于声明哨兵错误,使它们不能被篡改。

在Go中,常量是编译时已知的值,仅限于数字、布尔值和字符串。接口类型error不属于这些类别中的任何一个;没有类型为error的值可以声明为常量:

1
2
3
4
5
package bluesky

import "errors"

const ErrInvalidUsername = errors.New("invalid username") // ❌ 编译错误

Dave的方法包括声明一个基于字符串的(非导出的)类型(因此与常量兼容),为该类型配备一个Error方法(使用值接收器)以使其满足错误接口,并使用该类型作为在相关包中声明哨兵错误的容器:

1
2
3
4
5
6
7
8
9
package bluesky

type bsError string

func (e bsError) Error() string {
  return string(e)
}

const ErrInvalidUsername bsError = "invalid username"

然后不可能篡改符号ErrInvalidUsername

1
2
3
4
5
6
7
package p

import "example.com/bluesky"

func init() {
  bluesky.ErrInvalidUsername = "" // ❌ 编译错误
}

尽管这种方法达到了其既定目标,但它并非万能药:

  1. 符号ErrInvalidUsername现在是某种非导出类型;我认为大多数Gopher会同意为包的导出成员选择非导出类型是不符合习惯的。标准库本身只包含少量此类声明,我想知道其维护者是否在回顾时后悔引入它们……
  2. 因为不能有类型为*string的常量,类型bsError必须在其Error方法中使用值接收器;因此,比较两个bsError值涉及对其底层字符串值的逐字节比较,而这种字符串比较比简单的指针比较更昂贵。

顺便说一句,syscall.Errno的设计在精神上与Dave的常量错误类型非常相似,但没有上述两个缺点。

Dave的方法曾经并且仍然在一些Gopher中流行;例如,Go社区的受人尊敬的成员和新的Go主题Fallthrough播客的频繁主持人Dylan Bourque仍然认为自己是它的粉丝。就我而言,Dave方法的非惯用和低效性质足以劝阻其使用。

请允许我简短地离题。虽然我通常欣赏并同意Dave的输出,但我与他在dotGo演讲中提出的另一个想法不一致。当他赞美Go的常量系统时,Dave专注于非类型常量的一个属性,他称之为"可替代性"。通过一个令人困惑的逻辑跳跃,Dave得出结论,哨兵错误也应该是可替代的,并遗憾错误工厂函数errors.New的结果的身份不能简化为它们的错误消息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

import (
  "errors"
  "fmt"
)

func main() {
  err1 := errors.New("oh no")
  err2 := errors.New
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计