深入理解go语言切片在函数传递中的用法

首先我先说明一下写这篇博客的原因,之前刷题的时候,有一道题,本想用回溯递归来解决,因为我最近都用go刷题,就出现了一些我没想到的问题,之前我一直以为将切片弹出最后一个元素,就是靠res=res[:len(res)]实现的,而且之前在某些时候用的时候确实起效,但是我却忽略了一个非常重要的事情,那就是切片的性质,即使你对res进行了不包括的最后一个元素的赋值,但是归根结底没有对他所指向的底层数组进行改变,也就是说最后一个元素并没有消失在底层数组中,接下来我们详细看一看。

slice参数

首先我们之前一直很明确的就是,切片和数组是不一样的,数组是值类型,切片是引用类型,所以我一直坚信数组传参是传值,即函数中的改变不会影响函数外的影响,切片是传递地址,函数中的改变,会直接影响函数外的对应切片,接下来我们为你先来简单验证一下

func main(){

	slice:=[]int{1,2,3}
	fmt.Printf("slice %v,slice address %p\n",slice,&slice)
	slice=changeSlice(slice)
	fmt.Printf("slice %v,slice address %p",slice,&slice)
}
func changeSlice(nums []int)[]int{
	nums[1]=111
	return nums

}
C:\Users\my\go\src\hello\learn>go run test1.go
slice [1 2 3],slice address 0xc0420023e0
slice [1 111 3],slice address 0xc0420023e0

我们看结果,果然好像和我们想的一样,外部切片中的值进行了改变,地址没有进行改变,那我们再看一个代码:

func main(){

	slice:=[]int{1,2,3}
	fmt.Printf("slice %v,slice address %p\n",slice,&slice)
	slice=changeSlice(slice)
	fmt.Printf("slice %v,slice address %p\n",slice,&slice)
}
func changeSlice(nums []int)[]int{
	fmt.Printf("nums: %v,nums addr: %p\n",nums,&nums)
	nums[1]=111
	return nums

}
C:\Users\my\go\src\hello\learn>go run test1.go
slice [1 2 3],slice address 0xc04204a3a0
nums: [1 2 3],nums addr: 0xc04204a400
slice [1 111 3],slice address 0xc04204a3a0

我们在函数中打印了传入函数的切片地址,发现和外部切片地址并不一样,可是不是传地址吗???,为了解决这个问题,我们要再了解一下slice的实现了

slice的实现

我们都知道切片不等同于数组,但他是依赖于数组实现的,切片是一种复合结构,它是由三部分组成的,第一部分是只想底层数组的指针ptr,第二部分是切片的大小len,最后是切片的容量cap

func main(){

	arr:= [5]int{1,2,3,4,5}
	slice:=arr[1:4]
	slice2:=arr[2:5]
	fmt.Printf("slice: %v,slice add %p\n",slice,&slice)
	fmt.Printf("slice: %v,slice add %p\n",slice2,&slice2)
	arr[2]=11
	fmt.Printf("slice: %v,slice add %p\n",slice,&slice)
	fmt.Printf("slice: %v,slice add %p",slice2,&slice2)
}
C:\Users\my\go\src\hello\learn>go run test1.go
slice: [2 3 4],slice add 0xc04204a3a0
slice: [3 4 5],slice add 0xc04204a3c0
slice: [2 11 4],slice add 0xc04204a3a0
slice: [11 4 5],slice add 0xc04204a3c0

我们看这个例子,有一个5个元素的数组,slice,slice2分别截取了数组的一部分,并且有共同的一部分,我们现在能明确的就是,两个切片公用一个数组,所以一个改变都改变,还有就是两个数组是两个不同的对象,很明显内存地址不同
从数组中切一块下来形成切片很好理解,有时候我们用make函数创建切片,实际上golang会在底层创建一个匿名的数组。如果从新的slice再切,那么新创建的两个切片都共享这个底层的匿名数组。

slice的复制

有的时候,我们希望只是引用其他切片的值,而不对他进行改变,那么就需要对切片进行复制,下面我们用两种方法说明一下;

