go语言中的切片,你真的了解吗?

2023年 7月 11日 31.4k 0

0 前言

切片 slice 是 golang 中一个非常经典的数据结构,其定位可以类比于其他编程语言中的数组. 本文介绍的内容会分为 slice 的使用教程、问题讲解以及源码解析,走读的源码为 go v1.19.

1 几个问题

首先呢,我觉得使用 go 的朋友对于切片这个数据结构不会感到陌生,一些基本的概念和用法应该是可以做到了然于心的. 下面我先抛出一轮问题,大家可以思考并给出自己的答案,然后带着问题进入本文后半段的学习. 在第 2 章中,我们会进行原理补充,把问题涉及的拼图碎片一块块集齐;最后在第 3 章中,我们会正面给出第 1 章中所有问题的答案.

下面,就正式开启灵魂拷问环节:

1.1 问题1

  • • 初始化切片 s 长度和容量均为 10
  • • 在 s 的基础上追加 append 一个元素

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,10)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

1.2 问题2

  • • 初始化切片 s 长度为 0,容量为 10
  • • 在 s 的基础上追加 append 一个元素

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,0,10)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

1.3 问题3

  • • 初始化切片 s 长度为 10,容量为 11
  • • 在 s 的基础上追加 append 一个元素

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,11)  
    s = append(s,10)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))
}

1.4 问题4

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取切片 s index = 8 往后的内容赋给 s1

求问 s1 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}

1.5 问题5

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取切片 s index 为 [8,9) 范围内的元素赋给切片 s1

求问 s1 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:9]
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))
}

1.6 问题6

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取切片 s index = 8 往后的内容赋给 s1
  • • 修改 s1[0] 的值

请问这个修改是否会影响到 s? 此时,s 的内容是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    s1[0] = -1
    t.Logf("s: %v",s)
}

1.7 问题7

  • • 初始化切片 s 长度为 10,容量为 12

请问,访问 s[10] 是否会越界?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    v := s[10]
    // 求问,此时数组访问是否会越界
}

1.8 问题8

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取 s 中 index = 8 后面的内容赋给 s1
  • • 在 s1 的基础上追加 []int{10,11,12} 3 个元素

请问,经过上述操作时候,访问 s[10] 是否会越界?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    s1 = append(s1,[]int{10,11,12}...)
    v := s[10]
    // ...
    // 求问,此时数组访问是否会越界
}

1.9 问题9

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取切片 s index = 8 往后的内容赋给 s1
  • • 在方法 changeSlice 中,对 s1[0] 进行修改

求问,经过上述操作之后,s 的内容是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    changeSlice(s1)
    t.Logf("s: %v",s)
}


func changeSlice(s1 []int){
  s1[0] = -1
}

1.10 问题10

  • • 初始化切片 s 长度为 10,容量为 12
  • • 截取切片 s index = 8 往后的内容赋给 s1
  • • 在方法 changeSlice 中,对 s1 进行 apend 追加操作

请问,经过上述操作后,s 以及 s1 的内容、长度和容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,10,12)  
    s1 := s[8:]
    changeSlice(s1)
    t.Logf("s: %v, len of s: %d, cap of s: %d",s, len(s), cap(s))
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1, len(s1), cap(s1))
}


func changeSlice(s1 []int){
  s1 = append(s1, 10)
}

1.11 问题11

  • • 初始化切片 s,内容为 []int{0,1,2,3,4}
  • • 截取 s 中 index = 2 前面的内容(不含s[2]),并在此基础上追加 index = 3 后面的内容

请问,经过上述操作后,s 的内容、长度和内容分别是什么?此时访问 s[4] 是否会越界?

func Test_slice(t *testing.T){
    s := []int{0,1,2,3,4}
    s = append(s[:2],s[3:]...)
    t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))
    v := s[4] 
    // 是否会数组访问越界
}

