0. 一个能“悄悄坑你”的真实示例:SError 的故事

先看你提到的这段代码,表面上看非常正常,甚至很多人第一眼不会觉得哪里有问题:

 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 (
    "log"
    "sync"
)

type SError struct {
    cause     error  // Wrapped error which is the root cause.
    text      string // Error text, which is created by New* functions.
    i18nText  string // 本地错误文字,用于客户端显式中文
    ignored   bool   // 能否忽略该错误
    info      map[string]string
    infoMutex sync.RWMutex
}

func (e *SError) Error() string {
    return e.text
}

// demoRetSerr 返回 (int32, *SError)。看上去,如果不想返回任何错误,就直接返回 (0, nil)
func demoRetSerr() (int32, *SError) {
    return 0, nil
}

func main() {
    var err error
    // 这里把第二个返回值的 *SError 赋给了 interface{} 类型的 err
    _, err = demoRetSerr()
    
    if err != nil {
        log.Printf("err != nil. err: %v", err)
    }
}

0.1 乍看之下哪里会有问题?

  • 我们 demoRetSerr 函数直接返回 (0, nil),而函数签名的第二个参数类型是 *SError
  • 回到 main 函数中,err 是一个 error 接口,承接了那个 nil 指针。
  • 如果“typed nil” 现象出现,那么 err != nil 这个判断就会莫名其妙地通过,从而在日志里打印出“err != nil. err: <nil>”。有时更可怕的是,实际逻辑会被误判,可能执行本不该执行的错误处理分支。

在某些 Go 版本或特定编译器优化下,你可能发现控制台就输出 err != nil. err: <nil>,让人“一头雾水”。这就是一个十分典型、却很隐蔽的 typed nil 场景。即使你写 return nil,对编译器来说:

  • 这是一个 *SError 类型(type = *main.SError),
  • 其底层 data 指针是 nil (data = nil),
  • 放到接口变量后,形成了 (type = *main.SError, data = nil)
  • Go 只会认“(type=nil, data=nil)”时才是 “真正的 nil”。

1. 用费曼学习法之“最简原理”再阐述

在 Go 里,接口值可以理解成 (type, data) 两部分:

  1. type:存放当前接口值实际对应的具体类型信息。
  2. data:指向这个数据在内存中的地址。

只有当 type=nil 并且 data=nil 时,接口值才是“真正的 nil”。如果 type*SError,即使 data=nil,接口值也不等于 nil。

因此,像上面示例中的 err,看似已经是“nil 指针”了,但赋值进接口后变成 “(type=*SError, data=nil)”,在 Go 的判定里,这不等于 nil。


2. 为什么它这么“容易挖坑”?

2.1 初学者会想当然

很多人会想:“我都直接 return nil 了,这还不是真正的 nil?” 但在 Go 里,nil 也需要区分“无类型的 nil”和“有具体类型但指针为 nil”。

2.2 出现在最常用的 error 接口上

Go 里最常见的接口就是 error。稍不留神,就写出 “typed nil” 的自定义错误类型,然后让别人在 if err == nil 里踩坑。

2.3 测试用例可能漏测

单测中往往只关心有错误还是没错误,可能不会特别注意日志打印里出现的 <nil>直到线上某些逻辑走岔了,再一查日志才发现 err != nil 却打印 <nil>


3. 更丰富的例子:经典“typed nil” 场景

除了上面你给的 SError 示例,我们再看看另一个常被引用的代码来说明“为什么它不等于 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
type MyError struct {
    msg string
}

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

func maybeError(returnNil bool) error {
    if returnNil {
        // 这里直接返回 nil -> (type=nil, data=nil)
        return nil
    }
    // 这里返回一个 nil 的 *MyError -> (type=*MyError, data=nil)
    var e *MyError = nil
    return e
}

func main() {
    err1 := maybeError(true)
    err2 := maybeError(false)

    fmt.Println("err1 == nil?", err1 == nil) // 结果是 true
    fmt.Println("err2 == nil?", err2 == nil) // 结果是 false
}
  • returnNil = true,返回真正的 (nil, nil)
  • returnNil = false,则是 “(type=*MyError, data=nil)”,从而 err2 != nil

