Go 中 nil 的多种形态
统一梳理 pointer、slice、map、channel、func、interface 上的 nil:零值含义、可做什么、会 panic 什么,以及 typed nil 与 nil 接口的差异。
[!info] 关联笔记
Go 中 nil 的多种形态
这个概念为什么会出现
Go 里很多类型的零值写作同一个词:nil。于是很容易形成错误模型:
“nil 就是空,空就是不能用。”
但真实行为分裂成多条线:
nilslice 可以append、range、lennilmap 可以读,一写就 panicnilpointer 一解引用就 panicnilchannel 上的收发会永远阻塞nil函数一调用就 panic- 接口里装了
nil指针,接口本身却不是nil
如果不把这些形态放在同一张地图上,后面学容器、错误返回、并发退出时,会反复被同一个词绊倒。
[!abstract] 一句话理解
nil是若干引用型类型的零值记号;它是否“可用”完全取决于类型。接口的nil还额外要求动态类型与动态值都为空。
最小可运行示例
业务场景:服务刚启动,很多字段还是“零值”
一个服务结构体里常见这些字段:
- 可选缓存指针
*Cache - 请求日志缓冲
[]string - 路由表
map[string]Handler - 事件 channel
- 可选 hook 函数
- 接口类型的依赖
它们初始都可能是 nil,但能不能直接用完全不同。
下面用最小代码把六种 nil 的“能做 / 不能做”摊开。
package main
import "fmt"
func main() {
// 声明但不初始化:得到各类型的零值 nil
var cache *int // 还没接入缓存客户端
var logs []string // 还没产生日志行
var routes map[string]int // 还没加载路由表(用 int 假装 handler id)
var events chan int // 还没创建事件队列
var hook func() // 还没注册钩子
var dep any // 还没注入依赖
// 1) 它们都 == nil
fmt.Println(
cache == nil,
logs == nil,
routes == nil,
events == nil,
hook == nil,
dep == nil,
)
// 全 true
// 2) nil slice / nil map:查长度是安全的
// 业务上常用来表示“当前为空”
fmt.Println("len logs/routes:", len(logs), len(routes)) // 0 0
// 3) nil slice 可以直接 append:适合“懒追加日志”
logs = append(logs, "server starting")
fmt.Println("logs:", logs) // [server starting]
// 4) nil map 可以读,不能写
// 读:像空 map,得到零值
fmt.Println("route /health:", routes["/health"]) // 0
// 写:会 panic(真正加载路由前必须 make)
// routes["/health"] = 1
// 5) 接口 nil 陷阱(错误返回场景)
// 业务函数本意“没错误”,却返回了某错误类型的 nil 指针
var typed *int = nil
var err error = typed // 把 nil 指针装进 error 接口
fmt.Println("err == nil?", err == nil) // false
// 调用方 if err != nil 会误判失败
}
建议运行:
go run .
期望输出:
true true true true true true
len logs/routes: 0 0
logs: [server starting]
route /health: 0
err == nil? false
结合场景记住
| 字段 | 业务上常见含义 | nil 时能不能直接用 |
|---|---|---|
*T | 还没注入依赖 | 解引用前必须判空 |
[]T | 还没有元素 | 可 len/append/range |
map | 还没构建索引 | 可读;写之前要 make |
chan | 还没建队列 | 收发会卡住;先 make |
func | 还没注册回调 | 调用前判空 |
interface | 还没赋值 | 注意 typed nil 陷阱 |
核心概念与准确模型
1. 哪些类型可以是 nil?
可以是 nil 的类型:
| 类型 | 零值 | 备注 |
|---|---|---|
| pointer | nil | 含 *T、unsafe.Pointer |
| slice | nil | 描述符全零 |
| map | nil | 未 make |
| channel | nil | 未 make |
| function | nil | 未赋值 |
| interface | nil | 动态类型与值皆空 |
| unsafe.Pointer | nil |
不能是 nil 的常见类型:
- 数值、bool、string(string 零值
"") - array、struct(字段各为零值)
string 的“空”是 "",不是 nil。
2. 总表:能做什么 / 何时 panic
| 类型 | == nil | 安全操作 | 危险操作 |
|---|---|---|---|
*T | 是 | 与 nil 比较、当可选值传递 | 解引用、写字段 |
[]T | 是 | len/cap/range/append | 对 nil 做 s[i](越界 panic) |
map[K]V | 是 | 读、len、range、delete | 写 m[k]=v |
chan T | 是 | 在 select 中禁用分支 | 收/发会阻塞;close(nil) panic |
func | 是 | 与 nil 比较 | 调用 |
interface | 是 | 与 nil 比较、type switch | 在无方法保证时乱调;typed nil 陷阱 |
3. nil slice vs 空 slice
var s []int // nil slice,len=0, cap=0
t := []int{} // 非 nil 空 slice
u := make([]int, 0) // 非 nil 空 slice
相同点:
len都是 0range都不进入循环append都能用
不同点:
s == nil仅对第一种为 true- JSON 编码:
nilslice →null;空 slice →[](常见坑) - 某些 API 用
nil表示“未设置”,[]表示“设置为空列表”
工程建议:
- 内部处理多用
len==0判断空 - 边界编码(JSON)明确选择
4. nil map
var m map[string]int
v := m["a"] // 0,ok 模式:v, ok := m["a"] → 0, false
delete(m, "a") // 安全 no-op
// m["a"] = 1 // panic: assignment to entry in nil map
必须 make 或字面量后才能写:
m = make(map[string]int)
m["a"] = 1
见 go-maps。
5. nil pointer
var p *int
fmt.Println(p == nil) // true
// fmt.Println(*p) // panic
方法调用:
func (p *P) Meth() {
if p == nil {
// 可选:允许 nil 接收者
}
}
Go 允许在 nil 指针上调用方法(接收者是 nil);方法体内若解引用仍 panic。有些标准库利用 nil 接收者语义,但公开 API 要文档化。
6. nil channel
var ch chan int
// <-ch // 永久阻塞
// ch <- 1 // 永久阻塞
// close(ch) // panic
在 select 中:
- case 使用 nil channel → 该 case 永不就绪(被禁用)
- 这是并发模式中的合法技巧
见 go-channels、go-select、go-channel-ownership-and-closing。
7. nil function
var f func(int) int
// f(1) // panic
if f != nil {
f(1)
}
函数字段做依赖注入时,常需 nil 检查或在构造时保证非 nil。
8. 接口 nil 与 typed nil(最重要陷阱)
接口值可以理解为:(动态类型, 动态值)。
- 只有两者都“空”时,接口
== nil - 动态类型存在、动态值是 nil 指针时,接口 ≠ nil
var p *int = nil
var i any = p
fmt.Println(p == nil) // true
fmt.Println(i == nil) // false
fmt.Printf("%T %v\n", i, i) // *int <nil>
错误返回经典事故:
func f() error {
var err *MyError = nil
return err // 返回的是非 nil 接口,装着 nil *MyError
}
调用方 if err != nil 会进入错误分支。正确:
func f() error {
return nil
}
// 或
var err *MyError
if bad {
err = &MyError{...}
}
return err // 若 err 仍是 nil 指针,问题依旧!应:
// if err != nil { return err }; return nil
更稳:
func f() error {
if bad {
return &MyError{...}
}
return nil
}
见 go-interface-values-and-nil、go-error-handling。
9. 类型断言与 nil 接口
var i any
v, ok := i.(int) // ok=false, v=0
var p *int
i = p
v2, ok := i.(*int) // ok=true, v2=nil
type switch 可 case nil 匹配接口本身为 nil 的情况。见 go-type-assertions-and-type-switch。
10. 比较规则摘要
- 同类型两个 nil 可比较(如两 nil 指针)
- slice/map/func 只能与
nil比较,不能两两比较 - 接口可比较(动态值可比较时);否则运行时 panic
细节见规范 Comparison operators。
11. 反射中的 nil
reflect.Value.IsNil 仅对特定 Kind 有意义;对接口要分清 IsValid、IsNil、Elem。反射是另一篇,但根因仍是“接口盒子 vs 内部值”。见 go-reflection。
12. JSON / 协议层的 nil
| Go 值 | 常见 JSON |
|---|---|
nil slice | null |
| 空 slice | [] |
nil map | null |
| 空 map | {} |
nil pointer 字段 | null 或 omitempty 省略 |
API 设计时明确“null 与空集合”语义,避免前后端扯皮。
实战中一类高频场景就是可空时间字段:LastLoginAt *time.Time 用 nil 表达“从未登录”,配合 omitempty 省略或输出 null;为什么不用值类型 time.Time 见 User 结构体实战 与 为何用 *time.Time。
边界与限制
- 一个词多种语义:语言层面统一写法,心智不统一。
- 接口 nil 检查不传递到动态值:
err != nil不等于动态指针非 nil。 - nil 接收者方法:强大但易滥用;团队要约定。
- 并发:nil map 读安全吗?读不写在无同步下仍可能与其他写竞态;“nil 不写”不自动等于并发安全。
- 泛型:
*T约束等场景下 nil 判断仍遵循具体类型规则。 - cgo/unsafe:另一套空指针现实,不在本篇展开。
- 调试:
fmt打印接口可能显示<nil>但比较非 nil——用%T与== nil对照。
常见误区
[!warning] 误区 1:nil 就是不能用 nil slice/map 读等操作可用。
[!warning] 误区 2:
err != nil百分百有错误对象 typed nil 会骗过检查。
[!warning] 误区 3:nil slice 与空 slice 完全相同 JSON 与
== nil不同。
[!warning] 误区 4:nil channel 一碰就 panic 收发是阻塞;close 才 panic。
[!warning] 误区 5:对 nil map delete 会 panic 不会。
[!warning] 误区 6:返回
var p *T; return p当作 error 的 nil 接口非 nil。
[!warning] 误区 7:
len(nil)panic 对 nil slice/map 的len安全,得 0。
[!warning] 误区 8:所有“空”都用 nil 表示 string 用
"";结构体用零值字段。
工程实践
判断清单
拿到一个可能为 nil 的值:
1. 它是什么类型?
2. 我要做的操作在该类型 nil 上是否定义?
3. 若是接口,动态类型是什么?(%T / 断言)
4. 边界编码要 null 还是空集合?
错误返回纪律
- 成功路径
return nil(裸 nil 接口) - 失败路径
return &MyError{...}或fmt.Errorf(...) - 避免
var e *MyError; return e - 包装使用
%w,判断用errors.Is/As
API 约定
- 文档写清:返回的 slice 是否可能 nil
- 公开 JSON 字段用指针区分“未设置 / 零值”时要谨慎
- 函数字段提供默认 no-op 而非留下 nil
调试技巧
fmt.Printf("i==nil %v type=%T value=%#v\n", i == nil, i, i)
对 error:
if err != nil {
fmt.Printf("%T\n", err)
}
实验
实验 A:六种 nil 打印
运行最小示例,注释/取消 map 写与 pointer 解引用,观察 panic 点。
实验 B:JSON 差异
package main
import (
"encoding/json"
"fmt"
)
func main() {
var s []int
t := []int{}
b1, _ := json.Marshal(s)
b2, _ := json.Marshal(t)
fmt.Println(string(b1), string(b2))
}
实验 C:typed nil error
package main
import "fmt"
type MyError struct{}
func (MyError) Error() string { return "x" }
func f() error {
var e *MyError
return e
}
func main() {
err := f()
fmt.Println(err == nil)
if err != nil {
fmt.Println("branch taken", err)
}
}
实验 D:nil channel 禁用 select
package main
import (
"fmt"
"time"
)
func main() {
var ch chan int
// ch = make(chan int) // 打开这行对比
select {
case <-ch:
fmt.Println("recv")
case <-time.After(50 * time.Millisecond):
fmt.Println("timeout")
}
}
总结
| 问题 | 答案 |
|---|---|
| nil 是什么? | 多种类型的零值记号 |
| 都一样吗? | 不一样,按类型看操作表 |
| 最大陷阱? | 接口装 typed nil |
| slice nil vs empty? | len 同;==nil 与 JSON 不同 |
| map nil? | 可读不可写 |
| channel nil? | 收发阻塞;可用于禁用 select |
一句话收束:
先问类型,再问 nil 上能不能做这件事;面对 error,警惕“非 nil 接口里的 nil 指针”。
自测题
1. 为什么 var s []int; s = append(s, 1) 合法?
2. var m map[int]int; m[1]=1 为何 panic?
3. 解释 var p *T=nil; var e error=p; e==nil 的结果。
4. nil 与空 slice 的 JSON 差异?
5. 对 nil channel close 会怎样?
6. len 作用于 nil map 安全吗?
答案
- append 把 nil slice 当空可用起点,分配底层数组。
- 写 nil map 非法。
- e 的动态类型是
*T,动态值 nil,故 e != nil。 - nil → null;空 → []。
- panic。
- 安全,得 0。
依据与延伸
- Go Spec — The zero value
- Go Spec — Slice types
- Go Spec — Map types
- Go Spec — Channel types
- Go Spec — Interface types
- Go Spec — Comparison operators
- Go FAQ — Why is my nil error value not equal to nil?
- Go Blog — Laws of Reflection
- Go Slices: usage and internals
- Go maps in action
- encoding/json
- errors
- fmt