11.异常

予早 2025-08-31 14:59:18
Categories: Tags:

C 错误处理

一般来说,在 C 语言内部函数代码出现错误时,会返回 1NULL,并且同时会设置一个错误码 errno,以此来表示错误类型。

#include <stdio.h>
#include <errno.h>
#include <string.h>  // 包含 strerror 函数的头文件

int main() {
    // 清除 errno 初始值,这是一个好的编程习惯
    errno = 0;

    FILE *file;
    char *filename = "example.txt";

    // 尝试以读取模式打开文件
    file = fopen(filename, "r");
    if (file == NULL) {
        // 打开文件出错
        printf("Failed to open file: %s\n", filename);
        // 查看错误码 errno
        printf("ErrNo: %d\n", errno);
        // perror 函数显示传入的字符串,后跟一个冒号、一个空格和当前 errno 值的文本表示形式
        perror("Error");
        // strerror 函数返回一个指针,指向当前 errno 值的文本表示形式
        printf("Error: %s\n", strerror(errno));
        // 在发生错误时,大多数的 C 或 UNIX 函数调用返回 1 或 NULL
        return 1;
    }

    // 文件操作
    printf("open file success\n");

    // process file...

    // 完成操作后,关闭文件
    fclose(file);

    return 0;
}

这种错误处理方式存在一个很大的问题:返回值有二义性,例如使用1表示函数发生某种错误,但是若该函数本身在正常情况下就可能返回1该如何处理呢?由于返回只能有一个值,所以有两种思路

将返回结果以指针作为参数传入,这样外部自然可以获取该返回值,然后函数返回值就返回一个整数表示发生什么错误

#include <stdio.h>

int div(int a, int b, int *result) {
    if (b == 0) {
        return -1; // 返回 -1 表示错误
    }
    *result = a / b; // 将结果存储在指针所指向的变量中
    return 0; // 返回 0 表示成功
}

int main() {
    int result;
    int err;

    err = div(1, 0, &result);

    // 错误处理
    if (err == -1) {
        printf("division by zero\n");
    } else {
        printf("result: %d\n", result);
    }

    return 0;
}

将实际返回结果和错误码定义为一个结构体,整体返回

Python 错误处理

在 Python 中并不区分错误和异常,所以 Python 中的错误处理,我们一般称为异常处理。所有 Exception 派系的编程语言也都类似。

def div(a, b):
    return a / b

try:
    result = div(1, 0)
    print(result)
except ZeroDivisionError as e:
    logging.error(e)
except Exception as e:
    logging.error(e)

当然,如果就不使用异常体系,仿照C方式处理异常完全可以,但是非常麻烦

go 对于 exception 的态度:https://go.dev/doc/faq#exceptions

GO语言采用defer+recover处理异常,而非try+catch+finally处理异常,采用panic抛出异常,而非raise抛出异常。

在日常开发中,我们对于错误处理会有两个最普遍的诉求:

  1. 附加错误信息:在拿到原有的底层代码或第三方库返回的错误后,我们可能希望附加一些业务信息,比如 userID,这样就知道这条错误是由哪个用户产生的。

  2. 附加错误堆栈:因为错误堆栈中有出错代码的位置,以及整个调用链路,这会方便我们定位问题。

在错误中附加了这两个信息以后,输出的错误日志就非常有价值了,我们可以根据输出信息快速定位问题。

Go 语言的错误处理非常简单,秉承着 Errors are values 的大道至简风格。不过也正是由于简单,也就暴露了 Go 错误处理的些许简陋。

在 Go 1.13 版本之前,给一个错误附加错误信息可以这样做:

newErr := fmt.Errorf("user %d is err: %s", userID, err)

不过,这里存在一个较大的问题,就是新的错误 newErr 与原来的 err 是两个完全不同的值,有时候我们想通过 newErr 找到错误的原始根因 err,就没办法了。

Go 1.13 版本引入 error wrapping 为 fmt.Errorf 提供了 %w 动词,能给解决无法通过 newErr 找到 err 的问题,我们只需要把原来的 %s 换成 %w 即可:

newErr := fmt.Errorf("user %d is err: %w", userID, err)

这样,我们就可以用 err = errors.Unwrap(newErr) 拿到错误根因 err 了。

但是,Go 依然没有提供获取错误堆栈信息的方法,只能我们自己想办法解决。

