前言
在学习Go语言时会对数组和切片的存储方式和特性产生疑惑,在经过一定实验后得到一些结论,因此在这里进行记录。
*后续实验代码省略package main
和import "fmt"
**笔记为个人理解加上网络资源汇总,感谢.
***若有错误欢迎评论
可能会有的疑惑
- 数组和切片有何不同?
- 和C/C++不同的特性,数组作为函数参数时的改变不会影响原数组,而切片却会产生影响。
- 数组首元素地址和数组地址相同,但切片却不同。数组和切片的存储方式有何区别?
- 从数组创建的切片有时和数组同变化,而有时却不同变化。切片的两种创建方式区别。
正文
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.扩容
到这里,我们的大多数疑惑都已经消除了,笔者还剩一点的想法。
数组定长以后才便于被函数调用,调用栈才能够留给数组正好的空间来复制到函数内。而另一方面,切片是可以动态收缩的容器,前面的实验告诉我们基于数组构造的切片指向的仍然是原数组,那么切片不断边长,后续加入的元素存放到哪里呢?
接到原数组的后面显然不现实,原因有几点:
- 数组为局部变量时大部分情况下会存放在栈内,因此数组后没有空闲空间
- 若由数组构造的切片不位于数组右端,如数组定义为
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 //采用备选值
}
}
}
总结以上策略,优先加倍扩容,若切片过大则按照四分之一向上扩容。
总结
数组和切片的细节我们终于大致了解了,在此做一个总结:
- 数组是在编译阶段就确定长度的一个容器,如此可以让编译器更好分配合适的空间存储数据,但无法灵活扩容。
- 切片则是没有提前约定长度的”数组“,它的长度动态可变。切片本质上是一个结构体,由指针、长度和容量构成。
- 由数组创建的切片指针仍然会指向原数组的对应位置,此时切片和数组对应位置同时变化;而当切片容量不足时会进行复制扩容,之后与原数组就没有关系了。
- 数组被函数调用是传值,对参数数组的改变不会影响原数组;切片则是传引用,会改变原切片的值。