如果你刚学 Go 语言,大概率会被「nil 切片」和「空切片」搞晕,明明打印出来都是 [],判空时有时相等有时不等,序列化后结果还不一样。

其实这俩看似相似,底层结构和使用场景却天差地别。今天咱们用大白话 + 代码例子,把这俩概念彻底讲透,以后写代码再也不踩坑。

一、先搞懂:切片的底层长啥样?

要分清 nil 切片和空切片,得先知道 Go 里切片(slice)的底层结构。毕竟两者的区别,本质就是这个结构里的字段不一样。

在 Go 中,切片不是 “纯粹的数组”,而是一个「指向数组的结构体」,里面装了三个核心信息:

  1. 指针(ptr):指向底层数组的内存地址(相当于 “门牌号”);
  2. 长度(len):当前切片里有多少个元素;
  3. 容量(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 字节)。

五、最佳实践:避坑指南

  1. 判断“空切片”的正确姿势

错误做法:用 slice == nil 判断是否为空

正确做法:用 len(slice) == 0 统一判断

// 无论nil还是空切片都安全
if len(userList) == 0 {
    fmt.Println("用户列表为空")
}
  1. 根据场景选择合适类型
场景 推荐类型 示例
错误处理/未初始化返回值 nil切片 return nil, err
API返回空集合 空切片 return []User{}
接收可选切片参数 nil切片 func Process(data []int)
  1. 解决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 序列化、错误判断等场景,一定要分清两者的区别,避免踩坑。