Go 语言不支持经典的面向对象语法元素,比如:类、对象、继承等。但 Go 语言也有方法(method)。和函数相比,Go 语言中的方法在声明形式上仅仅多了一个参数,Go 称之为 receiver 参数。而 receiver 参数正是方法与类型之间的纽带。

Go 方法的一般声明形式如下:

  1. func (receiver T/*T) MethodName(参数列表) (返回值列表) {
  2. // 方法体
  3. }

Go 方法具有如下特点:

  • 方法名的首字母是否大写决定了该方法是否是导出方法 ;
  • 方法定义要与类型定义放在同一个包内。

receiver 参数的基类型本身不能是指针类型或接口类型:

  1. type MyInt *int
  2. func (r MyInt) String() string { // invalid receiver type MyInt (MyInt is a pointer type)
  3. return fmt.Sprintf("%d", *(*int)(r))
  4. }
  5. type MyReader io.Reader
  6. func (r MyReader) Read(p []byte) (int, error) { // invalid receiver type MyReader (MyReader is an interface type)
  7. return r.Read(p)
  8. }

1. 方法的本质

前面提到过:Go 语言没有类,方法与类型通过 receiver 联系在一起,我们可以为任何非内置原生类型定义方法,比如下面的类型 T:

  1. type T struct {
  2. a int
  3. }
  4. func (t T) Get() int {
  5. return t.a
  6. }
  7. func (t *T) Set(a int) int {
  8. t.a = a
  9. return t.a
  10. }

C++的对象在调用方法时,编译器会自动传入指向对象自身的 this 指针作为方法的第一个参数。而对于 Go 来说,receiver 其实也是同样道理,我们将 receiver 作为第一个参数传入方法的参数列表,上面示例中的类型 T 的方法就可以等价转换为下面的普通函数:

  1. func Get(t T) int {
  2. return t.a
  3. }
  4. func Set(t *T, a int) int {
  5. t.a = a
  6. return t.a
  7. }

Go 方法的一般使用方式如下:

  1. var t T
  2. t.Get()
  3. t.Set(1)

我们可以将上面方法调用用下面的方式做等价替换:

  1. var t T
  2. T.Get(t)
  3. (*T).Set(&t, 1) // 这个是可以编译通过的

Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。

Method Expression 体现了 Go 方法的本质:其自身的类型就是一个普通函数。我们甚至可以将其作为右值赋值给一个函数类型的变量:

  1. var t T
  2. f1 := (*T).Set // f1的类型,也是T类型Set方法的原型:func (t *T, int)int
  3. f2 := T.Get // f2的类型,也是T类型Get方法的原型:func(t T)int
  4. f1(&t, 3)
  5. fmt.Println(f2(t))

2. 正确选择 receiver 类型

我们再来看一下方法和函数的”等价变换公式“:

  1. func (t T) M1() <=> M1(t T)
  2. func (t *T) M2() <=> M2(t *T)
  • 当 receiver 参数的类型为 T 时,即选择值类型的 receiver。

我们选择以 T 作为 receiver 参数类型时,T 的 M1 方法等价为 M1(t T)。我们知道 Go 函数的参数采用的是值拷贝传递,也就是说 M1 函数体中的 t 是 T 类型实例的一个副本,这样 M1 函数的实现中无论对参数 t 做任何修改都只会影响副本,而不会影响到原 T 类型实例。

  • 当 receiver 参数的类型为 *T 时,即选择指针类型的 receiver。

我们选择以T 作为 receiver 参数类型时,T 的 M2 方法等价为 M2(t T)。我们传递给 M2 函数的 t 是 T 类型实例的地址,这样 M2 函数体中对参数 t 做的任何修改都会反映到原 T 类型实例。

  1. // method_nature_1.go
  2. package main
  3. type T struct {
  4. a int
  5. }
  6. func (t T) M1() {
  7. t.a = 10
  8. }
  9. func (t *T) M2() {
  10. t.a = 11
  11. }
  12. func main() {
  13. var t T // t.a = 0
  14. println(t.a)
  15. t.M1()
  16. println(t.a)
  17. t.M2()
  18. println(t.a)
  19. }

运行该程序:

  1. $ go run method_nature_1.go
  2. 0
  3. 0
  4. 11

无论是 T 类型实例,还是T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为T 类型的方法。下面例子证明了这一点:

  1. // method_nature_2.go
  2. package main
  3. type T struct {
  4. a int
  5. }
  6. func (t T) M1() {
  7. }
  8. func (t *T) M2() {
  9. t.a = 11
  10. }
  11. func main() {
  12. var t T
  13. t.M1() // ok
  14. t.M2() // <=> (&t).M2()
  15. var pt = &T{}
  16. pt.M1() // <=> (*pt).M1()
  17. pt.M2() // ok
  18. }

通过例子我们看到 T 类型实例 t 调用 receiver 类型为T 的 M2 方法是没问题的,同样T 类型实例 pt 调用 receiver 类型为 T 的 M1 方法也是可以的。实际上这都是 Go 语法甜头(syntactic sugar),即 Go 编译器在编译和生成代码时为我们自动做的转换。

结论:

  • 如果要对类型实例进行修改,那么为 receiver 选择*T 类型;
  • 如果没有对类型实例修改的需求,那么为 receiver 选择 T 类型或T 类型均可;但考虑到 Go 方法调用时,receiver 是以值拷贝的形式传入方法中的。如果类型 size 较大,以值形式传入会导致较大损耗,这时选择T 作为 receiver 类型可能更好些。

3. 利用对 Go 方法本质的理解巧解难题

  1. // method_nature_3.go
  2. package main
  3. import (
  4. "fmt"
  5. "time"
  6. )
  7. type field struct {
  8. name string
  9. }
  10. func (p *field) print() {
  11. fmt.Println(p.name)
  12. }
  13. func main() {
  14. data1 := []*field{{"one"}, {"two"}, {"three"}}
  15. for _, v := range data1 {
  16. go v.print()
  17. }
  18. data2 := []field{{"four"}, {"five"}, {"six"}}
  19. for _, v := range data2 {
  20. go v.print()
  21. }
  22. time.Sleep(3 * time.Second)
  23. }

该示例在我的多核 MacOS 上运行结果如下(由于 goroutine 调度顺序不同,结果可能与下面的有差异):

  1. $ go run method_nature_3.go
  2. one
  3. two
  4. three
  5. six
  6. six
  7. six

好了,我们来分析一下。首先,我们根据Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数,对这个程序做个等价变换(这里我们利用 Method Expression),变换后的源码如下:

  1. // method_nature_4.go
  2. package main
  3. import (
  4. "fmt"
  5. "time"
  6. )
  7. type field struct {
  8. name string
  9. }
  10. func (p *field) print() {
  11. fmt.Println(p.name)
  12. }
  13. func main() {
  14. data1 := []*field{{"one"}, {"two"}, {"three"}}
  15. for _, v := range data1 {
  16. go (*field).print(v)
  17. }
  18. data2 := []field{{"four"}, {"five"}, {"six"}}
  19. for _, v := range data2 {
  20. go (*field).print(&v)
  21. }
  22. time.Sleep(3 * time.Second)
  23. }

我们可以很清楚地看到使用 go 关键字启动一个新 goroutine 时是如何绑定参数的:

  • 迭代 data1 时,由于 data1 中的元素类型是 field 指针(*field),因此赋值后 v 就是元素地址,每次调用 print 时传入的参数(v)实际上也是各个 field 元素的地址;
  • 迭代 data2 时,由于 data2 中的元素类型是 field(非指针),需要将其取地址后再传入。这样每次传入的&v 实际上是变量 v 的地址,而不是切片 data2 中各元素的地址

那么原程序如何修改一下才能让其按期望输出(“one”、“two”、“three”, “four”, “five”, “six”)呢?

  • 其实只需将 field 类型 print 方法的 receiver 类型由*field 改为 field 即可。
  1. // method_nature_5.go
  2. ... ...
  3. type field struct {
  4. name string
  5. }
  6. func (p field) print() {
  7. fmt.Println(p.name)
  8. }
  9. ... ...

修改后的程序的输出结果为(因 goroutine 调度顺序不同,在你的机器上的结果输出顺序与这里可能会有不同):

  1. one
  2. two
  3. three
  4. four
  5. five
  6. six
  1. // method_nature_4.go
  2. package main
  3. import (
  4. "fmt"
  5. "time"
  6. )
  7. type field struct {
  8. name string
  9. }
  10. func (p field) print() {
  11. fmt.Println(p.name)
  12. }
  13. func main() {
  14. data1 := []*field{{"one"}, {"two"}, {"three"}}
  15. for _, v := range data1 {
  16. go (field).print(*v) // 解引用后进行值拷贝
  17. }
  18. data2 := []field{{"four"}, {"five"}, {"six"}}
  19. for _, v := range data2 {
  20. go (field).print(v) // 直接值拷贝
  21. }
  22. time.Sleep(3 * time.Second)
  23. }

4. 小结

本节要点:

  • Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数;
  • Go 语法甜头使得我们通过类型实例调用类型方法时无需考虑实例类型与 receiver 参数类型是否一致,编译器会为我们做自动转换;
  • receiver 参数类型选择时要看是否要对类型实例进行修改;如有修改需求,则选择*T;如无修改需求,T 类型 receiver 传值的性能损耗也是考量因素之一。