切片

122 阅读8分钟

本文已参与「新人创作礼活动,一起开启掘金创作之路。

背景

容器是用来存储一组相关事物,Go语言里slice是非常有用的内置的数据结构,我们在日常的代码编写不可能绕过它们,而想要用好slice,必须要理解它的特性,今天我先说说我对切片(slice)的理解

WHAT

什么是切片(slice)? 切片([slice]是 Golang 中一种比较特殊的数据结构,这种数据结构更便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append() 来实现的,这个函数可以快速且高效地增长切片,也可以通过对切片再次切割,缩小一个切片的大小。因为切片的底层也是在连续的内存块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。

WHY

Go语言中的切片(sice)结构的本质是对数组的封装,它描述一个数组的片段。 无论是数组还是切片,都可以通过下标来访问单个元素。数组是定长的,长度定义好之后,不能再更改。在Go语言中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如Bjm和FJin就是不同的类型。而切片则非常灵话,它可以动态地扩客,且切片的类型和长度无关。例如:


package main

 

import "fmt"

 

func main() {

no1 := [1]int{1}

no2 := [2]int{2,3}

if no1 == no2 {

fmt.Println("类型一致")

}

}

如果你的编辑器有错误提示,相信这几行代码都不用写完就有错误提示了,更不会通过编译器编译,因为no1和no2的长度不同,根本不是同一类型,因此不能比较

数组是一片连续的内存,而切片实际上是一个结构体,包含三个字段,长度,容量及一个底层数组,源码是这样的


type slice struct {

array unsafe.Pointer

len   int

cap   int

}

HOW

1. 切片的成员操作


package main

 

import "fmt"

 

func main() {

slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

//slice的容量获取

fmt.Println("容量的获取", cap(slice)) //容量的获取 10

fmt.Println("长度的获取", len(slice)) //长度的获取 10

//slice的截取 截取的是半包含半不包含

d1 := slice[2:5]

fmt.Println("截取1", d1) //截取1 [2 3 4]

d2 := d1[2:6:7]

fmt.Println("截取2", d2) //截取2 [4 5 6 7]    s2从s1的索引2 (闭区间)到索引6 (开区间,元素真正取到索引5), 容量到索引7 (开区间, 真正到索引 6),为5。

//向slice中添加元素 使用append函数

d2 = append(d2, 100)

d2 = append(d2, 200)

//重新赋值

d1[2] = 20

fmt.Println("新增1", d2)   //新增1 [4 5 6 7 100]

fmt.Println("新增2", d2)   //新增2 [4 5 6 7 100 200]

fmt.Println("赋值", slice) //赋值 [0 1 2 3 20 5 6 7 100 9]

}

从最后一次的打印结果来看肯定会有人为为什么没有200这个数据,因为slice,d1,d2三者的元素都指向同一个一个底层数组,接着向d2中添加一个元素100,此时的d2的容量刚好够用,可以直接追加,不过要修改原数组中对应位置的值,此时,d2的容量不够用,需要进行扩容。于是,d2“另起炉灶”, 将原来的元素复制到新的位置扩大自己的容量。并且为了应对未来可能的append带来的再一次扩容, d2会在此次扩容的时候多留一些buffer,将新的容量扩大到原来的2倍。注意,d2此时的底层数组元素和slice. d1已经没有关系了。最后,修改d1索引为2位的元素:


d1[2] = 20

这次操作只会影响原始数组相应位置的元素,影响不到d2了,它已经“远走高飞”了,

最后执行打印d1时,只会打印d1长度内的元素,所以只打印出了3个元素,尽管底层数组不止3个

2. 切片的扩容

一般都是在向切片追加了无素之后,由于容量不足,才会引起扩容。向切片追加元素调用的是append 函数。append 函数的原型如下:


// The append built-in function appends elements to the end of a slice. If

// it has sufficient capacity, the destination is resliced to accommodate the

// new elements. If it does not, a new underlying array will be allocated.

// Append returns the updated slice. It is therefore necessary to store the

// result of append, often in the variable holding the slice itself:

// slice = append(slice, elem1, elem2)

// slice = append(slice, anotherSlice...)

// As a special case, it is legal to append a string to a byte slice, like this:

// slice = append([]byte("hello "), "world"...)

func append(slice []Type, elems ...Type) []Type

Append函数的参数长度可变,因此可以追加多个值到slice 中,还可以在切片后面追加“...

 

符号直接传入slice, 即追加切片里所有的元素。

 

Append函数的返回值是一个新的切片,Go语言的编译器不允许调用了append函数后不使用返回值。所以下面的用法是错的,不能通过编译:


append(slice, elemI, elem2)

append(slice, anotherSlice..)

使用append函数可以向slice 追加元素,实际上是往底层数组相应的位置放置要追加的元素。 但是底层数组的长度是固定的,如果索引len-1 所指向的元素已经是底层数组的最后一个元素, 那就不能再继续放置新的元素了。

这时,slice 会整体迁移到新的位置,并且新底层数组的长度也会增加,使得可以继续放 置新增的元素。同时,为了应对未来可能再次发生的append 操作,新的底层数组的长度,也 就是新slice 的容量需要预留一定的 buffer. 否则, 每次添加元素的时候,都会发生迁移,成本太高。

新slice预留的buffer 大小是有一定规律的。 注意,下面这些说法是不准确的:

说法1:当原slice 容量小于1024 的时候,新slice 容量变成原来的2倍:

说法2:当原slice容量超过1024, 新slice 容量变成原来的1.25倍。

为了说明切片的扩容规律,首先通过下面的程序来验证一下扩容的行为:


package main

 

import "fmt"

 

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

}

}

 

}

首先创建一个空的slice: s, 接着,在一个循环里不断地向它append 新的元素。同时,记录容量的变化,并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后新的容量,并且记下此时向s添加的元素。这样就可以观察,新老S的容量变化情况,从而找出规律。代码的运行结果如下:


[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

在老s容量小于1024 的时候,新s的容量的确是老s的2倍,目前还算正确。但当老s容量大于等于1024的时候,情况就有变化了。例如,向s中添加元素1280 的容量为1280, 新s的容量则变成了1696, 两者并不是1.25 倍的关系( 1696/1280=1.32 加完1696后,新的容量2304当然也不是1696 的1.25倍(2304/1696=1.358)。

 

要想弄清真实的扩容规律是怎样的,需要深入Go源码,研究一下扩容函数的具体逻辑切片的本质上是一个运行时特性,因此Go语言的编译器在针对扩容行为发生时会将其跳转到扩容函数

 

源码解析

Slice.go文件中Growslice()函数


newcap := old.cap

doublecap := newcap + newcap

if cap > doublecap {

newcap = cap

} else {

if old.cap < 1024 {

newcap = doublecap

} else {

// Check 0 < newcap to detect overflow

// and prevent an infinite loop.

for 0 < newcap && newcap < cap {

newcap += newcap / 4

}

// Set newcap to the requested cap when

// the newcap calculation overflowed.

if newcap <= 0 {

newcap = cap

}

}

}

 

代码的后半部分还对neweap 进行了内存对齐,而这个和内存分配策略相关。进行内存对齐之后新s的容量要大于等于老s容量的2倍或者1.25 倍。

之后,向Go内存管理器申请内存,将老s中的数据复制过去,并且将append的元素添加到新的底层数组中。最后,向gorwslice函数调用者返回一个新的切片,这个切片的长度并没有变化,而容量却增大了。