所以Go是传值还是传引用
前言
在日常使用Go开发的过程中,我们经常会在函数的参数中这样使用去传递:
func (s *LoginLogs) Start(ctx context.Context, msg *gjson.Json){}类似于上面代码带 * 的用法,在高级语言中,带 * 一般定义这个参数是一个指针 ,那么既然是指针参数,在Go语言中,它属于传值还是传引用呢?
关于Go是传值(值传递)还是传引用(引用传递)这个问题,在Go的论坛和各种交流群中,经常引发了很多人的讨论。就这个问题也让我思考了很久,通过根据网上查阅的资料和官方的相关文档,来按照我的理解,来阐述一下这个问题的答案到底是什么?
来自官方爸爸的说法
有天在Go的官网探索,在Go的官方文档发现有关于Go的常见问题的解答,于是乎在里面找到了一个关于值传递和引用传递的说法,上传送门:
https://go.dev/doc/faq#pass_by_value
大概的意思就是:与C系列的所有语言一样,Go 中的所有内容都是按值传递的。也就是说,一个函数总是得到被传递的东西的副本,就好像有一个赋值语句将值赋给参数一样。
文档解释了三种常见的场景,包括了基本数据类型、map 和 slice 类型、接口类型:
1、将int值传递给函数会生成的副本int,传递指针值会生成指针的副本,但不会复制它指向的数据。
2、map 和切片 slice 的行为类似于指针:它们是包含指向底层 map 或 slice 数据的指针的描述符。复制一个 map 或者 slice 的值并不复制它所指向的数据。
3、复制一个interface值会复制存储在interface中的东西。如果 interface 持有一个结构体,复制的 interface 就会复制该结构体。如果 interface 持有一个指针,复制的 interface 会复制该指针,但同样不会复制它所指向的数据
官方的答复,也对这个讨论了很久的问题做一个正式的解释,结论给出来了:
这个例子告诉我们了什么?没事多看看官方的文档 ,说不定你的疑惑就在文档里面解决了
虽然问题的结论已经出来了,但是相信很多人都不能理解为什么它只有值传递,没有引用传递,不是明明用了指针吗,这还不是引用传递,接下来,我们举几个例子,用实践和大白话来说明下,Go为什么是值传递而不是引用传递。
函数的值传递
值传递(pass by value),是指的在调用函数的时候将实际参数的值复制一份传递到函数中,在函数中对参数进行的任何修改,都不会影响到实际的参数。值传递实际上就是传递实参的副本,他们之间的内存地址是不同的。来看看下面的例子:
func TestPassByValue(t *testing.T) {str := "this is a test text"println(fmt.Printf("变量str的内存地址:%p", &str))HelloStrFunction(str)}func HelloStrFunction(str string) {println(fmt.Printf("参数str的内存地址:%p", &str))}
我们定义了一个变量,并打印他的内存地址,然后传到一个函数中,再次打印传进去的参数,结果如下:
可以看到打印出来的内存地址是不一样的,这就很常见,也符合我们对值传递的看法,那么再看看下面这个例子:
func TestPassByValue(t *testing.T) {str := "this is a test text"println(fmt.Printf("变量str的内存地址:%p", &str))HelloStrFunction(&str)}func HelloStrFunction(str *string) {println(fmt.Printf("参数str的内存地址:%p", &str))}
将参数改为指针类型(引用类型)参数,如果是指针参数是引用传递的话,传递的地址应该是一样的,结果如下:
很显然,在指针参数的内存地址也是不一样的,这就能说明,在Go语言中,函数的传递是值传递。
那既然是值传递,我们在函数内修改参数 str 的值,应该不会影响到 TestPassByValue() 函数中的值吧,Let's try it:
func TestPassByValue(t *testing.T) {str := "this is a test text"println(fmt.Printf("变量str的内存地址:%p", &str))HelloStrFunction(&str)println(str)}func HelloStrFunction(str *string) {println(fmt.Printf("参数str的内存地址:%p", &str))*str = "this is a change params"}
运行结果如下:
在函数内改变了str的值为 "this is a change params" ,在外部调用的主函数中,输出来了的变量 str 的值被修改了。这就奇怪了,说好的只有值传递不改变外部调用函数的变量值,那为什么在这里还是被改变了呢?来仔细解刨一下这当中的奥妙。
当我们定义变量 str 时,会在内存中开辟出一片内存空间,当此时,内存空间对应着一块地址,地址是value_str:0xc0006102f039 ,这个时候传递到函数中同样是一个地址,产生了地址拷贝,产生了另一个地址param_str:0xc0006220c839。当传过去的值是指向内存空间的地址时,拷贝出的地址,同样也指向着同一个内存空间,这个时候,是可以对这块公用的内存空间进行修改的,如下图所示:
也就是说,这两个内存地址,实际上是指针的指针,可以理解为把指针当做一个值包装起来,传递进去是一个地址。他们最根源其实都是指向同一个指针。
因此在引用类型(指针类型)下,函数传递的值是可以被修改的,但是本质上传递进去的参数还是值传递。引用类型不等于引用传递!
函数的引用传递
引用传递(pass by reference),是指的在调用函数的时候将实际参数的地址直接传递到函数中,在函数中对参数进行的任何修改,都将影响到实际的参数。引用传递与上诉引用类型的值传递的区别是,他的地址并不会改变,最终输出的内存地址都是一样的,像在PHP语言中,引用传递就存在。但在Go语言中,官方已经明确表明了没有引用传递。
Map类型和Slice这类特殊类型
Map类型
先来看看Map类型,在Map中,不需要指定引用类型,也可以对Map的值进行修改,看看下面的例子,定义了一个key value 类型都为string的map:
func TestMap(t *testing.T) {m := make(map[string]string)m["map_key"] = "map_value"println(fmt.Printf("外部的map的内存地址:%p", &m))HelloMapFunction(m)fmt.Println(m)}func HelloMapFunction(m map[string]string) {println(fmt.Printf("函数内的map的内存地址:%p", &m))m["map_key"] = "change map_value"}
以上代码输入结果如下:
首先可以看到,外部的map的内存地址和传递到函数内的内存地址确实是不一样的,也是一个值传递的体现,但是,在函数 HelloMapFunction() 中修改了这个map的值,在外部的map的值也受到了影响,并且map没有使用指针类型,这又是为什么呢?
带着这个疑问,我搜索了一些关于map的底层实现,map的底层实际上是一张哈希表,通过键值对进行映射。键通过哈希函数生成哈希值,然后go底层的map数据结构就存储相应的hash值,进行索引,最终是在底层使用的数组存储key,和value。
通过查阅资料发现,创建map类型的底层runtime方法,其返回的是一个 *hmap 类型,是一个指针类型。
func makemap(t *maptype, hit int, h *map) *hmap{}下面是go-map的底层结构:
// Go map的一个header结构type hmap struct {count int // map的大小. len()函数就取的这个值flags uint8 //map状态标识B uint8 // 可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子即:map长度=6.5*2^B//B可以理解为buckets已扩容的次数noverflow uint16 // 溢出buckets的数量hash0 uint32 // hash 种子buckets unsafe.Pointer //指向最大2^B个Buckets数组的指针. count==0时为nil.oldbuckets unsafe.Pointer //指向扩容之前的buckets数组,并且容量是现在一半.不增长就为nilnevacuate uintptr // 搬迁进度,小于nevacuate的已经搬迁extra *mapextra // 可选字段,额外信息}//额外信息type mapextra struct {overflow *[]*bmapoldoverflow *[]*bmapnextOverflow *bmap}//在编译期间会产生新的结构体,buckettype bmap struct {tophash [8]uint8 //存储哈希值的高8位data byte[1] //key value数据:key/key/key/.../value/value/value...overflow *bmap //溢出bucket的地址}
也就是Go语言对map这一特殊类型做了一层底层的封装,实际上当map被创建出来时,返回的就已经是一个指针类型了。在Go中,chan类型也是类似于此的实现
Slice 类型
Slice就比较有意思了。怎么个有意思之处,我们再看一个例子:
func TestSlices(t *testing.T) {names := []string{"杜兰特", "詹姆斯", "欧文"}fmt.Printf("变量names的内存地址:%p \n", names)HelloSliceFunction(names)fmt.Println(names)}func HelloSliceFunction(s []string) {fmt.Printf("变量s的内存地址:%p \n", s)s[1] = "哈登"}
上面代码运行结果如下:
惊奇的发现,地址竟然是一样的!并且slice里面的值也被改变了,这不就是引用传递吗?这又是为什么呢,官方出现Bug翻车了吗?[手动狗头]
其实并不是这样的,仔细看下代码我们能发现两个事情:
1、没有用取地址符 & 来取地址
2、在没用取地址符 & 的情况下,能用 %p 打印地址
最开始看到这个结果我也很吃惊,直到点进去标准库fmt的实现。之所以能够同时做到上面的两件事,是因为标准库针对这些类型做了一个处理:
func (p *pp) fmtPointer(value reflect.Value, verb rune) {var u uintptrswitch value.Kind() {case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.Slice, reflect.UnsafePointer:u = value.Pointer()default:p.badVerb(verb)return}switch verb {......}
当类型是chan、Map、Slice等类型的时候,调用 value.Pointer() ,标准库做了处理使得这些类型不需要取地址符也能获取到对应的地址,点进去 value.Pointer() 的实现我们又能发现一些解释:
func (v Value) Pointer() uintptr {k := v.kind()switch k {case Pointer:......case Chan, Map, UnsafePointer:return uintptr(v.pointer())case Func:......case Slice:return (*SliceHeader)(v.ptr).Data}panic(&ValueError{"reflect.Value.Pointer", v.kind()})}type SliceHeader struct {Data uintptrLen intCap int}
打印Slice的时候,是取 SliceHeader 结构体的 Data 属性。在调用%p输出时,是在输出slice的底层存储数组的元素的地址。听到这里可能还是有点懵,让我们看一张图就明白了:
那需要拿到slice对象的切片地址应该怎么拿呢?很简单,带上取地址符不就完了:
func TestSlices(t *testing.T) {names := []string{"杜兰特", "詹姆斯", "欧文"}fmt.Printf("变量names的内存地址:%p \n", &names)HelloSliceFunction(names)fmt.Println(names)}func HelloSliceFunction(s []string) {fmt.Printf("变量s的内存地址:%p \n", &s)s[1] = "哈登"}
上面代码运行结果如下:
很明显,传入函数的地址产生了地址拷贝,导致地址不一样了,所以 slice 的传递本质上还是值传递!是不是很巧妙?至于为什么能够修改值,其实在底层实现中已经能看出来了,其传递的也是一个指针类型。
总结
作为一名Go语言的初学者来说,关于是参数的传递是引用传递还是值传递,引用类型和引用传递的混淆,是经常会出现的。针对这些疑惑,对这一块做了相对详细的学习,通过实验来证明官方的说法是正确的,那就是Go语言只有值传递,没有引用传递。
另外在很多地方,最让人容易产生误解的就是map类型和slice类型了,确实,Go标准库的一些实现的确会让人造成这样的一些误解,本文对这两种类型也做了一些浅谈,表述自己的理解。
Anyway,总体实验过程还是有趣的~
以上内容,如有不正确或者有歧义的地方,欢迎大家参与讨论!