func main(){

	slice:=[]int{1,2,3,4}
	slice1:=make([]int,len(slice))
	for i,k:=range slice{
		slice1[i]=k
	}
	fmt.Printf("slice:%v,slice add %p\n",slice,&slice)
	fmt.Printf("slice:%v,slice add %p\n",slice1,&slice1)
	slice[1]=111
	fmt.Println(slice,slice1)
}
C:\Users\my\go\src\hello\learn>go run test1.go
slice:[1 2 3 4],slice add 0xc04204a3a0
slice:[1 2 3 4],slice add 0xc04204a3c0
[1 111 3 4] [1 2 3 4]


定义一个切片后,要想不改变他的值,首先肯定要另外开辟一块内存地址,然后进行赋值,现而今见,内存地址不一样,改变一个的值,不会影响另外一个,这样就完成了,复制,go语言中,为了方便复制,也有一个函数就是copy

func main(){

	slice:=[]int{1,2,3,4}
	slice1:=make([]int,len(slice))
	copy(slice1,slice)
	fmt.Printf("slice:%v,slice add %p\n",slice,&slice)
	fmt.Printf("slice:%v,slice add %p\n",slice1,&slice1)
	slice[1]=111
	fmt.Println(slice,slice1)
}

结果与上面是一样的,我们观察一下,发现不管你使用那种方法都必须,新开辟一块内存,这是必须的,复制之后,两个切片的底层数组是不一样的

slice追加

append:

创建复制切片都是常用的操作,还有一个追加元素或者追加数组也是很常用的功能。golang提供了append函数用于给切片追加元素。append第一个参数为原切片,随后是一些可变参数,用于将要追加的元素或多个元素。

func main() {

    slice := make([]int, 1, 2)
    slice[0] = 111

    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

    slice = append(slice, 222)
    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

    slice = append(slice, 333)
    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

}
slice [111], slice addr 0xc4200660c0, len 1, cap 2 
slice [111 222], slice addr 0xc4200660c0, len 2, cap 2 
slice [111 222 333], slice addr 0xc4200660c0, len 3, cap 4

对于切片的append,就是在底层数组进行追加或扩容,这就取决于接下来我们要介绍的容量了

cap

无论数组还是切片,都有长度限制。也就是追加切片的时候,如果元素正好在切片的容量范围内,直接在尾部追加一个元素即可。如果超出了最大容量,再追加元素就需要针对底层的数组进行复制和扩容操作了。
从上面的例子,我们也能看出这一点,当容量不够时,就会扩容到原来的两倍。
这里我们还要说一个特殊情况,

func main() {
    arr:=[3]int{1,2,3}
	slice := arr[1:2]

	fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

	slice = append(slice, 222)
	fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

	slice = append(slice, 333)
	fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))
	slice[1]=111
	fmt.Println(arr,slice)
}
C:\Users\my\go\src\hello\learn>go run test1.go
slice [2], slice addr 0xc04204a3a0, len 1, cap 2
slice [2 222], slice addr 0xc04204a3a0, len 2, cap 2
slice [2 222 333], slice addr 0xc04204a3a0, len 3, cap 4
[1 2 222] [2 111 333]

从这里,我们大概可以看出来,当追加超出原本容量时,再改变切片内容后,对原来数组是没有影响的
slice和array的关系十分密切,通过两者的合理构建,既能实现动态灵活的线性结构,也能提供访问元素的高效性能。当然,这种结构也不是完美无暇,共用底层数组,在部分修改操作的时候,可能带来副作用,同时如果一个很大的数组,那怕只有一个元素被切片应用,那么剩下的数组都不会被垃圾回收,这往往也会带来额外的问题。

作为函数切片的参数

直接改变切片
回到最开始的问题,当函数的参数是切片的时候,到底是传值还是传引用?从changeSlice函数中打出的参数s的地址,可以看出肯定不是传引用,毕竟引用都是一个地址才对。然而changeSlice函数内改变了s的值,也改变了原始变量slice的值,这个看起来像引用的现象,实际上正是我们前面讨论的切片共享底层数组的实现。

即切片传递的时候,传的是数组的值,等效于从原始切片中再切了一次。原始切片slice和参数s切片的底层数组是一样的。因此修改函数内的切片,也就修改了数组。

