如果你刚学 Go 语言,大概率会被「nil 切片」和「空切片」搞晕,明明打印出来都是 []
,判空时有时相等有时不等,序列化后结果还不一样。
其实这俩看似相似,底层结构和使用场景却天差地别。今天咱们用大白话 + 代码例子,把这俩概念彻底讲透,以后写代码再也不踩坑。
一、先搞懂:切片的底层长啥样?
要分清 nil 切片和空切片,得先知道 Go 里切片(slice)的底层结构。毕竟两者的区别,本质就是这个结构里的字段不一样。
在 Go 中,切片不是 “纯粹的数组”,而是一个「指向数组的结构体」,里面装了三个核心信息:
- 指针(ptr):指向底层数组的内存地址(相当于 “门牌号”);
- 长度(len):当前切片里有多少个元素;
- 容量(cap):底层数组能容纳的最大元素个数(超过就会扩容)。
可以简单理解为:切片是个 “快递盒”,指针是 “仓库地址”,长度是 “当前装了几个快递”,容量是 “这个盒子最多能装几个快递”。
用代码表示这个结构体(Go 源码里的真实定义):
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片长度
cap int // 切片容量
}
知道了这个结构,接下来看 nil 切片和空切片的区别,就像看两个 “快递盒” 的不同状态。
二、nil 切片:没地址的 “空盒子”
1. 什么是 nil 切片?
nil 切片就是「指针字段为 nil」的切片 —— 相当于这个 “快递盒” 没有对应的 “仓库地址”,而且里面肯定没装东西(len=0,cap=0)。
简单说:nil 切片是 “未初始化” 的切片,就像刚造好的空盒子,还没分配存放东西的仓库。
nil 切片底层指针为nil
,没有指向任何内存空间。
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}
}
2. 怎么创建 nil 切片?
最常见的方式是「用 var 声明但不赋值」:
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 切片打印出来是 []
,len 和 cap 都是 0,而且和 nil 比较会返回 true。
三、空切片:有地址但没东西的 “空盒子”
1. 什么是空切片?
空切片是「指针字段不为 nil」的切片 —— 相当于这个 “快递盒” 有对应的 “仓库地址”,但仓库里暂时没装东西(len=0,cap 可能是 0 或其他值)。
简单说:空切片是 “已初始化但没元素” 的切片,就像盒子已经绑定了仓库,但仓库里还没放东西。
2. 怎么创建空切片?
常见的有 3 种方式,核心都是 “主动初始化但不填元素”:
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] // 从索引3截到3(没元素)
fmt.Println("s4==nil:", s4 == nil) // 输出:s4==nil: false
}
这里最关键的是:空切片打印出来也是 []
,len 是 0,但和 nil 比较会返回false
, 因为它的指针字段不是 nil(有仓库地址)。
容量为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}
四、核心区别:3 个维度彻底分清
看完定义和创建方式,咱们用表格总结两者的核心区别,一目了然:
对比维度 | nil 切片 | 空切片 |
---|---|---|
指针(ptr) | nil(无仓库地址) | 非 nil(有仓库地址,可能指向空数组) |
len/cap | len=0,cap=0 | len=0,cap≥0(可能有容量) |
与 nil 比较 | s == nil → true |
s == nil → false |
内存分配 | 未分配底层数组(没仓库) | 已分配底层数组(有仓库,可能是空) |
JSON 序列化 | 变成 null (比如 []int(nil) → null) |
变成 [] (比如 []int{} → []) |
这里重点说两个容易踩坑的区别:
1. JSON 序列化结果完全不同
这是实际开发中最容易出问题的场景!比如接口返回切片时,nil
切片和空切片的 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: []
}
如果前端期望接收的是数组([]
),但你返回了 nil 切片(JSON 是 null
),就会导致前端报错,这是很多新手调试半天找不到的 bug!
2. 内存分配:空切片可能占内存
nil 切片因为指针是 nil,所以不占用底层数组的内存(相当于只有个空盒子,没仓库);
空切片的指针非 nil,所以会占用底层数组的内存(即使数组是空的,也需要分配一小块内存存空数组)。
比如用 make([]int, 0, 5)
创建的空切片,虽然 len=0,但 cap=5,底层会分配一个能装 5 个 int 的数组(占 40 字节,因为 int 是 8 字节)。
五、最佳实践:避坑指南
- 判断“空切片”的正确姿势
❌ 错误做法:用 slice == nil 判断是否为空
✅ 正确做法:用 len(slice) == 0 统一判断
// 无论nil还是空切片都安全
if len(userList) == 0 {
fmt.Println("用户列表为空")
}
- 根据场景选择合适类型
场景 | 推荐类型 | 示例 |
---|---|---|
错误处理/未初始化返回值 | nil切片 | return nil, err |
API返回空集合 | 空切片 | return []User{} |
接收可选切片参数 | nil切片 | func Process(data []int) |
- 解决JSON序列化问题
在 BFF 层(Backend For Frontend)做数据转换:
func convertToSafeSlice(grpcData []string) []string {
if grpcData == nil {
return []string{} // 将nil转为空切片
}
return grpcData
}
样确保返回给前端的 JSON 始终是[]
而非null
。
六、常见误区:这些说法都是错的!
最后澄清几个新手常犯的误区:
误区 1:“nil 切片就是空切片”
错!nil 切片是 “未初始化的空”,空切片是 “已初始化的空”,两者的指针和序列化结果都不同。
误区 2:“空切片的 cap 一定是 0”
错!空切片的 len 是 0,但 cap 可以是任意非负值。比如 make([]int, 0, 10)
是空切片,但 cap=10。
误区 3:“nil 切片不能用 append”
对!nil 切片虽然没初始化,但可以直接用 append(Go 会自动为它分配底层数组):
var s []int // nil切片
s = append(s, 1, 2, 3)
fmt.Println(s) // 输出:[1 2 3](正常使用,没问题)
这一点很友好,不用先判断切片是不是 nil,直接 append 就行。
七、总结:一句话记住核心区别
如果记不住太多细节,记住这句话就行:
nil 切片是 “没地址的空盒子”(ptr=nil),空切片是 “有地址但没东西的空盒子”(ptr≠nil);判空用 len(s)==0,序列化要注意 null 和 [] 的区别。
其实在大部分业务场景中,nil 切片和空切片可以混用(比如 append、遍历),但在 JSON 序列化、错误判断等场景,一定要分清两者的区别,避免踩坑。