幸运的是,以上这两个问题 pkg/errors 包都可以帮我们解决。

pkg/errors 包使用示例如下:

错误处理方式

if err != nil

func foo() (string, error) {
    // do something
    return "", nil
}
data, err := foo()
if err != nil {
    // 处理错误
    return
}
// 正常逻辑
fmt.Println(data)

出现

package main

func div(a int, b int) int {
    return a / b
}

func main() {
    div(1, 0) // panic: runtime error: integer divide by zero
}

panic

panic 内置函数用于停止当前goroutinue正常执行,相当于Java中的raise

当函数 F 调用panic时,F函数的正常执行立即停止,随后被F defer 的函数将会有序执行,最后 F 函数返回值给其调用者

对于函数F调用者 G,

package main

func div(a int, b int) int {
    panic("自定义异常")
    return a / b
}

func main() {
    div(1, 0) // panic: 自定义异常
}

defer

defer是语句之一,用于推迟函数执行,是一种上下文工具,无论正常异常与否都会执行,相当于Java中的finally

延迟执行语句,defer会将其后的语句压入栈中,等函数后面语句执行完后,一条一条弹栈执行(注意顺序),语句中的值在压栈时就已经确定,但函数闭包结构中的值运行时才会获取

可以用于释放资源

package main

import "fmt"

func div(a int, b int) int {
    tag := 1
    defer func() {
        fmt.Printf("推迟函数%v被执行了\n", tag) // 推迟函数2被执行了
    }()
    defer fmt.Println(tag) // 1
    tag++
    defer func() {
        fmt.Printf("推迟函数%v被执行了\n", tag) // 推迟函数2被执行了
    }()
    defer fmt.Println(tag) // 2

    panic("自定义异常")
    return a / b
}

func main() {
    div(1, 1) // panic: 自定义异常
}

recover

package main

import (
    "fmt"
)

func div(a int, b int) int {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    panic("自定义异常")
    return a / b
}

func main() {
    div(1, 1) // panic: 自定义异常
    fmt.Println("main 正常执行完毕")
}

func recover() any

recover内置函数允许程序管理panicking goroutine的行为。

在延迟函数内(不是在延迟函数调用的函数内)执行恢复调用,通过恢复正常执行来停止panicking sequence,并取回传递给 panic 调用的错误值。

若在延迟函数之外调用recover,则不会停止panicking sequence,在这种情况下,或者当goroutine没有处于panicking状态,或者panic入参为nil,则recover返回nil。因此,recover的返回值报告了goroutine是否处于恐慌状态。

package main

import (
    "fmt"
)

func div(a int, b int) int {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    panic("自定义异常")
    return a / b
}

func main() {
    div(1, 1) // panic: 自定义异常
    fmt.Println("main 正常执行完毕")
}

Go 异常处理最佳实践

Go 中异常处理非常有争议。

Go 语言设计上异常处理机制分为两大块,一是errors,而是panic,按照Go的设计,panic专用于处理对程序有致命性影响的问题,其他一些可以处理的错误使用errors进行,但errors本身与实际业务开发实践的脱节和Go迟迟无法拿出方案(致使各种库异常处理花样百出加剧异常处理难度)导致Go的异常处理非常具有争议。

方法一:朴素异常处理,等于没有异常处理功能,直接pass

package main

import (
    "errors"
    "fmt"
)

func Func0() error {
    err := Func11()
    if err != nil {
        return err
    }
    err = Func12()
    if err != nil {
        return err
    }
    return errors.New("发生了一个错误 0")
}

func Func11() error {
    return errors.New("发生了一个错误 11")
}

func Func12() error {

    err := Func21()
    if err != nil {
        return err
    }
    err = Func22()
    if err != nil {
        return err
    }
    return errors.New("发生了一个错误 12")
}

func Func21() error {
    return errors.New("发生了一个错误 21")
}

func Func22() error {
    return errors.New("发生了一个错误 22")
}

func main() {
    err := Func0()
    if err != nil {
        fmt.Printf(err.Error())

        // 若在这里要分情况处理异常,例如 "发生了一个错误 22" 的异常时 不打印,那么如何处理
        // 这里的问题是异常没有类型,如果非要处理,就得 在判断完 err != nil 为真之后:
        // 就得 if err.Error() != "发生了一个错误 22"{fmt.Printf(err.Error())}
        // 这会非常非常非常麻烦,且当error错误信息由程序动态生成时无法处理异常
        // 不区分类型而要处理异常是异常处理的灾难性用法,这与具体语言无关,这种用法完全与异常处理相违背
    }
}

