Go 语言面试题之 Error 详解

自从 Go 语言在国内火热以来,除去泛型,其次最具槽点的就是 Go 对错误的处理方式,一句经典的 if err != nil 暗号就能认出你是一个 Go 语言爱好者。

err 里藏的是什么

自然,大家对 Go error 的关注度更是高涨,Go team 也是,因此在 Go 2 Draft Designs 中正式提到了 error handling(错误处理)的相关草案,希望能够在未来正式的解决这个问题。

在 Go 语言中,错误处理 是编程的重要组成部分,而 error 类型是 Go 内置的错误接口。与其他语言使用异常(Exception)不同,Go 倾向于通过返回值显式地处理错误,这种设计使得代码更加可控和易于维护。掌握 Go 的 error 类型及其最佳实践,是面试中常被考察的核心内容。

在今天这篇文章中,我们将一同跟踪 Go2 error,看看他是怎么 "挣扎" 的,能不能破局?

为什么要吐槽 Go1

要吐槽 Go1 error,就得先知道为什么大家到底是在喷 Error 哪里处理的不好。在 Go 语言中,error 其实本质上只是个 Error 的 interface

go 复制代码
type error interface {
    Error() string
}

实际的应用场景如下:

go 复制代码
func main() {
 x, err := foo()
 if err != nil {
   // handle error
 }
}

单纯的看这个例子似乎没什么问题,但工程大了后呢?

显然 if err != nil 的逻辑是会堆积在工程代码中,Go 代码里的 if err != nil 甚至会达到工程代码量的 30% 以上:

go 复制代码
func main() {
 x, err := foo()
 if err != nil {
   // handle error
 }
 y, err := foo()
 if err != nil {
   // handle error
 }
 z, err := foo()
 if err != nil {
   // handle error
 }
 s, err := foo()
 if err != nil {
   // handle error
 }
}

暴力的对比一下,就发现四行函数调用,十二行错误,还要苦练且精通 IDE 的快速折叠功能,还是比较麻烦的。

另外既然是错误处理,那肯定不单单是一个 return err 了。在工程实践中,项目代码都是层层嵌套的,如果直接写成:

go 复制代码
if err != nil {
 return err
}

在实际工程中肯定是不行。你怎么知道具体是哪里抛出来的错误信息,实际出错时只能瞎猜。大家又想出了 PlanB,那就是加各种描述信息:

go 复制代码
if err != nil {
 logger.Errorf("煎鱼报错 err:%v", err)
 return err
}

虽然看上去人模人样的,在实际出错时,也会遇到新的问题,因为你要去查这个错误是从哪里抛出来的,没有调用堆栈,单纯几句错误描述是难以定位的。

这时候就会发展成到处打错误日志

go 复制代码
func main() {
 err := bar()
 if err != nil {
  logger.Errorf("bar err:%v", err)
 }
 ...
}

func bar() error {
 _, err := foo()
 if err != nil {
  logger.Errorf("foo err:%v", err)
  return err
 }

 return nil
}

func foo() ([]byte, error) {
 s, err := json.Marshal("hello world.")
 if err != nil {
  logger.Errorf("json.Marshal err:%v", err)
  return nil, err
 }

 return s, nil
}

虽然到处打了日志,就会变成错误日志非常多,一旦出问题,人肉可能短时间内识别不出来。

最常见的就是到 IDE 上 ctrl + f 搜索是在哪出错。同时在实际应用中我们会自定义一些错误类型,在 Go 则需要各种判断和处理:

go 复制代码
if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
       ...
    }
    return err
}

你得判断不等于 nil,还得对自定义的错误类型进行断言,整体来讲比较繁琐。

汇总来讲,Go1 错误处理的问题至少有:

  • 在工程实践中,if err != nil 写的烦,代码中一大堆错误处理的判断,占了相当的比例,不够优雅。

  • 在排查问题时,Go 的 err 并没有其他堆栈信息,只能自己增加描述信息,层层叠加,打一大堆日志,排查很麻烦。

  • 在验证和测试错误时,要自定义错误(各种判断和断言)或者被迫用字符串校验。

Go1.13 的挽尊

在 2019 年 09 月,Go1.13 正式发布。其中两个比较大的两个关注点分别是包依赖管理 Go modules 的转正,以及错误处理 errors 标准库的改进:

Error wrapping

在本次改进中,errors 标准库引入了 Wrapping Error 的概念,并增加了 Is/As/Unwarp 三个方法,用于对所返回的错误进行二次处理和识别。

同时也是将 Go2 error 预规划中没有破坏 Go1 兼容性的相关功能提前实现了。

