4

go语言传参是值传递还是引用传递

 2 years ago
source link: https://studygolang.com/articles/35220
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

go语言传参是值传递还是引用传递

peachestao · 2天之前 · 298 次点击 · 预计阅读时间 7 分钟 · 大约8小时之前 开始浏览    

 曾经在某次go面试中被面试官问到:“go中引用类型有哪些?”,我答到:“slice,map,channel”,面试官:“其实go没有引用类型,都是值类型“,当时我就懵了,这么基础的问题居然我跟面试官意见不同。现在想想也许是我听错了,他应该说的是:”go没有引用传递,都是值传递“。我们今天就来聊一下这个话题。

一个简单的例子

func modifySlice(slice []int) {
  slice[0] = 11
}

func main() {
  mySlice := []int{1, 2, 3}
  modifySlice(mySlice)
  fmt.Println(mySlice)
}

先看下运行结果:

[11 2 3]

这段代码很简单,modifySlice函数接收一个int类型的切片,并将切片的第一个元素值改成11,main函数定义了一个int类型切片变量mySlice,调用modifySlice函数并将其传入,最后打印出mySlice变量。从结果看mySlice变量在其他函数内部的修改反应到了函数外部(并不总是这样,如果切片在其他函数内部发生了扩容,则函数内部的修改不会反应到函数外部,这个下面会细讲),那么是否就意味着:“go传参是引用传递”呢?先不着急下结论,在这之前我们先来了解一下go语言中的值类型和引用类型。

go中的值类型和引用类型

值类型:基本类型,包含int、float、bool、string、struct、数组,特点是变量直接存储值,通常在栈中分配这些类型的变量值。我们以int类型为例来说明

i := 10
j := i 

i赋值给j,相当于重新开辟一段内存,并将变量i的值拷贝一份存入到新的内存地址中,内存存储形式见下图

50e94a082f16cebd84049c781dd67c04.png

引用类型:map、slice、channel、interface,特点是变量存储的是一个地址,这个地址存储最终的值,内容通常在堆上分配,通过GC回收。

我们以slice类型为例来说明,先看下slice底层实现

// src/runtime/slice.go
type slice struct {
  array unsafe.Pointer // 指针
  len  int // 长度
  cap  int // 容量
}

可以看出切片由指针:指向底层数组指针;长度:切片可用元素个数;容量:底层数组元素个数这三个字段组成,其中len<=cap,通过下标访问切片元素时下标不能大于len,否则会引发panic,当向切片添加元素时容量不够时会重新开辟一块内存空间,生成一个新的长度更大的数组(具体扩容多少倍不同的go版本实现不同,这个在以后的文章中会详细讲解),并将之前旧数组中的元素全部拷贝过来,之后将新的元素加入,切片指向新的数组,旧底层数组占用的内存会被回收。

sliceA := make([]int,2,4)
sliceA[0] = 1
sliceA[1] = 2

声明了一个长度为2,容量为4的sliceA变量,其内存存储形式见下图

e8e506f6a377ef7c448efc77b6fedd38.png

解开谜底

想要弄清楚go传参是值传递还是引用传递,只需要验证一件事情:“变量传入前和传入后内变量的底层地址是否发生了改变?如果发生了改变就是值传递,如果没有改变就是引用传递”。如何取变量的内存地址呢?可以通过在变量前加取址符号&,再配合fmt.Printf函数拿到,fmt.Printf("%p", &变量名),我们再回到第一个例子,这次稍加改造一下

func modifySlice(slice []int) {
  fmt.Printf("调用中:%p\n",&slice)
  slice[0] = 11
}

func main() {
  mySlice := []int{1, 2, 3}
  fmt.Printf("调用前:%p\n",&mySlice)
  modifySlice(mySlice)
  fmt.Printf("调用后:%p\n",&mySlice)
}

增加了变量mySlice在调用函数前、函数中和函数返回后的地址打印,结果如下:

调用前:0xc00000c080
调用中:0xc00000c0a0
调用后:0xc00000c080

