由 Go 结构体指针引发的值传递的思考

date
Dec 10, 2023
slug
golang-struct-pointer-pass-by-value
status
Published
tags
Go
summary
Go 到底是值传递还是引用传递
type
Post
这篇笔记的思考开始于一篇帖子中提的问题:下面这段代码中,都是从 map 中取一个元素并调用其方法,为什么最后一行无法编译通过
要回答这个问题,涉及到 Go 中的几个概念,隐式引用转换和可寻址 Addressable
 

隐式引用转换

先看第一次调用 Write 的地方,首先 sVals[1] 返回的是一个 S 类型的值赋值给变量 s,而之所以能够在 S 类型的变量 s 上调用 *S 类型的 Write ,是因为 Go 支持隐式引用转换,这个调用的完整写法应该是:
Go 隐式引用转换后可以简写成
那么为什么第二个 Write 调用无法编译通过呢?这涉及到另一个概念:可寻址与临时值。
 

可寻址和临时值

可寻址 Addressable 指的是能够通过内存地址来访问变量的特性。如果一个变量是可寻址的,那么你可以使用取地址操作符 & 来获取它的内存地址。
而临时值都是不可寻址的,临时值一句话概括就是表达式的中间状态,它们的生命周期很短,只在表达式计算过程中存在。临时值只有在赋值给某个变量后临时值才算完成了使命,这个过程相当于一个值被创建出来最终安家落户,有了自己的地址,之后才能询问这个值的地址是多少。
下面是几个可寻址例子
下面是几个不可寻址的例子
再回到刚才的问题,当调用
时,如果 Go 可以进行隐式引用转换,那么就应该转换成下面这种形式:
但实际上却报了下面的错误
这个错误是说不能在类型 S 上调用指针方法 Write,这说明 Go 没有将 sVals[1] 进行引用转换。为什么没有进行引用转换呢?
这里可以做一个假设,按理说 sVals[1] 的元素已经存在于内存了,也就是说应该可以被寻址的,所以应该进行隐式引用转换成功。如果没有进行引用转换,是不是说取出来的对象是一个不能被寻址的对象呢?
事实上确实是就是这样,sVals[1] 取出来的并不是原始的对象,而是原对象的一个重新生成的副本,这就涉及到另一个概念:值传递。
 

map 的值传递

在 Go 中,所有的函数参数和返回值都是通过值传递的,这意味着它们都是原始数据的副本,而不是引用或指针。
这个原则在 map 中也成立,从 map 中取出一个元素返回的也是该元素的副本,而并不是该元素本身。所以上述代码中
返回的是一个副本,也就是说这是一个临时值,而对于临时值是不可寻址的。所以引用转换是不可能的,最后无法编译通过报出错误。
 

回答最初的问题

到这里就已经可以回答前面的问题了,由于 sVals[1] 是一个临时值所以不可寻址,所以无法进行引用转换,无法将 S 类型的变量 s 转换成 *S 类型,最后导致编译错误,报出不能在 S 类型上调用 Write 方法。
 

为什么要这样设计

为什么 map 要返回一个副本回来,而不是返回原始对象的地址?这种设计选择是出于安全性和一致性的考虑。由于 map 可能在运行时进行重新哈希以调整大小,重哈希后元素的地址可能发生变化,所以如果支持返回地址,那么可能会在程序运行中出现错误。例如一开始持有了一个元素的地址,之后 map 发生重哈希,地址都变了,再用之前获取的地址做操作,肯定会出问题。
既然返回的是一个副本,那么想要做出修改的话就需要注意了。例如下面这段代码
可以看到在 map 中取一个元素并修改其内容并不会影响 map 中原有元素。
那么应该如何修改 map 中的元素呢?
第一种是先修改,再回写:
第二种就是 map 中存放指针类型
用指针操作赋值是完整写法应该是
(*s).Name,而 *s 是从指责中取出对象操作,自然可以赋值。
 

容易混淆的值传递、引用传递与值类型、引用类型

前面一直在讨论值传递,与之相对应的是引用传递。这两种传递方式决定了函数调用时参数是如何传递的:
  • 值传递:值传递复制数据
  • 引用传递:引用传递复制的是数据的地址
Go 采用的就是值传递,当调用一个需要参数的函数时,函数参数会复制一份,如果参数是一个指针,也会复制出来一个新的指针对象,但注意复制的是指针对象,即新旧两个指针对象已经完全独立,有各自的内存地址,但是两个指针对象内部指向的目标对象地址没有改变,如下面代码和图示:
notion image
这也证明了有种说法称 Go 支持引用传递的说法是不严谨的,这种说法认为,通过传递指针,可以实现在函数内部修改对象的效果,所以 Go 支持引用传递,而事实上这里面依旧是值传递,只不过复制的是指针本身。
 
除此之外 Go 中数据类型还分为值类型和引用类型,这两种类型决定了数据是如何在内存中存储的:
  • 值类型:值类型直接存储数据,如基本数据类型(如 int、float、bool)、结构体(struct)和数组都是值类型。
  • 引用类型:而引用类型存储的是数据的引用,如切片(slice)、映射(map)、通道(channel)等都是引用类型。
可以在 runtime/map.go 中看到通过 makemap 函数创建一个 map 对象,实际上返回的是一个 *hmap 的指针类型;
在 runtime/chan.go 中可以看到通过 makechan 创建 channel 时返回的是一个 *hchan 指针类型;
在 runtime/slice.go 的 makeslice 返回的直接就是一个指针 unsafe.Pointer
这些都证明了上述几个类型都是引用类型,也就意味着这些类型作为函数参数传递时复制的都是指针。
无论是值类型还是引用类型(如指针),在作为参数传递给函数时都是通过值传递的方式。对于指针,虽然函数接收的是指针的副本,但由于这个副本指向原始数据的相同内存地址,所以函数内部对该地址的数据所做的修改会影响到原始数据。
 

可能得性能问题

最后一个问题,既然函数传递和容器类结构维护存取的都是副本,那么如果反复传递一些大对象,就会频繁复制对象,导致性能下降,所以传递对象时,应该尽量传递对象的指针,因为即使复制指针,指针类型长度也在可控范围内,如在 32 位机上占用 4 字节,在 64 位机上占用 8 字节。
 
 

© 菜皮 2020 - 2024