这是我参与「第五届青训营 」笔记创作活动的第11天
基础语法别人说的很清除了,这里我参考了他人文章,加上我自己的理解说一下slice的底层知识
数组与切片
因为切片(slice)比数组更好用,也更安全,Go推荐使用slice而不是数组。
1.数组和切片有何异同
Go语言中的切片(slice)结构的本质是对数组的封装,它描述一个数组的片段。无论是数组还是切片,都可以通过下标来访问单个元素。 数组就是一片连续的内存,是定长的,长度定义好之后,不能再更改。在Go语言中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如[3]int和[5]int就是不同的类型,他们无法比较,而[3]int和[3]int是相同类型,可以比较,此外数组的大小取决于数组长度
func main() {
arr1 := [3]int{1, 2, 3}
arr2 := [3]int{1, 2, 3}
arr3 := [5]int{1, 2, 3, 4, 5}
fmt.Println(arr1 == arr2) // true 可以比较,类型相同,值也相等
fmt.Println(arr1) == arr3) // 编译错误 类型不同
fmt.Println(unsafe.Sizeof(arr3)) // 40 数组占用内存大小为元素大小的总和,`int`占`8`个字节,所以5*8 = 40
}
复制代码
切片则非常灵活,它可以动态地扩容,且切片的类型和长度无关。
slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指针
len int // 长度
cap int // 容量
}
复制代码
slice 的数据结构如下:
我们可以知道,只要底层数组的指针不一样,那么两个slice就是不相等的,所以Go定义slice是无法比较的
我们还能知道切片只有3个字段,1个指针,2个int参数,所以一个切片的大小固定为3*8 = 24
2.切片如何被截取
截取是一种常见的创建切片的方式,可以从数组或者slice直接截取,需要指定起始索引和终止索引的位置
截取用如下方式:
data := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice := data[2:4:6]
复制代码
data[left,right,max]
截取的索引范围为 left - right-1,也就是前闭后开,新slice的len是right-left
max代表截取后的容量,也是开区间,cap为max-left,不写则为原数组或切片容量
要满足left <= right<= max,当left == right时,slice为空
基于已有slice创建的新slice对象会和老slice共用底层数组,对新老slice底层数组的更改会影响到彼此,截取数组也同理。
但是如果执行append操作使得slice底层数组扩容,底层数组改变了,那么在做修改就不会互相影响了,是否是影响关键在于是否共用底层数组
看下面一段代码:
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) // [2 3 20]
fmt.Println(s2) // [4 5 6 7 100 200]
fmt.Println(slice) // [0 1 2 3 20 5 6 7 100 9]
}
复制代码
s1截取了slice索引2到4的位置,len为5-2=3,cap默认到数组结尾也就是10-2=8
s2截取了slice索引2到5的位置,len为6-2=4,cap为7-2=5
接着,向 s2 尾部追加一个元素 100:
s2 = append(s2, 100)
复制代码
s2容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。
再次向 s2 追加元素200:
s2 = append(s2, 100)
复制代码
这时,s2 的容量不够用,该扩容了。s2开辟了新数组,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。
最后,修改 s1索引为2位置的元素:
s1[2] = 20
复制代码
此时就不会影响s2了
打印只能打印len以内的元素,s1只能打印3个元素,虽然底层数组后面还有元素。
3.切片是如何扩容的
我们通过调用append实现切片添加元素,append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ... 传入 slice,直接追加一个切片。
slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)
复制代码
我们在append元素时,实际就是往索引为len的位置添加元素,当len-1为底层数组最后一个元素时就会自动扩容,开辟一个大数组,把原数组的数拷贝过去,然后在往len的位置添加元素,这些是内部实现的,我们不会感知到
在1.18版本之前,网上说的扩容策略是这样的:
当原 slice 容量小于
1024的时候,新 slice 容量变成原来的2倍;原 slice 容量超过1024,新 slice 容量变成原来的1.25倍。
在1.18中,扩容策略是这样的:
当原slice容量(oldcap)小于
256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4
事实真是这样的吗?做个测试
/*
我先创建了一个空的 slice,然后,在一个循环里不断往里面 append 新的元素。然后记录容量的变化,
并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后的容量,同时记下此时 slice 里的元素。
这样,我就可以观察,新老 slice 的容量变化情况,从而找出规律。
*/
func main() {
s := make([]int, 0)
oldCap := cap(s)
for i := 0; i < 2048; i++ {
s = append(s, i)
newCap := cap(s)
if newCap != oldCap {
fmt.Printf("[%d -> %4d] cap = %-4d | after append %-4d cap = %-4d\n", 0, i-1, oldCap, i, newCap)
oldCap = newCap
}
}
}
复制代码
运行结果(1.18版本之前):
[0 -> -1] cap = 0 | after append 0 cap = 1
[0 -> 0] cap = 1 | after append 1 cap = 2
[0 -> 1] cap = 2 | after append 2 cap = 4
[0 -> 3] cap = 4 | after append 4 cap = 8
[0 -> 7] cap = 8 | after append 8 cap = 16
[0 -> 15] cap = 16 | after append 16 cap = 32
[0 -> 31] cap = 32 | after append 32 cap = 64
[0 -> 63] cap = 64 | after append 64 cap = 128
[0 -> 127] cap = 128 | after append 128 cap = 256
[0 -> 255] cap = 256 | after append 256 cap = 512
[0 -> 511] cap = 512 | after append 512 cap = 1024
[0 -> 1023] cap = 1024 | after append 1024 cap = 1280
[0 -> 1279] cap = 1280 | after append 1280 cap = 1696
[0 -> 1695] cap = 1696 | after append 1696 cap = 2304
复制代码
运行结果(1.18版本):
[0 -> -1] cap = 0 | after append 0 cap = 1
[0 -> 0] cap = 1 | after append 1 cap = 2
[0 -> 1] cap = 2 | after append 2 cap = 4
[0 -> 3] cap = 4 | after append 4 cap = 8
[0 -> 7] cap = 8 | after append 8 cap = 16
[0 -> 15] cap = 16 | after append 16 cap = 32
[0 -> 31] cap = 32 | after append 32 cap = 64
[0 -> 63] cap = 64 | after append 64 cap = 128
[0 -> 127] cap = 128 | after append 128 cap = 256
[0 -> 255] cap = 256 | after append 256 cap = 512
[0 -> 511] cap = 512 | after append 512 cap = 848
[0 -> 847] cap = 848 | after append 848 cap = 1280
[0 -> 1279] cap = 1280 | after append 1280 cap = 1792
[0 -> 1791] cap = 1792 | after append 1792 cap = 2560
复制代码
根据上面的结果我们可以看出在1.18版本之前:
在原来的slice容量oldcap小于1024的时候,新 slice 的容量newcap的确是oldcap的2倍。
但是,当oldcap大于等于 1024 的时候,情况就有变化了。当向 slice 中添加元素 1280 的时候,原来的slice 的容量为 1280,之后newcap变成了 1696,两者并不是 1.25 倍的关系(1696/1280=1.325)。添加完 1696 后,新的容量 2304 当然也不是 1696 的 1.25 倍。
在1.18版本之后:
在原来的slice 容量oldcap小于256的时候,新 slice 的容量newcap的确是oldcap 的2倍。
但是,当oldcap容量大于等于 256 的时候,情况就有变化了。当向 slice 中添加元素 512 的时候,老 slice 的容量为 512,之后变成了 848,两者并没有符合newcap = oldcap+(oldcap+3*256)/4 的策略(512+(512+3*256)/4)=832。添加完 848 后,新的容量 1280 当然也不是 按照之前策略所计算出的的1252。
可见网上的扩容策略并不正确,看growslice的扩容源码
1.16
// go 1.16.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for newcap < cap {
newcap += newcap / 4
}
}
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
}
复制代码
1.18
// go 1.18 src/runtime/slice.go:178
func growslice(et *_type, old slice, cap int) slice {
// ……
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
// ……
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
}
复制代码
如果只看前半部分,现在网上各种文章里说的 newcap 的规律是对的。现实是,后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 按照前半部分生成的newcap。之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。
总结:扩容策略并不是单纯的满足扩容策略公式,容量很大之后续进行内存对齐,具体扩到多大无法得知。
4.切片作为函数参数
网上很多人说切片是引用类型,那么传到函数里做修改就一定会改变原切片吗?
实则不是,前文说过,切片实际就是一个包含三个元素的结构体,作为参数也就是传递了一个普通的结构体,
指针,len,cap这些字段是复制到函数里的。
另外,值得注意的是,Go 语言的函数参数传递,只有值传递,没有引用传递。
函数作为参数有以下两种情况:
- 当我们做普通
赋值修改时,类似s[i]=10这种操作会改变 slice 底层数组元素值,也就影响了原切片 - 当我们做
append时,改变了len,cap,但这些原切片是不会改变的,所以当原切片遍历也就不会变,这时就需要我们传递切片的指针做修改,或者直接返回修改后的切片
例子1:
func f(s []int) {
// i只是一个副本,不能改变s中元素的值
/*for _, i := range s {
i++
}
*/
for i := range s {
s[i] += 1
}
}
func main() {
s := []int{1, 1, 1}
f(s)
fmt.Println(s) //[2 2 2]
}
复制代码
函数里对切片每一个元素+1,改变了底层数组的值,也就影响到了原切片
例子2:
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]
复制代码
myAppend 函数里,虽然改变了 s,但它只是一个值传递,改变的是副本的len和cap,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]。
而 newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]。
最后,将 newS 赋值给了 s,s 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]。
总结:当切片作为函数参数时,如果只做赋值修改,例如一些排序算法,不会涉及append,那么可以传递切片。如果在函数里需要append,需要传递切片的指针,或者传递切片,但要返回修改后的切片,让原切片接收它