方法二:哨兵值,全局变量当作异常类型(方法一的补救,不够作为一个语言级异常处理方案,pass)

package main

import (
    "errors"
    "fmt"
)

var (
    Err0  = errors.New("发生了一个错误 0")
    Err11 = errors.New("发生了一个错误 11")
    Err12 = errors.New("发生了一个错误 12")
    Err21 = errors.New("发生了一个错误 21")
    Err22 = errors.New("发生了一个错误 22")
)

func Func0() error {
    err := Func11()
    if err != nil {
        return err
    }
    err = Func12()
    if err != nil {
        return err
    }
    return Err0
}

func Func11() error {
    return Err11
}

func Func12() error {

    err := Func21()
    if err != nil {
        return err
    }
    err = Func22()
    if err != nil {
        return err
    }
    return Err12
}

func Func21() error {
    return Err21
}

func Func22() error {
    return Err22
}

func main() {
    err := Func0()
    if err != nil {
        // 该用法预先定义一些异常,这些异常作为全局变量,但返回这些异常时直接引用全局变量即可
        // 尽管这些异常在语言上类型一样,但由于本身不是同一个变量,所以可以将每一个变量视为一种异常,这样可以达到按异常类型处理异常的效果
        // 但是这种方法本身是一种 哨兵值 的编程技巧,是指定义一些全局值,用于做特殊处理,异常处理本身是一个语言级别的需求,编程技巧并不能强制要求该写法,且改写法本身有很大问题
        // 由于这些错误以全局变量定义,且必须对外开发,并且Go语言中 errors.New 不能赋给一个常量,所以这个全局错误是允许被更改的,当全局错误被更改,按值作为异常类型的错误处理将完全混乱
        // 本方案尽管与没有类型一说的异常处理提升巨大,但要作为一个语言级别的功能的异常处理来说,是远远不够的,在有其他更好方案的前提下,完全不必犹豫地抛弃本方案
        // 由于 Go 在异常处理实现方案的不足且目前仍处于探索和讨论(争吵)阶段,会有一些历史库使用了该方式,若在程序中使用这些库,需做好兼容
     // 当然,最好是不要使用这些库并且提出 issue 以鞭策这些库向更好的方向发展,该淘汰的就淘汰吧
     // Go 标准库中大量使用该用法:
        // https://github.com/golang/go/blob/go1.23.1/src/bufio/bufio.go#L22
        // https://github.com/golang/go/blob/go1.23.1/src/io/io.go#L29
        // https://github.com/golang/go/blob/go1.23.1/src/os/error.go#L16
        if err != Err22 {
            fmt.Printf(err.Error())
        }
        // 处理多种错误时,使用 seitch case
        //     switch err {
    case bufio.ErrNegativeCount:
        // do something
        return
    case bufio.ErrBufferFull:
        // do something
        return
    default:
        // do something
        return
    }
    }
}

另外,上面两个方案都没有打印堆栈,整体上看我们需要更佳合适的方案,需要:

  1. (必须)按类型的异常,并且可以按类型处理异常
  2. (必须)异常中包含错误信息,且这些错误信息是可以自由指定的
  3. (必须)异常中包含错误发生处的堆栈信息,以便定位问题
  4. (推荐)异常类型可以有子类型,这样可以极大拓展按异常类型处理异常的能力,面向对象语言基于类,其他语言可考虑用等价方案
  5. (推荐)异常可以不必层层传递而可以跨层抛出,这样极大地提高业务编码体验,避免无意义透传

如何处理一个异常:

  1. 调用某一个函数,该函数会抛出异常
  2. 调用方不能无视这个异常,只能有两种选择
    1. 选择处理该异常,最后不再抛出
    2. 打印错误信息或者什么都不做(推荐不处理,因为这样会导致处处打印错误信息,会造成重复打印),最后抛出异常

对于Go语言来说

  1. 调用一个函数,函数会返回err
  2. 调用方不能使用_无视这个异常,考虑三种选择:
    1. 选择处理该异常,最后不再返回err
    2. 什么都不做,返回err
    3. 若真的觉得不会发生异常,可以if err !=nil panic(err),没有忽视异常且看你有多少自信能保证一定不会出问题,考量一下

