数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素。goalng中的数组在定义时必须指定长度,创建之后长度是不可变的。因为在数组创建过程中,golang会根据定义的长度去申请连续的内存空间。与其他编程语言一样,数组的指针指向数组开头元素。
golang的切片类型是基于数组实现的,可以理解为一个管理数组自动扩容的结构,具体的结构体定义如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
array是一个指向数组结构的指针,len和cap字段用于维护数组的长度和容量。当我们定义一个切片类型时,切片的初始容量为0,当我们逐渐append变量时,切片会依据某种策略进行扩容。具体的扩容策略比较复杂,具体可以查看go/src/runtime/slice.go
的源码。
func growslice(et *_type, old slice, cap int) slice {
......
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 > oldcap*2 ,就直接分配需要的cap。否则,如果oldcap< 1024, 直接加倍。如果old <= 1024,反复增加25%。直到足够存储全部内容。实际上的逻辑更加复杂,最终的cap还需要根据数据类型所占的空间进行调整,具体可以参考源码。接下来,通过如下实验验证两种简单的情况。首先是不指定默认长度和容量时。
func TestNewSlice(T *testing.T){
var slice []int
fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
for i:=0;i<100;i++{
slice = append(slice, i)
fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
}
}
实验结果如下:
slice len = 0 slice cap = 0
slice len = 1 slice cap = 1
slice len = 2 slice cap = 2
slice len = 3 slice cap = 4
slice len = 4 slice cap = 4
slice len = 5 slice cap = 8
slice len = 6 slice cap = 8
slice len = 7 slice cap = 8
slice len = 8 slice cap = 8
......
接下来是通过make指定初始的长度和容量时。
func TestMakeSlice(T *testing.T){
var slice = make([]int,0,10)
fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
for i:=0;i<100;i++{
slice = append(slice, i)
fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
}
}
实验结果如下:
slice len = 0 slice cap = 3
slice len = 1 slice cap = 3
slice len = 2 slice cap = 3
slice len = 3 slice cap = 3
slice len = 4 slice cap = 6
slice len = 5 slice cap = 6
slice len = 6 slice cap = 6
slice len = 7 slice cap = 12
......
因此,如果我们知道切片大致需要的容量时,最好通过 make方法,指定cap值。这样可以有效避免数组的频繁扩容。从而避免切片在扩容时导致的性能损失。这部分损失包括扩容时重新申请内存、数据的拷贝以及后续的垃圾回收。
golang数组和切片的区别除了体现在是否支持扩容外,还体现在传值操作上。具体我们通过如下实验来说明。
func TestSliceObj(t *testing.T) {
var a1 = [3]int{1,2,3}
fmt.Printf("the a1 = %v \t\t a1 ptr = %p \t\t a1[0] ptr = %p\n",a1,&a1,&a1[0])
a2 := a1
a1[0] = 10
fmt.Printf("after change the a1 = %v \t\t a1 ptr = %p \t\t a1[0] ptr = %p\n",a1,&a1,&a1[0])
fmt.Printf("after change the a2 = %v \t\t a2 ptr = %p \t\t a2[0] ptr = %p\n",a2,&a2,&a2[0])
var s1 = []int{1,2,3}
fmt.Printf("the s1 = %v \t\t s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
s2 := s1
s1[0] = 10
fmt.Printf("after change the s1 = %v \t\t s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
fmt.Printf("after change the s2 = %v \t\t s2 ptr = %p \t\t s2[0] ptr = %p\n",s2,&s2,&s2[0])
}
实验结果如下:
=== RUN TestSliceObj
the a1 = [1 2 3] a1 ptr = 0xc0000ca0a0 a1[0] ptr = 0xc0000ca0a0
after change the a1 = [10 2 3] a1 ptr = 0xc0000ca0a0 a1[0] ptr = 0xc0000ca0a0
after change the a2 = [1 2 3] a2 ptr = 0xc0000ca0e0 a2[0] ptr = 0xc0000ca0e0
the s1 = [1 2 3] s1 ptr = 0xc0000b40a0 s1[0] ptr = 0xc0000ca140
after change the s1 = [10 2 3] s1 ptr = 0xc0000b40a0 s1[0] ptr = 0xc0000ca140
after change the s2 = [10 2 3] s2 ptr = 0xc0000b40e0 s2[0] ptr = 0xc0000ca140
--- PASS: TestSliceObj (0.00s)
通过实验结果我们发现,对于数组array来说,数组的指针和数组第一个元素的第一个指针是相同的,说明数组指针确实指向数组第一个元素地址。接着我们将a1值赋值给a2并修改a1的值。我们发现a1和a2分别指向不同的内存空间,同时对a1的修改不会影响a2。说明赋值过程是值传递,在赋值过程中会重新申请一块空间。
对于切片slice来说,切片指针指向slice struct的位置,而切片第一个元素的地址位才是底层数组真正的地址位。然后我们将s1赋值给s2,我们发现是s1和s2指针分别指向两个不同的空间,说明该赋值过程同样也是值传递,即当前内存中存在两个slice结构体对象,分别为s1和s2。与数组不同的是,两个切片所对应的底层数组是同一个。当我们修改s1的值之后,s2也同样发生了变化。
那么这是不是就说明,golang中切片类型的赋值是指针复制或者说是浅拷贝呢?
答案是否定的,接下来介绍一个golang切片使用过程中常见的坑。首先做一组实验。
func TestAppendSlice(T *testing.T){
var s1 = []int{1,2,3,4}
fmt.Printf("the s1 = %v \t\t s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
s2 := s1
s1 = append(s1, 5)
fmt.Printf("after change the s1 = %v \t\t s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
fmt.Printf("after change the s2 = %v \t\t s2 ptr = %p \t\t s2[0] ptr = %p\n",s2,&s2,&s2[0])
}
实验结果如下:
=== RUN TestAppendSlice
the s1 = [1 2 3 4] s1 ptr = 0xc00000c0e0 s1[0] ptr = 0xc000018200
after change the s1 = [1 2 3 4 5] s1 ptr = 0xc00000c0e0 s1[0] ptr = 0xc00001a180
after change the s2 = [1 2 3 4] s2 ptr = 0xc00000c120 s2[0] ptr = 0xc000018200
--- PASS: TestAppendSlice (0.00s)
通过实验结果我们发现,当我们将s1赋值给s2之后,再修改s1,s2并未像之前一样也发生变化。另外我们发现s2对应的底层数组与s1是相同的,这和之前的实验结论一致。不同之处在于,s1在修改后指针指向的底层数组地址发生了变化,这是因为append操作恰好出发了一次数组扩容,从而导致切片重新申请了一块连续地址。
因此在使用切片时一定注意这一点,尤其是当发生参数传递时,需要确定自己期待的是值传递还是指针传递。否则可能会遇到难以解释的bug。