如果你刚接触 Go,几乎一定会在切片这里踩一次坑:两个值打印出来都是 [],可一个和 nil 比较为 true,另一个却是 false,到了 JSON 序列化时还会一个变成 null、一个变成 []。这篇文章不绕概念,先把切片底层结构拆开,再对照 nil 切片、空切片的创建方式、行为差异和使用场景,最后给出实际开发里更稳的判断和返回策略。
切片为什么会让人混淆
要分清 nil 切片和空切片,先得知道 Go 里的切片到底是什么。它不是一个“纯数组”,而是一个描述底层数组的结构,至少包含三个核心信息:
- 指针(ptr):指向底层数组的内存地址;
- 长度(len):当前切片里有多少个元素;
- 容量(cap):底层数组最多还能容纳多少元素。
可以把它理解成一个“访问底层数组的说明书”:指针决定去哪里取数据,长度决定当前能读多少,容量决定后续追加元素时还能不能复用现有空间。
Go 运行时里的切片结构可以简化理解为下面这样:
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片长度
cap int // 切片容量
}
后面所有区别,本质上都落在这个结构上:array 是否为 nil,以及是否已经初始化出可用的底层空间。
nil 切片:未初始化的空
什么叫 nil 切片
nil 切片最关键的特征是:底层指针为 nil。它通常表示“这个切片还没有初始化”,同时它的 len 和 cap 也都是 0。
也就是说,它不是“有一个空数组”,而是“当前根本没有指向任何底层数组”。
type sliceHeader struct {
array unsafe.Pointer
len int
cap int
}
func main() {
var s1 []int
fmt.Printf("s1 (addr: %p): %+8v\n", &s1, *(*sliceHeader)(unsafe.Pointer(&s1)))
// output: s1 (addr: 0x1400012e000): {array: <nil> len: 0 cap: 0}
}
怎么创建 nil 切片
最常见的方式就是只声明、不赋值:

func returnNilSlice() []string {
return nil // 返回nil切片
}
func main() {
// 方式1:var声明切片,不赋值(默认是nil切片)
var s1 []int
fmt.Println("s1:", s1) // 输出:s1: []
fmt.Println("s1==nil:", s1 == nil) // 输出:s1==nil: true
fmt.Println("s1 len/cap:", len(s1), cap(s1)) // 输出:s1 len/cap: 0 0
// 方式2:函数返回未初始化的切片(比如犯错时返回nil)
s2 := returnNilSlice()
fmt.Println("s2==nil:", s2 == nil) // 输出:s2==nil: true
}
这里有一个很容易误判的点:nil 切片打印出来同样是 []。所以只看输出内容,根本看不出它和空切片有什么区别;真正的区别要靠和 nil 比较,或者观察底层结构。
空切片:已经初始化,但当前没有元素
什么叫空切片
空切片的特点是:长度为 0,但底层指针不一定是 nil。它代表“这个切片已经初始化好了,只是当前还没有元素”。
因此,空切片和 nil 切片看起来都“空”,但语义并不一样。一个表示“未初始化”,一个表示“已准备好,只是里面暂时没数据”。
常见创建方式
空切片通常来自主动初始化:
func main() {
// 方式1:用 []Type{} 字面量创建
s1 := []int{}
fmt.Println("s1:", s1) // 输出:s1: []
fmt.Println("s1==nil:", s1 == nil) // 输出:s1==nil: false(关键区别!)
fmt.Println("s1 len/cap:", len(s1), cap(s1)) // 输出:s1 len/cap: 0 0
// 方式2:用 make 创建,指定 len=0(cap 可省略,默认和 len 相等)
s2 := make([]string, 0) // len=0,cap=0
s3 := make([]float64, 0, 5) // len=0,cap=5(有容量但没元素)
fmt.Println("s2==nil:", s2 == nil) // 输出:s2==nil: false
fmt.Println("s3 len/cap:", len(s3), cap(s3)) // 输出:s3 len/cap: 0 5
// 方式3:切片截取,结果没元素(比如从非空切片截一个空范围)
s4 := []int{1, 2, 3}[3:3]
fmt.Println("s4==nil:", s4 == nil) // 输出:s4==nil: false
}
注意这里最容易被忽略的一点:空切片的 len 一定是 0,但 cap 不一定是 0。比如 make([]float64, 0, 5) 就已经提前分配了容量。
对于容量为 0 的空切片,底层通常会指向运行时里的特殊地址 zerobase,多个空切片可能共享这一地址:
type sliceHeader struct {
array unsafe.Pointer
len int
cap int
}
func main() {
s2 := []int{}
s3 := make([]int, 0)
s4 := make([]string, 0)
s5 := make([]int, 0, 5)
fmt.Printf("s2 (addr: %p): %+8v\n", &s2, *(*sliceHeader)(unsafe.Pointer(&s2)))
fmt.Printf("s3 (addr: %p): %+8v\n", &s3, *(*sliceHeader)(unsafe.Pointer(&s3)))
fmt.Printf("s4 (addr: %p): %+8v\n", &s4, *(*sliceHeader)(unsafe.Pointer(&s4)))
fmt.Printf("s5 (addr: %p): %+8v\n", &s5, *(*sliceHeader)(unsafe.Pointer(&s5)))
}
输出如下:
s2 (addr: 0x1400000c018): {array:0x100c1c360 len: 0 cap: 0}
s3 (addr: 0x1400000c030): {array:0x100c1c360 len: 0 cap: 0}
s4 (addr: 0x1400000c048): {array:0x100c1c360 len: 0 cap: 0}
s5 (addr: 0x1400000c060): {array:0x140000160f0 len: 0 cap: 5}
从这个结果也能看出来,空切片并不等于“没有地址”。尤其是带容量的空切片,往往已经有实际分配的底层数组。
真正需要记住的几个区别
如果只记术语,写代码时还是容易混。更直接的方法,是把两者放到几个真实会影响行为的维度里对照看:
| 对比维度 | nil 切片 | 空切片 |
|---|---|---|
| 指针(ptr) | nil | 非 nil |
| len/cap | len=0,cap=0 | len=0,cap≥0 |
| 与 nil 比较 | s == nil 为 true |
s == nil 为 false |
| 底层数组 | 未分配 | 可能已分配 |
| JSON 序列化 | null |
[] |
最容易出问题的是 JSON 序列化
这是业务代码里最常见、也最容易排查半天的差异。两个切片在 Go 里看着都空,可到了 JSON,结果完全不是一回事:
func main() {
// nil 切片序列化
var nilSlice []int
nilJson, _ := json.Marshal(nilSlice)
fmt.Println("nil切片JSON:", string(nilJson)) // 输出:nil切片JSON: null
// 空切片序列化
emptySlice := []int{}
emptyJson, _ := json.Marshal(emptySlice)
fmt.Println("空切片JSON:", string(emptyJson)) // 输出:空切片JSON: []
}
如果前端约定接收数组,而后端返回了 null,就可能触发解析异常、渲染分支错误,或者额外写一层空值兼容逻辑。很多接口问题,本质上不是“没有数据”,而是“空集合该返回 [] 还是 null 没约定清楚”。
内存语义也不同
nil 切片不指向任何底层数组,因此不会因为“空”这件事额外持有数组空间;空切片则可能已经完成了初始化,甚至预留好了容量。
例如:
make([]int, 0, 5)
虽然它的 len 还是 0,但 cap 已经是 5,底层已经分配了一个可容纳 5 个 int 的数组。如果按 8 字节的 int 来看,就是 40 字节的底层数组空间。
这也是为什么“空切片”并不总是“零成本空值”。它有时候是在为接下来的追加操作做准备。
怎么写更稳:判断、返回值和序列化策略
判断切片是否为空,优先用 len
很多人第一次写判断时,会下意识用 slice == nil。这个判断只能识别 nil 切片,识别不了已经初始化但长度为 0 的空切片。