方案三:使用类型+switch type

自定义错误类型,具有丰富上下文信息

https://github.com/golang/go/blob/go1.23.1/src/io/fs/fs.go#L250

package main

import (
    "fmt"
)

type BaseErr struct {
    Msg string
}

func (e *BaseErr) Error() string {
    return fmt.Sprintf("Err0: %+v", e.Msg)
}

type Err0 struct {
    BaseErr
}

type Err11 struct {
    BaseErr
}

type Err12 struct {
    BaseErr
}

type Err21 struct {
    BaseErr
    ExtraMsg string
}

type Err22 struct {
    BaseErr
}

func Func0() error {
    err := Func11()
    if err != nil {
        return err
    }
    err = Func12()
    if err != nil {
        return err
    }
    return &Err0{BaseErr{Msg: "发生了什么什么异常"}}
}

func Func11() error {
    return &Err11{BaseErr{Msg: "发生了什么什么异常"}}
}

func Func12() error {

    err := Func21()
    if err != nil {
        return err
    }
    err = Func22()
    if err != nil {
        return err
    }
    return &Err12{BaseErr{Msg: "发生了什么什么异常"}}
}

func Func21() error {
    return &Err21{BaseErr{Msg: "发生了什么什么异常"}, "额外异常信息"}
}

func Func22() error {
    return &Err22{BaseErr{Msg: "发生了什么什么异常"}}
}

func main() {
    err := Func0()
    if err != nil {
        switch e := err.(type) {
        case *Err22:
            //..
            fmt.Println(e.Error())
        case *Err21:
            fmt.Printf(e.ExtraMsg)
        default:
            //...
            fmt.Printf(e.Error())
        }
    }
}

上面基本实现了按类型处理错误,但是没有堆栈信息

Join 合并多个错误返回

package main

import (
    "errors"
    "fmt"
)

// 模拟从文件加载配置的函数
func loadFromFile() error {
    return errors.New("failed to load config from file")
}

// 模拟从数据库加载配置的函数
func loadFromDatabase() error {
    return errors.New("failed to load config from database")
}

// 模拟从环境变量加载配置的函数
func loadFromEnv() error {
    return nil // 假设环境变量加载成功
}

// 配置加载函数,尝试从多个来源加载配置
func loadConfig() error {
    var errs []error

    // 逐个尝试加载配置
    if err := loadFromFile(); err != nil {
        errs = append(errs, err)
    }
    if err := loadFromDatabase(); err != nil {
        errs = append(errs, err)
    }
    if err := loadFromEnv(); err != nil {
        errs = append(errs, err)
    }

    // 如果有错误,使用errors.Join组合返回
    if len(errs) > 0 {
        return errors.Join(errs...)
    }

    return nil
}

func main() {
    // 尝试加载配置
    if err := loadConfig(); err != nil {
        // 打印组合后的错误信息
        fmt.Println("Failed to load config:", err)
    } else {
        fmt.Println("Config loaded successfully")
    }
}

errors.Is 用于检查一个错误是否等于另一个错误,或者是否包裹了另一个错误。它通过深度优先遍历错误树来执行检查。

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    err := os.ErrNotExist

    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("File does not exist")
    }
}

errors.As 用于检查一个错误是否可以转换为另一种特定类型的错误,并在成功时进行类型转换。

package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    err := fmt.Errorf("wrapping: %w", &os.PathError{Op: "open", Path: "file.txt", Err: errors.New("file not found")})

    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Printf("Failed to %s %s: %v\n", pathErr.Op, pathErr.Path, pathErr.Err)
    }
}

nil 错误值可能不等于 nil

Why is my nil error value not equal to nil?

package main

import "fmt"

type MyError struct {
    msg string
}

func (e *MyError) Error() string {
    return e.msg
}

func returnsError() error {
    var p *MyError = nil
    return p // Will always return a non-nil error.
}

func main() {
    err := returnsError()
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    fmt.Println("success")
}

可以发现,main 函数中的 if err != nil 错误检查结果为 true,但使用 fmt.Println 输出的值却为 nil

出现这一怪异现象的原因与 Go 的接口实现有关。

在 Go 中一个接口对象实际包含两个属性:类型 T 和具体的值 V。例如,如果我们将值为 3int 类型对象存储在接口中,则生成的接口对象实际上内部保存了:T=int, V=3