1.12 问题12

  • • 初始化切片 s 长度和容量均为 512
  • • 在 s 的基础上追加 append 一个元素

请问经过上述操作后,切片s 的内容、长度以及容量分别是什么?

func Test_slice(t *testing.T){
    s := make([]int,512)  
    s = append(s,1)
    t.Logf("len of s: %d, cap of s: %d",len(s),cap(s))
}

2 使用及原理

2.1 基本介绍

go 语言中的切片对标于其他编程语言中通俗意义上的“数组”. 切片中的元素存放在一块内存地址连续的区域,使用索引可以快速检索到指定位置的元素;切片长度和容量是可变的,在使用过程中可以根据需要进行扩容.

2.2 数据结构

go语言中的切片,你真的了解吗?

type slice struct {
    // 指向起点的地址
    array unsafe.Pointer
    // 切片长度
    len   int
    // 切片容量
    cap   int
}

切片的类型定义如上,我们称之为 slice header,对应于每个 slice 实例,其中核心字段包括:

  • • array:指向了内存空间地址的起点. 由于 slice 数据存放在连续的内存空间中,后续可以根据索引 index,在起点的基础上快速进行地址偏移,从而定位到目标元素
  • • len:切片的长度,指的是逻辑意义上 slice 中实际存放了多少个元素
  • • cap:切片的容量,指的是物理意义上为 slice 分配了足够用于存放多少个元素的空间. 使用 slice 时,要求 cap 永远大于等于 len

通过 slice 数据结构定义可以看到,每个 slice header 中存放的是内存空间的地址(array 字段),后续在传递切片的时候,相当于是对 slice header 进行了一次值拷贝,但内部存放的地址是相同的,因此对于 slice 本身属于引用传递操作

此外,在这里我们聊到了切片的长度 len 和容量 cap 两个概念,这两个概念很重要,我们需要分清楚两者的区别,这一点会伴随我们研究切片的流程始终.

2.3 初始化

下面先来介绍下切片的初始化操作:

  • • 声明但不初始化

下面给出的第一个例子,只是声明了 slice 的类型,但是并没有执行初始化操作,即 s 这个字面量此时是一个空指针 nil,并没有完成实际的内存分配操作.

  var s []int 
  • • 基于 make 进行初始化

make 初始化 slice 也分为两种方式, 第一种方式如下:

  s := make([]int,8)

此时会将切片的长度 len 和 容量 cap 同时设置为 8. 需要注意,切片的长度一旦被指定了,就代表对应位置已经被分配了元素,尽管设置的会是对应元素类型下的零值.

第二种方式,是分别指定切片的长度 len 和容量 cap,代码如下:

  s := make([]int,8,16)

如上所示,代表已经在切片中设置了 8 个元素,会设置为对应类型的零值;cap = 16 代表为 slice 分配了用于存放 16 个元素的空间. 需要保证 cap >= len. 在 index 为 [len, cap) 的范围内,虽然内存空间已经分配了,但是逻辑意义上不存在元素,直接访问会 panic 报数组访问越界;但是访问 [0,len) 范围内的元素是能够正常访问到的,只不过会是对应元素类型下的零值.

  • • 初始化连带赋值

初始化 slice 时还能一气呵成完成赋值操作. 如下所示:

  s := []int{2,3,4}

这样操作的话,会将 slice 长度 len 和容量 cap 均设置为 3,同时完成对这 3 个元素赋值.

下面我们来一起过目一下切片初始化的源码,方法入口位于 golang 标准库文件 runtime/slice.go 文件的 makeslice 方法中:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
    // 根据 cap 结合每个元素的大小,计算出消耗的总容量
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))
    if overflow || mem > maxAlloc || len  cap {
        // 倘若容量超限,len 取负值或者 len 超过 cap,直接 panic
        mem, overflow := math.MulUintptr(et.size, uintptr(len))
        if overflow || mem > maxAlloc || len  [-1,3,4]
  changeSlice(s)
}


