Go语言中具体错误类型优于哨兵错误的深入解析

本文详细探讨了在Go语言中为何具体错误类型比哨兵错误更优越,包括性能优势、不可篡改性和可扩展性,并介绍了errutil.Find作为errors.As的高效替代方案。

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

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.Iserrors.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也是如此。这两个函数确实依赖反射来执行安全检查并防止恐慌;反射可能很慢,有时甚至成为性能瓶颈,尤其是在CPU密集型工作负载(如解析X.509证书)中。您可能想完全 dismiss 我对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 finds the first error in err's tree that matches type T,
// and if so, returns the corresponding value and true.
// Otherwise, it returns the zero value and false.
//
// 其余文档省略
//
func Find[T error](err error) (T, bool)

通常,调用errors.As可以有利地重构为调用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
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. 更类型安全:由于其泛型类型约束,保证不会恐慌。
  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 // 😱
}

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

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中流行;例如,Dylan Bourque,Go社区的受人尊敬的成员和新的Go主题Fallthrough播客的频繁主持人,仍然认为自己是它的粉丝。就我而言,Dave方法的不符合习惯和低效性质足以劝阻其使用。

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

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

import (
  "errors"
  "fmt"
)

func main() {
  err1 := errors.New("oh no")
  err2 := errors.New("oh no")
  fmt.Println(err1 == err2) // false(令Dave懊恼)
}

然而,正如Axel Wagner(又名Merovius)在Reddit上敏锐指出的那样,Dave希望的行为会产生不良影响,以至于errors.New的测试套件包括一个不等式检查。

防止篡改是一个值得称赞的目标,但可以通过其他方式实现。暂时抛开常量……你能想到其他不能被“篡改”的东西吗?没错:类型本身!

类型不能被“篡改”

InvalidUsernameErrorUnknownAvailabilityError这样的类型声明根本不能被您的包的客户端以任何方式修改:

1
2
3
4
5
6
7
8
package p

import "example.com/bluesky"

func init() {
    bluesky.InvalidUsernameError = struct{}     // ❌ 编译错误
    bluesky.UnknownAvailabilityError = struct{} // ❌ 编译错误
}

轻而易举!但这样的具体错误类型还有一张王牌……

可扩展性

哨兵错误无法携带有关失败的身份之外的信息。例如,io.EOF表示字节源已耗尽,但并未说明是哪个字节源;这种省略在古老的io.EOF的情况下是可以容忍的。

但某些失败情况在某个阶段需要上下文信息。在您的bluesky包的后续版本中,您很可能希望允许bluesky.IsAvailable的调用者以编程方式更详细地询问该函数的错误结果。合法的问题包括:

  • 发生此失败时正在查询什么用户名?
  • 失败的根本原因是什么?预期的状态码?失败的请求?其他什么?

我从本文开始就一直倡导的错误类型可以轻松容纳附加字段,以向后兼容的方式携带有关失败的上下文信息:

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 package bluesky

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

-type InvalidUsernameError struct{}
+type InvalidUsernameError struct {
+  Username string
+}

-func (*InvalidUsernameError) Error() string {
-  return "invalid username"
+func (e *InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
 }

-type UnknownAvailabilityError struct{}
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}

