DEBUG.WIKI
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

Go 返回了 nil 指针,调用方的 err != nil 仍然成立

空指针转成 error 后仍携带动态类型。先在具体指针层判断,再显式返回 nil,避免成功结果进入错误分支。

检查函数返回空指针,上层包装为 error 后却进入错误分支。下面的示例可以看出差别:

package main

import "fmt"

type CheckError struct{ Message string }

func (e *CheckError) Error() string { return e.Message }

func check() *CheckError { return nil }

func run() error { return check() }

func main() {
    err := run()
    fmt.Printf("type=%T, value=%#v, nil=%v\n", err, err, err == nil)
}

预期能看到类型 *main.CheckError、值 (*main.CheckError)(nil),以及 nil=false。这适用于 Go 的接口赋值和返回值转换规则,不取决于是否使用具名返回值。

空指针进入接口后,类型仍然存在

可以把接口值理解为动态类型和动态值的组合。check() 的结果虽然是空指针,但类型是 *CheckError。返回到声明为 error 的函数时,它成为带有该动态类型的接口值。

只有接口本身没有动态类型和值时,才等于 nil。应沿返回路径找到具体指针转成接口的位置。

排查时可使用上面的 %T 和 %#v 检查受控样本。不要为了确认“是否为空”直接调用 err.Error():这个示例的方法会读取空接收者的字段并导致 panic。方法能否接受空指针取决于其实现,不能一概推断。

在转换边界显式返回 nil

如果底层接口暂时不能改变,在包装函数里先判断具体指针:

func run() error {
    result := check()
    if result == nil {
        return nil
    }
    return result
}

这里的判断发生在 result 仍是 *CheckError 时。成功分支返回没有动态类型的 nil;失败分支再把非空指针转成 error,调用方继续使用普通的 err != nil 即可。

如果检查函数本来就是用来报告错误,更直接的接口是返回 error,并让成功分支返回字面量 nil:

func check(valid bool) error {
    if !valid {
        return &CheckError{Message: "invalid input"}
    }
    return nil
}

即使返回类型改成了 error,也不要再声明一个空的 *CheckError,最后统一 return 这个指针;那样仍然会产生同样的接口值。

不要把调用方绑定到一种错误指针

把外层条件改成与 (*CheckError)(nil) 比较,只能识别这一种具体类型。以后增加其他错误实现时,调用方就要跟着修改。对约定“成功返回 nil”的接口,优先修正产生错误值的位置。

同样,不应把所有带空指针的接口都自动改成空接口。某些接口实现允许空接收者且有明确行为;这里的修复范围是错误返回契约,不是对全部接口值做统一清洗。

验证时至少覆盖成功和失败两条路径:成功必须满足 err == nil,失败必须保留非空错误及原有信息。若要改变公开函数签名,先检查调用方;无法同时迁移时保留底层签名,先修正包装层。回退代码后仍需检查错误分支,避免把真实失败当作成功吞掉。