likes
comments
collection
share

Go精进之路 | 切片

作者站长头像
站长
· 阅读数 29

引言

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。

Go精进之路 | 切片

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

Go精进之路 | 切片

append 操作导致切片容量不足时,会创建新的底层数组,从而使得原始切片与新的切片解耦。

切片的扩容特性

切片的扩容指当切片的元素超过其当前容量时,Go 运行时会自动进行扩容以容纳更多的元素。因此切片部分满足零值可用定理,即使是零值切片也可以进行赋值操作。

当使用 append 函数向切片添加元素,并且容量不足以容纳更多元素时,Go 运行时会创建一个新的底层数组,并将现有元素复制到这个新数组中。

  • 如果原切片的容量小于1024个元素,新容量通常是原来的两倍。
  • 如果原切片的容量大于或等于1024个元素,则增长因子通常会逐渐减小,可能是 1.25 倍等,这是为了平衡内存的分配效率和使用效率。

但过于频繁的重新分配和复制是不必要的性能损耗,因此在创建切片时应该根据使用场景对切片的容量规模进行预估,以cap参数的形式传递给make

切片的常见问题

切片底层数据是存储在堆上还是栈上?

在 Go 语言中,切片的底层数组可以存储在堆上或栈上,这取决于其生命周期和编译器的逃逸分析结果。逃逸分析是编译器用来决定数据应存储在堆还是栈的技术。主要的考虑因素包括:

  1. 栈上存储
  • 局部作用域:如果一个切片的底层数组在其定义的函数内被创建,并且只在函数内部使用,那么它很可能在栈上分配。栈上的内存分配和释放速度快,但是一旦函数执行完毕,相关内存就会被释放。
  • 生命周期短:如果编译器可以确定切片的生命周期不会超出其定义的作用域,它通常会将其放在栈上。
  1. 堆上存储
  • 逃逸到函数外部:如果切片在函数外部被引用,例如被返回给调用者或存储在全局变量中,那么它的底层数组会被分配在堆上。堆上的内存由垃圾回收器管理,这允许在函数执行完毕后仍然存在。
  • 大尺寸对象:对于较大的数组,即使它们没有逃逸到函数外部,编译器也可能选择在堆上分配内存,以避免栈溢出或过大的栈分配。

代码题:求输出

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
评论
请登录