❝「第11期」 距离大叔的80期小目标还有69期,今天大叔要跟大家唠的磕是 —— golang中函数的参数为切片是传引用还是传值。这是一道非常基础也是非常经典的面试,相信无论你去哪家大厂面试,当问到切片问题时可以说是必问题,在大牛面前回答时稍有不妥就有可能要回家等通知了。下面一起来了解一下吧。
❞
切片传参的幻觉 - 传引用
golang中函数的参数为切片时是传引用还是传值?对于这个问题,当你百度一轮过后,你会发现很大一部分人认为是传引用,通常他们会贴出下面这段代码进行佐证:
pacakge main
func changeSlice(s []int) {
s[1] = 111
}
func main() {
slice := []int{0, 1, 2, 3}
fmt.Printf("slice: %v \n", slice)
changeSlice(slice)
fmt.Printf("slice: %v\n", slice)
}
上面代码中,在main函数里边初始化一个切片变量slice,接着调用changeSlice函数,参数为切片变量slice。而函数changeSlice的主要处理逻辑是改变切片的第二个元素的值。下面我们看一下运行打印的结果:
slice: [0 1 2 3]
slice: [0 111 2 3]
从输出结果我们看到,函数changeSlice内对切片的修改,main函数中的切片变量slice也跟着修改了。咋一看,这不就是引用传递的表现吗?
但事实上真的传引用吗?
理清三个重要概念
在探讨函数切片参数到底是以哪种方式传递时,我们先来理清下面三个重要的概念:
- 传值(值传递)
- 传指针
- 传引用(引用传递)
传值(值传递)
是指在调用函数时将实际参数拷贝一份传递到函数中,这样在函数中对参数进行修改不会影响到实际参数。这个简单不必赘述。
传指针
形参是指向实参地址的指针,当对形参的指向进行操作时,就相当于对实参本身进行操作。听起来比较绕是吧,我们来看个例子就知道了:
func main() {
a := 10
pa := &a
fmt.Printf("value: %p\n", pa)
fmt.Printf("addr: %p\n", &pa)
modify(pa)
fmt.Println("a 的值被修改了,新值为:", a)
}
func modify(p *int) {
fmt.Printf("函数内的 value: %p\n", p)
fmt.Printf("函数内的 addr: %p\n", &p)
*p = 1
}
上面代码中定义了一个变量 a,并把地址保存在指针变量pa里面;接着打印pa的值和pa的地址,然后调用modify函数,参数为指针变量pa;modify函数中首先打印形参p的值和p的地址,接着修改p的值为1。
我们打印输出的结果:
value: 0xc000016088
addr: 0xc00000e028
函数内的 value: 0xc000016088
函数内的 addr: 0xc00000e038
a 的值被修改了,新值为: 1
从输出结果中我们可以看到,这是一个指针的拷贝。指针pa 和 p 的值虽然相同,但是存放这两个指针的内存地址是不同的,因此这是两个不同的指针。
注意:任何存放在内存里的东西都有自己的地址,指针也不例外,它虽然指向别的数据,但是也有存放该指针的内存。
结合图来看相信会更清晰一点:
传引用(引用传递)
是指在调用函数时将实际参数的地址传递到函数中,在函数中对参数所进行的修改,将影响实际参数。
假设以上面demo为例子,如果在modify函数中打印指针变量p的地址也是0xc00000e028,那么我们就认为是引用传递
但这里我们不能用go来举例子,原因请接着往下看。
官方打假:Go函数传参只有值传递
看完传值、传指针、传引用的概念后,如果你坚持认为是传引用,好,大叔要在这里直接把你击垮。
根据Go官方文档声明:Go里面函数传参只有值传递一种方式。也就是说,在Go中,函数的传参只有传值一种方式。官方文档传送门
如果你还不服气,咱们直接看例子:
package main
import "fmt"
func changeSlice(s []int) {
fmt.Printf("func: %p \n", &s)
s[1] = 111
}
func main() {
slice := []int{0, 1, 2, 3}
fmt.Printf("slice: %v slice addr %p \n", slice, &slice)
changeSlice(slice)
fmt.Printf("slice: %v slice addr %p \n", slice, &slice)
}
打印输出:
slice: [0 1 2 3] slice addr 0xc0000a6020
func: 0xc0000a6060
slice: [0 111 2 3] slice addr 0xc0000a6020
如果函数切片参数传的是引用,那么上面这个例子中,main函数中打印的切片slice的地址应该和changSlice函数中打印切片的地址一样的,但从输出结果来看并不是这样的。
因此在这里,我们可以非常肯定地说:有关Go函数中切片参数是传引用的说法是错误的,另外有关有关引用传递是针对slice、map、channel三种数据类型地说法也是错误的
切片参数本质还是传值
从上面的分析来看,切片参数传递的方式并非是传引用。反而极有可能是传指针,而在传指针那小节的分析中我们可以知道,传指针其实就是指针的拷贝,形参和实参是两个不同的指针,但是它们的值是一样的。本质上可以说还是传值。
那到底是不是这样的呢?大叔疑问句都出来了,说明90%是有可能的,接下我们就验证一下那剩下的10%。
slice的结构体
我们先来看一下切片的结构体是长啥样的:
type slice struct {
array unsafe.Pointer
len int
cap int
}
type Pointer *ArbitraryType
切片,顾名思义就是数组切下来的一部分,其结构体包含了三部分,第一部分是指向底层数组的指针,其次是切片的大小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等这些),这样就可以修改原内容数据。
这里要注意的是:引用类型和传引用是两个概念。
切片参数,修改形参一定影响实参吗
是的,看到这里,我们应该问自己一个问题:函数的切片参数,在函数内修改形参一定会影响外部的实参吗?(疑问句一出必然有鬼!)
我们来看个例子:
func main() {
slice := make([]int, 2, 3)
for i := 0; i len(slice); i++ {
slice[i] = i
}
fmt.Printf("slice: %v, addr: %p \n", slice, slice)
changeSlice(slice)
fmt.Printf("slice: %v, addr: %p \n", slice, slice)
}
func changeSlice(s []int){
s = append(s, 3)
s = append(s, 4)
s[1] = 111
fmt.Printf("func s: %v, addr: %p \n", s, s)
}
上面代码中,先是创建好一个长度为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会做哪些操作呢?
- 创建一个新的临时切片t,t的长度和slice切片的长度一样,但是t的容量是slice切片的2倍,新建切片的时候,底层也创建了一个匿名的数组,数组的长度和切片容量一样。
- 复制slice里面的元素到t里,即填入匿名数组中。然后把t赋值给slice,现在slice的指向了底层的匿名数组。
- 转变成小于容量的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。具体如下表:
作业
好了,写到这里本次的分享就要接近尾声了,最后想想还是有必要留个作业给大家,检验一下大家对以上知识点的接受程度:
func main() {
slice := make([]int, 2, 3)
for i := 0; i len(slice); i++ {
slice[i] = i
}
ret := changeSlice(slice)
ret[1] = 111
fmt.Printf("slice: %v, ret: %v \n", slice, ret)
}
func changeSlice(s []int) []int {
s[0] = 10
s = append(s, 3)
s = append(s, 4)
s[1] = 100
return s
}
// 问:最终打印输出什么内容?
大家在评论中写下你的答案吧~
关注公众号「大叔说码」 获取更多干货,今天的分享就到此为止,我们下起见~
参考:
1、 https://www.flysnow.org/2018/02/24/golang-function-parameters-passed-by-value.html
2、https://segmentfault.com/a/1190000015246182
3、https://studygolang.com/articles/9876