症状
一条 printf 就能完整暴露悖论:var err error = (*MyErr)(nil) 之后打印 %v | %T | err != nil,输出
诊断思路
先用三种格式打印矛盾三元组完成诊断;再找“生产者”——声明接口或 error 返回类型、却返回具体类型 nil 指针的函数(var e *NotFoundErr; return e 是标准形态);最后在边界写断言测试:空用例断言 err == nil,带类型 nil 会失败、修复后通过,契约被钉死。错误包装链里 return err 把带类型 nil 原样透传,是第二常见入口。
根因
Go 接口值是“具体类型 + 值”的二元组,只有两部分都为空的接口才等于 nil。存入 nil *MyErr 得到 (类型=*MyErr, 值=nil),类型字被设置,比较即为非 nil;方法调用经类型字分发,这就是 nil 接收者方法能先成功一阵的原因。
修复与验证
修复属于生产函数:接口边界返回字面量 nil,查找失败路径 if !exists(id) { return nil };每个包装点加守卫——err == nil 时返回 nil,否则带类型 nil 会穿越包装存活下来。nil 接收者方法防御性读字段的做法只会让问题更迷惑:它让带类型 nil 走得更深才引爆。验证时用表驱动测试遍历每个返回接口的构造器与查找函数,对空用例同时断言 err == nil 与 value == nil。不要用 reflect 的 IsNil 检查去容忍带类型 nil——那只是给契约违规打补丁,把失败挪得更深。
代码示例
var err error = (*MyErr)(nil)
fmt.Printf("%v | %T | non-nil? %v\n", err, err, err != nil)
// <nil> | *main.MyErr | non-nil? true
func FindUser(id string) error {
var e *NotFoundError // stays nil when absent
// ...
return e // BUG: (*NotFoundError, nil) — non-nil interface
}
func FindUser(id string) error {
if !exists(id) {
return nil // literal nil: both words empty
}
return &NotFoundError{ID: id}
}
func Wrap(err error) error {
if err == nil {
return nil // without this, a typed nil survives wrapping
}
return fmt.Errorf("context: %w", err)
}