Skip to content

09 复合类型类型 - 数组与切片的使用和注意事项 -3

切片零值

  • 切片的零值nil,这意味着一个未初始化的切片会等于 nil
  • 零值切片可以通过 append 进行扩展,而不会发生运行时错误。
  • 零值切片没有底层数组,它的 len(长度)和 cap(容量)都为 0,但 Go 会在需要时动态分配数组来存储数据。
  • 和数组不同,切片的零值可以安全地使用,即使它是 nil,仍可以对它进行操作(如 append),而不会发生运行时错误。可以理解为切片的零值相当于一个未指向任何底层数组的空切片。
go
var slice []int  // 声明一个零值切片
fmt.Println(slice == nil)    // true,表示切片未被初始化
fmt.Printf("len = %d, cap = %d\n", len(slice), cap(slice))  // len = 0, cap = 0

slice = append(slice, 1)     // 可以直接 append
fmt.Println(slice)           // 输出:[1]

零值切片的特点

  1. 等于 nil:未初始化的切片是 nil

    go
    var slice []int
    fmt.Println(slice == nil)  // true
  2. 长度和容量为 0:零值切片的长度和容量都是 0。

    go
    fmt.Println(len(slice))  // 0
    fmt.Println(cap(slice))  // 0
  3. 可以使用 append:即使是零值切片,append 也可以正常使用,Go 会在底层分配新的数组存储追加的元素。

    go
    slice = append(slice, 1, 2, 3)
    fmt.Println(slice)  // 输出:[1 2 3]
  4. 无需手动初始化:如果你不确定切片是否被初始化,可以直接使用 append 而无需显式地检查它是否是 nil

make() 创建的切片对比

make() 创建的切片并不会等于 nil,即使长度为 0,因为 make() 会为切片分配一个底层数组。

go
var slice1 []int           // 零值切片,等于 nil
slice2 := make([]int, 0)   // 长度为 0,容量为 0 的切片

fmt.Println(slice1 == nil)  // true,表示 slice1 是 nil
fmt.Println(slice2 == nil)  // false,slice2 被初始化,不等于 nil

fmt.Println(len(slice1))  // 0
fmt.Println(len(slice2))  // 0

何时使用零值切片

  • 在不确定切片大小时,可以安全地声明一个零值切片,稍后通过 append 动态添加数据。
  • 在某些接口中,你可以返回一个零值切片(nil)来表示不存在任何结果,而不是返回一个空的切片。

切片的遍历

  • Go 提供了两种方式遍历切片:通过索引或使用 range 关键字。
  • range 更加简洁,也更具可读性。

使用 for 循环通过索引遍历

  • 适合在遍历过程中需要使用索引进行一些操作,如通过索引修改元素。
go
slice := []int{1, 2, 3, 4, 5}
for i := 0; i < len(slice); i++ {
    fmt.Printf("Index: %d, Value: %d\n", i, slice[i])
}
// 输出:
// Index: 0, Value: 1
// Index: 1, Value: 2
// Index: 2, Value: 3
// Index: 3, Value: 4
// Index: 4, Value: 5

使用 range 关键字遍历

go
slice := []int{1, 2, 3, 4, 5}

// i 是索引,v 是值
for i, v := range slice {
    fmt.Printf("Index: %d, Value: %d\n", i, v)
}
// 输出:
// Index: 0, Value: 1
// Index: 1, Value: 2
// Index: 2, Value: 3
// Index: 3, Value: 4
// Index: 4, Value: 5

// 只遍历值,忽略索引
for _, v := range slice {
    fmt.Printf("Value: %d\n", v)
}
// 输出:
// Value: 1
// Value: 2
// Value: 3
// Value: 4
// Value: 5

切片的复制

Go 提供了内置的 copy() 函数用于在切片之间进行元素复制。

使用 copy() 复制切片

go
src := []int{1, 2, 3}
dst := make([]int, len(src))  // 创建目标切片,长度等于 src
copy(dst, src)                // 复制 src 到 dst
fmt.Println("dst:", dst)      // 输出:[1, 2, 3]
  • copy(dst, src) 会将 src 中的元素复制到 dst 中。如果 dst 的长度小于 src,只会复制部分元素。
  • copy() 返回复制的元素数量。

部分复制

copy 会根据目标切片的长度进行复制,多余的元素会被忽略。

go
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 2)  // 创建长度为 2 的目标切片

n := copy(dst, src)    // 只复制 src 的前两个元素
fmt.Println("dst:", dst, "Copied:", n)  // 输出:[1, 2] Copied: 2

切片的扩容

  • 通过 append() 可以动态添加元素,当切片容量不足时,Go 会自动扩展底层数组的容量。
  • 如果切片的容量不足,Go 会为你分配一个新的、更大的底层数组。
  • 当容量不足时,Go 会自动将容量增加到当前容量的 2 倍(或更大,具体取决于已有数据的大小),以减少频繁扩容带来的性能损耗。

使用 append() 动态扩容