从结果看出存储变量mySlice本身的地址在函数外和函数中不一样,证明go传参是值传递,他引用类型map,channel一样可以通过这种方式验证。不过你可能还有个疑问:为什么变量mySlice在modifySlice中的修改函数外面也会跟着被修改呢?

这次我们不加取址符号&,再看一下地址

func modifySlice(slice []int) {
  fmt.Printf("调用中:%p\n",slice)
  slice[0] = 11
}

func main() {
  mySlice := []int{1, 2, 3}
  fmt.Printf("调用前:%p\n",mySlice)
  modifySlice(mySlice)
  fmt.Printf("调用后:%p\n",mySlice)
  fmt.Println(mySlice)
}

打印结果:

调用前:0xc0000a0000
调用中:0xc0000a0000
调用后:0xc0000a0000

不加&符号在函数中和函数外得到的地址的一样的。我们来看下fmt.Printf的到底做了什么,

通过源码阅读,调用链为:Printf->Fprintf->doPrintf->printArg->fmtPointer->Pointer,省去中间函数,直接看Pointer的实现

func (v Value) Pointer() uintptr {
  k := v.kind()
  switch k {
  case Ptr:
  case Chan, Map, UnsafePointer:
    return uintptr(v.pointer())
  case Func:
  case Slice:// 走到这个分支
    return (*SliceHeader)(v.ptr).Data
  }
  panic(&ValueError{"reflect.Value.Pointer", v.kind()})
}

最后进入了case Slice这个分支,返回(*SliceHeader)(v.ptr).Data,意思是将slice的指针转换成了*SliceHeader,再取其Data字段值,我们来看下SliceHeader底层结构

type SliceHeader struct {
  Data uintptr
  Len  int
  Cap  int
}

这个结构跟slice底层结构一样,所以可以转换,回顾刚才关于slice的介绍,slice第一个字段值是指向底层数组的地址,fmt.Printf("%p",mySlice)拿到的是mySlice底层数组的指针,切片mySlice在函数外和传入函数中指向的底层数组是同一个,所以在函数内对其元素的更改在函数外也会跟着更改

我们再来看一下加取址符号&的底层实现

func (v Value) Pointer() uintptr {
  k := v.kind()
  switch k {
  case Ptr:// 走到这个分支
    if v.typ.ptrdata == 0 {
      return *(*uintptr)(v.ptr)
    }
    fallthrough
  case Chan, Map, UnsafePointer:
    return uintptr(v.pointer())
  case Func:
  case Slice:
    return (*SliceHeader)(v.ptr).Data
  }
  panic(&ValueError{"reflect.Value.Pointer", v.kind()})
}

加上&后走的是case Ptr分支 ,因为v.typ.ptrdata>0,执行到fallthrough,这个关键字的意思是强制调到下面分支继续执行,所以执行case Chan, Map, UnsafePointer这个分支,我们再来看看v.Pointer代码

// pointer returns the underlying pointer represented by v.
func (v Value) pointer() unsafe.Pointer {
  if v.typ.size != ptrSize || !v.typ.pointers() {
    panic("can't call pointer on a non-pointer Value")
  }
  if v.flag&flagIndir != 0 {
    return *(*unsafe.Pointer)(v.ptr)
  }
  return v.ptr
}

看函数上方的注释:”pointer returns the underlying pointer represented by v“,意思是:返回由v表示的底层指针,即存储变量本身的内存地址

接下来谈一下上面说的:”切片在函数内部的更改并不总是反应到函数外部“,这也是一些刚学go语言不久的同学容易踩的坑,这里详细讲一下,我们改动一下modifySlice的代码

func main() {
  mySlice := []int{1, 2, 3}
  fmt.Printf("调用前地址:%p\n",mySlice)
  modifySlice(mySlice)
  fmt.Printf("调用后地址:%p\n",mySlice)
  fmt.Printf("调用后值:%v\n",mySlice)
}

