[Golang]再谈slice切片与append

        在浏览studygolang的一篇帖子时,发现楼主提出了一个slice与append的问题,我一寻思,这玩意儿我之前研究过一会儿,肯定能解答,然后还没敲5个字就发现我也不明白了,然后再次去研究了一遍,终于觉得自己理解了,现在记录一下。

        


        首先放出原帖子的代码:

package main

import "fmt"

func main() {
        arr := make([]int, 3, 4)             //创建一个长度为 3 ,容量为 4 的切片
        fmt.Println(arr, len(arr), cap(arr)) //[0 0 0] 3 4
        // -----
        fmt.Printf("%p\n", arr)
        addNum(arr)
        // -----
        fmt.Println(arr, len(arr), cap(arr)) //[0 0 0] 3 4
        fmt.Printf("%p\n", arr)
}

func addNum(sli []int) {
        fmt.Printf("%p\n", sli)
        sli = append(sli, 4)
        fmt.Println(sli, len(sli), cap(sli)) //[0 0 0 4] 4 4
        fmt.Printf("%p\n", sli) 
}

        疑问就是,为什么addNum操作后,arr没有变成[0 0 0 4]呢?

        还是得再重新回顾一下相关的知识点。

1、函数传参方式

        Golang中的传参都是值拷贝传递。

package main

import "fmt"

type TS struct {
	Field int
}

func FunctionA(ts TS) {
	ts.Field = 1
}

func FunctionB(ts *TS) {
	ts.Field = 1
}

func main() {
	ts := TS{
		Field: 0,
	}
	FunctionA(ts)
	fmt.Println(ts)	//{0}
	FunctionB(&ts)
	fmt.Println(ts) //{1}
}

        FunctionA参数为结构体类型,FunctionB参数为引用类型。传入的时候,分别拷贝了一个结构体副本和一个引用副本,副本对象与原对象是处在不同内存地址中的,所以修改结构体副本对原对象无法产生影响;但是,引用类型副本由于是和它的原对象指向同一个地址的,所以修改引用类型副本指向的对象就影响到了原引用对象。

2、slice的数据结构 

type slice struct {
    array unsafe.Pointer
    len int
    cap int
}

        slice 并不是一个引用类型,而是一个通常的结构体(查看runtime/slice.go),它包含了一个指针,指向底层数组的首地址,有效数据长度len,以及最大容量cap。

        这三个成员属性共同描述了一个slice。当我们把一个slice作为参数传递时,也发生了slice结构体的值拷贝:一样的len、cap,当然还有指向同一个地址的array指针。看下面代码:

package main

import "log"

func main() {
	var s = []int{1, 1, 1}
	A(s)
	log.Println(s) //[1, 0, 1]
}

func A(slice []int) {
	slice[1] = 0
}

        比如说,函数中把参数slice的第2个元素内容从1改成0时,操作的是底层数组的第二个元素,所以传参后在函数内部修改slice参数的元素值,也会影响外面创建并传入的s。

3、append函数

内置函数append用于给slice的尾部添加元素。下面的代码展示了append的作用:

	var slice = make([]int, 3)
	slice = []int{1, 2, 3}

	log.Println("before:", slice) //[1, 2, 3]
	slice = append(slice, 4)
	log.Println("after:", slice) //[1, 2, 3, 4]

在go源码builtin/builtin.go中有append函数的解释:

// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
//	slice = append(slice, elem1, elem2)
//	slice = append(slice, anotherSlice...)
// As a special case, it is legal to append a string to a byte slice, like this:
//	slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type

如果要增加元素(扩容)的slice有足够的cap(即添加了新元素后,新slice的len没有超过原来的cap),那么就不会重新创建底层数组;否则会重新创建底层数组(创建方式见runtime/slice.go中的函数growslice,主要涉及cap的增加策略,这里不再阐述)。

看下面的代码:

package main

import "log"

func main() {
	var s = make([]int, 3, 3)
	log.Printf("开始:s的变量地址:%p, s的底层数组首地址:%p\n", &s, s)
	A(s)
	log.Printf("调用A后:s的变量地址:%p, s的底层数组首地址:%p\n", &s, s)
	log.Println("原始slice s:", s)
}