func changeSlice(s []int){
  s[0] = -1
}

如代码所示,将主方法 Test_slice 中声明的切片 s 作为 changeSlice 方法的入参进行传递,同时在 changeSlice 方法中对 s 内的元素进行修改,这样是会直接影响到 Test_slice 中的切片 s 的.

产生这个结果的原因就在于切片的传递是引用传递,而非值传递. 关于这一点,我们可以不用死记硬背,而是可以结合 2.2 小节中我们聊到的 slice header 数据结构,进行逻辑梳理:

每个切片实例对应一个 slice header,其中存储了三个字段:

  • • 切片内存空间的起始地址 array;
  • • 切片长度 len;
  • • 以及切片容量 cap.

综上,每次我们在方法间传递切片时,会对 slice header 实例本身进行一次值拷贝,然后将 slice header 的副本传递到局部方法中.

然而,这个 slice header 副本中的 array 和原 slice 指向同一片内存空间,因此在局部方法中执行修改操作时,还会根据这个地址信息影响到原 slice 所属的内存空间,从而对内容发生影响.

2.5 内容截取

接下来我们聊聊 slice 的截取操作.

我们可以修改 slice 下标的方式,进行 slice 内容的截取,形如 s[a:b] 的格式,其中 a b 代表切片的索引 index,左闭右开,比如 s[a:b] 对应的范围是 [a,b),代表的是取切片 slice index = a ~ index = b-1 范围的内容.

此外,这里我聊到的 a 和 b 是可以缺省的:

  • • 如果 a 缺省不填则默认取 0 ,则代表从切片起始位置开始截取. 比如 s[:b] 等价于 s[0:b]
  • • 如果 b 缺省不填,则默认取 len(s),则代表末尾截取到切片长度 len 的终点,比如 s[a:] 等价于 s[a:len(s)]
  • • a 和 b 均缺省也是可以的,则代表截取整个切片长度的范围,比如 s[:] 等价于 s[0:len(s)]

下面给出一个对 slice 执行截取操作的代码示例:

func Test_slice(t *testing.T){
   s := []int{1,2,3,4,5}
   // s1: [2,3,4,5]
   s1 := s[1:]
   // s2: [1,2,3,4]
   s2 := s[:len(s)-1]
   // s3: [2,3,4] 
   s3 := s[1:len(s)-1]
   // ...
}

在对切片 slice 执行截取操作时,本质上是一次引用传递操作,因为不论如何截取,底层复用的都是同一块内存空间中的数据,只不过,截取动作会创建出一个新的 slice header 实例.

go语言中的切片,你真的了解吗?

  s := []int{2,3,4,5}
  s1 := s[1:]
  // ...

以下面的代码为例,

s1 = s[1:] 的操作,会创建出一个 s1 的 slice header,其中的字段 array 会在 s.array 的基础上向右偏移一个切片元素大小的数值;s1.len 和 s1.cap 也会以 s1 的起点为起点,以 s 原定的 len 和 cap 终点为终点,最终推算得出 s1.len = s.len - 1;s1.cap = s.cap - 1.

2.6 元素追加

下面介绍的是切片的追加操作. 通过 append 操作,可以在 slice 末尾,额外新增一个元素. 需要注意,这里的末尾指的是针对 slice 的长度 len 而言. 这个过程中倘若发现 slice 的剩余容量已经不足了,则会对 slice 进行扩容. 扩容有关的内容我们放到 2.7 小节再作展开.

func Test_slice(t *testing.T){
    s := []int{2,3,4}  
    s = append(s,5)
    // s: [2,3,4,5]
}

结合我个人的经验,一些 go 的初学者在对 slice 进行初始化以及赋值操作时,有可能会因为对 slice 中 len 和 cap 概念的混淆,最终出现错误的使用方式,形如下面这个代码示例:

func Test_slice(t *testing.T){
    s := make([]int,5)
    for i := 0; i 

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论