本文是对 Go 官方博客文章 Go Slices: usage and internals 的翻译与总结。

简介

Go 语言中的切片(Slice)提供了一种方便且高效的方式来处理类型化数据序列。切片类似于其他语言中的数组,但具有一些独特的属性。

数组 (Arrays)

切片类型是构建在 Go 数组类型之上的抽象,因此要理解切片,我们必须先理解数组。

  • 固定大小:数组类型定义指定了长度和元素类型(例如 [4]int)。数组的大小是固定的,长度是其类型的一部分。
  • 无需显式初始化:数组的零值是一个随时可用的数组,其元素本身被置零。
  • 值类型:Go 的数组是值类型。数组变量表示整个数组,而不是指向第一个元素的指针。当赋值或传递数组时,会复制其完整内容。

切片 (Slices)

切片建立在数组之上,提供了强大的功能和灵活性。

  • 无固定长度:切片的类型规范是 []T,没有指定的长度。
  • 创建方式:可以使用内置函数 make([]T, len, cap) 创建切片。如果省略容量(cap),它默认为指定的长度。
  • 零值:切片的零值是 nil。对于 nil 切片,lencap 均为 0。
  • 切片操作:可以通过指定半开区间(如 b[1:4])对现有的切片或数组进行“切片”。

切片的内部原理

切片本质上是数组片段的描述符。 它在底层由三个部分组成:

  1. 指针 (Pointer):指向底层数组中切片起始元素的指针。
  2. 长度 (Length):切片当前引用的元素个数(可通过 len() 获取)。
  3. 容量 (Capacity):底层数组中从切片起始元素到底层数组末尾的元素个数(可通过 cap() 获取)。

注意:对切片进行切片操作(slicing)不会复制切片的数据,而是创建一个指向原始底层数组的新切片值。因此,修改新切片的元素会直接修改原始切片(及底层数组)的元素。切片不能增长超过其容量,否则会导致运行时 panic。

增长切片 (copy 和 append)

当切片需要超出其容量时,必须分配一个新的、更大的底层数组,并将原始数据复制过去。

  • copy 函数copy(dst, src []T) int 用于将数据从源切片复制到目标切片。
  • append 函数append(s []T, x ...T) []T 用于向切片末尾追加元素。如果底层数组容量不足,append 会自动分配一个更大的数组,复制原有数据,并返回更新后的切片。
a := []string{"John", "Paul"}
b := []string{"George", "Ringo", "Pete"}
a = append(a, b...) // 追加另一个切片的所有元素

常见的内存泄漏“陷阱”

由于对切片进行切片操作不会复制底层数组,整个底层数组将保留在内存中,直到没有任何切片引用它。

场景:如果你将一个大文件读取到内存中(得到一个大字节切片),然后通过切片操作只返回其中很小的一段匹配数据。由于返回的小切片仍然引用着原始的大数组,垃圾回收器(GC)无法释放整个大文件的内存。

解决方法:在返回之前,将需要保留的小段数据 copyappend 到一个新的切片中,从而解除对原始大数组的引用。

func CopyDigits(filename string) []byte {
    b, _ := ioutil.ReadFile(filename)
    b = digitRegexp.Find(b)
    
    // 复制到新的切片中,避免原始大数组 b 驻留内存
    c := make([]byte, len(b))
    copy(c, b)
    return c
}