再来说一下append操作:

  slice := make([]int, 2, 3)
    for i := 0; i < len(slice); i++ {
        slice[i] = i
    }

    fmt.Printf("slice %v %p \n", slice, &slice)

    ret := changeSlice(slice)
    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)

    ret[1] = 1111

    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}

func changeSlice(s []int) []int {
    fmt.Printf("func s %v %p \n", s, &s)
    s = append(s, 3)
    return s
}
输出:
slice [0 1] 0xc42000a1e0 
func s [0 1] 0xc42000a260 
slice [0 1] 0xc42000a1e0, ret [0 1 3] 
slice [0 1111] 0xc42000a1e0, ret [0 1111 3]

从输出可以看出,当slice传递给函数的时候,新建了切片s。在函数中给s进行了append一个元素,由于此时s的容量足够到,并没有生成新的底层数组。当修改返回的ret的时候,ret也共用了底层的数组,因此修改ret的原始,相应的也看到了slice的改变。

 func main() {
    slice := make([]int, 2, 2)
    for i := 0; i < len(slice); i++ {
        slice[i] = i
    }

    fmt.Printf("slice %v %p \n", slice, &slice)

    ret := changeSlice(slice)
    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)

    ret[1] = -1111

    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}

func changeSlice(s []int) []int {
    fmt.Printf("func s %v %p \n", s, &s)
    s[0] = -1
    s = append(s, 3)
    s[1] =  1111
    return s
}
slice [0 1] 0xc42000a1a0 
func s [0 1] 0xc42000a200 
slice [-1 1] 0xc42000a1a0, ret [-1 1111 3] 
slice [-1 1] 0xc42000a1a0, ret [-1 -1111 3]

对比可以看出,此时append后,明显容量不够用,就会新生成一个底层数组,所以内存地址改变,并且改变新切片对原来的没有任何影响

通过上面的分析,我们大致可以下结论,slice或者array作为函数参数传递的时候,本质是传值而不是传引用。传值的过程复制一个新的切片,这个切片也指向原始变量的底层数组。(个人感觉称之为传切片可能比传值的表述更准确)。函数中无论是直接修改切片,还是append创建新的切片,都是基于共享切片底层数组的情况作为基础。也就是最外面的原始切片是否改变,取决于函数内的操作和切片本身容量。

那我们想要传引用的时候怎么办的,我想有的同学肯定想到了,传地址,没错

func main() {
	slice := make([]int, 2, 2)
	for i := 0; i < len(slice); i++ {
		slice[i] = i
	}

	fmt.Printf("slice %v %p \n", slice, &slice)

	ret := changeSlice(&slice)
	fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)

	ret[1] = -1111

	fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}
C:\Users\my\go\src\hello\learn>go run test1.go
slice [0 1] 0xc04204a3a0
func s &[0 1] 0xc04206e020
slice [-1 1111 3] 0xc04204a3a0, ret [-1 1111 3]
slice [-1 -1111 3] 0xc04204a3a0, ret [-1 -1111 3]


再来看刚才的例子,解决了,内存地址没变,值也变了

总结:

golang提供了array和slice两种序列结构。其中array是值类型。slice则是复合类型。slice是基于array实现的。slice的第一个内容为指向数组的指针,然后是其长度和容量。通过array的切片可以切出slice,也可以使用make创建slice,此时golang会生成一个匿名的数组。

因为slice依赖其底层的array,修改slice本质是修改array,而array又是有大小限制,当超过slice的容量,即数组越界的时候,需要通过动态规划的方式创建一个新的数组块。把原有的数据复制到新数组,这个新的array则为slice新的底层依赖。

数组还是切片,在函数中传递的不是引用,是另外一种值类型,即通过原始变量进行切片传入。函数内的操作即对切片的修改操作了。当然,如果为了修改原始变量,可以指定参数的类型为指针类型。传递的就是slice的内存地址。函数内的操作都是根据内存地址找到变量本身。

参考:GO切片:用法和本质

猜你喜欢

转载自blog.csdn.net/LYue123/article/details/88363685