golang数组切片原理解析

数组介绍

数组是一种非常有用的数据结构,因为其占用的内存是连续分配的。由于内存连续,CPU能把正在使用的数据缓存更久的时间。而且内存连续很容易计算索引,可以快速迭代数组里的所有元素。数组的类型信息可以提供每次访问一个元素时需要在内存中移动的距离。既然数组的每个元素类型相同,又是连续分配,就可以以固定速度索引数组中的任意数据,提高工作效率。

数组声明和初始化

golang中声明数组需要告诉数组长度,以及存放数据类型,一旦初始化成功,那么存储的数据类型和数组长度就都不能改变了,如果需要存储更多的元素,就需要先创建一个更长的数组,再把原来数组里的值复制到新数组里。

一维数组声明

func main() {
	var array1 [5]int		// 声明一个包含 5 个元素的整型数组,var array1 [3]*string
	array2 := [5]int{10, 20, 30, 40, 50}		// 声明一个有长度为5的一维整形数组
	array3 := [...]int{10, 20, 30, 40, 50}		// 声明一个未知长度的一维整形数组
	array4 := [...]int{2:3,4:6,10,20,30,40,50}	// 声明一个索引2为3,索引4为6的长度为10的整形数组
	array5 := [5]*int{0: new(int), 1: new(int)} // 声明整型指针初始化索引为0和1的整形数组
	array6 := [3]*string{new(string), new(string), new(string)}   // 申明包含3个元素的指向字符串的指针数组

	*array5[0],*array5[1] = 10,20	// 只初始化了0和1的索引,其它为nil,没有创建内存空间
	*array6[0],*array6[1],*array6[2] = "Red","Blue","Green"

	fmt.Println(array1)		// [0 0 0 0 0]
	fmt.Println(array2)		// [10 20 30 40 50]
	fmt.Println(array3)		// [10 20 30 40 50]
	fmt.Println(array4)		// [0 0 3 0 6 10 20 30 40 50]
	fmt.Println(array5)		// [0xc00000a0a0 0xc00000a0a8 <nil> <nil> <nil>]
	fmt.Println(array6)		// [0xc00003a1f0 0xc00003a200 0xc00003a210]
}

二维数组声明

数组本身只有一个维度,不过可以组合多个数组创建多维数组。多维数组很容易管理具有父子关系的数据或者与坐标系相关联的数据。

下面是常见的二维数组创建方式:

func doublearray(){

	var array1 [4][2]int		// 声明一个二维整型数组,两个维度分别存储 4 个元素和 2 个元素
	array2 := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}	// 使用数组字面量来声明并初始化一个二维整型数组
	array3 := [4][2]int{1: {20, 21}, 3: {40, 41}}	// 声明并初始化外层数组中索引为 1 个和 3 的元素
	array4 := [4][2]int{1: {0: 20}, 3: {1: 41}}		// 声明并初始化外层数组和内层数组的单个元素

	fmt.Println(array1)		// [[0 0] [0 0] [0 0] [0 0]]
	fmt.Println(array2)		// [[10 11] [20 21] [30 31] [40 41]]
	fmt.Println(array3)		// [[0 0] [20 21] [0 0] [40 41]]
	fmt.Println(array4)		// [[0 0] [20 0] [0 0] [0 41]]
	
}

数组传递与赋值

针对上面做的一维与二维数组初始化的案例,我们可以对此进行赋值,并引用一些错误案例,达到加深强化的目的:

func error1(){
	
	array5 := [5]*int{0: new(int), 1: new(int)}
	*array5[1],*array5[2] = 10,20	// 只初始化了0和1的索引,其它为nil,没有创建内存空间
	fmt.Println(array5)

}

/*
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 pc=0x10a59a6]
*/

这个的出错原因是因为Go 指针声明后没有对指针先初始化而直接赋值导致的错误,也就是说nil并不是内存地址。

func error2(){

	// 声明第一个包含 4 个元素的字符串数组
	var array1 [4]string
	// 声明第二个包含 5 个元素的字符串数组
	array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}
	// 将 array2 复制给 array1
	array1 = array2

	fmt.Println(array1,array2)

/*
   # command-line-arguments
   .\main.go:47:9: cannot use array2 (type [5]string) as type [4]string in assignment
*/

}

这个错误是由于array1的长度是4,array2的长度是5,复制数组指针,只会复制指针的值,而不会复制指针所指向的值,并且长度4的数组并不能接收5的内存地址,导致了报错。这种情况在动态语言里是可以存在的。

正确的索引赋值,如下:

func indexAssign(){
	// 声明两个不同的二维整型数组
	var array1 [2][2]int
	var array2 [2][2]int
	// 为每个元素赋值
	array2[0][0] = 10
	array2[0][1] = 20
	array2[1][0] = 30
	array2[1][1] = 40
	// 将 array2 的值复制给 array1
	array1 = array2

	// 因为每个数组都是一个值,所以可以独立复制某个维度
	fmt.Println("array1:",array1,"array2:",array2)		// array1: [[10 20] [30 40]] array2: [[10 20] [30 40]]

	// 将 array1 的索引为 1 的维度复制到一个同类型的新数组里
	var array3 [2]int = array1[1]
	// 将外层数组的索引为 1、内层数组的索引为 0 的整型值复制到新的整型变量里
	var value int = array1[1][0]

	fmt.Println("array3:",array3,"value:",value)	// array3: [30 40] value: 30
}

切片的内部实现

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

创建和初始化

Go 语言中有几种方法可以创建和初始化切片。是否能提前知道切片需要的容量通常会决定要如何创建切片。

