数组和切片的比较

前言

在学习Go语言时会对数组和切片的存储方式和特性产生疑惑,在经过一定实验后得到一些结论,因此在这里进行记录。
*后续实验代码省略package mainimport "fmt"
**笔记为个人理解加上网络资源汇总,感谢.
***若有错误欢迎评论

可能会有的疑惑

  1. 数组和切片有何不同?
  2. 和C/C++不同的特性,数组作为函数参数时的改变不会影响原数组,而切片却会产生影响。
  3. 数组首元素地址和数组地址相同,但切片却不同。数组和切片的存储方式有何区别?
  4. 从数组创建的切片有时和数组同变化,而有时却不同变化。切片的两种创建方式区别。

正文

1.数组和切片

简单来说,数组就是在编译阶段已经能够确定大小的一个容器,或通俗来说给定长度的一个向量。如var a [10]int创建了一个长度为10的数组,a:=[...]int{0,1,2,3}也是一个长度为4的数组(数组在定义时使用[…]来让编译器自己选择大小)。
而切片可以认为是不填写长度的“数组”(连[…]也没有)var a []int,或由make函数创建的“数组”。
这两者最显著的不同之处就是一个确定长度而另一个不确定。Go语言对数据类型的检查非常严格,因此数组和切片不能互相作为对方函数的参数,长度不同的数组也不能兼容。如下:

func alpha(x []int) {				//函数参数为切片
	//function body 1
}

func beta(x [8]int) {				//函数参数为长度为8的数组
	//function body 2
}

func gamma(x [7]int) {				//函数参数为长度为7的数组
	//function body 3
}

func main() {
	var a = [7]int{0,1,2,3,4,5,6}	//创建了一个数组
	alpha(a)						//编译不能通过,数组不同于切片
	beta(a)							//编译不能通过,数组长度需要一致
	gamma(a[:])						//编译不能通过,需要使用长度为7的数组调用
}

那如何进行类型的转换呢?数组到切片使用定义即可,切片到数组可以一个一个从切片搬运(不知道是否又更高效的方法)。

2.值传递和引用传递

在C/C++中,数组作为函数参数时,函数内部对数组的改变实际上就是对原数组的改变,但Go语言不是如此,如下企图将首元素变为9999的函数foo失效了,而切片的实现goo成功:

func foo(x [3]int) {		//数组版
	x[0] = 9999			//将数组首元素变为9999
}

func goo(x []int) {		//切片版
	x[0] = 9999			//同样的操作
}

func main() {
	a := [3]int{0,1,2}	//数组[0,1,2]
	foo(a)				//调用数组版实现
	fmt.Println(a)		//输出为[0,1,2],事与愿违
	b := a[:]			//b为a的切片版
	goo(b)				//调用切片版实现
	fmt.Println(b)		//输出为[9999,1,2],成功!
}

相信大家会想到Go中数组作为参数也是和其他变量一样复制一份,导致函数内操作的数组已经和原数组不同了,即值传递的方式进行函数调用。
那切片为什么又不是这样了呢?若要究其原因,我们需要了解两者内部存储的方式,就需要我们卖关子到第三个问题上了。

3.存储的差异

首先开始我们的实验:

func main() {
	a := [3]int{0, 1, 2}		//数组
	b := a[:]					//切片
	str1 := `					//格式化输出字符串,好看的输出我们需要的地址
		 a	:  %p				//a的地址
		a[0]:  %p				//a[0]的地址
		a[1]:  %p				//a[1]的地址
		a[2]:  %p				//a[2]的地址
	`
	str2 := `					//对b同理
		 b	:  %p
		b[0]:  %p
		b[1]:  %p
		b[2]:  %p
	`
	fmt.Printf(str1, &a, &a[0], &a[1], &a[2])
	/*输出如下
		 a	:  0xc00000a1c8
		a[0]:  0xc00000a1c8
		a[1]:  0xc00000a1d0
		a[2]:  0xc00000a1d8
	*/
		
	fmt.Printf(str2, &b, &b[0], &b[1], &b[2])
	/*输出如下
		 b	:  0xc000004078
		b[0]:  0xc00000a1c8
		b[1]:  0xc00000a1d0
		b[2]:  0xc00000a1d8
	*/
}