更稳的写法是统一用长度判断:
// 无论nil还是空切片都安全
if len(userList) == 0 {
fmt.Println("用户列表为空")
}
这样既能覆盖 nil 切片,也能覆盖空切片,逻辑上更符合“有没有元素”这个业务问题本身。
返回 nil 还是空切片,要看场景语义
两者都能用,不代表应该随手混用。比较实用的经验是按语义选:
| 场景 | 推荐 | 示例 |
|---|---|---|
| 错误处理、未初始化返回值 | nil 切片 | return nil, err |
| API 返回空集合 | 空切片 | return []User{} |
| 可选切片参数 | nil 切片更自然 | func Process(data []int) |
这背后的判断标准很简单:
- 如果你要表达“还没有值”“没初始化”“出错了没结果”,nil 切片通常更贴近语义;
- 如果你要表达“结果就是一个空集合”,尤其是给前端或外部调用方返回数据时,空切片更明确。
对外返回 JSON 时,最好主动做一层转换
如果上游数据可能是 nil,但下游协议要求始终返回数组,那么在 BFF 层或接口组装层统一转换,通常最省事:
func convertToSafeSlice(grpcData []string) []string {
if grpcData == nil {
return []string{} // 将nil转为空切片
}
return grpcData
}
这样可以保证最终返回给前端的 JSON 是 [],避免把 null 暴露出去,让调用方额外兜底。
几个常见误区,一次说清
误区一:nil 切片就是空切片
不是。它们都可以表现为空,但一个是“未初始化的空”,一个是“已初始化的空”。底层指针、是否和 nil 相等、JSON 输出结果都不同。
误区二:空切片的 cap 一定是 0
也不对。空切片只要求 len == 0,不要求 cap == 0。例如 make([]int, 0, 10) 仍然是空切片,但容量已经是 10。
误区三:nil 切片不能直接 append
这同样是误解。nil 切片可以直接追加,Go 会在需要时自动分配底层数组:
var s []int // nil切片
s = append(s, 1, 2, 3)
fmt.Println(s) // 输出:[1 2 3](正常使用,没问题)
这也是 Go 切片设计里很实用的一点:你通常不必先写一层“如果是 nil 就先初始化”的样板代码,再去调用 append。
最后记住这一句就够了
nil 切片是“没有底层地址的空”,空切片是“已经有底层地址、但暂时没元素的空”。
落到写代码上,可以直接记成两条:
- 判断有没有元素,用
len(s) == 0; - 对外做 JSON 返回时,明确区分
null和[],不要把它留给调用方猜。
大多数日常操作里,两者都能被遍历、都能 append,差异不会立刻暴露;但一旦涉及接口协议、错误返回、数据初始化语义,这个区别就必须分清。









