数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素。goalng中的数组在定义时必须指定长度,创建之后长度是不可变的。因为在数组创建过程中,golang会根据定义的长度去申请连续的内存空间。与其他编程语言一样,数组的指针指向数组开头元素。

golang的切片类型是基于数组实现的,可以理解为一个管理数组自动扩容的结构,具体的结构体定义如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
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。