(正经版)面试官:切片作为函数参数是传值还是传引用?

9,031 阅读12分钟

「第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 的值虽然相同,但是存放这两个指针的内存地址是不同的,因此这是两个不同的指针。

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

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

「看评论图片1」

传引用(引用传递)

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

假设以上面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],最终得到的切片如下:

「看评论图片2」

再看个例子:

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会做哪些操作呢?

  1. 创建一个新的临时切片t,t的长度和slice切片的长度一样,但是t的容量是slice切片的2倍,新建切片的时候,底层也创建了一个匿名的数组,数组的长度和切片容量一样。(实际上不一定是两倍扩容,详情可参考文章 Golang 中关于slice扩容策略
  2. 复制slice里面的元素到t里,即填入匿名数组中。然后把t赋值给slice,现在slice的指向了底层的匿名数组。
  3. 转变成小于容量的append方法。

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

「看评论图片3」

再回到刚刚举的例子,之所外部的实参切片变量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。具体如下表:

「看评论图片4」

作业

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

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