讲了个故事,把切片参数是传值还是传引用的梗聊明白了

1,051 阅读17分钟

大叔是个有视觉洁癖的人,文章写完后发现超过掘金的字符限制了,very sad~~ 想要看更好的视觉效果或者是图片如果看不了朋友,可以看公众号文章:传送门

「第11期」 距离大叔的80期小目标还有69期,今天大叔要跟大家捞的磕是 —— 说说golang中函数的切片参数是传引用还是传值。这是一道非常基础也是非常经典的面试,相信无论你去哪家大厂面试,当问到切片问题时可以说是必问题,在大牛面前回答时稍有不妥就有可能要回家等通知了。下面一起来了解一下吧。

故事要从很久很久之前说起......

切片传参的幻觉

相传很久之前有位大叔在衙门当程序员,专门处理民间那些有的没的疑难杂症。

有一天衙门抓来了两位草民二狗和来福,大叔一问才知道这两人因为争论一个问题干起来了!二狗腿被打断了一条,来福的腿毛都被咬光了,非常之惨。这哪得了啊,大叔得赶紧问问是什么问题呀?

一问之下才得知,原来他们俩在争论:golang函数中切片作为参数,到底是传值还是传引用? 二狗坚持认为是传值,气焰非常之嚣张;来福则情钟于传引用,说祖传秘籍就黑纸白纸这么记录着呢,不会错的。

大叔心里嘀咕了一下:我(cao)擦(dan)咧,这道题我也不会啊!咋办呀?这件案子不解决,万一领导请去喝茶,这个季度的KPI又要喂了狗!

得想想办法,大叔左思右想,决定去找算命的找找头绪。大叔花了十块大洋找了十位算命先生来算这道题,其中八位算命先生认为是传引用,剩下那两位则认为是传值。本着少数服从多数的原则,是不是答案就是传引用了呢?大叔暗喜~

在回去的路上,大叔越想越觉得不妥,好歹我也是在衙门工作啊,全心全意为人民服务是我的工作宗旨,这是个多么神圣的岗位,我怎么能如此草率呢?大叔一脚把可乐瓶踢到数十米开外,发誓绝不让自己的职业节操碎了一地。

回到衙门的公寓,大叔打开笔记本电脑,靠神不如靠自己,干脆自己试试看,于是大叔打开vscode,写下下面这段测试代码:

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)
}

编译运行打印输出:

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

大叔咋一看输出结果,咦~ changeSlice函数修改了切片,变量 slice也跟着修改了,这不就是引用传递的表现吗?难道这厮真的是传引用?大叔摸着良心喃喃自语。鉴于对良心的忠诚,于是大叔又在机械键盘上敲下了下面这个例子:

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)
}

上面的代码跟前面的例子大致是一样的,只不过这次把切片的地址打印出来了。大叔认为,如果是传引用,那么参数切片s的地址和main函数中slice变量的地址应该是同一个的。基于这个结论,大叔再次运行代码,这次有了意外发现:

slice: [0 1 2 3] slice addr 0xc0000a6020 
func: 0xc0000a6060 
slice: [0 111 2 3] slice addr 0xc0000a6020

what???大叔硬是愣了两秒。参数切片s的地址和main函数中slice变量的地址竟然不是同一个,不是传引用吗?为什么地址会不一样呢?

大叔从裤袋中掏出根芙蓉王,点上火,缓缓吸上两口,锁眉寻思:这货果然是有点问题!

让切片“显出原形”

slice作为函数参数传递一定不是传引用,不然函数里面打印参数的地址应该和外面切片变量的地址一样才对。大叔抖了抖烟灰又吸上两口,心里寻思:既然不是传引用,难道是传值?那问题又来了,如果是传值,那为什么changeSlice函数内修改了切片也会直接影响main函数中切片变量呢?这又说不通了。难道传的是指针?一下子 值传递指针传递引用传递 这些概念把大叔搞得糊里糊涂的,大叔想想有必要把当年去技校进修时的笔记拿出来翻翻确认一下这些概念。

大叔走到墙角,找到了一本书皮发霉发黄的羊皮书,吹了吹上面那两公分厚的粉尘,嗯,还能看!