func A(slice []int) {
	log.Printf("---A开始:参数slice的变量地址:%p, slice的底层数组首地址:%p\n", &slice, slice)
	slice = append(slice, 9)
	log.Println("A函数内的slice结果:", slice)
	log.Printf("---A结束:参数slice的变量地址:%p, slice的底层数组首地址:%p\n", &slice, slice)
}

执行结果:

2021/11/17 11:07:49 开始:s的变量地址:0xc00000c080, s的底层数组首地址:0xc000016460
2021/11/17 11:07:49 ---A开始:参数slice的变量地址:0xc00000c0e0, slice的底层数组首地址:0xc000016460
2021/11/17 11:07:49 A函数内的slice结果: [0 0 0 9]
2021/11/17 11:07:49 ---A结束:参数slice的变量地址:0xc00000c0e0, slice的底层数组首地址:0xc000018150
2021/11/17 11:07:49 调用A后:s的变量地址:0xc00000c080, s的底层数组首地址:0xc000016460
2021/11/17 11:07:49 原始slice s: [0 0 0]

可以注意到,A函数内的slice参数的地址是和外面的s的地址不一样的,这印证了golang传参是拷贝传递; 另外,传参拷贝后的slice也是len=3, cap=3的切片,在对它进行append时,由于增加一个元素后,len变成了4,超过了原来的cap=3,所以底层数组被重新分配,底层数组的地址也就变了。

现在我们创建一个条件,创建原始切片时将容量改为4, 让s的容量充足,再来实验:

package main

import "log"

func main() {
	var s = make([]int, 3, 4)
	log.Printf("开始:s的变量地址:%p, s的底层数组首地址:%p\n", &s, s)
	A(s)
	log.Printf("调用A后:s的变量地址:%p, s的底层数组首地址:%p\n", &s, s)
	log.Println("原始slice s:", s)
}

func A(slice []int) {
	log.Printf("---A开始:参数slice的变量地址:%p, slice的底层数组首地址:%p\n", &slice, slice)
	slice = append(slice, 9)
	log.Println("A函数内的slice结果:", slice)
	log.Printf("---A结束:参数slice的变量地址:%p, slice的底层数组首地址:%p\n", &slice, slice)
}

关于底层数组地址的情况,可以预见地没有再发生改变了:

2021/11/17 11:16:22 开始:s的变量地址:0xc00000c080, s的底层数组首地址:0xc000016460
2021/11/17 11:16:22 ---A开始:参数slice的变量地址:0xc00000c0e0, slice的底层数组首地址:0xc000016460
2021/11/17 11:16:22 A函数内的slice结果: [0 0 0 9]
2021/11/17 11:16:22 ---A结束:参数slice的变量地址:0xc00000c0e0, slice的底层数组首地址:0xc000016460
2021/11/17 11:16:22 调用A后:s的变量地址:0xc00000c080, s的底层数组首地址:0xc000016460
2021/11/17 11:16:22 原始slice s: [0 0 0]

到此,复现了原楼主的情况:既然两个切片的底层数组地址都一样,为什么slice是[0 0 0 9], 而s却是[0 0 0]呢?

分析:

        其实答案很简单,首先注意到我在上面打印地址的时候,特地给底层数组打印时称之为“底层数组地址”,然后结合slice的数据结构:哪些东西才能描述一个切片?当然是array指针、len和cap。没错,尽管slice和s两者的底层数组首地址一样,但是它们的len不一样(slice是4,s是3),所以它们的读取有效长度也不一样,从而使最终打印结果出现了偏差。

        还有一点,之前一直有一个误区,就是把切片当成真引用类型看待了,认为传参后对它做的任何修改都会影响到原切片。现在看来这个“修改”指的只是修改元素的内容,并不包括append扩容。


个人拙见,如有不当,烦请赐教,十分感谢。

你可能感兴趣的:(Golang,golang,go语言,slice,append)