这篇笔记的思考开始于一篇帖子中提的问题:下面这段代码中,都是从 map 中取一个元素并调用其方法,为什么最后一行无法编译通过
go
import "testing"
type S struct {
Name string
}
func (s *S) Write() {
s.Name = "name"
}
func TestX(t *testing.T) {
m := map[int]S{1: {"A"}}
// 这能编译通过:
s := sVals[1]
s.Write()
// 这里不能编译通过
sVals[1].Write()
// 报错 cannot call pointer method Write on S
}
要回答这个问题,涉及到 Go 中的几个概念,隐式引用转换和可寻址 Addressable
隐式引用转换
先看第一次调用 Write
的地方,首先 sVals[1]
返回的是一个 S
类型的值赋值给变量 s
,而之所以能够在 S
类型的变量 s
上调用 *S
类型的 Write
,是因为 Go 支持隐式引用转换,这个调用的完整写法应该是:
go
s := sVals[1]
(&s).Write()
Go 隐式引用转换后可以简写成
go
s := sVals[1]
s.Write()
那么为什么第二个 Write
调用无法编译通过呢?这涉及到另一个概念:可寻址与临时值。
可寻址和临时值
可寻址 Addressable 指的是能够通过内存地址来访问变量的特性。如果一个变量是可寻址的,那么你可以使用取地址操作符 &
来获取它的内存地址。
而临时值都是不可寻址的,临时值一句话概括就是表达式的中间状态,它们的生命周期很短,只在表达式计算过程中存在。临时值只有在赋值给某个变量后临时值才算完成了使命,这个过程相当于一个值被创建出来最终安家落户,有了自己的地址,之后才能询问这个值的地址是多少。
下面是几个可寻址例子
go
// **局部变量**:函数内的局部变量是可寻址的。
func main() {
x := 5
p := &x // x 是可寻址的
}
// **全局变量**:全局变量也是可寻址的。
var globalVar int
func main() {
p := &globalVar // globalVar 是可寻址的
}
// **数组的元素**:数组或切片的元素是可寻址的。
func main() {
arr := [3]int{1, 2, 3}
p := &arr[1] // arr[1] 是可寻址的
}
// **结构体的字段**:如果你有一个结构体变量,那么它的字段是可寻址的。
type MyStruct struct {
Field int
}
func main() {
s := MyStruct{Field: 5}
p := &s.Field // s.Field 是可寻址的
}
下面是几个不可寻址的例子
go
// **直接从函数调用返回的值**:不能对函数调用的结果直接取地址。
func myFunc() int {
return 5
}
func main() {
// p := &myFunc() // 这是错误的,因为 myFunc() 的结果不可寻址
}
// **基本类型字面量**:如直接对 **5** 取地址是不允许的。
func main() {
// p := &5 // 错误,字面量不可寻址
}
// **临时结果**:如表达式的中间结果。
func main() {
x := &MyStruct{5} // 正确,因为这是一个变量
// y := &MyStruct{5}.Field // 错误,.Field 是一个临时值
}
再回到刚才的问题,当调用
go
sVals[1].Write()
时,如果 Go 可以进行隐式引用转换,那么就应该转换成下面这种形式:
go
(&sVals[1]).Write
但实际上却报了下面的错误
go
cannot call pointer method Write on S
这个错误是说不能在类型 S
上调用指针方法 Write
,这说明 Go 没有将 sVals[1]
进行引用转换。为什么没有进行引用转换呢?
这里可以做一个假设,按理说 sVals[1]
的元素已经存在于内存了,也就是说应该可以被寻址的,所以应该进行隐式引用转换成功。如果没有进行引用转换,是不是说取出来的对象是一个不能被寻址的对象呢?
事实上确实是就是这样,sVals[1]
取出来的并不是原始的对象,而是原对象的一个重新生成的副本,这就涉及到另一个概念:值传递。
map 的值传递
在 Go 中,所有的函数参数和返回值都是通过值传递的,这意味着它们都是原始数据的副本,而不是引用或指针。
这个原则在 map 中也成立,从 map 中取出一个元素返回的也是该元素的副本,而并不是该元素本身。所以上述代码中
go
sVals[1]
返回的是一个副本,也就是说这是一个临时值,而对于临时值是不可寻址的。所以引用转换是不可能的,最后无法编译通过报出错误。
回答最初的问题
到这里就已经可以回答前面的问题了,由于 sVals[1]
是一个临时值所以不可寻址,所以无法进行引用转换,无法将 S
类型的变量 s
转换成 *S
类型,最后导致编译错误,报出不能在 S
类型上调用 Write
方法。
为什么要这样设计
为什么 map 要返回一个副本回来,而不是返回原始对象的地址?这种设计选择是出于安全性和一致性的考虑。由于 map 可能在运行时进行重新哈希以调整大小,重哈希后元素的地址可能发生变化,所以如果支持返回地址,那么可能会在程序运行中出现错误。例如一开始持有了一个元素的地址,之后 map 发生重哈希,地址都变了,再用之前获取的地址做操作,肯定会出问题。
既然返回的是一个副本,那么想要做出修改的话就需要注意了。例如下面这段代码
go
m := map[int]S{}
m[1] = S{Name: "11"}
s := m[1]
s.Name = "22"
fmt.Println(s)
fmt.Println(m)
// 输出
// {22}
// map[1:{11}]
可以看到在 map 中取一个元素并修改其内容并不会影响 map 中原有元素。
那么应该如何修改 map 中的元素呢?
第一种是先修改,再回写:
go
m := map[int]S{}
m[1] = S{Name: "11"}
s := m[1]
s.Name = "22"
m[1] = s // 回写
fmt.Println(s)
fmt.Println(m)
// 输出
// {22}
// map[1:{22}]
第二种就是 map 中存放指针类型
go
m := map[int]*S{}
m[1] = &S{Name: "11"}
s := m[1]
s.Name = "22"
fmt.Println(s)
fmt.Println(m[1])
// 输出
// &{22}
// &{22}
用指针操作赋值是完整写法应该是
(*s).Name
,而 *s
是从指责中取出对象操作,自然可以赋值。
容易混淆的值传递、引用传递与值类型、引用类型
前面一直在讨论值传递,与之相对应的是引用传递。这两种传递方式决定了函数调用时参数是如何传递的:
- **值传递:**值传递复制数据
- 引用传递:引用传递复制的是数据的地址
Go 采用的就是值传递,当调用一个需要参数的函数时,函数参数会复制一份,如果参数是一个指针,也会复制出来一个新的指针对象,但注意复制的是指针对象,即新旧两个指针对象已经完全独立,有各自的内存地址,但是两个指针对象内部指向的目标对象地址没有改变,如下面代码和图示:
go
s := &S{Name: "s"}
fmt.Printf("函数外,s指针本身的地址:%p\n", &s)
fmt.Printf("函数外,s指向对象的地址:%p\n", s)
fmt.Println("---")
updateObj(s)
func updateObj(s *S) {
fmt.Printf("函数内,s指针本身的地址:%p\n", &s)
fmt.Printf("函数内,s指向对象的地址:%p\n", s)
s.Name = "updated"
}
// 输出
// 函数外,s指针本身的地址:0x1400000e058
// 函数外,s指向对象的地址:0x1400005e6d0
// ---
// 函数内,s指针本身的地址:0x1400000e060
// 函数内,s指向对象的地址:0x1400005e6d0
// &{updated}
这也证明了有种说法称 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 字节。
本文由mdnice多平台发布