Go slice深度剖析
前言
大家好,我是大熊,今天给大家带来一期关于Go中slice
(切片) 的使用方法。 Slice
在编程中经常作为动态数组使用,提供了丰富API的同时,也给开发者带来一定风险。 如果不了解内部的实现机制,有可能会遇到莫名其妙的现象,本文从浅入深来把这个类型说清楚。
热身环节
先看一段代码,了解一下对slice
的理解程度。
var s = make([]int, 3, 3)
s = append(s, 1, 2, 3)
fmt.Println(&s[0] == &append(s, 4)[0])
var s1 = make([]int, 3, 4)
s1 = append(s1, 1, 2, 3)
fmt.Println(&s1[0] == &append(s1, 4)[0])
输出结果为 false
和 true
, 可能很多小伙伴一下就看出来与slice
的cap
的容量是有关系, 但在实际的开发中接手的可能是其他同学的代码,甚至slice
是作为参数传递进来的,稍有疏忽就有可能导致bug的出现。
slice是什么,作用是什么?
个人理解:slice
的底层数据是数组,slice
是对数组的封装,它描述了一个数组的片段。和数组相同,切片也可以通过下标来访问单个元素。
这种解释并没给大家添加新的学习理解成本,解释为在数组基础上做了进一步关于数组的常用方法封装,因此我们只要掌握其中的API方法就可以使用,文章这里通过深入剖析原理,也是希望帮助读者能使用的更加熟练。
Go
中数组是定长的,长度定义好之后,不能再更改。 在Go
中, 数组其长度是类型的一部分,限制了它的表达能力,比如 [3]int
和 [4]int
就是不同的类型, 数组就是一片连续的内存。
切片则非常灵活,它可以动态地扩容。切片的类型和长度无关,slice
实际上是一个结构体,包含三个字段:长度、容量、底层数组。
slice
的提出,极大减轻开发的编程负担, 再也不用像C\c++
中那样自己申请空间,手动的判断是否溢出,最后还要手动的释放掉。
数据结构如下: runtime/slice.go
type slice struct {
array unsafe.Pointer // 底层数组指针
len int // 长度
cap int // 容量
}
array
: 是一个非安全类型的指针,指向底层数组,是一个连续的内存块。len
: 指slice
的实际长度, 即slice
中含有的元素个数。cap
: 指slice
的底层数组长度,即slice
的容量,slice
可以容纳多少元素。
注意:底层数组是可以被多个 slice
同时指向的,因此对一个 slice
的元素进行操作是有可能影响到其他 slice
。
slice 的定义方式有下面3种方法
var identifier []type
make([]T, length, capacity)
- 从数组或者切片获得,
arr[low:high:max]
,这种情况下和arr共享数组,len=high-low
,cap=max-low
, 并且遵循"左闭右开"的原则。
make()
比 new()
函数多一些操作,new()
函数只会进行内存分配并做默认的赋0
初始化,而make()
可以先为底层数组分配好内存,然后从这个底层数组中再额外生成一个slice
并初始化。
另外,make
只能构建slice
、map
和channel
这 3 种结构的数据对象,因为它们都指向底层数据结构,都需要先为底层数据结构分配好内存并初始化。
关于 silce 自身占用的大小
对于每一个slice
结构都由3部分组成:容量(capacity
)、长度(length
)和指向底层数组某元素的指针,它们各占8字节, 也就是64bit。
关于1个机器字长,64位机器上一个机器字长为64bit,共8字节大小,32位架构则是32bit,占用4字节),所以任何一个slice都是24字节(3个机器字长。
注意区分 array 和 slice 比较
-
虽然
nil slice
和Empty slice
的长度和容量都为0,输出时的结果都是[]
,且都不存储任何数据,但它们是不同的。nil slice
不会指向底层数组,而空slice
会指向底层数组,只不过这个底层数组暂时是空数组。 -
在定义上区别主要是定义时数组长度是否有值。 例如:
=[...]string {}
定义一个自动推导的string数组。[]int{}
声明出的就是一个int
切片,并初始化为空切片,不能访问Number[0]
。
方法
下面我们主要介绍一下关于slice
中常用方法。
append 方法
切片带有自动扩容机制,一般是在使用了append
函数向切片追加了元素之后,切片的容量不足,引起了扩容。
扩容机制的规律是什么呢?
在Go18
前后有所区别,在底层调用的方法 growSlice
函数,这里不做详细的介绍, 仅仅是做一下粗略的描述(后边我们看源码时在单独讲解):
- 当切片的容量小于一个阈值
threshold
时,每次扩容将容量翻倍; - 当切片的容量大于等于
threshold
时,每次扩容将容量增加原来容量的1.25
倍。 - 在计算出新的容量后,函数根据新的容量计算出需要分配的内存大小
capmem
,并通过roundupsize
函数进行内存对齐。(这里也是为啥有些同学算不出来上面的倍数的原因) - 最后,函数将capmem转换为新的容量
newcap
,并返回新的切片。
注意:超出原 slice.cap
限制,就会重新分配底层数组(新建一个新的),即便原数组并未填满。,如果没有重新分配底层数组,那么一直是在同一个数组下进行操作。
copy 函数
可以将一个slice拷贝到另一个slice中。 函数 copy
在两个 slice
间复制数据,复制长度以 len
小的为准。 两个 slice
可指向同一底层数组,允许元素区间重叠。
复制长度以 len
小的为准的含义 举例来说: copy(s1, s2)
- 如果
s1
的长度大于s2
, 则会将s2
放在s1
的最前面进行覆盖。 - 如果
s1
的长度小于s2
, 则会将s1
使用s2
全部覆盖,多的s2
部分不进行复制。
因此:应及时将所需数据 copy
到较小的 slice
,以便释放超大号底层数组内存。
slice遍历迭代
slice
的遍历,常用的遍历方法和数组相似
for... range
for
和len(slice)
结合
具体的如下所示:
for i := 0; i < len(slice); i++ {
fmt.Println(slice[i])
}
for _,v := range slice{
fmt.Println(v)
}
对切片/数组截取使用
可以使用如下格式进行切片的使用和截取
语法 | 示例 |
---|---|
make[type, len, cap] | sliceA := make([]int, 5, 10) //length = 5; capacity = 10 |
slice[start : end] | sliceB := sliceA[0:5] //length = 5; capacity = 10 |
slice[start : ] | sliceC := sliceA[0:] //length = 5; capacity = 10 |
slice[: end ] | sliceD := sliceA[:5] //length = 5; capacity = 10 |
slice[start : end : cap] | sliceE := sliceA[0:5:5] //length = 5; capacity = 5 |
其他
string
与slice
string
底层是一个byte
数组,因此string
也可以进行切片处理。 但是string
是不可变的,所以不能通过slice[0]='s'
的方式进行修改。【这种情况下编辑器是编译不通过的】
如果要修改需要将string
转为byte
数组改变值以后再改成string
, 如下例子:
str := "abcdefghijk"
slice := []byte(str) //将str转为byte切片
slice[0]='z'//将第1个元素a改为z
str =string(slice)//转回string
fmt.Println(str) //zbcdefghijk
- 快速复制一个新的
slice
写法
var s = []int{1,3}
fmt.Println(append([]int(nil), s...)) // type... 是一种固定的写法,表示将s内的数据全部解构出来
- 序列化与反序列化中的坑
如下代码,在和前端同学写接口时经常遇到:
var s []int
b, _ := json.Marshal(s)
fmt.Println(string(b)) //null
s := []int{} //对底层的数组进行初始化
b, _ := json.Marshal(s)
fmt.Println(string(b)) //[]
切片作为函数参数会被改变吗
根据slice
定义,它其实是一个结构体,当slice
作为函数参数时,就是一个普通的结构体。
- 若直接传
slice
在调用者看来,实参slice
并不会被函数中的操作改变; - 若传的是
slice
的指针,在调用者看来,是会被改变原slice
的。
其实不管传的是 slice
还是 slice
指针,如果改变了 slice
底层数组的数据,会反应到实参slice
的底层数据。
根本原因是:底层数据在 slice
结构体里是一个指针,尽管 slice
结构体自身不会被改变,也就是说底层数据地址不会被改变。但是通过指向底层数据的指针,可以改变切片的底层数据。通过 slice
的 array
字段就可以拿到数组的地址。另外在Go语言里,函数参数传递,只有值传递,没有引用传递。
slice
作为参数的时候,一定要注意底层的数组修改是否会带来其他的影响。
性能陷阱:
- 在大批量添加数据时,建议一次性分配足够大的空间,以减少内存分配和数据复制开销。
- 初始化足够长的
len
属性,改用索引号进行操作。索引号的效率高。 - 切片拷贝时需要判断实际拷贝的元素个数。
- 及时释放不再使用的
slice
对象,避免持有过期数组,造成 GC 无法回收。 - 谨慎使用多个切片操作同一个数组, 以防读写冲突。 读写冲突在多协程情况下一定要慎重考虑。
- 当同一个底层数组有很多
slice
时,一切将变得混乱不堪,因为我们不可能记住谁在共享它,通过修改某个slice
元素时,将也会影响那些可能我们不想影响的slice
。【这个地方容易出问题】。 - 切片引用切片场景:如果一个切片有大量的元素,而它只有少部分元素被引用,其他元素存在于内存中,但是没有被使用,则会造成内存泄露。
其实有很多同学还会选择多个去公用一个数组,原因基本都是避免了底层数组的频繁创建来提升性能。【富贵险中求,其实个人觉得,如果了解底层原理,写出一些骚操作问题是不大的,在安全和性能之间平衡好】
几个例子
最后再看看几个输出,来加深我们的理解。
例子1: 可以加强下 底层数组是可以被多个 slice
同时指向的,因此对一个 slice
的元素进行操作是有可能影响到其他 slice
。
package main
import "fmt"
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
s2 = append(s2, 100)
s2 = append(s2, 200)
s1[2] = 20
fmt.Println(s1)
fmt.Println(s2)
fmt.Println(slice)
}
结果如下,这里主要是
s1: [2 3 20]
s2: [4 5 6 7 100 200]
slice: [0 1 2 3 20 5 6 7 100 9]
例子2: 将slice
作为参数传递 和 作为指针传递的理解加深。
package main
import "fmt"
func myAppend(s []int) []int {
// 这里 s 虽然改变了,但并不会影响外层函数的 s
s = append(s, 100)
return s
}
func myAppendPtr(s *[]int) {
// 会改变外层 s 本身
*s = append(*s, 100)
return
}
func main() {
s := []int{1, 1, 1}
newS := myAppend(s)
fmt.Println(s)
fmt.Println(newS)
s = newS
myAppendPtr(&s)
fmt.Println(s)
}
输出结果为:
[1 1 1]
[1 1 1 100]
[1 1 1 100 100]
例子3: 手动代码实现slice的增加和删除操作
//增加
func Add(s []int, index int, value int) []int {
//如果插入的长度超过了切片的长度,则直接在末尾插入元素
if index > len(s) {
return append(s, value)
}
//如果插入的位置在切片中间,则需要讲该位置后面的元素全部向后移
s = append(s[:index+1], s[index:len(s)-1]...)
s[index] = value
return s
}
//删除
func Delete(s []int, index int, value int) []int {
//如果删除的位置超出了切片的长度,则直接返回原切片。
if index >= len(s) {
return s
}
// 如果删除的位置在切片中间,则需要将该位置后面的元素全部向前移动一位
copy(s[index:], s[index+1:])
return s[:len(s)-1]
}