很多人看了就会疑惑:“明明里面是 nil,为什么 if err2 == nil 不通过?” 这正是和 SError 场景如出一辙的坑。


4. 工作中容易忽视的写法

4.1 返回“空指针”当作“空接口值”

1
2
3
4
5
func GetConfig() interface{} {
    var config *Config = nil
    // 误以为返回了 nil 接口值
    return config
}

结果调用方 if GetConfig() == nil {...} 永远判断不出来。

4.2 自定义 error,常见

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type SomeErr struct {
    Message string
}

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

func doSomething() error {
    var e *SomeErr = nil
    return e // typed nil
}

后面 if err == nil 期待“没错误”,其实 err != nil

4.3 与 sync/atomic 或其他魔法操作混用

有些高级用法中,通过 atomic.Value、或者 channel 传递接口,都可能把某个 nil 指针塞进来;接口变成 typed nil 而不自知。


5. 现实中的 2 个翻车故事

故事 1:日志狂刷“err != nil. err: <nil>”

  • 团队里写了一个 RPC 返回 error 的接口,如果没真正错误,就 return (*CustomErr)(nil).
  • 结果在调用方 if err != nil 逻辑里,一直以为出现了错误,于是疯狂打日志告警。
  • 线上监控炸了,运维们满头雾水,最终才发现是 typed nil 导致 err != nil == true 的假象。

故事 2:“不存在”数据触发了后续流程

  • 在一个电商项目里,“查询不到订单” 时返回了 (type=*Order, data=nil)
  • 调用方写 if order == nil { ... } else { ... },结果误走了 else 分支,竟然开始发货流程!
  • 导致上线后出现了“幽灵订单”,财务对账时一片混乱;大家排查数据库、仓储、消息队列都没发现问题,最后是 typed nil 搞的鬼。

6. 如何避免这类陷阱?

  1. 返回“真正的 nil”
    当表示“啥也没有”时,就要 return nil,并且这个 nil 不要挂任何具体类型。

    • 比如示例里的 demoRetSerr,若想表示无错误,最好就返回 (0, nil)并且别让调用方把它赋到 var err error 后出现歧义。
    • 若要彻底避免,还可以把函数直接签名改为 (int32, error) 而不是 (int32, *SError),这样保证返回值与 error 接口的类型一致。
  2. 调试或日志打印
    当怀疑 typed nil 时,用 fmt.Printf("%T, %#v\n", x, x) 看看接口内到底存了什么(会显示类似 *main.SError(nil))。或用反射 reflect.ValueOf(x) 分析底层类型和指针。

  3. 类型断言/反射
    如果确实要判断底层是否 nil 指针,可以:

    1
    2
    3
    
    if v, ok := err.(*SError); ok && v == nil {
        // 这里才是底层指针为 nil
    }
    

    不过大部分场景下,最好通过直接返回 nil 来避免把问题带到调用方。


7. 从 SDE 视角的 3 点建议

  1. API 设计要“真空”
    当函数需要表示“无数据”或“无错误”,务必返回真·nil ((type=nil, data=nil))。若使用自定义指针类型,尽量在文档里明确说明可能是 typed nil,需要用类型断言来判别。

  2. 单元测试 & 日志不可省
    对关键逻辑要做单元测试,包含“空值”分支;出现“打印 <nil> 却 err != nil”时,应直接排查 typed nil。也建议在测试中做 %T, %#v 的检查。

  3. 约定统一的团队风格

    • 不要随意返回 (*MyType)(nil) 给接口;要么全部直接返回 nil,要么在文档或注释里明确提示。
    • error 场景中尤其要注意,能返回 nil 就别给调用方 typed nil,不要让调用方踩坑。

8. 最后小结

  • 核心原理:Go 接口底层是 (type, data),只有 (nil, nil) 才是真正的 nil。
  • “看似”nil,不是真的 nil:当 type 不为 nil,哪怕 data=nil,也会被认作非 nil。
  • 真实教训:许多生产环境的翻车场景都和 typed nil 有关,比如错误处理或数据查询的逻辑分支走歪了。
  • 实用做法最好返回真·nil,或让调用方知道有 typed nil 的可能并做断言或日志排查。