简单来讲,Go1.13 后 Go 的 error 就可以嵌套了,并提供了三个配套的方法。例子:

css 复制代码
func main() {
 e := errors.New("脑子进煎鱼了")
 w := fmt.Errorf("快抓住:%w", e)
 fmt.Println(w)
 fmt.Println(errors.Unwrap(w))
}

输出结果:

go 复制代码
$ go run main.go
快抓住:脑子进煎鱼了
脑子进煎鱼了

在上述代码中,变量 w 就是一个嵌套一层的 error。最外层是 "快抓住:",此处调用 %w 意味着 Wrapping Error 的嵌套生成。因此最终输出了 "快抓住:脑子进煎鱼了"。

需要注意的是,Go 并没有提供 Warp 方法,而是直接扩展了 fmt.Errorf 方法。而下方的输出由于直接调用了 errors.Unwarp 方法,因此将 "取" 出一层嵌套,最终直接输出 "脑子进煎鱼了"。

对 Wrapping Error 有了基本理解后,我们简单介绍一下三个配套方法:

go 复制代码
func Is(err, target error) bool
func As(err error, target interface{}) bool
func Unwrap(err error) error

errors.Is

方法签名:

go 复制代码
func Is(err, target error) bool

方法例子:

go 复制代码
func main() {
 if _, err := os.Open("non-existing"); err != nil {
  if errors.Is(err, os.ErrNotExist) {
   fmt.Println("file does not exist")
  } else {
   fmt.Println(err)
  }
 }

}

errors.Is 方法的作用是判断所传入的 err 和 target 是否同一类型,如果是则返回 true。

errors.As

方法签名:

go 复制代码
func As(err error, target interface{}) bool

方法例子:

go 复制代码
func main() {
 if _, err := os.Open("non-existing"); err != nil {
  var pathError *os.PathError
  if errors.As(err, &pathError) {
   fmt.Println("Failed at path:", pathError.Path)
  } else {
   fmt.Println(err)
  }
 }

}

errors.As 方法的作用是从 err 错误链中识别和 target 相同的类型,如果可以赋值,则返回 true。

errors.Unwarp

方法签名:

go 复制代码
func Unwrap(err error) error

方法例子:

css 复制代码
func main() {
 e := errors.New("脑子进煎鱼了")
 w := fmt.Errorf("快抓住:%w", e)
 fmt.Println(w)
 fmt.Println(errors.Unwrap(w))
}

该方法的作用是将嵌套的 error 解析出来,若存在多级嵌套则需要调用多次 Unwarp 方法。

民间自救 pkg/errors

Go1 的 error 处理固然存在许多问题,因此在 Go1.13 前,早已有 "民间" 发现没有上下文调试信息在实际工程应用中存在严重的体感问题。

因此 github.com/pkg/errors 在 2016 年诞生了,该库也已经受到了极大的关注。

官方例子如下:

ruby 复制代码
type stackTracer interface {
    StackTrace() errors.StackTrace
}

err, ok := errors.Cause(fn()).(stackTracer)
if !ok {
    panic("oops, err does not implement stackTracer")
}

st := err.StackTrace()
fmt.Printf("%+v", st[0:2]) // top two frames

// Example output:
// github.com/pkg/errors_test.fn
// /home/dfc/src/github.com/pkg/errors/example_test.go:47
// github.com/pkg/errors_test.Example_stackTrace
// /home/dfc/src/github.com/pkg/errors/example_test.go:127

简单来讲,就是对 Go1 error 的上下文处理进行了优化和处理,例如类型断言、调用堆栈等。若有兴趣的小伙伴可以自行到 github.com/pkg/errors 进行学习。

另外你可能会发现 Go1.13 新增的 Wrapping Error 体系与 pkg/errors 有些相像。

你并没有体会错,Go team 接纳了相关的意见,对 Go1 进行了调整,但调用堆栈这块因综合原因暂时没有纳入。

Go2 error 要解决什么问题

在前面我们聊了 Go1 error 的许多问题,以及 Go1.13 和 pkg/errors 的自救和融合。你可能会疑惑,那...Go2 error 还有出场的机会吗?即使 Go1 做了这些事情,Go1 error 还有问题吗?

并没有解决,if err != nil 依旧一把梭,目前社区声音依然认为 Go 语言的错误处理要改进。

Go2 error proposal

在 2018 年 8 月,官方正式公布了 Go 2 Draft Designs,其中包含泛型和错误处理机制改进的初步草案:

Go2 Draft Designs

注:Go1.13 正式将一些不破坏 Go1 兼容性的 Error 特性加入到了 main branch,也就是前面提到的 Wrapping Error。

