您的位置 首页 golang

面试官:切片作为函数参数是传值还是传引用?

切片传参的幻觉 – 传引用

golang中函数的参数为切片时是传引用还是传值?对于这个问题,当你百度一轮过后,你会发现很大一部分人认为是传引用,通常他们会贴出下面这段代码进行佐证:

上面代码中,在main函数里边初始化一个切片变量slice,接着调用changeSlice函数,参数为切片变量slice。而函数changeSlice的主要处理逻辑是改变切片的第二个元素的值。下面我们看一下运行打印的结果:

 slice: [0 1 2 3] 
slice: [0 111 2 3]  

从输出结果我们看到,函数changeSlice内对切片的修改,main函数中的切片变量slice也跟着修改了。咋一看,这不就是引用传递的表现吗?

但事实上真的传引用吗?

理清三个重要概念

在探讨函数切片参数到底是以哪种方式传递时,我们先来理清下面三个重要的概念:

  • 传值(值传递)
  • 穿指针
  • 传引用(引用传递)

传值(值传递)

是指在调用函数时将实际参数拷贝一份传递到函数中,这样在函数中对参数进行修改不会影响到实际参数。这个简单不必赘述。

传指针

形参是指向实参地址的指针,当对形参的指向进行操作时,就相当于对实参本身进行操作。听起来比较绕是吧,我们来看个例子就知道了:

上面代码中定义了一个变量 a,并把地址保存在指针变量pa里面;接着打印pa的值和pa的地址,然后调用modify函数,参数为指针变量pa;modify函数中首先打印形参p的值和p的地址,接着修改p的值为1。

我们打印输出的结果:

从输出结果中我们可以看到, 这是一个指针的拷贝 。指针pa 和 p 的值虽然相同,但是存放这两个指针的内存地址是不同的,因此这是两个不同的指针。

注意:任何存放在内存里的东西都有自己的地址,指针也不例外,它虽然指向别的数据,但是也有存放该指针的内存

结合图来看相信会更清晰一点:

传引用(引用传递)

是指在调用函数时将实际参数的地址传递到函数中,在函数中对参数所进行的修改,将影响实际参数。

假设以上面demo为例子,如果在modify函数中打印指针变量p的地址也是0xc00000e028,那么我们就认为是引用传递

但这里我们不能用go来举例子,原因请接着往下看。

官方打假:Go函数传参只有值传递

看完 传值、传指针、传引用 的概念后,如果你坚持认为是传引用,好,大叔要在这里直接把你击垮。

根据Go官方文档声明: Go里面函数传参只有值传递一种方式 。也就是说,在Go中,函数的传参只有传值一种方式。官方文档传送门

如果你还不服气,咱们直接看例子:

打印输出:

如果函数切片参数传的是引用,那么上面这个例子中,main函数中打印的切片slice的地址应该和changSlice函数中打印切片的地址一样的,但从输出结果来看并不是这样的。

因此在这里,我们可以非常肯定地说: 有关Go函数中切片参数是传引用的说法是错误的,另外有关有关引用传递是针对slice、map、channel三种数据类型的说法也是错误的

切片参数本质还是传值

从上面的分析来看,切片参数传递的方式并非是传引用。反而极有可能是传指针,而在传指针那小节的分析中我们可以知道,传指针其实就是指针的拷贝,形参和实参是两个不同的指针,但是它们的值是一样的。本质上可以说还是传值。

那到底是不是这样的呢?大叔疑问句都出来了,说明90%是有可能的,接下我们就验证一下那剩下的10%。

slice的 结构体

我们先来看一下切片的结构体是长啥样的:

切片,顾名思义就是数组切下来的一部分,其结构体包含了三部分,第一部分是指向底层数组的指针,其次是切片的大小len和切片的容量cap。(果然含有指针成员变量。)

上面的结构体看着有点变扭,不够直观,我们造个例子:一个数组 arr := [5]int{0,1,2,3,4},生成一个切片 slice := arr[1:4],最终得到的切片如下:

再看个例子:

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

    slice1 := arr[1:4]
    slice2 := arr[2:5]

    // 打印一
    fmt.Printf("arr %v, slice1 %v, slice2 %v   arr addr: %p, slice1 addr: %p, slice2 addr: %p\n", arr, slice1, slice2, &arr, &slice1, &slice2)

    // 打印二
    fmt.Printf("arr[2] addr: %p, slice1[1] addr: %p, slice2[0] addr: %p\n", &arr[2], &slice1[1], &slice2[0])

    arr[2] = 2222
    
    // 打印三
    fmt.Printf("arr: %v, slice1: %v, slice2: %v\n", arr, slice1, slice2)


    slice1[1] = 1111
    
    // 打印四
    fmt.Printf("arr: %v, slice1: %v, slice2: %v\n", arr, slice1, slice2)
}
  

上面代码中我们创建一个数组,并生成两个切片。打印它们的值和对应的地址。另外,修改数组或者切片的某个单元的值,观察数组和切片中单元的值的变化:

 arr [0 1 2 3 4], slice1 [1 2 3], slice2 [2 3 4]   arr addr: 0xc000014090, slice1 addr: 0xc00000c080, slice2 addr: 0xc00000c0a0
