golang数据结构初探之动态数组slice

动态数组slice

slice 又称动态数组,依托于数组实现,可以方便的进行扩容和传递,实际使用时比数组更灵活。但正是因为灵活,实际使用时更容易出错,避免出错的最好方法便是了解其实现原理。

特性速览

初始化

声明和初始化切片的方式主要有以下几种方式:

  • 变量声明
  • 字面量
  • 使用内置函数make()
  • 从切片和数组中切取
变量声明

这种声明方式的切片变量与声明其他类型变量一样,变量值都为零值,对于切片来讲零值为nil

var s []int
字面量

也可以使用字面量初始化切片,需要了解的是空切片是指长度为空,其值并不是nil。

声明长度为0的切片时,推荐使用变量声明的方式获得一个nil切片,而不是空切片,因为nil切片不需要内存分配。

s1:=[]int{}	//空切片
s2:=[]int{1,2,3}	//长度为3的切片
内置函数make()

内置函数make() 可以创建切片,切片元素均初始化为相应类型的零值。

推荐指定长度的同时预估空间,可有效的减少切片扩容时内存分配以及拷贝次数。

s1:=make([]int,12)	//指定长度
s2:=make([]int,10,100)	//指定长度和空间
切取

切片可以基于数组和切片创建,需要了解的是切片与原数组或者原切片共享底层空间,修改切片会影响原数组或者原切片。

切片表达式[low:high] 表示的是左闭右开[low,high)区间,切取的长度为 high - low。

array := [5]int{1,2,3,4,5}
s1 := array[0:2]	//从数组中切取
s2 := s1[0:1]			//从切片切取
fmt.Println(s1)		// [1,2]
fmt.Println(s2)		// [1]

另外,适用于任意类型的内置函数new() 也可以创建切片:

s := *new([]int)	//此时创建的切片值为nil
切片操作

内置函数append()用于向切片中追加元素:

s := make([]int,0)	//初始化切片
s = append(s,1)			//添加1个元素
s = append(s,2,3,4)			//添加多个元素
s = append(s,[]int{5,6}...)			//添加1个切片
fmt.Println(s)			//[1,2,3,4,5,6]

当切片空间不足时,append()会先创建新的大容量切片,添加元素后返回新切片。

内置函数len()和cap()分别用于查询切片的长度和容量,由于切片的本质为结构体,结构体中直接存储了切片的长度和容量,所以这两个函数的时间复杂度都为O(1)。

实现原理

slice依托数组实现,底层数组对用户屏蔽,在底层数组容量不足的时候可以 实现自动重分配并生产新的slice。

数据结构

源码包中src/runtime/slice.go:slice 定义了slice的数据结构:

type slice struct {
	array unsafe.Pointer	//底层数组
	len   int		//长度
	cap   int		//容量
}

从数据结构上看slice很清晰,array指针指向底层数组,len表示数组长度,cap表示底层数组的容量。

切片操作
使用make()创建slice

使用make()创建slice时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即为容量。

例如,slice := make([]int , 5 , 10)语句所创建的slice的结构如下图所示:

image-20210817165334892

该slice的长度为5,即可以使用下标slice[0]~slice[4]来操作里面的元素,capacity为10,表示后续想slice添加新元素的时候可以不必重新分配内存,直接使用预留的内存即可,直到预留的内存不足再进行扩容。

使用数组创建slice

使用数组创建slice时,slice将于原数组共用一部分内存

例如,slice := array[5,7]语句所创建的slice的结构如下图所示:

image-20210817171146578

切片从数组array[5]开始,到数组array[7]结束(不包含7),即切片的长度为2,数组后面的内存都作为切片的预留内存,即capacity为5。

数组和数组的切片共享底层存储空间,这是使用过程中需要额外注意的地方。如果切片是从数组中创建而来,那么对切片的操作会影响原始数组,如果有多个切片从同一个数组中创建,那么对一个切片的操作会影响其他切片。

slice扩容

使用append向slice追加元素时,如果slice空间不足,则会触发slice扩容,扩容实际上是重新分配一块更大的内存,将原slice的数据拷贝进新的slice中,然后返回新slice,扩容后再将数据追加进去。

例如,当向一个capacity为5,且length也为5的slice再次追加1个元素时,就会发生扩容,如下图所示:

image-20210817172105601

扩容操作只关心容量,会把原slice的数据拷贝到新slice中,追加数据由append在扩容结束后进行。在上图中可以看出,扩容完成后,len还是5,但是cap由5变成了10,原slice的数据也拷贝到了新slice中了。

扩容容量的选择遵循一下基本规则:

  • 如果原slice的容量小于1024,则新slice的容量将扩容到2倍
  • 如果原slice的容量大于等于1024,则新slice的容量将扩容1.25倍
image-20210817172421806

在该规则的基础上,还会考虑元素类型与内存分配规则,对实际扩展值做一些微调。从这个基本规则中可以看出Go对slice的性能和空间使用率的思考。

  • 当切片较小时,采用较大的扩容倍速,可以避免频繁扩容,从而减少内存分配次数和数据拷贝的代价
  • 当切片较大时,采用较小的扩容倍速,主要是为了避免浪费空间

使用append()向slice添加一个元素的实现步骤如下:

  • 加入slice的容量够用,直接追加进去,slice.len++,返回slice
  • 原slice的容量不够,将slice先扩容,扩容后得到新的slice
  • 将新元素追加进新slice中,slice.len++,返回新slice