仅当 TV 都未设置时(T=nil, V 未设置),接口的值才为 nil

如果我们将 *int 类型的 nil 指针存储在接口对象中,则无论指针的值是什么,接口类型都将是 T=*int, V=nil

同理虽然 p 在初始化时赋值为 nilvar p *MyError = nil),但是它会被赋值给接口类型 error,我们得到的接口类型将是 T=*MyError, V=nil

Go 1.13 为 errorsfmt 标准库包引入了新功能:

Go 1.20 新增了 errors.Join 函数返回包装后的错误列表。

pkg/errors 是目前 Go 错误处理的最优解。

记录错误调用链

我们可以在错误调用链中,使用 pkg/errors 提供的 errors.Wrap 方法为错误附加一些信息,以此来记录链路调用过程。

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

func Foo() error {
    return errors.New("foo error")
}

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.Wrap(err, "bar")
    }
    return nil
}

func main() {
    err := Bar()
    if err != nil {
        fmt.Printf("err: %s\n", err)
    }
}

记录错误堆栈

修改 main 函数的错误处理,只需要将 fmt.Printf 中格式化错误的动词从 %s 改成 %+v 即可:

func main() {
    err := Bar()
    if err != nil {
        fmt.Printf("err: %+v\n", err)
    }
}

可以看到,错误从产生开始,整个调用链堆栈信息都被记录了下来。

但是这里存在重复的问题,错误调用链被打印了两次。这其实是因为 pkg/errors 包提供的 errors.New 函数本身在构造错误时就已经记录了堆栈信息,而 errors.Wrap 又记录了一遍。

所以,如果错误是通过 errors.New 构造的,调用链中间不应该再次使用 errors.Wrap 附加错误信息,而应该使用 errors.WithMessage

修改 Bar 函数如下:

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.WithMessage(err, "bar")
    }
    return nil
}

不要做冗余的错误检查

pkg/errors 包提供了更方便的使用方法,我们无需编写这种代码:

func Bar() error {
    err := Foo()
    if err != nil {
        return errors.WithMessage(err, "bar")
    }
    return nil
}

可以直接去掉那冗余的错误检查:

func Bar() error {
    err := Foo()
    return errors.WithMessage(err, "bar")
}

这不对执行结果造成任何影响。

我们无需判断 err 是否为 nil,因为 pkg/errors 内部的方法帮我们做好了这项检查:

func WithMessage(err error, message string) error {
    if err == nil {
        return nil
    }
    return &withMessage{
        cause: err,
        msg:   message,
    }
}

对于 errors.Wrap/errors.WithStack 同样如此。

兼容 Sentinel error 处理

因为 pkg/errors 包提供的 errors.Wrap/errors.WithStack/errors.WithMessage 这三个方法都会返回新的错误,所以默认情况下 Sentinel error 相等性判断就会失效。

不过 pkg/errors 包考虑到了这点,提供了 errors.Cause 方法可以得到一个错误的根因。

func Foo() error {
    return io.EOF
}

func Bar() error {
    err := Foo()
    return errors.WithMessage(err, "bar")
}

func main() {
    err := Bar()
    if err != nil {
        if errors.Cause(err) == io.EOF {
            fmt.Println("EOF err")
            return
        }
        fmt.Printf("err: %+v\n", err)
    }
    return
}

可以发现,pkg/errors 包充分考虑了人类和程序对错误的不同处理。

pkg/errors 包可以非常方便的向一个已有错误添加新的上下文,错误堆栈可以方便我们程序员排查问题,errors.Cause 获取错误根因的方法,可以方便程序中对错误进行相等性检查。

如果我们的代码中全局都在使用 pkg/errors 包,那么通过 errors.New/errors.Errorf 构造的错误天然就已经携带了错误堆栈信息。

通常在调用链中间过程直接返回底层错误即可,如果想要附加信息,则可以使用 errors.WithMessage,不要使用 errors.Wrap/errors.WithStack 以免造成堆栈信息的重复。

如果与标准库或来自第三方的代码包进行交互,可以考虑使用 errors.Wrap/errors.WithStack 在原错误基础上建立堆栈跟踪。

在错误处理调用链顶层,可以使用 %+v 来记录包含足够详细信息的错误。

源码解读:https://jianghushinian.cn/2024/09/14/go-error-guidelines-pkg-errors/#pkg-errors-%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BB