func sliceinit(){

	slice1 := make([]string, 5)		// 创建一个长度和容量都是5的字符串切片
	slice2 := make([]int, 3, 5)		// 创建一个长度为3,容量为5的整形切片
	slice3 := []string{"Red", "Blue", "Green", "Yellow", "Pink"}	// 创建一个长度和容量为5的字符串切片
	slice4 := []string{49: ""}		// 创建一个使用空字符串初始化第50个元素
	slice5 := make([]int, 0)		// 使用 make 创建空的整型切片

	fmt.Println(slice1)		// [    ]
	fmt.Println(slice2)		// [0 0 0]
	fmt.Println(slice3)		// [Red Blue Green Yellow Pink]
	fmt.Println(slice4)		// [                                                 ]
	fmt.Println(slice5)		// []

}

上面代码显示了有两种创建切片的方法,我们分别对这两种进行说明:

第一种创建切片的方法是使用内置的 make 函数。当使用 make 时,需要传入一个参数,指定切片的长度,如上面的slice2的切片,可以访问 3 个元素,而底层数组拥有 5 个元素。剩余的 2 个元素可以在后期操作中合并到切片,可以通过切片访问这些元素。如果基于这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素。

第二种常用的创建切片的方法是使用切片字面量,这种方法和创建数组类似,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定。如slice3、slice4和slice5,但如果在[]运算符里指定了一个值,那么创建的就是数组而不是切片。只有不指定值的时候,才会创建切片。

另外,根据一些具体的业务场景,还可能需要生成空切片:

// 创建 nil 整型切片
var slice []int

// 使用 make 创建空的整型切片
slice := make([]int, 0)
// 使用切片字面量创建空的整型切片
slice := []int{}

空切片在底层数组包含 0 个元素,也没有分配任何存储空间。想表示空集合时空切片很有用,例如,数据库查询返回 0 个查询结果的时候。

切片赋值

func sliceUse(){

	// 创建一个整型切片
	// 其长度和容量都是 5 个元素
	slice := []int{10, 20, 30, 40, 50}
	// 创建一个新切片
	// 其长度是 2 个元素,容量是 4 个元素
	newSlice := slice[1:3]
	// 修改 newSlice 索引为 1 的元素
	// 同时也修改了原来的 slice 的索引为 2 的元素
	newSlice[1] = 35

	fmt.Println("newSlice:",newSlice,"slice:",slice)		// newSlice: [20 35] slice: [10 20 35 40 50]
}

切片只能访问到其长度内的元素。试图访问超出其长度的元素将会导致语言运行时异常,如下图所示。与切片的容量相关联的元素只能用于增长切片。在使用这部分元素前,必须将其合并到切片的长度里。
在这里插入图片描述

func sliceUse2()  {
	// 创建一个整型切片
	// 其长度和容量都是 5 个元素
	slice := []int{10, 20, 30, 40, 50}
	// 创建一个新切片
	// 其长度为 2 个元素,容量为 4 个元素
	newSlice := slice[1:3]
	// 使用原有的容量来分配一个新元素
	// 将新元素赋值为 60
	newSlice = append(newSlice, 60)

	fmt.Println("newSlice:",newSlice,"slice:",slice)		// newSlice: [20 30 60] slice: [10 20 30 60 50]
}

因为 newSlice 在底层数组里还有额外的容量可用,append 操作将可用的元素合并到切片的长度,并对其进行赋值。由于和原始的 slice 共享同一个底层数组,slice 中索引为 3 的元素的值也被改动了。

在这里插入图片描述

切片增长策略

我们可以来看一个程序:

func main() {
	//append()添加元素和切片扩容
	var numSlice []int
	for i := 0; i < 10; i++ {
		numSlice = append(numSlice, i)
		fmt.Printf("%v  len:%d  cap:%d  ptr:%p\n", numSlice, len(numSlice), cap(numSlice), numSlice)
	}
}

/*
   [0]  len:1  cap:1  ptr:0xc0000a2058
   [0 1]  len:2  cap:2  ptr:0xc0000a20a0
   [0 1 2]  len:3  cap:4  ptr:0xc0000a0160
   [0 1 2 3]  len:4  cap:4  ptr:0xc0000a0160
   [0 1 2 3 4]  len:5  cap:8  ptr:0xc0000b8140
   [0 1 2 3 4 5]  len:6  cap:8  ptr:0xc0000b8140
   [0 1 2 3 4 5 6]  len:7  cap:8  ptr:0xc0000b8140
   [0 1 2 3 4 5 6 7]  len:8  cap:8  ptr:0xc0000b8140
   [0 1 2 3 4 5 6 7 8]  len:9  cap:16  ptr:0xc0000d2000
   [0 1 2 3 4 5 6 7 8 9]  len:10  cap:16  ptr:0xc0000d2000
*/

我们会发现,每次当这个 append 操作完成,len长度要大于cap的时候,cap会进行动态扩容,numSlice拥有一个全新的底层数组,这个数组的容量是原来2倍。

我们可以看切片的源码,在GOROOT/src/runtime/slice.go下:

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
	newcap = cap
} else {
	if old.len < 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
		}
	}
}

从上面的源码可看出:

  • 首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)。
  • 否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap),
  • 否则判断,如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的1/4,即(newcap=old.cap,for {newcap += newcap/4})直到最终容量(newcap)大于等于新申请的容量(cap),即(newcap >= cap)
  • 如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)。

所以我们最后可以得到这个策略为: 函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量。


参考文献:

[1]. Go语言实战 ,威廉•肯尼迪

[2]. Go语言基础之切片

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

submarineas

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

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

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

打赏作者

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

抵扣说明:

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

余额充值