Go精进之路 | 切片
引言
Go 语言中的切片(slice)是一个非常灵活且强大的数据结构,作为数组之上的抽象,它提供了对数组的动态大小视图,实现了在大多数场合对数组的完美替代。对于一个花费大量时间使用的工具,深入了解它并了解如何高效地使用它是很值得的。
本文总结了切片的基础使用方式,探讨切片的实现原理,并在最后总结切片高效使用的原则。
切片的使用
Go 语言中的切片(slice)是一个非常灵活且强大的数据结构,它提供了对数组的动态大小视图。切片是引用类型,意味着当你传递切片到函数中时,实际上传递的是对底层数组的引用。以下是有关 Go 语言切片的一些关键点:
定义和初始化
切片可以通过直接声明或者使用 make
函数来创建。它们可以基于数组或者其他切片进行初始化。
var s []int // 声明一个整型切片,初始为 nil
s := []int{1, 2, 3} // 初始化一个包含元素的切片
s := make([]int, 10) // 使用 make 创建一个长度和容量都为 10 的切片
长度和容量
长度(length)是切片中元素的数量。 容量(capacity)是从切片的起始元素到底层数组末尾的元素数量。
s := []int{2, 3, 5, 7, 11}
fmt.Println(len(s)) // 输出: 5
fmt.Println(cap(s)) // 输出: 5
切片操作
可以使用切片操作来截取切片的一部分,这不会复制底层数组中的元素。
s := []int{2, 3, 5, 7, 11}
s = s[1:4] // s 现在引用了原数组的 3, 5, 7
动态增长
切片可以使用 append
函数动态增长。如果增长后的长度超过原切片的容量,将会分配一个新的底层数组,并将旧元素复制到新数组中。
s := make([]int, 0, 5)
s = append(s, 2, 3, 5, 7, 11, 13) // 在超过原始容量时,切片的容量通常会翻倍
传递给函数
由于切片是引用类型,所以当切片传递到函数中时,函数内对切片的修改会影响原始数据。
func modifySlice(s []int) {
s[0] = 100
}
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // 输出: [100, 2, 3]
切片实现原理
在探讨切片的实现原理前,先简单区分一下切片与数组的区别。
Go语言数组是一个固定长度的、容纳同构类型元素的连续序列。Go数组是值语义的,这意味着一个数组变量表示的是整个数组,与C语言中数组变量可视为指向数组第一个元素的指针不同。在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。
切片之于数组就像是文件描述符之于文件。数组承担底层存储空间的角色,切片为数组的访问提供了一层抽象。因此,可以称切片是数组的“描述符”。切片之所以能在函数参数传递时避免较大性能损耗,是因为它是“描述符”的特性,无论底层的数组元素类型有多大,切片这个描述符是固定大小的。
切片的内部表示有三个字段
type slice struct {
array unsafe.Pointer
len int
cap int
}
其中,array
是指向下层数组的指针,该元素也是切片的起始元素,len
是切片的长度,cap
是切片的最大容量。
当使用s := make([]byte, 5)
创建一个切片实例时,会为切片创建一个底层数组,如果没有在make
中指定cap参数,那么默认cap=len。

当使用语法糖s := u[low:high]
切片操作时,相当于对底层数组又创建了一个新的描述符,对这个切片的修改会反应到底层数组中,也会反应到基于同一底层数组的其他切片中。这个新切片的容量取决于从新切片的第一个元素到底层数组的末尾。

当 append
操作导致切片容量不足时,会创建新的底层数组,从而使得原始切片与新的切片解耦。
切片的扩容特性
切片的扩容指当切片的元素超过其当前容量时,Go 运行时会自动进行扩容以容纳更多的元素。因此切片部分满足零值可用定理,即使是零值切片也可以进行赋值操作。
当使用 append
函数向切片添加元素,并且容量不足以容纳更多元素时,Go 运行时会创建一个新的底层数组,并将现有元素复制到这个新数组中。
- 如果原切片的容量小于1024个元素,新容量通常是原来的两倍。
- 如果原切片的容量大于或等于1024个元素,则增长因子通常会逐渐减小,可能是 1.25 倍等,这是为了平衡内存的分配效率和使用效率。
但过于频繁的重新分配和复制是不必要的性能损耗,因此在创建切片时应该根据使用场景对切片的容量规模进行预估,以cap
参数的形式传递给make
。
切片的常见问题
切片底层数据是存储在堆上还是栈上?
在 Go 语言中,切片的底层数组可以存储在堆上或栈上,这取决于其生命周期和编译器的逃逸分析结果。逃逸分析是编译器用来决定数据应存储在堆还是栈的技术。主要的考虑因素包括:
- 栈上存储
- 局部作用域:如果一个切片的底层数组在其定义的函数内被创建,并且只在函数内部使用,那么它很可能在栈上分配。栈上的内存分配和释放速度快,但是一旦函数执行完毕,相关内存就会被释放。
- 生命周期短:如果编译器可以确定切片的生命周期不会超出其定义的作用域,它通常会将其放在栈上。
- 堆上存储
- 逃逸到函数外部:如果切片在函数外部被引用,例如被返回给调用者或存储在全局变量中,那么它的底层数组会被分配在堆上。堆上的内存由垃圾回收器管理,这允许在函数执行完毕后仍然存在。
- 大尺寸对象:对于较大的数组,即使它们没有逃逸到函数外部,编译器也可能选择在堆上分配内存,以避免栈溢出或过大的栈分配。
代码题:求输出
func main() {
c := []int{11, 12, 13} print := func(s []int) {
for i := 0; i < 5; i++ {
s = append(s, i)
}
}
print(c)
fmt.Print(c)
}
输出为[11 12 13]
原切片使用字面量初始化,默认容量为3,当原切片append
时,触发底层数组容量不足,重新分配新的底层数组。
最后s
的值为[11 12 13 0 1 2 3 4]
转载自:https://juejin.cn/post/7372484169580986408