Go 中 nil 的多种形态

统一梳理 pointer、slice、map、channel、func、interface 上的 nil:零值含义、可做什么、会 panic 什么,以及 typed nil 与 nil 接口的差异。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 中 nil 的多种形态

这个概念为什么会出现

Go 里很多类型的零值写作同一个词:nil。于是很容易形成错误模型:

“nil 就是空,空就是不能用。”

但真实行为分裂成多条线:

  • nil slice 可以 appendrangelen
  • nil map 可以读,一写就 panic
  • nil pointer 一解引用就 panic
  • nil channel 上的收发会永远阻塞
  • 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 的类型:

类型零值备注
pointernil*Tunsafe.Pointer
slicenil描述符全零
mapnilmake
channelnilmake
functionnil未赋值
interfacenil动态类型与值皆空
unsafe.Pointernil

不能nil 的常见类型:

  • 数值、bool、string(string 零值 ""
  • array、struct(字段各为零值)

string 的“空”是 "",不是 nil

规范:The zero value

2. 总表:能做什么 / 何时 panic

类型== nil安全操作危险操作
*T与 nil 比较、当可选值传递解引用、写字段
[]Tlen/cap/range/append对 nil 做 s[i](越界 panic)
map[K]V读、lenrangedeletem[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 都是 0
  • range 都不进入循环
  • append 都能用

不同点:

  • s == nil 仅对第一种为 true
  • JSON 编码:nil slice → null;空 slice → [](常见坑)
  • 某些 API 用 nil 表示“未设置”,[] 表示“设置为空列表”

工程建议:

  • 内部处理多用 len==0 判断空
  • 边界编码(JSON)明确选择

go-slicesencoding/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-channelsgo-selectgo-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-nilgo-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 有意义;对接口要分清 IsValidIsNilElem。反射是另一篇,但根因仍是“接口盒子 vs 内部值”。见 go-reflection

12. JSON / 协议层的 nil

Go 值常见 JSON
nil slicenull
空 slice[]
nil mapnull
空 map{}
nil pointer 字段null 或 omitempty 省略

API 设计时明确“null 与空集合”语义,避免前后端扯皮。

实战中一类高频场景就是可空时间字段:LastLoginAt *time.Timenil 表达“从未登录”,配合 omitempty 省略或输出 null;为什么不用值类型 time.TimeUser 结构体实战为何用 *time.Time

边界与限制

  1. 一个词多种语义:语言层面统一写法,心智不统一。
  2. 接口 nil 检查不传递到动态值err != nil 不等于动态指针非 nil。
  3. nil 接收者方法:强大但易滥用;团队要约定。
  4. 并发:nil map 读安全吗?读不写在无同步下仍可能与其他写竞态;“nil 不写”不自动等于并发安全。
  5. 泛型*T 约束等场景下 nil 判断仍遵循具体类型规则。
  6. cgo/unsafe:另一套空指针现实,不在本篇展开。
  7. 调试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 还是空集合?

错误返回纪律

  1. 成功路径 return nil(裸 nil 接口)
  2. 失败路径 return &MyError{...}fmt.Errorf(...)
  3. 避免 var e *MyError; return e
  4. 包装使用 %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 安全吗?

答案
  1. append 把 nil slice 当空可用起点,分配底层数组。
  2. 写 nil map 非法。
  3. e 的动态类型是 *T,动态值 nil,故 e != nil。
  4. nil → null;空 → []。
  5. panic。
  6. 安全,得 0。

依据与延伸

创建于 2026/7/14 更新于 2026/7/15