这一看,还真有新发现,笔记是这样记录的:

划重点

技校官网明确声明:Go里面函数传参只有值传递一种方式,详情可参考技校文档:golang.org/ref/spec#Ca…

什么是 传值(值传递)指针传递传引用(引用传递)

  • 传值(值传递) :是指在调用函数时将实际参数拷贝一份传递到函数中,这样在函数中对参数进行修改不会影响到实际参数。
  • 指针传递 :形参是指向实参地址的指针,当对形参的指向进行操作时,就相当于对实参本身进行操作,看例子:
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
}

运行输出:

value: 0xc000016088
addr: 0xc00000e028
函数内的 value: 0xc000016088
函数内的 addr: 0xc00000e038
a 的值被修改了,新值为: 1

上面代码中定义了一个变量 a,并把地址保存在指针变量pa里面,因为Go中只有值传递,所以指针变量pa传给函数的形参p后,形参其实就是指针变量pa的一份拷贝,它们本身将各自拥有不同的地址,但是两者的值是一样的。注意:任何存放在内存里的东西都有自己的地址,指针也不例外,它虽然指向别的数据,但是也有存放该指针的内存

从上面的输出结果可以看到,这是一个指针的拷贝,pa与p的地址不同,是两个不同的指针,但两者的值是一样的。

  • 传引用(引用传递):是指在调用函数时将实际参数的地址传递到函数中,在函数中对参数所进行的修改,将影响实际参数。由于Go里边不存在引用传递,民间关于Go中的引用传递是针对slice、map、channel的传言是错误的

既然Go中没有引用传递的说法,那为什么slice、map、channel这三种数据类型作为函数参数时,函数内对参数的修改会影响到实际参数呢?难道这三种数据类型走的都是指针传递的套路?在创建时就是一个指针类型了?

我们都知道 map 和 chan 都是使用make函数创建的,技校提供的源码如下:

// 源码路径:/usr/local/go/src/runtime/map.go
// makemap implements a Go map creation make(map[k]v, hint)
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If bucket != nil, bucket can be used as the first bucket.
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
    //省略无关代码
}
// 源码路径:/usr/local/go/src/runtime/chan.go
func makechan(t *chantype, size int64) *hchan {
    //省略无关代码
}

果然,对于 map 和 chan 这两种数据类型,在创建时make函数返回的都是一个指针。那slice类型呢?事实上slice 在这三种数据类型中算是比较奇葩的存在,我们先看看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中的数据
  • 打印三和打印四可以看出:修改数组和切片共同部分的数据,对两者都有直接影响,再次印证第二点的结论。

有时候我们用make函数创建切片,实际上go会在底层创建一个匿名的数组。如果从新的slice再切,那么新创建的切片都共享这个底层的匿名数组。

func main() {
    slice := make([]int, 5)
    for i:=0; i<len(slice);i++{
      slice[i] = i
    }
    fmt.Printf("slice %v \n", slice)

    slice2 := slice[1:4]
    fmt.Printf("slice %v, slice2 %v \n", slice, slice2)

    slice[1] = 1111
    fmt.Printf("slice %v, slice2 %v \n", slice, slice2)
}

打印输出:

slice [0 1 2 3 4] 
slice [0 1 2 3 4], slice2 [1 2 3] 
slice [0 1111 2 3 4], slice2 [1111 2 3]

如果你用fmt.Printf()函数直接打印slice,你会发现slice的内存地址是可以直接通过%p打印的,但是需要注意的是,直接打印slice 和 打印 &slice 是两种不一样东西,前者打印的是slice这个结构体中的指针指向数组元素的地址,后者打印的是存储这个slice的地址

func main() {
    arr := [5]int{0, 1, 2, 3, 4}
    slice := arr[0:3]
    fmt.Printf("slice pointer addr: %p\n", slice)
    fmt.Printf("arr[0] addr: %p\n", &arr[0])
}

打印输出:

slice pointer addr: 0xc0000ac030
arr[0] addr: 0xc0000ac030

