一篇文章彻底弄懂go语言方法的本质

九路 等级 183 0 0

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

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

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

上面方法声明中的 T 称为 receiver 的基类型。通过 receiver,上述方法被绑定到类型 T 上。换句话说:上述方法是类型 T 的一个方法,我们可以通过类型 T 或*T 的实例调用该方法。

var t T
t.MethodName(参数列表)

var pt *T = &t
pt.MethodName(参数列表)

Go 方法具有如下特点:

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

由于方法定义与类型定义必须放在同一个包下面,因此我们可以推论得到:我们不能为原生类型(诸如:int、float64、map 等)添加方法, 只能为自定义类型定义方法。

错误的作法:
func (i int) String() string { // cannot define new methods on non-local type int
        return fmt.Sprintf("%d", i) 
}

vs.

正确的作法:
type MyInt int

func (i MyInt) String() string {
        return fmt.Sprintf("%d", int(i))
}

同理,我们也可以推论得出:我们也不能横跨 Go 包为其他包内的自定义类型定义方法。

  • 每个方法只能有一个 receiver 参数,不支持多 receiver 参数列表或变长 receiver 参数。一个方法只能绑定一个基类型,Go 语言不支持同时绑定多个类型的方法。

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

    type MyInt *int
    func (r MyInt) String() string { // invalid receiver type MyInt (MyInt is a pointer type)
      return fmt.Sprintf("%d", *(*int)(r))
    }
    

type MyReader io.Reader func (r MyReader) Read(p []byte) (int, error) { // invalid receiver type MyReader (MyReader is an interface type) return r.Read(p) }

和其他主流编程语言相比,Go 语言从函数到方法仅仅多出了一个 receiver,这大大降低了 Gopher 们学习方法的门槛。但即便如此,Gopher 们在把握方法本质以及如何选择 receiver 的类型时仍存在困惑,本节我就针对这些困惑做重点的说明。

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

```go
type T struct { 
        a int
}

func (t T) Get() int {  
        return t.a 
}

func (t *T) Set(a int) int { 
        t.a = a 
        return t.a 
}

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

func Get(t T) int {  
        return t.a 
}

func Set(t *T, a int) int { 
        t.a = a 
        return t.a 
}

这种转换后的函数就是方法的原型。只不过在 Go 语言中,这种等价转换是由 Go 编译器在编译和生成代码时自动完成的。Go 语言规范中提供了方法表达式(method expression) 的概念,可以让我们更充分地理解上面的等价转换。

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

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

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

var t T
T.Get(t)
(*T).Set(&t, 1)

这种直接以类型名 T 调用方法 M 的表达方式被称为Method Expression。类型 T 只能调用 T 的方法集合(Method Set)中的方法;同理T 只能调用T 的方法集合中的方法(关于方法集合,我们会在下一节中详细讲解)。我们看到:Method Expression有些类似于 C++中的 static 方法,static 方法在使用时以该 C++类的某个对象实例作为第一个参数,而 Go 语言的 Method Expression 在使用时,同样以 receiver 参数所代表的实例作为第一个参数。

这种通过 Method Expression 对方法进行调用的方式与我们之前所做的方法到函数的等价转换是如出一辙的。 这就是 Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。

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

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

2. 正确选择 receiver 类型

有了上面对 Go 方法本质的分析,我们再来理解 receiver 并在定义方法时选择正确的 receiver 类型就简单多了。我们再来看一下方法和函数的”等价变换公式“:

func (t T) M1() <=> M1(t T)
func (t *T) M2() <=> M2(t *T)

我们看到:M1 方法的 receiver 参数类型为 T,而 M2 方法的 receiver 参数类型为*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 类型实例。

我们以下面的例子演示一下选择不同的 receiver 类型对原类型实例的影响:

package main

type T struct {
    a int
}

func (t T) M1() {
    t.a = 10
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t T // t.a = 0
    println(t.a)

    t.M1()
    println(t.a)

    t.M2()
    println(t.a)
}

运行该程序:输出

0
0
11

在该示例中,M1 和 M2 方法体内都对字段 a 做了修改,但 M1(采用值类型 receiver)修改的只是实例的副本,对原实例并没有影响,因此 M1 调用后,输出 t.a 的值仍为 0;M2(采用指针类型 receiver)修改的是实例本身,因此 M2 调用后,t.a 的值变为了 11。

很多 Go 初学者还有这样的疑惑:是不是 T 类型实例只能调用 receiver 为 T 类型的方法,不能调用 receiver 为T 类型的方法呢?答案是否定的。无论是 T 类型实例,还是T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为*T 类型的方法。下面例子证明了这一点:

package main

type T struct {
    a int
}

func (t T) M1() {
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t T
    t.M1() // ok
    t.M2() // <=> (&t).M2()

    var pt = &T{}
    pt.M1() // <=> (*pt).M1()
    pt.M2() // ok
}

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

到这里,我们可以得出 receiver 类型选用的初步结论:

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

对于 receiver 的类型的选择其实还有一个重要因素,那就是类型是否要实现某个 interface,这个考量因素在下一节中将有详细说明。

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

下面的这个例子来自于笔者博客的一次真实的读者咨询,他的问题代码如下:

package main

import (
    "fmt"
    "time"
)

type field struct {
    name string
}

func (p *field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go v.print()
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
        go v.print()
    }

    time.Sleep(3 * time.Second)
}

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

one
two
three
six
six
six

这位读者的问题显然是:为什么对 data2 迭代输出的结果是三个"six",而不是 four、five、six?

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

package main

import (
        "fmt"
        "time"
)

type field struct {
        name string
}

func (p *field) print() {
        fmt.Println(p.name)
}

func main() {
        data1 := []*field{{"one"}, {"two"}, {"three"}}
        for _, v := range data1 {
                go (*field).print(v)
        }

        data2 := []field{{"four"}, {"five"}, {"six"}}
        for _, v := range data2 {
                go (*field).print(&v)
        }

        time.Sleep(3 * time.Second)
}

这里我们把对 field 的方法 print 的调用替换为 Method Expression 形式,替换前后的程序输出结果是一致的。但变换后,问题是不是豁然开朗了,我们可以很清楚地看到使用 go 关键字启动一个新 goroutine 时是如何绑定参数的:

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

在 for range 语句中循环变量是复用的,这样一来这里的v在整个 for range 过程中只有一个,因此 data2 迭代完成之后,v是元素"six"的拷贝。

这样,一旦启动的各个子 goroutine 在 main goroutine 执行到 Sleep 时才被调度执行,那么最后的三个 goroutine 在打印&v 时,打印的也就都 v 中存放的值"six"了。而前三个子 goroutine 各自传入的是元素"one"、“two"和"three"的地址,打印的就是"one”、"two"和"three"了。

那么原程序如何修改一下才能让其按期望输出(“one”、“two”、“three”, “four”, “five”, “six”)呢?其实只需将 field 类型 print 方法的 receiver 类型由*field 改为 field 即可。


... ...

type field struct {
    name string
}

func (p field) print() {
    fmt.Println(p.name)
}

... ...

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

one
two
three
four
five
six

至于其中的原因,大家可以参考我的分析思路自行分析一下

4. 小结

本节要点:

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