切片
导读:
- 切片的基本操作
- 切片和数组的基本概念
- 切片数组的底层数据结构
- issues
基本操作
// 初始化一个切片,设置长度为5,容量为10
a := make([]int,5,10)
// 获取一个新的切片,遵循左闭右开的原则,取值时并且只看长度不能看容量
// b切片,长度为1,容量为8(下文有讲,向右原则)
b:= a[2:3]
// 判断切片长度为0
len(a) == 0
// append 数据
a = append(a, 1)
// 将切片内容清空(element设置为初始值,比如int就是0,string就是"")
clear(a)
//
// 将一个切片转为一个数组,注意,新数组不能超过切片的长度,且是数据的深度拷贝
var arr = [4]int(a)
切片和数组的基本概念
数组是拥有一段连续内存的数据结构,切片是存储了这个数据结构地址,长度以及容量的 struct,这里俗称这种数据结构 (类似切片) 叫引用类型
生成数组有两种方式 [3]int
和 [...]int
但是后者其实只是一种语法题,go 会自动推断出容量,因为推断是在编译期搞定的,所以并不会影响数组运行时的效率。
不同长度的相同数据类型数组不是一个类型,比如 [1]string{}
和 [2]string{}
就是两个类型
t.Extra = &Array{Elem: elem, Bound: bound}
这段代码可以解释一下,它来自 go 的源代码,可以看出,生成数组的是一个 struct,那么显而易见了,里面的各项参数都必须一致的情况下 struct 才是一致的,所以,必须类型和容量都满足才行
但是切片没有这个烦恼,只要数据类型一致就是一种类型,因为它在编译期间的结构体中只有类型,并没有数量,数量需要在运行时才能确定
“切片的切片” 的容量是和 “切片的” 容量是不一致的 (比如这里的 a 和 b),我们来看一个例子:
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5, 6}
b := a[2:5]
fmt.Println(len(a), cap(a), len(b), cap(b))
}
output:6, 6, 3, 4
我猜你肯定以为 b 的容量也是 6,但是不是,go 规定,切片只能向右看,不能向左看,我们来说一下上面这个例子,a 就不用说了,a 的底层数据结构数组就是 6 个长度,所以 a 自然长度和容量都是 6,但是 b,它是切片的切片,遵从左闭右开的规则,它的长度是从 index 的 2 到 4,也就是说是 [3 4 5]
,自然它的长度就是 3,这毫无疑问,又因为切片遵从 “只能向右看容量” 的规则,它的容量从 index 2 开始往后算,也就是 6 - 2 = 4
所以它的长度是 3 容量是 4
切片,数组的底层数据结构
严格意义来说,go 的切片不存在扩容,如果切片想要的数据量大于底层数组的容量时,那么系统会做两件事,开辟新的数组,给这个数组生成新的切片,之前的数组和切片并没有任何的改变,而且如果没有被引用了,还会被 gc 掉
数组在数量小于等于 4 的时候,直接分配在栈内存里,如果大于四且没有逃逸到堆上时,变量就会在静态存储区初始化然后拷贝到栈上
静态存储区:内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。它主要存放静态数据、全局数据和常量。
栈区:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。任何临时变量都是处于栈区的,包括在 main () 函数中定义的变量
堆区:亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意大小的内存,程序员自己负责在适当的时候用 free 或 delete 释放内存。动态内存的生存期可以由我们决定,如果我们不释放内存,程序将在最后才释放掉动态内存。但是,良好的编程习惯是:如果某动态内存不再使用,需要将其释放掉,否则,我们认为发生了内存泄漏现象。
切片的底层数据结构。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
实际上,切片就是一个 struct (struct 是 go 的基本组成单位,实际上很多东西都可以使用结构体来表达,比如接口底层也是结构体)
当我们使用字面量的方式去生成一个 slice 的时候,会在编译期间搞定,但是如果使用 make 去生成一个切片时,就会在运行时生成一个切片,在运行时生成切片,不仅仅会进行边界检查,而且还会看是否逃逸到堆上。
**slice 的扩容规律,**如果期望容量大于当前容量的两倍就会使用期望容量,如果当前切片的长度小于 256 (opens new window) 就会将容量 x2,
如果当前切片的长度大于 256 就会逐步 (按照这个公式 cap += (cap + 768) / 4
) 从 2 倍的倍增速率调整到 1.25 的倍率,增加到新容量大于期望容量。不过这还不是全部
slice 扩容具体操作。扩容的时候并不是严格按照这个规律来的,这只是一个大致的规律,实际运行的时候会进行 padding
(填充),也就是内存的对齐,将容量字节数尽量靠近 2 的次方,比如说期望的新容量是 5,这时期望分配的内存大小为 40 字节 (具体看一个位置多少个字节,这里是按照 8 举例子),运行时会向上取整内存的大小到 48 字节 (取整到 go 的推荐值 (opens new window)),所以新切片的容量是 48 / 8 = 6
我们知道当切片扩容的时候,重新分配底层数组是会牵涉到内存的复制的,因此尽量减少内存复制就是我们要追求的事情了,当我们往后面追加数据的时候,如果可以提前预估要使用的容量,那么就不牵涉到多次的内存复制了
func main() {
const sliceSize = 1000
m := make([]int, 0, sliceSize)
for i := 0; i < sliceSize; i++ {
m = append(m, i)
}
}
slice 的浅拷贝 s := make([]int, 3,12)
,s1 := s[1:2]
,那么 s1 和 s 是浅层拷贝,他们拥有一个共同的底层数组,更改任意一个另一个的值也会发生改变。
**slice 的深拷贝。**copy 内置函数会将源内存 data 直接一次性拷贝,所以慎用,很消耗资源。
slice 的边界检查消除以及优化。go 在编译数组或者切片的时候都会在编译期间进行边界越界的检查,不过也只是普通的检查,比如直接使用整数或者常量访问数组,对于用变量去获取切片数组的情况根本就检查不了,这个时候 go 的运行时就会起作用,发出 Panic,那么边界检查有什么缺点呢?就是会降低运行时的效率,当然这里指的是运行时的检查,因为编译器的检查首先本身能力有限,其次只会降低编译速度而已,所以 go 从 1.17 开始,就开始支持了边界检查的消除,为了就是在证明不会越界的情况下,提高代码的运行效率,下面我们看一下取消运行时边界检查的例子:
package main
func main() {
// 这种不能确定,所以还是会检查
a := []int{1,2,3}
for i := range a {
a[i]
}
// 这样,就可以消除边界检查,因为运行时确定不可能出现问题
for i := len(a) - 1; i >= 0; i-- {
_ = a[i]
}
}
// 或者这样也可以
func A(i []int, b []byte) {
if len(i) > 256 {
i = i[:256] // 这里就是给运行时一个暗示,表示i的最大index不会超过 256
for _, v := range b { // v的最大值也不会超过 256(byte最大值 255)
_ = i[v]
}
}
}
append 追加数据的操作
前文我们知道 slice 会发生扩容情况,那么这种扩容一般是在 append 操作的时候发生的,通常来说操作是这样的:
a := make([]int,3,10)
a = append(a,1) // 注意,append操作是追加的意思,所以这里不是 100,而是0001
// 0001
当 slice 底层数组不够 append 的时候,就会发生扩容:
a := make([]int,3,4)
a[0] = 1
a[1]= 2
a[2] = 3
// 这里还是相同的数组,因为容量没有超过
a = append(a,4)
// 这里的a 指向的就不同于之前的那个数组了。
//底层就变成了一个新的数组 1 2 3 4 5 0 0 0 ;fmt.Println(cap(a)) : 8
a = append(a,5)
同样底层数组的不同区域的切片 append 的时候经常会发生意想不到的结果:
func main() {
a := make([]int,3,5)
a1 := a[1:2]
fmt.Println(a,a1)
a1 = append(a1,2)
fmt.Println(a,a1)
a = append(a,4)
fmt.Println(a,a1)
a1 = append(a1,5)
fmt.Println(a,a1)
}
//[0 0 0] [0]
// [0 0 2] [0 2]
// [0 0 2 4] [0 2]
// [0 0 2 5] [0 2 5]
从上面的案例可以说明,首先,a1 a 指向同一个底层数组,其次,a1 和 a 在 append 的时候,都是在各自切片的后面添加数据,他们会互相影响,在写代码的时候容易出现 bug
对 append 的优化
对一个未知大小的切片进行 append 操作的最佳选择是初始化一个 nil 切片:
var s []string
s = append(s,"a")
s 在初始化的时候没有分配内存,在 append 的时候分配了一个底层数组,下面这种方式就会浪费一次内存分配
s := make([]string,0)
s = append(s,"a")
这种方式,在 s 初始化的时候会给予它一个底层长度为 0 的数组,即便长度为 0,go 并未分配实际的内存空间,但是仍然浪费了执行片段,append 的时候还要再次分配底层数组。
不过使用 make 的方式比较适合已知容量的场景。
除此之外还有两种初始化的方式:
[]int(nil)
[]string("a")
我们知道 []int{}
和 var s []int
是两种皆然不同的初始化方式,虽然都是 0 长度,但是前者不是 nil,后者是 nil,不过 append 的时候不会介意是否是 nil,这也提醒了我们判断是否是空切片的方式,使用 len == 0 才是正确的方法,“是否等于 nil” 是错误用法。
[]string(nil)
的用法非常少见,通常来说,使用场景就下面这么一个:
src := []string("hi","there","!")
s := append([]string(nil), src...)
我们这里使用一个值为 nil 的切片主要是为了符合类型的需求。
切片的 copy
copy 是 go 语言的内置函数,全局使用,copy(a,b []Type)
,copy 是深度拷贝,它将后者的数据完全拷贝给前者。
要注意的是,将要被复制的元素能复制的量取决于前者的 length
比如下面这种情况,被复制的元素就是 0,但是并不会 panic
src := []int{1,2,3}
var d []int
// []
copy(d,src)
一般来说,我们会使用相同的 length:
src := []int{1,2,3}
d := make([]int,len(src))
copy(d,src)
或者直接使用 append 也能做到 copy
src := []int{1,2,3}
d := append([]int(nil),src...)
切片未被合理 gc
当切片完成自己的使命时,我们希望它可以正常的被 gc 掉,通常来说,我们可以手动使用 runtime.GC()
来强制系统进行垃圾回收,下面我们看一种 bug,这种 bug 出现以后,我们手动的垃圾回收将会无效
s := make([]int,10)
// 此处原本的想法是只取两个数据
// 但是造成了10个数据都不能垃圾回收,8个浪费
b := s[8:]
runtime.GC()
runtime.KeepLive(b)
本来我们期望 s 底层数组可以被垃圾回收,但是 b 也指向这个相同的底层数组,那么这个垃圾回收就无法执行。
正确的方法是
copy(res,s[8:])
return res
当然了,我们深究 gc 的本质就会发现,三色 gc 算法是看某个对象是否还被变量持有,是否可以通过变量的方式追踪到,来作为回收标准的,要么我们像前者一样,不再让变量持有值,要么将值变成 nil 即可:
type A struct {
v []byte
}
s := make([]A,10)
// 此处原本的想法是只取两个数据
// 但是造成了10个数据都不能垃圾回收,8个浪费
for i:= 0;i<8;i++ {
// 这里是手动的将前八个数据丢弃掉
s[i].v = nil
}
b := s[8:]
runtime.GC()
runtime.KeepLive(b)
这种多切片指向底层数组而造成的无法正常垃圾回收的行为很常见。在工作中还是应该检查好自己的代码,避免这种行为的发生。
切片中的 range 注意事项
range 时,我们直接修改返回的值是不会生效的,因为返回的值并不是原始的数据,而是数据的复制。
type Student struct {
year int
}
func main() {
result := []Student{
{12},
{13},
}
for _, student := range result {
student.year++
}
// 12 13
fmt.Println(result)
}
也就是说,这里的 student :=
student 的改变不会影响 result 中的任何数据,除非这里的 []Student
是 []*Student
下面我们演示一下,正确的在 range 时的操作方式:
type Student struct {
year int
}
func main() {
result := []Student{
{12},
{13},
}
for i := range result {
result[i].year++
}
// 13 14
fmt.Println(result)
}
正确的方式就是直接使用 result 本身进行操作,就可以真正的去改变 result 了。
并且,在 range 的时候,range 后面的数据其实也是复制品,也就是说,这里的 := range result
result 也是复制品,原有的 result 如何变化都不会影响 range 结果。
// 这里的 result
for i := range result {
// 跟这里面的result不是一个值,
//只有里面的result才是跟外面的result是一个值
result[i].year++
}
下面我们再看一个案例:
s := make([]int,3)
for range s {
s = append(s,10)
}
// [0 0 0 10 10 10]
你猜结果是多少呢?是会一直 range 吗?因为数据在一直添加啊,nonono,只会 range 3 次而已,因为 range 后面的 s 是固定不变的,它本身只是原有 s 的复制品而已。
综上:
- range 后面的数据是原有数据的复制品
- range 前面的 k v 更是后面复制品输出数据的复制品
- range 里面的数据才是跟外面的数据保持一致
第三点很关键,range 后面的数据跟 range 里面的数据并不是一个:
s := make([]int,3)
// 这里的s
for range s {
// 这里的s 跟外面保持一致
s = append(s,10)
}
不能把 range 当做 function 来类比:
func main(){
s := make([]int,1)
range(s)
}
func range(s any)(k,v any){
s[0]++
}
如果是函数,函数体的变量 s 和函数内部的 s 就是同一个,显然,range 中,range 后面的 s 和 range 里面的 s 并不是同一个。
切片转化为数组
在 go 1.20 版本中,新添加了切片转为数组或者数组指针的操作,具体实现如下:
s:= make([]int,2,4)
a := [1]int(s)
a1 := [2]int(s)
a2 := (*[1]int)(s)
a3 := (*[2]int)(s)
a[0] == s[0]
,也就是说,转化出来的就是底层的那个数组的复制,注意并不是底层数组本身,不过这里相同切片转化的数组指针是指向这个切片的底层数组的,所以 a2 和 a3,s 是公用一个数组的,如果更改了 a2[0],那么 a3,s 也是会发生改变的,a1 a 已经是数据的复制了,他们有了分别的生活了。
下面我们看一个具体的代码演示:
func main() {
s := make([]int, 2, 4)
a := [1]int(s)
a1 := [2]int(s)
a2 := (*[1]int)(s)
a3 := (*[2]int)(s)
fmt.Println(s, a, a1, a2, a3)
a[0] = 1
fmt.Println(s, a, a1, a2, a3)
a2[0] = 12
fmt.Println(s, a, a1, a2, a3)
}
//[0 0] [0] [0 0] &[0] &[0 0]
//[0 0] [1] [0 0] &[0] &[0 0]
//[12 0] [1] [0 0] &[12] &[12 0]
// 可以证明确实是指向同样的底层数据
fmt.Println(&a2[0], &a3[0], &s[0])
// 0x1400012a020 0x1400012a020 0x1400012a020
当一个切片转化为一个数组的时候,数组的长度不能大于切片的长度,而不是容量
那么下面这种代码就会出现 bug
s:= make([]int,2,4)
a := [3]int(s)
a1 := (*[3]int)(s)
// panic: cannot convert slice with length 2 to array or pointer to array with length 3
下面我们看一下转化后的特殊值
s:= make([]int,2,4)
a := [0]int(s) // []
a1 := (*[0]int)(s)// &[]
//将非空切片转为长度为 0 的数组,得到的指针不是 nil,比如 b2
var j []int
b := [1]int(j) // panic
b1 := [0]int(j)// []
b2 := (*[0]int)(j)// <nil>
b3 := (*[1]int)(j)// panic
//将 nil 切片转为长度为 0 的数组,得到的指针为 nil
c := make([]int,0)
u := (*[0]int)(c) //&[]
u1 :=[0]int(c) // []
issues
问题一:
如果有多个切片指向了同一个底层数组,那么你认为应该注意些什么
一定要避免 a 切片的更改造成的底层数据的改变,对 b 切片的结果造成影响,因为它们指向同一个数据底层
a := []int{1,2,3}
b := a[:]
a[2] = 4
fmt.Println(a,b)
[1 2 4] [1 2 4]
问题二:
怎样沿用扩容的思想对切片进行 “缩容
a:= []int{1,2,3}
b := a[:2] // b = [1,2]
// 如果确定a的数据多余的没有任何的用途了
nb := make([]int,2)
copy(nb,b)
所谓扩容的思想,就是创造一个新的底层数据
问题三:
nil 切片和空切片 (比如 []int {}) 的区别
最大的区别就是指向的底层数组的地址不一样
- nil 压根就没有地址
- 空切片是有正儿八经的地址的,只不过这个地址指向的数组不占用空间,这个数组叫做 zero 数组,并且所有的空切片指向同一个数组就是这个 zero 数组,也可以说在 go 里,zero 数组是唯一的存在,它存在的目的就是为了空切片
a := [0]int{}
fmt.Println(a)
fmt.Println(unsafe.Sizeof(a)) // 0
空的数据是不占内存空间的,还有类似的,比如空的 struct 也是一样的
a := struct{}{}
fmt.Println(a)
fmt.Println(unsafe.Sizeof(a)) // 0
问题四:
slice 和 array 的不同使用场景是什么
如果数据是固定的,可以用数组,否则还是切片更加灵活,实际上绝大多数情况下还是切片更好用
参考资料
- https://www.jianshu.com/p/9ea2fba64f06
- https://chai2010.cn/advanced-go-programming-book
- https://blog.csdn.net/kevin_tech/article/details/122138489
- https://blog.csdn.net/weixin_39927993/article/details/112099007