从前面的内容我们可以知道,Go里面函数传参只有值传递一种方式,都是一个拷贝。指针传递实际上就是指针的拷贝,形参和实参有不同的地址,但是它们的值是一样的,所以修改形参能影响实参,我们把指针传递的类型称为引用类型

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

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

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

我们再回到切片作为函数参数的问题上,掌握上面的知识点后,我们可以认为,当slice作为参数传入函数时,本质是slice值的拷贝,只不过slice内部含有引用类型的数据(指向底层数组的指针array),因此修改函数内形参存储的元素可以达到修改实参slice里存储元素的目的。

看到这里,大叔咽了口水,头顶冒出一行字:我TM啥时候写过这玩意? 赶紧狠狠吸上一口芙蓉王压压惊。

不管啥时候写的,这份笔记算是点醒了大叔,原来是这么一回事,二狗和来福这两个憨憨的架算是白打了。

函数内修改形参切片一定会影响外部的实参吗

笔记看到这里,大叔觉得奇奇怪怪的知识点又被收在囊下了,于是跃跃欲试地写起了测试demo:

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[1] = 111
    fmt.Printf("func s: %v, addr: %p \n", s, s)
}

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

写完后,大叔得捋了捋啊:

  • 首先第一次打印没什么好说的肯定是赋值后的切片 slice: [0 1] 以及 该切片包含指针所指向的地址,此时切片的指针指向的 底层数组arr为:[0, 1]
  • 接着调用函数changeSlice,函数changeSlice内先是往切片中追加元素,于是切片s的指针指向的 底层数组arr变为了[0, 1, 3],接着改变切片第二个元素的值为111,于是底层数组arr又变为了[0, 111, 3]
  • 执行完函数changeSlice后,最后再打印外部的slice,因为slice的指针指向底层数组的起始和终点位置分别是数组的第0个单元(arr[0])和第1个单元(arr[1]), 所以最终打印的slice会是[0, 111]
  • 在打印切片地址时,因为形参切片s是实参切片slice的拷贝,它们的值是一样,所以上面打印的切片地址应该是一样的

嘀咕完后,激动的心颤抖的手,大叔迫不及待地运行起来代码:

slice: [0 1], addr: 0xc000138000 
func s: [0 111 3], addr: 0xc000138000 
slice: [0 111], addr: 0xc000138000

看到输出结果跟自己推测的是一样以后,大叔一脸自豪~ 牛x~

正所谓 人生得意须尽欢,莫使金樽空对月,既然懂其套路,怎么来都行,随便玩,随便改!于是大叔在上面的代码又做了修改:

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)
}

这次大叔在函数changeSlice中添加了 s = append(s, 4) 这段代码,继续往切片中添加元素。大叔心想,最终输出跟上面的一样,没毛病!于是非常开心地运行起代码:

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

看到输出结果后,大叔定住了,口水又咽了两口。什么玩意儿?不是共享着底层的数组吗?咋又改不了了呢?连地址也不一样了。看了日历,今天也不是七月十四啊,也是宜写代码的啊!

这又是怎么回事呢?大叔想不明白,于是目光又回到了那本破旧的笔记上。笔记接着是这样写的:

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

也就是说:使用append方法给slice追加元素的时候,由于slice的容量还未满,因此等同于扩展了slice指向数组的内容,可以理解为重新切了一个数组内容附给slice,同时修改了数组的内容。

如果切片进行append一个元素时数组越界了,超出切片容量了,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,将发生以下操作:

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

数组 [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。具体如下表:

切片切片字面量数组剩下空间长度容量
s[1:3][1, 2]224
s[1:1][]404
s[4:4][]101
s[4:5][4]011

深入了解可参考:studygolang.com/articles/98…

看到这里,大叔似乎有点明白了,上面的代码,当代码执行完 s = append(s, 4) 后,切片发生了扩容,切片s重新指向了扩容后的新的底层数组,因此再次修改切片s的元素时,不会影响外部切片slice。

作业

大叔望着天花板整整定住了五分钟,脑回路在疯狂整理刚刚看过的笔记。经过一番挣扎后,大叔终于是明白了。于是立马挥毫写下下面的代码:

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、 www.flysnow.org/2018/02/24/…

2、segmentfault.com/a/119000001…

3、studygolang.com/articles/98…