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

2023-12-18 14:36:39 浏览数 (1)

这篇笔记的思考开始于一篇帖子中提的问题:下面这段代码中,都是从 map 中取一个元素并调用其方法,为什么最后一行无法编译通过

代码语言:javascript复制

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 支持隐式引用转换,这个调用的完整写法应该是:

代码语言:javascript复制

s := sVals[1]
(&s).Write()

Go 隐式引用转换后可以简写成

代码语言:javascript复制

s := sVals[1]
s.Write()

那么为什么第二个 Write 调用无法编译通过呢?这涉及到另一个概念:可寻址与临时值。

可寻址和临时值

可寻址 Addressable 指的是能够通过内存地址来访问变量的特性。如果一个变量是可寻址的,那么你可以使用取地址操作符 & 来获取它的内存地址。

而临时值都是不可寻址的,临时值一句话概括就是表达式的中间状态,它们的生命周期很短,只在表达式计算过程中存在。临时值只有在赋值给某个变量后临时值才算完成了使命,这个过程相当于一个值被创建出来最终安家落户,有了自己的地址,之后才能询问这个值的地址是多少。

下面是几个可寻址例子

代码语言:javascript复制

// 局部变量:函数内的局部变量是可寻址的。
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 是可寻址的
}

下面是几个不可寻址的例子

代码语言:javascript复制

// 直接从函数调用返回的值:不能对函数调用的结果直接取地址。
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 是一个临时值
}

再回到刚才的问题,当调用

代码语言:javascript复制

sVals[1].Write()

时,如果 Go 可以进行隐式引用转换,那么就应该转换成下面这种形式:

代码语言:javascript复制

(&sVals[1]).Write

但实际上却报了下面的错误

代码语言:javascript复制

cannot call pointer method Write on S 

这个错误是说不能在类型 S 上调用指针方法 Write,这说明 Go 没有将 sVals[1] 进行引用转换。为什么没有进行引用转换呢?

这里可以做一个假设,按理说 sVals[1] 的元素已经存在于内存了,也就是说应该可以被寻址的,所以应该进行隐式引用转换成功。如果没有进行引用转换,是不是说取出来的对象是一个不能被寻址的对象呢?

事实上确实是就是这样,sVals[1] 取出来的并不是原始的对象,而是原对象的一个重新生成的副本,这就涉及到另一个概念:值传递。

map 的值传递

在 Go 中,所有的函数参数和返回值都是通过值传递的,这意味着它们都是原始数据的副本,而不是引用或指针。

这个原则在 map 中也成立,从 map 中取出一个元素返回的也是该元素的副本,而并不是该元素本身。所以上述代码中

代码语言:javascript复制

sVals[1]

返回的是一个副本,也就是说这是一个临时值,而对于临时值是不可寻址的。所以引用转换是不可能的,最后无法编译通过报出错误。

回答最初的问题

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

为什么要这样设计

为什么 map 要返回一个副本回来,而不是返回原始对象的地址?这种设计选择是出于安全性和一致性的考虑。由于 map 可能在运行时进行重新哈希以调整大小,重哈希后元素的地址可能发生变化,所以如果支持返回地址,那么可能会在程序运行中出现错误。例如一开始持有了一个元素的地址,之后 map 发生重哈希,地址都变了,再用之前获取的地址做操作,肯定会出问题。

既然返回的是一个副本,那么想要做出修改的话就需要注意了。例如下面这段代码

代码语言:javascript复制

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 中的元素呢?

第一种是先修改,再回写:

代码语言:javascript复制

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 中存放指针类型

代码语言:javascript复制

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 采用的就是值传递,当调用一个需要参数的函数时,函数参数会复制一份,如果参数是一个指针,也会复制出来一个新的指针对象,但注意复制的是指针对象,即新旧两个指针对象已经完全独立,有各自的内存地址,但是两个指针对象内部指向的目标对象地址没有改变,如下面代码和图示:

代码语言:javascript复制

s := &S{Name: "s"}

fmt.Printf("函数外,s指针本身的地址:%pn", &s)
fmt.Printf("函数外,s指向对象的地址:%pn", s)
fmt.Println("---")
updateObj(s)

func updateObj(s *S) {
	fmt.Printf("函数内,s指针本身的地址:%pn", &s)
	fmt.Printf("函数内,s指向对象的地址:%pn", 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 字节。

0 人点赞