错误处理(Error Handling)

第一个要解决的问题就是大量 if err != nil 的问题,针对此提出了 Go2 error handling 的草案设计。

简单例子:

go 复制代码
if err != nil {
 return err
}

优化后的方案如下:

go 复制代码
func CopyFile(src, dst string) error {
 handle err {
  return fmt.Errorf("copy %s %s: %v", src, dst, err)
 }

 r := check os.Open(src)
 defer r.Close()

 w := check os.Create(dst)
 handle err {
  w.Close()
  os.Remove(dst) // (only if a check fails)
 }

 check io.Copy(w, r)
 check w.Close()
 return nil
}

主函数:

css 复制代码
func main() {
 handle err {
  log.Fatal(err)
 }

 hex := check ioutil.ReadAll(os.Stdin)
 data := check parseHexdump(string(hex))
 os.Stdout.Write(data)
}

该提案引入了两种新的语法形式,首先是 check 关键字,其可以选中一个表达式 check f(x, y, z)check err,其将会标识这是一个显式的错误检查。

其次引入了 handle 关键字,用于定义错误处理程序流转,逐级上抛,依此类推,直到处理程序执行 return 语句,才正式结束。

错误值打印(Error Printing)

第二个要解决的问题是错误值(Error Values)、错误检查(Error Inspection)的问题,其引申出错误值打印(Error Printing)的问题,也可以认为是错误格式化的不便利。

官方针对此提出了提出了 Error Values 和 Error Printing 的草案设计。

简单例子如下:

go 复制代码
if err != nil {
 return fmt.Errorf("write users database: %v", err)
}

优化后的方案如下:

go 复制代码
package errors

type Wrapper interface {
 Unwrap() error
}

func Is(err, target error) bool
func As(type E)(err error) (e E, ok bool)

该提案增加了错误链的 Wrapping Error 概念,并同时增加 errors.Iserrors.As 的方法,与前面说到的 Go1.13 的改进一致,不再赘述。

需要留意的是,Go1.13 并没有实现 %+v 输出调用堆栈的需求,因为此举会破坏 Go1 兼容性和产生一些性能问题,大概会在 Go2 加入。

try-catch 不香吗

社区中另外一股声音就是直指 Go 语言反人类不用 try-catch 的机制,在社区内也产生了大量的探讨,具体可以看看相关的提案 Proposal: A built-in Go error check function, "try"。

目前该提案已被拒绝,具体可参见 go/issues/32437#issuecomment-512035919 和 Why does Go not have exceptions。

三、面试题解析

1. Go 的错误处理机制

  • 问题 :Go 为什么不使用异常,而使用 error 返回值?
  • 回答:Go 通过显式返回错误值而非异常,使程序流程更加清晰,避免隐藏控制流,同时提高了代码可读性和可维护性。

2. 如何区分不同错误类型?

  • 问题:如何判断一个错误是特定类型?
  • 回答 :可以通过类型断言或 errors.As 检查。例如:
go 复制代码
go
var myErr MyError
if errors.As(err, &myErr) {
    fmt.Println("This is MyError:", myErr.Msg)
}

3. 设计错误类型的原则

  • 错误应包含足够信息便于调试,但不要暴露内部实现。
  • 尽量使用小而具体的错误类型,而非单一的大错误。
  • 使用错误包装处理错误链,方便追踪根本原因。

四、总结

Go 的 error 是一种接口类型,通过显式返回值实现错误处理,强调简洁、可控和可读。掌握 error 类型的创建、自定义、包装及类型判断技巧,不仅能写出更加健壮的代码,也能在面试中回答关于错误处理的常见问题。

通过对 error 的深入理解,你将能够在 Go 项目中实现规范、灵活和安全的错误处理机制。



相关推荐
JaguarJack18 分钟前
PHP 的异步编程 该怎么选择
后端·php·服务端
风象南18 分钟前
AI 写代码效果差?大多数人第一步就错了
人工智能·后端
BingoGo20 分钟前
PHP 的异步编程 该怎么选择
后端·php
焗猪扒饭10 小时前
redis stream用作消息队列极速入门
redis·后端·go
树獭非懒11 小时前
AI大模型小白手册|Embedding 与向量数据库
后端·python·llm
sunny_11 小时前
面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了
前端·面试·node.js
ayqy贾杰13 小时前
Agent First Engineering
前端·vue.js·面试
IT_陈寒13 小时前
SpringBoot实战:5个让你的API性能翻倍的隐藏技巧
前端·人工智能·后端
有道AI情报局14 小时前
网易有道龙虾 NAS 服务器部署与实战指南
github