slice拷贝

使用copy()内置函数拷贝两个切片时,会将原切片的数据逐个拷贝到目标切片指向的数组中,拷贝数量取决于两个切片长度的最小值。假如目标切片容量不够,不会发生扩容的情况。

小结
  • 每个切片都指向一个底层数组
  • 每个切片都保存了当前切片的长度和容量
  • 使用len()计算切片长度的时间复杂度为O(1)
  • 使用cat()计算切片容量的时间复杂度为O(1)
  • 通过函数传递切片时,不会拷贝整个切片,因为切片本身只是一个结构体而已
  • 使用append()向切片追加元素时有可能会发生扩容现象,扩容后会生成新的切片

此处有几个值得注意的编程建议:

  • 创建切片时,如果可以估计到使用容量,尽可能的指定,可以避免在追加元素的过程中出现扩容操作,有利于提升性能
  • Copy()函数切片拷贝时需要判断实际拷贝的元素个数,有可能目标切片长度不足,产生丢失数据的情况
  • 谨慎使用多个切片操作同一个数组,以防出现读写冲突

切片表达式

slice表达式可以基于一个字符串生成子字符串,也可以从一个数组或者切片中生成切片。Go语言提供了两种表达式:

  • 简单表达式: array[low : high]
  • 扩展表达式: array[low : high : max]
简单表达式

简单表达式日常使用的频率最高,其格式为 array[low : high]。如果a为数组或者切片,则该表达式将切取 array位于[low : high)区间的元素并生成一个新的切片。如果array为字符串,稍微有一点特殊的是该表达式会生成一个字符串,而不是切片。

简单表达式生成的切片的长度为 high - low。例如我们使用简单表达式切取数组array并生成新的切片b:

array :=[5]int{1,2,3,4,5}
b := a[1:4]

此时得到的切片b的长度为3,元素分别为:

b[0] ==2 
b[1] ==3 
b[2] ==4 
底层数组共享

根据之前介绍的切片的数据结构,我们知道每个切片包含三个元素:

type slice struct {
	array unsafe.Pointer	//底层数组
	len   int		//长度
	cap   int		//容量
}

这里需要着重强调的是,使用简单表达式生成的切片将于原数组或者切片共享底层数组。 新切片的生成逻辑可以使用以下伪代码表示:

b.array = &array[low]
b.len = high - low
b.cap = len(a) = low
边界问题

如果简单表达式切取的对象为字符串或者数组,那么在表达式 a[low : high] 中 low 和 high的取值需要满足以下关系

0 <= low <= high < len(a)

如果简单表达式切片的对象为切片,那么在表达式 a[low : high] 中 low 和 high 的最大取值可以为a的容量,而不是a的长度:

a <= low <= high <= cap(a)
切取string

表达式a[low : high]作用于数组、切片时会产生新的切片,作用于字符串时则会产生新的字符串,而不是切片。

这是由string和slice的类型差异决定的,slice可以支持随机读写,而string不可以。

默认值

为了使用方便,简单表达式 a[low : high] 中low 和high 都是可以省略的。

low默认值为0,而high的默认值为表达式作用对象的长度

a[:high] 等同于 a[0:high]
a[0:] 等同于 a[0:len(a)]
a[:] 等同于 a[0:len(a)]
扩展表达式

简单表达式生成的切片与原数组或者切片共享底层数组避免了拷贝元素,节约内存空间的同时,也会带来读写冲突的风险。

新切片b( b := a[low : high] ) 不仅可以读写a[low] 到 a[high-1]之间的元素,而且在使用append(b,x)函数增加新元素x时,还可能会覆盖a[high]以及后面的元素。例如:

a := [5]int{1,2,3,4,5}
b := a[1:4]
b = append(b,0)	//此时a[4]将由5变为0

使用新切片覆盖a[high]以及后面的元素,有可能是非预期的,从而产生灾难性的后果。

Go团队很早就关注到了这个风险,并且在Go1.2中就提供了一种可以限制新切片容量的表达式,即扩展表达式:

a[low : high : max]

扩展表达式中的max用于限制新生成切片的容量,新切片的容量为 max-low,表达式中low、high 和max的关系需要满足如下:

o <= low <= high <= max <= cap(a)

对于一个长度为10的数组,使用扩展表达式切取其中两个元素生成的新切片的拓扑结构如下图所示:

image-20210817182149227

如果使用简单表达式,那么上图中切片的容量为5,而使用扩展表达式时切片的容量则被限制为2.

扩展表达式常见于偏底层的代码中,比如Go源码。扩展表达式生成的切片被限制存储容量,当使用append()函数向此切片追加元素时,如果容量不足则会产生一个全新的slice,而不会覆盖原数组或者切片。

扩展表达式中的a[low : high : max]只有low是可以省略的,其默认值为0。这一点与简单表达式不同。

如果缺失了 high 或者max, 则会产生编译错误。

小结
  • slice表达式分为简单表达式 a[low , high] 和扩展表达式 a[low : high : max]
  • 简单表达式作用于数组、切片时会产生新的切片,作用于字符串时会产生新的字符串
  • 扩展表达式只能作用于数组、切片,不用作用于字符串
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

董洪臣

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值