-func (*UnknownAvailabilityError) Error() string {
-  return "unknown availability"
+func (e *UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
 }

+func (e *UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}

 func IsAvailable(username string) (bool, error) {
   // 实际实现省略
   switch rand.IntN(3) {
   case 0:
-  return false, new(InvalidUsernameError)
+  return false, &InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, new(UnknownAvailabilityError)
+    return false, &UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

这些更改将允许客户端提取此类上下文信息。此外,这些更改不会破坏任何客户端!以bluesky的具体错误类型为目标的现有调用errors.As或更快的errutil.Find将继续像以前一样工作。

这个例子可能足以说服您,这种基于结构体的具体错误类型有利于未来验证您的包。

编辑(2025-04-06):标准库中这种方法的足够最近的例子是类型http.MaxBytesError;有关其设计的讨论,请参见问题#30715和Go Time播客的第240集。

关于使用指针接收器的重要性

在设计选择的多重宇宙中,让我们将目光转向一个宇宙,其中您改为对错误类型的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, InvalidUsernameError{}
  case 1:
    return false, UnknownAvailabilityError{}
  default:
    return false, nil
  }
}

请注意,您的客户端然后可以自由依赖errors.Is(甚至直接比较)而不是errors.Aserrutil.Find

1
2
3
4
avail, err := IsAvailable("🤪")
if errors.Is(err, bluesky.InvalidUsernameError{}) { // true
    // ...
}

假设您然后用附加字段增强您的具体错误类型:

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 package bluesky

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

-type InvalidUsernameError struct{}
+type InvalidUsernameError struct {
+  Username string
+}

-func (InvalidUsernameError) Error() string {
-  return "invalid username"
+func (e InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
}

-type UnknownAvailabilityError struct{}
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}

-func (UnknownAvailabilityError) Error() string {
-  return "unknown availability"
+func (e UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
}

+func (e UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}

 func IsAvailable(username string) (bool, error) {
   // 实际实现省略
   switch rand.IntN(3) {
   case 0:
-  return false, InvalidUsernameError{}
+  return false, InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, UnknownAvailabilityError{}
+    return false, UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

不幸的是,此类更改会破坏依赖errors.Is的客户端:

1
2
3
4
avail, err := IsAvailable("🤪")
if errors.Is(err, bluesky.InvalidUsernameError{}) { // false
  // ...
}

这个例子应该足以说服您为具体错误类型的Error方法使用指针接收器。

编辑(2025-04-02):在从Axel Wagner获得关于此部分的一些反馈后,我觉得必须提到指针接收器的另一个(可能更关键)优势:与值接收器相反,指针接收器消除了在类型断言和调用errors.Aserrutil.Find时应使用哪种目标类型的歧义。

从哨兵错误过渡是危险的

如果您从哨兵错误开始,请准备好长期承担这一负担;至少在您的bluesky包的下一个主要版本发布之前。诚然,如果您幸运且所有客户端都依赖errors.Is而不是直接比较(使用==!=),您可以利用Is方法(errors.Is检查其存在)安全过渡到具体错误类型:

 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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
 package bluesky

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

+// 已弃用:改用InvalidUsernameError。
 var ErrInvalidUsername = errors.New("invalid username")

+// 已弃用:改用UnknownAvailabilityError。
 var ErrUnknownAvailability = errors.New("unknown availability")

+type InvalidUsernameError struct {
+  Username string
+}
+
+func (e *InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
+}
+
+func (*InvalidUsernameError) Is(err error) bool {
+  return err == ErrInvalidUsername
+}
+
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}
+
+func (e *UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
+}
+
+func (*UnknownAvailabilityError) Is(err error) bool {
+  return err == ErrUnknownAvailability
+}
+
+func (e *UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}
+
 func IsAvailable(username string) (bool, error) {
   // 实际实现省略
   switch rand.IntN(3) {
   case 0:
-  return false, ErrInvalidUsername
+  return false, &InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, ErrUnknownAvailability
+    return false, &UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

如果您发现自己处于这种理想情况,这些更改不会破坏任何客户端;这是真的。但总的来说,我会避免这种无拘无束的乐观主义。因此,如果您预计在某个阶段需要错误类型,我认为最好从一开始就使用它们,完全跳过哨兵错误。

讨论

具体错误类型对我很有用,尤其是在github.com/jub0bs/cors中,我最近添加了对CORS配置错误的编程处理支持。这篇文章可能已经说服您从现在开始偏爱它们而不是哨兵错误。

然而,我也不想过度推销具体错误类型。由于我知识范围之外的原因,它们可能不适合您的一些项目。如果您处于这种情况,我希望收到您的来信;在社交媒体或Gophers Slack上找到我。

例如,您可能更喜欢不透明错误,让您的客户端根据行为而不是类型断言错误,这是Dave Cheney推广的一种方法,我不敢在这里介绍,因为担心将这篇已经很长的文章变成一篇令人昏昏欲睡的论文。

无论您做什么,请记住模式是情境性的,而不是绝对的。始终运用判断力。在设计选择中 deliberate,并抵制将它们委托给某些无意识的AI工具的诱惑。😇

致谢

我在Slack上与其他Gopher进行的一些公开和私人对话为这篇文章提供了素材。特别感谢Roger Peppe、Axel Wagner、Justen Walker、Bill Moran、Noah Stride和Frédéric Marand。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计