编程 SA5011 说这里会 nil 解引用:证据其实是你自己写的那句 if x == nil

2026-11-06 00:04:18

SA5011 说这里会 nil 解引用:证据其实是你自己写的那句 if x == nil

项目地址:https://github.com/dominikh/go-tools (staticcheck,模块路径 honnef.co/go/tools)

一条 SA5011 长什么样

staticcheck 从 2020.1 起,输出会带 related information(release notes:https://staticcheck.dev/changes/2020.1/ ):

foo.go:6:14: possible nil pointer dereference (SA5011)
	foo.go:2:5: this check suggests that the pointer can be nil

对应代码:

func fn(x *int) {
    if x == nil {
        log.Println("x is nil, returning")
    }
    // lots of code here
    log.Println(*x)
}

第二行才是重点。它表达的含义是「第 2 行你拿它和 nil 比过,所以我怀疑它可以为 nil」,并不表示「这里一定是 nil」。SA5011 的中文名常被写成「可能的空指针解引用」,但它实际抓的是「nil 检查和后面的解引用没接上」这种组合。检查项定义见 https://staticcheck.dev/docs/checks/#SA5011 。

修复也很直接:那个 if 分支只打日志、不返回,本来就该 return;不 return 才是这段代码的真 bug。

判定依据:nil 比较 + 无保护解引用

这一类检查走控制流图,只在一个组合上报:同一个指针既被和 nil 比较过,又在某条路径上被无保护地解引用。所以它天然偏向「写了检查但控制流没断」的代码,而不是「完全忘了检查」的代码。

跨函数时它依赖 analysis facts 传播。golangci-lint 的 #5979(https://github.com/golangci/golangci-lint/issues/5979 )里那批「时有时无」的测试误报,维护者 ldez 归因到 #3228 的 cache and facts 问题;报告者 kolyshkin 的复现结论是:重跑同一条 CI job 无效,清掉 golangci-lint 缓存就恢复。这不是代码问题,是 facts 缓存问题。

三种常见误报

1. 多余的第二次 nil 检查

type T struct{ w *int }

func f(t *T) {
    if t == nil {
        return
    }
    w := t.w
    if t == nil { // 这行不可能成立
        return
    }
    println(w)
}

issue #641(https://github.com/dominikh/go-tools/issues/641 )就是这个例子。作者 fhs 认为前面已经 return 过,第二次检查不可能命中,属误报;dominikh 的回答是 working as designed:「移除不可能的 nil 检查才是这里正确的修法」,并把它作为设计意图而不是缺陷关掉。

这类「误报」的正确处理是删代码,不建议加抑制。

2. 测试里 t.Fatal 之后

got, err := load(cfgPath)
if got == nil {
    t.Fatalf("nil cfg: %v", err)
}
if got.Name != want { // SA5011 指这里
    ...
}

t.Fatal 内部走 runtime.Goexit,人读起来当然知道下面不会执行。但某仓库在把工具链升到 Go 1.26.3 后提交的修复(https://github.com/forgekeep/nebula-mesh/commit/a37909982e2a770b148da42134b556c6f7302eda )说:staticcheck 不再把 t.Fatal / t.Fatalf 当作 noreturn,此后报 SA5011;他们的修法是在 Fatalf 后补一个显式 return,让控制流对分析器可见。

这个说法在 2026.1/2026.2 的 release notes 里没有对应条目,属于社区 commit 的描述;不同工具链版本行为可能不同,遇到时先确认自己跑的 staticcheck 版本,别直接照抄结论。

同一类代码还会撞上 #5979 的缓存抖动:同样的测试,偶尔报、偶尔不报,清缓存就好。

3. 自定义 Fatalf / 自己的退出函数

func (l *Logger) Fatalf(format string, args ...any) {
    // 记录后 os.Exit(1)
}

if obj == nil {
    logger.Fatalf("obj is nil")
}
obj.Do() // SA5011

分析器只认标准库里的终止函数,自己的 Fatalf 不在名单里。issue #1651(https://github.com/dominikh/go-tools/issues/1651 )提的就是这个:允许配置自定义 exit 函数,状态仍是 open。在那之前只有两个选择:后面补 return,或者在该行加 //nolint:staticcheck 并写明理由。

拿到一条 SA5011 先做什么

  1. 读 related information 指的那行 nil 比较,确认它是不是在当前路径之前。
  2. 判断:那处比较为真时,控制流真的不往下走吗?return、panic、os.Exit、log.Fatal 算;自定义 Fatalf、某些版本下的 t.Fatal 不算。
  3. 控制流确实终止:删掉多余的检查,或补 return 让分析器看得见。
  4. 控制流不终止:SA5011 说的是真 bug,按真 bug 修。
  5. 前四步都不成立,才考虑抑制。

golangci-lint 里抑制要带原因,否则 nolintlint 会再报一条:

obj.Do() //nolint:staticcheck // Fatalf 走 os.Exit,SA5011 看不到

2026.2 之后,这条检查没了

Staticcheck 2026.2 的 release notes(https://staticcheck.dev/changes/2026.2/ )写着:

SA5011 has been disabled due to internal changes. It is unclear whether it will be enabled again in the future. As an alternative, gopls contains the nilness analyzer, which has similar checks.

提交 5161aaa(https://github.com/dominikh/go-tools/commit/5161aaa1814bc64a9999cd01e99c461b96f55eab )给了原因:SA5011 依赖 SSI,而 SSI 即将移除;用 backward dataflow 重写不是不能做,但对一个「能安全报的范围很窄」的检查来说代价不值。

所以升到 2026.2 之后,CI 里 SA5011 消失,这并不代表代码变干净了,只是检查被撤了。替代是 gopls 的 nilness analyzer(https://go.dev/gopls/analyzers ),默认开启,报的范围比 SA5011 宽:impossible condition(对函数做 nil 比较)、tautological condition(&v 之后判 p != nil)、nil dereference、panic(nil),也包括动态方法调用上的 nil 解引用——官方示例里 G.Wait 返回 err 已判过非 nil 后又在别的分支调 err.Error() 就是这种。

取舍

  • 留在旧版 staticcheck:SA5011 还能用,代价是 t.Fatal 与 facts 缓存两类噪音;把测试排除掉(golangci-lint 的 run.tests: false,或 staticcheck 的 -tests=false)能让噪音消失,但也把测试里真的 nil 解引用一起漏了。
  • 升到 2026.2:SA5011 没了,nilness 主要在编辑器和 gopls 侧,CI 想要等价覆盖得自己补一条 gopls/vet 之外的检查,这一步目前没有官方现成方案。

检查项与配置文档:https://staticcheck.dev/docs/checks/ 。

复制全文 生成海报 SA5011 staticcheck Go lint nilness

推荐文章

程序员茄子在线接单