func modifySlice(slice []int) {
  fmt.Printf("函数内部-添加元素前地址:%p\n",slice)
  slice = append(slice, 4)
  fmt.Printf("函数内部-添加元素后地址:%p\n",slice)
  fmt.Printf("函数内部值:%v\n",slice)
}

改成在向切片添加一个元素4,看打印结果

调用前地址:0xc0000b6000
函数内部-添加元素前地址:0xc0000b6000
函数内部-添加元素后地址:0xc0000ac060
函数内部值:[1 2 3 4]
调用后地址:0xc0000b6000
调用后值:[1 2 3]

可以看出添加新元素时由于容量是3不足导致扩容,切片的底层数组地址变了,导致切片在函数内部添加了元素4而函数外部却没有添加。

有没有办法让切片在函数内部的更改始终影响到函数外部?答案是传参以指针的形式传入,代码如下

func main() {
  mySlice := []int{1, 2, 3}
  fmt.Printf("调用前切片地址:%p\n",mySlice)
  fmt.Printf("调用前切片底层地址:%p\n", &mySlice)
  modifySlice(&mySlice)
  fmt.Printf("调用后切片地址:%p\n",mySlice)
  fmt.Printf("调用后切片底层地址:%p\n", &mySlice)
  fmt.Printf("调用后值:%v\n",mySlice)
}

func modifySlice(slice *[]int) {
  fmt.Printf("函数内部-添加元素前切片地址:%p\n",slice)
  fmt.Printf("函数内部-添加元素前切片底层地址:%p\n",&slice)
  *slice = append(*slice, 4)
  fmt.Printf("函数内部-添加元素后切片地址:%p\n",slice)
  fmt.Printf("函数内部-添加元素后切片底层地址:%p\n",&slice)
  fmt.Printf("函数内部值:%v\n",*slice)
}

 modifySlice接收的参数类型改成了*[]int,用&mySlice传入,另外增加了函数内部和外部存储变量本身的地址打印,结果如下

调用前切片地址:0xc000136000
调用前切片底层地址:0xc000126020
函数内部-添加元素前切片地址:0xc000126020
函数内部-添加元素前切片底层地址:0xc00012e020
函数内部-添加元素后切片地址:0xc000126020
函数内部-添加元素后切片底层地址:0xc00012e020
函数内部值:[1 2 3 4]
调用后切片地址:0xc00012c060
调用后切片底层地址:0xc000126020
调用后值:[1 2 3 4]

可以看出slice和mySlice值都为[1,2,3,4],函数内部指针变量slice指向了函数外部mySlice的底层相同的地址0xc000126020,我们看下这句代码:*slice = append(*slice, 4), *slice意思是取slice的值,我们知道slice=&mySlice,所以可以理解为*slice相当于外部函数的mySlice,这样一来代码可以改成:mySlice = append(mySlice, 4),相当于在函数内部对函数外部的变量进行了更改,这就是以指针作为参数传入时函数内部添加的元素也影响到了函数外部的根本原因。另外函数内slice的底层地址0xc00012e020与数外部mySlice底层地址0xc000126020不同,  证明指针变量传参也是值传递,如果描述的还不够清楚,请看下面的图示

a5010b3a7595c195dcac632e48554310.png

总结

在Go语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。

最后引用一段英文来结束今天的文章

Golang is a pass by value language, so any time we pass a value to a function either as a receiver or as an argument that data is copied in memory and so the function by default is always going to be working on a copy of our data structure. We can address this problem and modify the actual underlying data structure through the use of pointers and memory address.

参考资料

https://stackoverflow.com/questions/39993688/are-slices-passed-by-value

https://www.jianshu.com/p/f201d6da488a

https://www.jianshu.com/p/18d3bde7d835

如果这篇文章对你有帮助,可以关注我的公众号,第一时间获取最新的原创文章

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Rhb2VyY2h1bg==,size_16,color_FFFFFF,t_70


有疑问加站长微信联系(非本文作者)

280

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK