在go语言实际开发过程,我们使用更多的是切片而不是数组,数组的固定长度注定了只能在一些特殊场景下才具有优势。
切片是长度可变的,所以切片的类型只有其存储的元素类型这一个维度。并且切片可以在编译期就创建出来,
// NewSlice returns the slice Type with element type elem.
func NewSlice(elem *Type) *Type {
if t := elem.cache.slice; t != nil {
if t.Elem() != elem {
base.Fatalf("elem mismatch")
}
if elem.HasTParam() != t.HasTParam() || elem.HasShape() != t.HasShape() {
base.Fatalf("Incorrect HasTParam/HasShape flag for cached slice type")
}
return t
}
t := newType(TSLICE)
t.extra = Slice{Elem: elem}
elem.cache.slice = t
if elem.HasTParam() {
t.SetHasTParam(true)
}
if elem.HasShape() {
t.SetHasShape(true)
}
return t
}
可以看到在创建出切片之后,将类型信息保存到了extra中,这样可以在运行时随时获取类型信息。
切片的长度是可变的,但是切片自身的大小是固定的。能够在栈上表示的数据结构都是大小固定的,可以将切片看作一个胖指针,其底层有一个指向数组的指针,切片的数据结构如下:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
Data指向一块连续的内存,切片的实现实际上就是依赖这个底层数组的,并同时记录了当前存储的元素数量和底层数组的容量信息。切片在使用上屏蔽了这个底层数组,我们可以在运行过程中修改数组的内容以及大小,在容量不足的时候进行自动扩容,上层使用无需关注底层数组的变化。
数组在编译期就可以获得大小以及元素信息,所以编译器对获取数组大小以及访问读写数组进行了简化,直接读写特定的内存位置。不过切片因为是动态的,在运行时确定内容,所以切片的操作需要依赖Go的运行时。
初始化
获取切片有三种方式
- 通过下标获得数组或者切片的一部分
- 使用字面量初始化切片
- 使用关键字make创建切片
使用下标
arr := [3]int{1,2,3}
slice := arr[0: 3]
通过下标初始化,就是创建一个切片结构体,然后让Data指向原数组,设置Len为切取的长度,Cap为数组的长度。这个过程中并没有拷贝底层数组,所以对切片的修改也会影响到原数组。
使用字面量
slice := []int{1, 2, 3}
切片是依赖于一个底层数组的,使用字面量进行初始化的过程相当于使用字面量创建数组然后使用下标对数组进行切片。
- 在静态存储区创建一个数组
- 将字面量存储到初始化的数组中
- 使用下标获得数组的切片
使用make关键字
使用make关键字创建切片,很多工作都需要运行时的参与。
slice := make([]int, len, cap)
需要传递一个必选的长度len以及可选的容量cap。
如果在编译期间能够确定切片的大小,并且切片足够小也不会发生逃逸的时候,就可以在编译期间完成这个切片。例如
slice := make([]int, 3,4)
就可以像使用字面量一样先初始化一个数组,然后用下标获得切片,不过不需要像字面量赋初值。底层数组同样是在静态存储区
var arr [4]int
n := arr[:3]
反之,当切片容量很大或者发生了逃逸的时候,那么切片的底层数组就需要运行时在堆上创建了。于是就需要申请一块连续的内存。内存大小 = 元素大小 * 切片容量。
切片的创建就是使用或者初始化一个数组,然后利用这个数组创建出切片结构体。无非使用make关键字的时候,底层数组可能会在运行时创建在堆上。
访问元素
对切片的访问,一是对切片内元素的访问,一是对切片长度和容量的访问。
对于获取切片长度和容量的操作,我们如果能够在编译期间就能够获得,那么当前就无需运行时的参与,对于这种情况,就可以直接将获取切片长度和容量的操作直接替换成长度和容量。
对切片元素的访问也会在中间代码生成阶段转换成对地址的直接访问。
追加与扩容
使用append向切片末尾添加元素。
slice = append(slice, 1, 2)
虽然使用的时候没有问题,但是我们要知道append返回的是一个新的切片。对于如下的情况
b := append(a, 1, 2)
虽然a,b指向的底层数组是一样的,但是a切片数据结构的len和cap并没有改变,也就是后续遍历a切片的时候,并不会看到新增的1,2元素。
我们在实际使用当中,也总是使用覆盖原切片的写法,但是不需要担心切片拷贝发生的开销,因为编译器对于这种写法进行了优化,可以直接利用原切片的内存空间,修改其len和cap。
当切片容量充足,也就是len < cap的时候,可以直接将元素放到末尾,当容量不足的时候就会进行扩容。
扩容可以简单分为两步:
- 确定新切片的大小
- 将旧切片的数据拷贝到新切片。
新切片的容量会根据运行时的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍,那么就会使用期望容量。
- 如果当前长度小于1024,就会将容量翻倍。
- 当前切片长度大于1024,每次增加25%的容量直到大于期望容量。
经过上面的步骤确定了大致容量后,如果切片元素的字节大小为1,8,2的倍数的时候,就会进行内存对齐,按照下面的数组向上取较大的数。
var class_to_size = [_NumSizeClasses]uint16{
0,
8,
16,
32,
48,
64,
80,
...,
}
经过策略和内存对齐确定新切片的容量之后,直接进行内存拷贝 runtime.memmove
即可。