Go 切片避坑:nil 切片和空切片到底差在哪

洛溪
洛溪
发布时间:2026-08-25 | 147人浏览过
原创

前言

如果你刚接触 Go,几乎一定会在切片这里踩一次坑:两个值打印出来都是 [],可一个和 nil 比较为 true,另一个却是 false,到了 JSON 序列化时还会一个变成 null、一个变成 []。这篇文章不绕概念,先把切片底层结构拆开,再对照 nil 切片、空切片的创建方式、行为差异和使用场景,最后给出实际开发里更稳的判断和返回策略。

如果你刚接触 Go,几乎一定会在切片这里踩一次坑:两个值打印出来都是 [],可一个和 nil 比较为 true,另一个却是 false,到了 JSON 序列化时还会一个变成 null、一个变成 []。这篇文章不绕概念,先把切片底层结构拆开,再对照 nil 切片、空切片的创建方式、行为差异和使用场景,最后给出实际开发里更稳的判断和返回策略。

切片为什么会让人混淆

要分清 nil 切片和空切片,先得知道 Go 里的切片到底是什么。它不是一个“纯数组”,而是一个描述底层数组的结构,至少包含三个核心信息:

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

最常见的方式就是只声明、不赋值:

用暖色专栏卡片对照 nil 切片和空切片的底层状态,展示指针是否为 nil、len 和 cap 的共同点与区别。
nil 切片和空切片的底层对照从底层结构看,nil 切片和空切片都可能长度为 0,但是否已经指向底层数组完全不同。
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 切片与空切片在 JSON 序列化和 API 返回中的不同结果。
JSON 返回值该用 null 还是 []接口层最常见的问题,不是切片能不能用,而是空值到底该返回 null 还是 []。

更稳的写法是统一用长度判断:

// 无论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,差异不会立刻暴露;但一旦涉及接口协议、错误返回、数据初始化语义,这个区别就必须分清。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表