从实验结果上来看,数组的结果在我们意料之内,数组的地址的确和首元素的地址相同,并线性地存储;另一方面,切片的各个元素也都和原数组对应元素地址相同。
但需要我们注意的是,切片b的地址却不是b[0]的地址,这就是切片和数组不同的一个重要表现。
事实上,切片不像数组那样直接指向存储的首元素,而是一个结构体,其中包含 首元素地址|当前长度|最大容量 三个部分,Go将切片的元素访问也设计为同数组的索引访问,而透明了中间的一层结构体。这就解释了为什么b的地址不等于首元素的地址(因为&b实际上时其结构体的地址),我们查看源码可以发现这一点:

	// 路径~\Go\src\runtime\slice.go
	type slice struct {
		array unsafe.Pointer	//首元素指针
		len   int				//长度
		cap   int				//容量
	}

再回到我们的第二点上,实际上切片和数组作为参数调用时都会进行复制,但Go进行的是浅层的复制(不会将指针指向的数组也进行复制),因此切片的首元素指针值被复制进了函数内,后续的变化仍然是原切片。
换句话说,即是从原切片指向对应元素变为了新复制的切片指向对应元素,指的人换了但指的位置不变。我们用实验来佐证这一观点:

func foo(x []int) {
	str1 := `								//好看的格式化打印字符串
		 x	:  %p
		x[0]:  %p
		x[1]:  %p
		x[2]:  %p
	`
	fmt.Printf(str1,&x,&x[0],&x[1],&x[2])	//打印各个地址
}

func main() {
	a := [3]int{0,1,2}						//被用来创建切片的备胎
	b := a[:]								//主角
	str2 := `
		 b	:  %p
		b[0]:  %p
		b[1]:  %p
		b[2]:  %p
	`
	fmt.Printf(str2,&b,&b[0],&b[1],&b[2])	//b的地址信息
	/*输出如下
		 b	:  0xc000004078
		b[0]:  0xc00000a1c8
		b[1]:  0xc00000a1d0
		b[2]:  0xc00000a1d8
	*/
	
	foo(b)
	/*输出如下
		 x	:  0xc000004090
		x[0]:  0xc00000a1c8
		x[1]:  0xc00000a1d0
		x[2]:  0xc00000a1d8
	*/
}

可以看到的确除了切片本身地址不同,其各个元素地址完全相同,实验成功。

4.扩容

到这里,我们的大多数疑惑都已经消除了,笔者还剩一点的想法。
数组定长以后才便于被函数调用,调用栈才能够留给数组正好的空间来复制到函数内。而另一方面,切片是可以动态收缩的容器,前面的实验告诉我们基于数组构造的切片指向的仍然是原数组,那么切片不断边长,后续加入的元素存放到哪里呢?
接到原数组的后面显然不现实,原因有几点:

  1. 数组为局部变量时大部分情况下会存放在栈内,因此数组后没有空闲空间
  2. 若由数组构造的切片不位于数组右端,如数组定义为var a [10]int,切片定义为b:=a[:5],此时b后添加的一个元素b = append(b, 10)10放到原数组后面破坏了数组物理和逻辑上的连续性。

另外,我们会发现对从数组创建的切片进行更改时,原数组有时发生变化,有时则不变。
实际上,刚(由数组)创建的切片指针指向切片的首元素,大小为切片的大小(像废话hhhhh),容量为切片的头索引到原数组末端的长度。规范来说,若有如下的切片构造:

	var a [length]int
	var b []int = a[low:high]

则相当于(用于展示,slice无法从外面调用)

	var a [length]int
	var b slice = slice {
		array	: &a[low]			//首元素指针
		len		: high - low		//长度
		cap		: length - low		//容量
	}

由实验可以很好地得到这一结论:

func print(x []int, low int, high int) {
	str := `
		array	: %p
		len		: %d
		cap		: %d
`
	fmt.Printf("\tlength=5, low=%d, high=%d.", low, high)
	fmt.Printf(str, &x[0], len(x), cap(x))
}
func main() {
	a := [5]int{0, 1, 2, 3, 4}
	b := a[:]		//low=0, high=5, length=5
	c := a[:2]		//low=0, high=2, length=5
	d := a[1:]		//low=1, high=5, length=5

	print(b, 0, 5)
	/*输出如下
	length=5, low=0, high=5.
		array	: 0xc00000c3c0
		len		: 5
		cap		: 5
	*/
	
	print(c, 0, 2)
	/*输出如下
	length=5, low=0, high=2.
		array	: 0xc00000c3c0
		len		: 2
		cap		: 5
	*/
	
	print(d, 1, 5)
	/*输出如下
	length=5, low=1, high=5.
		array	: 0xc00000c3c8
		len		: 4
		cap		: 4
	*/
}

可以看到符合前面的结论。
另一方面,在切片创建完成后不断使用append在尾部加入元素会发生什么呢?结论是:当没有越过原数组的界前会在原数组上不断用新值往后覆盖;越界后切片会将数据复制到新的一个更大的区域(即扩容)后插入新值。
来个例子即可一目了然:

func print(a [6]int, b []int) {
	str := `
		&b = %p					//打印切片的首元素地址
		a  = %v					//打印原数组a的值
		b  = %v 				//打印切片的值
	
	`
	fmt.Printf(str, &b[0], a, b)
}
func main() {
	a := [6]int{0, 1, 2, 3, 4, 5}
	b := a[:3]					//初始切片为[0,1,2]
	print(a, b)					//打印初始情况
	for i := 3; i <= 6; i++ {	//不断在切片末尾添加999
		b = append(b, 999)
		print(a, b)				//观察a,b值的变化
	}
}

输出结果如下:

		&b = 0xc00000c3c0
		a  = [0 1 2 3 4 5]
		b  = [0 1 2] 
	
	
		&b = 0xc00000c3c0
		a  = [0 1 2 999 4 5]
		b  = [0 1 2 999] 
	
	
		&b = 0xc00000c3c0
		a  = [0 1 2 999 999 5]
		b  = [0 1 2 999 999] 
	
	
		&b = 0xc00000c3c0
		a  = [0 1 2 999 999 999]
		b  = [0 1 2 999 999 999] 
	
	
		&b = 0xc00001a180
		a  = [0 1 2 999 999 999]
		b  = [0 1 2 999 999 999 999] 

可以看到每加一次999,原数组都会被覆盖一个999,直至越界后切片搬了新家(&b发生变化)。
在切片由于容量不足而扩容时,可大致认为复制到新的大空间且容量加倍。具体的,我们参见源码:

// 路径~\Go\src\runtime\slice.go
	newcap := old.cap
	doublecap := newcap + newcap
	//cap为新容量的备选值,前面已经保证cap>=old.cap
	if cap > doublecap {		//若旧容量加倍后上溢为负数
		newcap = cap			//则采用备选值
	} else {					//否则(即doublecap合格)
		if old.cap < 1024 {		//且旧容量小于1024
			newcap = doublecap	//就直接加倍
		} else {				//否则(旧容量大于等于1024)
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < cap {	//那么逐渐按照1/4向上加
				newcap += newcap / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {	//如果上溢
				newcap = cap	//采用备选值
			}
		}
	}

总结以上策略,优先加倍扩容,若切片过大则按照四分之一向上扩容

总结

数组和切片的细节我们终于大致了解了,在此做一个总结:

  1. 数组是在编译阶段就确定长度的一个容器,如此可以让编译器更好分配合适的空间存储数据,但无法灵活扩容。
  2. 切片则是没有提前约定长度的”数组“,它的长度动态可变。切片本质上是一个结构体,由指针、长度和容量构成。
  3. 由数组创建的切片指针仍然会指向原数组的对应位置,此时切片和数组对应位置同时变化;而当切片容量不足时会进行复制扩容,之后与原数组就没有关系了。
  4. 数组被函数调用是传值,对参数数组的改变不会影响原数组;切片则是传引用,会改变原切片的值。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值