go
slice := []int{1, 2, 3} // len = 3, cap = 3
slice = append(slice, 4) // 添加一个元素,len = 4, cap = 6
slice = append(slice, 5, 6, 7) // 一次添加多个元素,len = 7, cap = 12
fmt.Printf("slice = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
// 输出:slice = [1, 2, 3, 4, 5, 6, 7], len = 7, cap = 12

控制扩容逻辑

在一些场景下,提前分配足够的容量可以避免不必要的扩容和内存重新分配。

go
slice := make([]int, 0, 10)  // len = 0, cap = 10
slice = append(slice, 1, 2, 3) // 即使添加了 3 个元素,切片仍然有充足的容量。len = 3, cap = 10
fmt.Printf("slice = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
// 输出:slice = [1, 2, 3], len = 3, cap = 10

切片的扩容规则

  • 如果新的容量大于 2 倍原容量,则新容量就是新申请的容量。
  • 否则,如果原切片长度 < 1024,则新容量为原来的 2 倍;如果原切片长度 >= 1024,则每次增加原来容量的 1/4,直到新容量 > 新申请的容量。
go
// 示例:使用 append 追加元素
slice := []int{1, 2, 3}    // len = 3, cap = 3
slice = append(slice, 4)  // 追加 4,len = 4, cap = 6
slice = append(slice, 5, 6, 7)  // 追加 5, 6, 7,len = 7, cap = 12
fmt.Printf("slice = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
// 输出:slice = [1 2 3 4 5 6 7], len = 7, cap = 12

扩容后切片解绑

go
   // 使用字面量初始化 slice
    slice := []int{1, 2, 3, 4, 5}
    fmt.Printf("Initial slice: %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))

    // 通过截取生成 newSlice
    newSlice := slice[1:4] // [2, 3, 4], len=3, cap=4
    fmt.Printf("newSlice before modification: %v, len = %d, cap = %d\n", newSlice, len(newSlice), cap(newSlice))

    // 修改 newSlice 的元素,观察 slice 是否改变
    newSlice[0] = 100
    fmt.Println("\nAfter modifying newSlice before append:")
    fmt.Printf("slice: %v\n", slice)
    fmt.Printf("newSlice: %v\n", newSlice)

    // 对 newSlice 进行扩容(触发扩容)
    newSlice = append(newSlice, 6, 7, 8) // 扩容,生成新数组
    fmt.Println("\nAfter append (expansion):")
    fmt.Printf("slice: %v\n", slice)
    fmt.Printf("newSlice: %v, len = %d, cap = %d\n", newSlice, len(newSlice), cap(newSlice))

    // 扩容后修改 newSlice 的元素,观察 slice 是否改变
    newSlice[0] = 200
    fmt.Println("\nAfter modifying newSlice after append:")
    fmt.Printf("slice: %v\n", slice)
    fmt.Printf("newSlice: %v\n", newSlice)

扩容前修改

  • newSlice := slice[1:4] 生成的切片与 slice 共享同一个底层数组,newSlice 初始为 [2, 3, 4]
  • 修改 newSlice[0] = 100 后,newSlice 中的第一个元素变成了 100,因为它与 slice 共享同一个底层数组,所以 slice 中的对应元素也改变为 100,即 slice 变为 [1, 100, 3, 4, 5]

扩容后

  • newSlice 使用 append 添加元素 6, 7, 8 时,触发了扩容。扩容会导致 Go 为 newSlice 分配一个新的底层数组,将原来的元素 [100, 3, 4] 复制到新数组中。因此,slicenewSlice 不再共享同一个底层数组。
  • 扩容后 slice 保持不变,仍然是 [1, 100, 3, 4, 5],而 newSlice 的内容变为 [100, 3, 4, 6, 7, 8],长度变为 6,容量变为 8。

扩容后修改

  • 修改 newSlice[0] = 200 时,由于 newSliceslice 已经解绑,slice 不再受到影响。因此,slice 保持为 [1, 100, 3, 4, 5],而 newSlice 变为 [200, 3, 4, 6, 7, 8]
bash
Initial slice: [1 2 3 4 5], len = 5, cap = 5
newSlice before modification: [2 3 4], len = 3, cap = 4

After modifying newSlice before append:
slice: [1 100 3 4 5]
newSlice: [100 3 4]

After append (expansion):
slice: [1 100 3 4 5]
newSlice: [100 3 4 6 7 8], len = 6, cap = 8

After modifying newSlice after append:
slice: [1 100 3 4 5]
newSlice: [200 3 4 6 7 8]
  • 扩容前slicenewSlice 共享同一个底层数组,修改 newSlice 会影响 slice
  • 扩容后newSliceslice 解绑,修改 newSlice 不再影响 slice

切片初始化建议

  • 在生产环境中,如何初始化和管理切片的容量是非常关键的。
  • 适当的初始化不仅可以提高程序的性能,还可以避免不必要的内存分配。

根据预估的容量进行初始化

  • 如果可以预估切片中可能存储的数据量,最好使用 make([]T, len, cap) 来预先分配合适的容量,避免扩容带来的性能开销。
  • 这种方法适用于数据量较大的场景,比如批量处理、大数据集等。
go
slice := make([]int, 0, 100)  // 创建容量为 100 的切片

在批量插入时提前分配容量

  • 对于需要一次性插入大量元素的场景,提前分配好容量有助于避免频繁扩容。
  • 这样可以保证一次性完成容量的分配,避免多次 append() 时扩容。
go
n := 1000  // 预估需要存储 1000 个元素
slice := make([]int, 0, n)  // 提前分配好容量
for i := 0; i < n; i++ {
    slice = append(slice, i)
}

动态扩容时减少内存占用

  • 在一些场景下,不清楚需要多少容量时,可以让 Go 自己处理扩容机制。
  • 但需要注意,过度扩容可能会导致内存浪费。如果切片不再需要增长,可以通过切片截取缩小切片的容量。
go
slice = slice[:len(slice):len(slice)]  // 将容量调整为和长度一致

使用 copy() 进行切片优化

  • 在一些对性能敏感的场景下,可以考虑复制重要的数据到新的切片中,避免不必要的共享底层数组。
go
slice := []int{1, 2, 3, 4, 5}
// 要删除前几个元素而不希望共享底层数组
newSlice := make([]int, len(slice[2:]))
copy(newSlice, slice[2:])  // 从索引 2 开始复制数据
fmt.Println(newSlice)      // 输出:[3, 4, 5]
// newSlice 不会再共享 slice 的底层数组,避免不必要的内存使用