声明:本文章是个人学习笔记,内容来自:https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/?from=from_parent_mindnote
切片,即动态数组,其长度并不固定,我们可以向切片追加元素,它会在容量不足时自动扩容。切片的长度是动态的,声明时只需要指定切片中的元素类型。
var nums []int
var T []interface
切片在编译期间生成的类型只会包含切片中的元素类型。切片内元素的类型都是在编译期间确定的,编译器确定了类型以后,会将类型存储在Extra
字段中帮助程序在运行时动态获取。
数据结构
运行时切片由如下的reflect.SliceHeader
结构体表示:
type SliceHeader struct{
Data uintptr // 指向数组的指针
Len int // 当前切片的长度
Cap int // 当前切片的容量,即Data数组的大小
}
Data
是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以可以将切片理解成一片连续的内存空间加上长度与容量的标识。
切片引入了一个抽象层,提供了对数组中部分连续片段的引用, 而作为数组的引用,可以在运行期间修改它的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,但是在上层看切片时没有变化的。
初始化
Go语言提供三种初始化切片的方式:
- 通过下标的方式获得数组或者切片的一部分
- 使用字面量初始化新的切片
- 使用关键字
make
创建切片
arr[0:3] or slice[0:3]
slice := []int{1,2,3}
slice := make([]int,10)
使用下标
使用下标初始化切片不会拷贝原数组或原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。
package main
import "log"
func main() {
s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
log.Printf("s: %p", &s)
log.Printf("s: %+v", s)
s1 := s[6:9]
log.Printf("s1: %p", &s1)
log.Printf("s1: %+v", s1)
s1[0] = 99
log.Printf("s: %+v", s)
log.Printf("s1: %+v", s1)
s1 = append(s1, 12)
log.Printf("s: %+v", s)
log.Printf("s1: %+v", s1)
}
输出:
➜ go run main.go
2021/07/05 17:15:05 s: 0xc0000a6040
2021/07/05 17:15:05 s: [1 2 3 4 5 6 7 8 9]
2021/07/05 17:15:05 s1: 0xc0000a6080
2021/07/05 17:15:05 s1: [7 8 9]
2021/07/05 17:15:05 s: [1 2 3 4 5 6 99 8 9]
2021/07/05 17:15:05 s1: [99 8 9]
2021/07/05 17:15:05 s: [1 2 3 4 5 6 99 8 9]
2021/07/05 17:15:05 s1: [99 8 9 12]
字面量
当使用字面量[]int{1,2,3}
创建新的切片时,cmd/compile/internal/gc.slicelit 函数会在编译期间将它展开如下所示的代码片段:
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
- 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组
- 将这些字面量元素存储到初始化的数组中
- 创建一个同样指向
[3]int
类型的数组指针 - 将静态存储区的数组
vstat
赋值给vauto
指针所在的地址 - 通过
[:]
操作获取一个底层使用vauto
的切片
关键字
如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是make
关键字创建切片时,很多工作都需要运行时的参与;调用方必须向 make 函数传入切片的大小以及可选的容量,类型检查期间的cmd/compile/internal/gc.typecheck1
函数会校验入参:
func typecheck1(n *Node, top int) (res *Node) {
switch n.Op {
...
case OMAKE:
args := n.List.Slice()
i := 1
switch t.Etype {
case TSLICE:
if i >= len(args) {
yyerror("missing len argument to make(%v)", t)
return n
}
l = args[i]
i++
var r *Node
if i < len(args) {
r = args[i]
}
...
if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {
yyerror("len larger than cap in make(%v)", t)
return n
}
n.Left = l
n.Right = r
n.Op = OMAKESLICE
}
...
}
}
上述函数不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。除了校验参数之外,当前函数会将 OMAKE 节点转换成 OMAKESLICE,中间代码生成的函数会根据下面两个条件转换OMAKESLICE
类型的节点:
- 切片的大小和容量是否足够小
- 切片是否发生了逃逸,最终在堆上初始化
当切片发生逃逸或者非常大时,运行时需要runtime.makeslice
在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4)
会被直接转换成如下所示的代码:
var arr [4]int
n:= arr[:3]
创建切片的过程中如果发生了以下错误会直接出发运行时错误并崩溃:
- 内存空间的大小发生了溢出
- 申请的内存大于最大可分配的内存
- 传入的长度小于0或者长度大于容量
追加与扩容
使用append
进行追加操作时,会根据返回值是否会覆盖原变量,选择进入两种流程,如果append
返回的新切片不需要赋值回原有的变量,就会进入如下的处理流程:
// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
ptr, len, cap = growslice(slice, newlen)
newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
先解构切片结构体获取它的数组指针、大小和容量,如果在追加元素后切片的大小大于容量,那么就会调用runtime.growslice
对切片进行扩容并将新的元素依次加入切片。
如果使用 slice = append(slice, 1, 2, 3)
语句,那么 append
后的切片会覆盖原切片,这时 cmd/compile/internal/gc.state.append
方法会使用另一种方式展开关键字:
// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
newptr, len, newcap = growslice(slice, newlen)
vardef(a)
*a.cap = newcap
*a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
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 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍就会使用期望容量;
- 如果当前切片的长度小于1024就会将容量翻倍;
- 如果当前切片的长度大于1024就会每次增加25%的容量,直到新容量大于期望容量;
切片拷贝
当使用copy(a,b)
对切片进行拷贝时,会分为两种情况进行处理拷贝操作,如果当前copy
不是在运行时调用的,copy(a,b)
会被直接转换成下面的代码:
n:=len(a)
if n> len(b){
n= len(a)
}
if a.ptr!=b.ptr{
memmove(a.ptr,b.ptr,n*sizeof(elem(a)))
}
memmove
负责拷贝内存。
如果拷贝发生在运行时,例如当使用go copy(a,b)
,拷贝实现方式如下:
func slicecopy(to, fm slice, width uintptr) int {
if fm.len == 0 || to.len == 0 {
return 0
}
n := fm.len
if to.len < n {
n = to.len
}
if width == 0 {
return n
}
...
size := uintptr(n) * width
if size == 1 {
*(*byte)(to.array) = *(*byte)(fm.array)
} else {
memmove(to.array, fm.array, size)
}
return n
}
整块拷贝内存会占用非常多的资源,在大切片上执行拷贝操作时要注意对性能的影响。
参考:https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/?from=from_parent_mindnote