Go 中过度的空指针检查
摘要
一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。
<p><a href="https://lobste.rs/s/z7eoo7/excessive_nil_pointer_checks_go">评论</a></p>
查看缓存全文
缓存时间: 2026/06/27 13:53
# Go 语言中过度的 nil 指针检查
来源:https://konradreiche.com/blog/excessive-nil-pointer-checks-in-go/
2026年6月16日我们来聊聊 Go 语言中的 nil 指针检查。你希望在生产环境中避免 panic,但这不是通过延迟的 `recover` 就能做到的。它始于防御性编程:检查输入、检查边界、在解引用前检查指针是否为 nil。
我开始在更多 Go 代码中看到 nil 检查。放在正确的位置,它们是编写安全代码所必需的;放在错误的位置,则表明代码已经不再清楚地说明什么可以为 nil、什么不可以。我在生成的代码中更常注意到这种模式,但这个症状并不新鲜,也不限于 AI。
既然 nil 检查成本低廉且能防止 panic,为什么不加呢?你的本能反应可能是:保险起见。但检查同时也会告诉下一位阅读者一些信息,而且常常是错误的信息。
## 对依赖的 nil 检查
考虑对一个依赖的 nil 检查。一个 `RateLimiter` 类型持有一个 Redis 客户端,第 10 到 12 行在使用它之前进行了 nil 检查:
```
1type RateLimiter struct {
2 redis *redis.Client
3}
4
5func (r *RateLimiter) Allow(ctx context.Context, req *Request) (bool, error) {
6 userID := GetUserID(req)
7 if userID == "" {
8 return false, nil
9 }
10 if r.redis != nil {
11 return r.checkLimit(ctx, userID)
12 }
13 return false, nil
14}
```
乍一看,这似乎是安全的做法,但你应该问自己:你为什么要用一个 nil 依赖来构造 `RateLimiter`?
如果 Redis 客户端是 nil,错误早就发生了,是在构造时。现在检查它并不能处理那个错误。更糟糕的是,它把在构造失败状态下运行当作可接受的状态。在 Go 中,我们希望快速失败、尽早失败。
nil 检查并不总是防御性编程。在这个例子中,它表明代码已经失去了对其对象来源的追踪。我们不再知道指针来自哪里、谁负责初始化它、或者什么不变性本应让 nil 不可能出现。
## 在构造器中对依赖进行 nil 检查
你可能会尝试通过把问题推上一层来解决。现在你检查 nil 并返回一个错误,将 nil 依赖标记为无效状态。
```
func NewRateLimiter(client *redis.Client) (*RateLimiter, error) {
if client == nil {
return nil, errors.New("redis client is nil")
}
return &RateLimiter{
redis: client,
}, nil
}
```
这样好一些,但仍然不正确。为什么?因为我们仍然允许无效状态进入系统。一个 nil 指针仍然被传递给了我们的函数,这迫使本应首先接收有效值的代码去决定是否信任输入。
构造器并不是错误发生的地方。错误发生在初始化点:
```
redisClient, err := NewRedisClient(addr)
if err != nil {
return nil, err
}
limiter := NewRateLimiter(redisClient)
```
一旦初始化失败,我们应该立即处理那个错误。我们不应该拿着一个 nil 指针继续,并强迫更深的下一层去重新发现结果。这样做也使得限流器的构造器一开始就不需要返回错误!
如果系统需要容忍存储暂时不可用,不要传播一个 nil,而是显式建模它。将其包装起来,使外层类型永远非 nil,并在内部处理重试或降级,暴露安全调用的方法。调用者获得一个保证存在的类型,而混乱被封装在内部。这就是封装复杂性的方式,而不是让整个代码库都暴露于它。
这与数据库约束是同样的思路。`NOT NULL` 或外键约束保证了坏的行根本不可能存在,所以每个查询都可以信任数据而无需重新检查。你希望对你的运行时值也有同样的保证,但有一个区别。数据库在每次写入时强制执行约束。你则建立一次约束,然后其余代码就可以依赖它,无需重复检查。
## 静默失败的成本
当我指出类似上面的代码时,我经常得到一个反映了对可靠性出于好意的直觉的回应,大意是:
> “我不想在这里返回错误并冒着因为我的小改动而导致程序宕机的风险。用 nil 检查包装或仅仅记录日志感觉更安全。”
这种选择看起来像是崩溃(糟糕) vs 继续(安全),但实际上却是大声失败(安全) vs 静默失败(糟糕)。显式返回的错误是:
- **大声的**:你能发现它发生了。
- **即时的**:你在靠近原因的地方发现它。
- **可归因的**:调用者可以将失败与导致失败的操作联系起来。
吞掉错误的做法正好相反。失败变成了:
- **静默的**:没有任何东西告诉你它发生了。
- **延迟的**:它在之后、在更多代码运行后才浮出水面。
- **模糊的**:当你看到症状时,原始原因更难识别。
原因和症状之间的差距就是成本,而且随着程序在无效状态下存活的时间越长,这个成本就越大。这就是为什么解决方法不是本地隐藏失败。解决方案是理解错误的去向。谁调用这个函数?错误如何传播?它们在哪里变成被拒绝的请求、失败的任务、重试、告警或关闭?
这需要你审视当前修改集之外的地方,但那是工作的一部分。如果返回错误会导致系统不必要地宕机过多,那么问题在于错误处理边界。
### 二阶成本
一旦失败变得静默,你就不知道发生了什么,这会隐藏 bug。它迫使你建立基础设施来检测操作的缺失:指标、仪表盘和告警。你现在正花费工程精力来重构你丢弃的信号。每次你容忍一个不可能或未处理的状态,你都要在之后通过观察它来付出代价。
## 外层 vs 内层
可能还不清楚每个检查应该在何时发生。把这些看作程序执行流程的不同阶段会有所帮助。之前我写道,更深层将不得不重新发现一个已经在上面处理过的错误。
这与操作顺序有关:外层是执行开始的地方,也是数据从外部世界进入的地方;内层是那些外部调用最终到达的代码。你在调用栈中走得越深,就越是深入内层。
在执行开始时,没有任何东西是有保证的,但你也没有做任何事情。在初始化期间,你设置程序所依赖的东西。对于每一件事,你决定:这是必须有的,还是有时可能不可用的?你的设计应该倾向于总是可用的依赖,并最小化那些可能消失的依赖。
### 对请求范围数据的 nil 检查
另一种 nil 出现在之后,当程序正在运行并处理工作时。请求范围的值——请求本身、它的字段以及从它派生的任何东西——不同于依赖。依赖在构造时固定。请求在每次调用时从外部到达:来自 HTTP 处理器、RPC、队列、测试辅助函数、另一个包。
在我们的 `RateLimiter` 中,很容易对请求本身进行 nil 检查:
```
1func (r *RateLimiter) Allow(ctx context.Context, req *Request) (bool, error) {
2 if req == nil {
3 return false, nil
4 }
5 userID := GetUserID(req)
6 if userID == "" {
7 return false, nil
8 }
9 return r.checkLimit(ctx, userID)
10}
```
但这在新的地方犯了同样的错误。请求不是到达 `Allow` 的。它更早进入程序,在传输边界——HTTP 处理器、RPC 调度、队列消费者等——然后一直在我们的代码中穿行。
等 `Allow` 运行时,请求已经是内层数据。在这里检查意味着一个深层函数在重新验证外层本应保证的东西,这正是我们对依赖所拒绝的做法。它传播了不确定性。检查应属于边界,在不可信的字节首次变成 `*Request` 的地方:
```
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req, err := DecodeRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// req 现在已验证。从这里开始的所有代码都可以信任它。
allowed, err := h.limiter.Allow(r.Context(), req)
// ...
}
```
你无法控制交给你的东西,所以在那个边界检查 nil 是合理的。数据从外部到达,你把它当作不可信的,因此要验证它:检查 nil,检查必须成立的约束。然后它跨入内层,你将其映射到你自己的类型和业务逻辑中。过了那个点,它就是可信的。它已成为系统的一个不变性。最终,`Allow` 完全不保留 nil 检查:
```
func (r *RateLimiter) Allow(ctx context.Context, req *Request) (bool, error) {
userID := GetUserID(req)
if userID == "" {
return false, nil
}
return r.checkLimit(ctx, userID)
}
```
这让你专注于实际逻辑,而不是用 nil 检查弄乱每个函数。你也可以把空的 `userID` 检查移到 HTTP 层,但在这里我们有意让限流器拥有那个策略。
以这种方式构建的系统更容易推理,也更容易修改。不这样构建的系统则相反:你添加检查,然后必须决定每个检查应该做什么。回退是什么?在这里合理吗?每个检查都是一个新分支,每个分支都是你必须为一种本不应存在的状态定义的行为。
## 结论
当 nil 检查能强制一个文档化的边界或模拟一个有意的可选状态时,它是好的。当 nil 检查静默地处理程序声称不可能的状态时,它是可疑的。
所以当 nil 检查无处不在时,它们在告诉你两件事之一。要么代码在保护不可信的边界输入,这是正常的;要么代码库从未建立其不变性,这是一个设计问题。
如果你在一个无法信任任何参数的系统上工作,解决方法不是添加更多的检查。你可能暂时不得不这样做,但真正的工作是开始建立那些检查所替代的不变性,并逐步用其余系统可以依赖的保证来取代由恐惧驱动的混乱。
相似文章
Golang 代码审查笔记 II
来自 elttam 的后续博客文章,介绍了提高安全性的新 Go 语言特性、在代码审计中发现的有问题的编码模式(footguns),以及用于捕获这些模式的 Semgrep 规则。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
编码模型做得太多了
一篇博客文章探讨“过度编辑”问题:编码大语言模型在修复简单错误时改写了过多代码,提出衡量指标与训练方法以鼓励最小化、忠实于原意的编辑。
无类型检查的生命周期借用检查
一篇博客文章介绍了一种玩具语言,它在运行时而非静态类型系统中强制执行借用检查,通过在栈上使用轻量级引用计数来支持内部指针和单一所有权,适用于动态类型环境。
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。