在 Go 语言官方文档中读到了 Go 语言设计理念的内容,讲的十分全面。计划用系列文章整理一下对Go语言的宏观理解。主要是翻译自原名为 Go History 的文章:Frequently Asked Questions (FAQ) - The Go Programming Language: go.dev/doc/faq。
先列一下文章链接:
- 谈谈Go语言宏观理解之一 - 鲜明特性| 青训营笔记
- 谈谈Go语言宏观理解之二 - 并发系统 | 青训营笔记
- 谈谈Go语言宏观理解之三 - 类型系统 | 青训营笔记
- 谈谈Go语言宏观理解之四 - 类型间关系 | 青训营笔记
- 谈谈Go语言宏观理解之五 - 值传参与指针传参 | 青训营笔记
这篇文章详细说明了古老的值传参 or 指针传参在 Go语言中的新情况。
值传参 or 指针传参?
这是一个古老的值传参/指针传参问题,在Java中全部是指针传参,不过熟悉C语言的程序员对此应该十分了解。
func (s *MyStruct) pointerMethod() { }// 指针传参
func (s MyStruct) valueMethod() { } // 值传参
对于不习惯指针的程序员来说,这两个例子之间的区别可能会让人感到困惑,但实际上情况非常简单。当在一个类型上定义一个方法时,形参(上述例子中的s)的行为就像它是该方法的一个参数一样。那么,是把形参定义为一个值还是一个指针,就像一个函数参数应该是一个值还是一个指针一样,是同一个问题。这里有几个考虑因素。
首先,也是最重要的,方法是否需要修改形参?如果需要,那么形参必须是一个指针。(数组/切片和map作为引用,所以它们的情况更微妙一些。但是在一个方法中改变数组/切片的长度,形参仍然必须是一个指针。)在上面的例子中,如果pointerMethod修改了s的字段,那么调用者会看到这些变化,但是valueMethod是用调用者参数的副本来调用的(这就是传递值的定义),所以它所做的变化对调用者来说是不可见的。
顺便说一下,在Java中,方法接收者总是指针,尽管它们的指针性质在某种程度上被掩盖了(有一个建议是在语言中增加值接收者)。Go中的值形参才是不寻常的。
其次是对效率的考虑。如果形参很大,例如一个大的结构体,那么使用指针形参就会轻量很多。
再次是一致性。如果该类型的一些方法必须有指针形参,那么其他的也应该有,以便无论如何使用该类型,方法集都是一致的。详见关于方法集的章节。
对于基本类型、切片和小结构等类型,值形参是非常便宜的,所以除非方法的语义需要指针,否则值形参是高效且清晰的。
值类型与指针类型的方法集合
Go语言标准提到,值类型 T的方法集合只包含接收参数类型为值类型 T的方法,而指针类型*T的方法集合不仅包含接收指针类型*T的方法,还包括接收相应值类型参数的方法。也就是说*T类型的方法集合包含T类型的方法集合,反之则不成立。
这种区别之所以产生,是因为如果接口值包含指针*T,则方法调用可以通过取消引用指针来获取值,但是如果接口值包含值T,则方法调用没有安全的方法来获取指针。如果接口方法以指针传参,那么方法就能够修改接口内部的值,这在 Go语言标准中是不被允许的。
即使有些情况下方法同步编译器能够获取形参地址,通过这个地址修改接口的值,该变化也不会反映到调用方。例如,如果在 bytes.Buffer的 Write方法中通过值传参的方式尝试把标准输入的内容写入buffer,以下代码将会把标准输入的内容拷贝到 buf的一个副本,这并不符合我们期望的行为。
var buf bytes.Buffer
io.Copy(buf, os.Stdin)
Goroutine 中的闭包
在使用闭包与并发时,可能会出现一些混淆。考虑一下下面的程序:
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}
// 等待所有的goroutines完成后再退出
for _ = range values {
<-done
}
}
人们可能误以为会看到a, b, c作为输出。这是因为循环的每一次迭代都使用同一个变量v的实例,所以每个闭包都共享这个单一的变量。当闭包运行时,它打印的是执行 fmt.Println 时的 v 值,但 v 可能在 goroutine 启动后被修改过。为了帮助在问题发生之前检测到这个问题和其他问题,运行go vet。
为了在每个闭包启动时将v的当前值与之绑定,必须修改内循环以在每个迭代中创建一个新变量。一种方法是将该变量作为参数传递给闭包:
for _, v := range values {
go func(u string) {
fmt.Println(u)
done <- true
}(v)
}
在这个例子中,v的值被作为一个参数传递给匿名函数。然后,该值可以在函数中作为变量u被访问。
更简单的方法是直接创建一个新的变量,使用一种可能看起来很奇怪但在Go中很好用的声明方式:
for _, v := range values {
v := v // 创建一个新的'v'。
go func() {
fmt.Println(v)
done <- true
}()
}
语言的这种行为,即不为每个迭代定义一个新的变量,回想起来可能是个错误。它可能会在以后的版本中得到解决,但为了兼容,在Go 1版本中不能改变。