arr[2] addr: 0xc0000140a0, slice1[1] addr: 0xc0000140a0, slice2[0] addr: 0xc0000140a0
arr: [0 1 2222 3 4], slice1: [1 2222 3], slice2: [2222 3 4]
arr: [0 1 1111 3 4], slice1: [1 1111 3], slice2: [1111 3 4]
  
  • 从打印一结果可以看出:创建的两个切片,它们各自拥有不同的地址
  • 从打印二结果可以看出:切片元素slice1[1]、slice2[0] 与数组元素arr[2]有着同样的地址,说明这些 切片共享着数组arr中的数据
  • 打印三和打印四可以看出:修改数组和切片共同部分的数据,对两者都有直接影响,再次印证第二点的结论。

从上面的分析中我们可以知道,两个不同的切片之所以能相互影响,主要因素是切片内部的指针指向同一个数据源,且两个切片的指针指向的数据源中有交集。

再回到切片作为函数参数的问题上,因为 Go里面函数传参只有值传递一种方式 ,所以当切片作为参数时,其实也是切片的拷贝,但是在拷贝的切片中,其包含的指针成员变量的值是一样的,也就是说它们指向的数据源是一样,因此在调用函数内修改形参能影响实参。

Go函数传值总结

通常,我们把在传值拷贝过程中,修改形参能直接修改实参的数据类型称为 引用类型

于是我们又可以这样总结:

Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。

这里要注意的是: 引用类型和传引用是两个概念

切片参数,修改形参一定影响实参吗

是的,看到这里,我们应该问自己一个问题:函数的切片参数,在函数内修改形参一定会影响外部的实参吗?(疑问句一出必然有鬼!)

我们来看个例子:

上面代码中,先是创建好一个长度为2,容量为3的空切片,然后给切片赋两个值,接着调用函数changeSlice,参数为赋完值的切片。函数changeSlice里面先是往切片中追加两个元素,然后再改变切片的第二个元素的值。

我们来看看打印结果:

 slice: [0 1], addr: 0xc00001a100 
func s: [0 111 3 4], addr: 0xc000014090 
slice: [0 1], addr: 0xc00001a100 
  

从输出结果我们可以看到,函数内修改形参s后,外部的实参slice并没有跟着改变,而且注意到一点是,形参s和实参slice的指针成员变量的值是不一样的,也就是说它们指向的数据源不是同一个了。

这又是为什么呢?

切片扩容

首先我们来了解一个知识点:

无论数组还是切片,都有长度限制。也就是追加切片的时候,如果元素正好在切片的容量范围内,直接在尾部追加一个元素即可。如果超出了最大容量,再追加元素就需要针对底层的数组进行复制和扩容操作了

也就是说:

  • 使用append方法给slice追加元素的时候,由于slice的容量还未满,因此等同于扩展了slice指向数组的内容,可以理解为重新切了一个数组内容附给slice,同时修改了数组的内容。
  • 使用append方法给slice追加元素的时候,如果此时slice的容量已满,再进行追加时,超出了切片的容量,数组就会越界了,于是就会出现扩容操作

当需要扩容时,append会做哪些操作呢?

  1. 创建一个新的临时切片t,t的长度和slice切片的长度一样,但是t的容量是slice切片的2倍,新建切片的时候,底层也创建了一个匿名的数组,数组的长度和切片容量一样。
  2. 复制slice里面的元素到t里,即填入匿名数组中。然后把t赋值给slice,现在slice的指向了底层的匿名数组。
  3. 转变成小于容量的append方法。

举个例子,数组arr = [3]int{0, 11, 22},生成一个切片slice := arr[1:3],使用append方法往切片slice中追加元素33,将发生以下操作:

再回到刚刚举的例子,之所外部的实参切片变量slice不受形参切片变量s修改的影响,因为在执行完 s = append(s, 4) 这段代码后,切片s的指针指向的数组发生了扩容,其指针指向了新的数组,因此当再次修改其第二个元素的值时,是不会影响外部切片变量slice的。

切片容量计算

具体的切片容量计算可参考下面的例子:

数组 [0, 1, 2, 3, 4] 中,数组有5个元素。如果切片 s = [1, 2, 3],那么3在数组的索引为3,也就是数组还剩最后一个元素的大小,加上s已经有3个元素,因此最后s的容量为 1 + 3 = 4。如果切片是 s1 = [4],4的索引在数组中是最大的了,数组空余的元素为0,那么s1的容量为 0 + 1 = 1。具体如下表:

作业

好了,写到这里本次的分享就要接近尾声了,最后想想还是有必要留个作业给大家,检验一下大家对以上知识点的接受程度:

大家在评论中写下你的答案吧~

转载:

文章来源:智云一二三科技

文章标题:面试官:切片作为函数参数是传值还是传引用?

文章地址:https://www.zhihuclub.com/99125.shtml

关于作者: 智云科技

热门文章

网站地图