go基础16-方法:方法集合与如何选择receiver类型?

111 阅读8分钟

receiver 参数类型对 Go 方法的影响

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

这个例子中有方法 M1 和 M2。M1 方法是 receiver 参数类型为 T 的一类方法的代表,而 M2 方法则代表了 receiver 参数类型为 *T 的另一类。下面我们分别来看看不同的 receiver 参数类型对 M1 和 M2 的影响。

首先,当 receiver 参数的类型为 T 时:

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

第二,当reciver参数为*T时:

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

据此我们也可以得出结论:当我们的方法 M2 采用类型为 *T 的 receiver 参数时,代表 *T 类型实例的 receiver 参数以值传递方式传递到 M2 方法体中的,实际上是 T 类型实例的地址,M2 方法体通过该地址可以对原 T 类型实例进行任何修改操作。

我们再通过一个更直观的例子,证明一下上面这个分析结果,看一下 Go 方法选择不同的 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
    println(t.a) // 0

    t.M1()
    println(t.a) // 0

    p := &t
    p.M2()
    println(t.a) // 11
}

在这个示例中,我们为基类型 T 定义了两个方法 M1 和 M2,其中 M1 的 receiver 参数类型为 T,而 M2 的 receiver 参数类型为 *T。M1 和 M2 方法体都通过 receiver 参数 t 对 t 的字段 a 进行了修改。

但运行这个示例程序后,我们看到,方法 M1 由于使用了 T 作为 receiver 参数类型,它在方法体中修改的仅仅是 T 类型实例 t 的副本,原实例并没有受到影响。因此 M1 调用后,输出 t.a 的值仍为 0。

而方法 M2 呢,由于使用了 *T 作为 receiver 参数类型,它在方法体中通过 t 修改的是实例本身,因此 M2 调用后,t.a 的值变为了 11,这些输出结果与我们前面的分析是一致的。

了解了不同类型的 receiver 参数对 Go 方法的影响后,我们就可以总结一下,日常编码中选择 receiver 的参数类型的时候,我们可以参考哪些原则。

选择 receiver 参数类型的第一个原则

基于上面的影响分析,我们可以得到选择 receiver 参数类型的第一个原则:*如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 T 作为 receiver 参数的类型。

这个原则似乎很好掌握,不过这个时候,你可能会有个疑问:如果我们选择了 *T 作为 Go 方法 receiver 参数的类型,那么我们是不是只能通过 *T 类型变量调用该方法,而不能通过 T 类型变量调用了呢?这个问题恰恰也是上节课我们遗留的一个问题。我们改造一下上面例子看一下:

  type T struct {
      a int
  }
  
  func (t T) M1() {
      t.a = 10
  }
 
 func (t *T) M2() {
     t.a = 11
 }
 
 func main() {
     var t1 T
     println(t1.a) // 0
     t1.M1()
     println(t1.a) // 0
     t1.M2()
     println(t1.a) // 11
 
     var t2 = &T{}
     println(t2.a) // 0
     t2.M1()
     println(t2.a) // 0
     t2.M2()
     println(t2.a) // 11
 }

我们先来看看类型为 T 的实例 t1。我们看到它不仅可以调用 receiver 参数类型为 T 的方法 M1,它还可以直接调用 receiver 参数类型为 *T 的方法 M2,并且调用完 M2 方法后,t1.a 的值被修改为 11 了。

其实,T 类型的实例 t1 之所以可以调用 receiver 参数类型为 *T 的方法 M2,都是 Go 编译器在背后自动进行转换的结果。或者说,t1.M2() 这种用法是 Go 提供的“语法糖”:Go 判断 t1 的类型为 T,也就是与方法 M2 的 receiver 参数类型 *T 不一致后,会自动将t1.M2()转换为(&t1).M2()。

同理,类型为 *T 的实例 t2,它不仅可以调用 receiver 参数类型为 *T 的方法 M2,还可以调用 receiver 参数类型为 T 的方法 M1,这同样是因为 Go 编译器在背后做了转换。也就是,Go 判断 t2 的类型为 *T,与方法 M1 的 receiver 参数类型 T 不一致,就会自动将t2.M1()转换为(*t2).M1()。

通过这个实例,我们知道了这样一个结论:**无论是 T 类型实例,还是 T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为 T 类型的方法。 这样,我们在为方法选择 receiver 参数的类型的时候,就不需要担心这个方法不能被与 receiver 参数类型不一致的类型实例调用了。

选择 receiver 参数类型的第二个原则

一般情况下,我们通常会为 receiver 参数选择 T 类型,因为这样可以缩窄外部修改类型实例内部状态的“接触面”,也就是尽量少暴露可以修改类型内部状态的方法。

不过也有一个例外需要你特别注意。考虑到 Go 方法调用时,receiver 参数是以值拷贝的形式传入方法中的。那么,如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择 *T 作为 receiver 类型可能更好些。

以上这些可以作为我们选择 receiver 参数类型的第二个原则。

方法集合

type Interface interface {
    M1()
    M2()
}

type T struct{}

func (t T) M1()  {}
func (t *T) M2() {}

func main() {
    var t T
    var pt *T
    var i Interface

    i = pt
    i = t // cannot use t (type T) as type Interface in assignment: T does not implement Interface (M2 method has pointer receiver)
}

在这个例子中,我们定义了一个接口类型 Interface 以及一个自定义类型 T。Interface 接口类型包含了两个方法 M1 和 M2,代码中还定义了基类型为 T 的两个方法 M1 和 M2,但它们的 receiver 参数类型不同,一个为 T,另一个为 *T。在 main 函数中,我们分别将 T 类型实例 t 和 *T 类型实例 pt 赋值给 Interface 类型变量 i。

运行一下这个示例程序,我们在i = t这一行会得到 Go 编译器的错误提示,Go 编译器提示我们:T 没有实现 Interface 类型方法列表中的 M2,因此类型 T 的实例 t 不能赋值给 Interface 变量。

为了方便查看一个非接口类型的方法集合,我这里提供了一个函数 dumpMethodSet,用于输出一个非接口类型的方法集合:

func dumpMethodSet(i interface{}) {
    dynTyp := reflect.TypeOf(i)

    if dynTyp == nil {
        fmt.Printf("there is no dynamic type\n")
        return
    }

    n := dynTyp.NumMethod()
    if n == 0 {
        fmt.Printf("%s's method set is empty!\n", dynTyp)
        return
    }

    fmt.Printf("%s's method set:\n", dynTyp)
    for j := 0; j < n; j++ {
        fmt.Println("-", dynTyp.Method(j).Name)
    }
    fmt.Printf("\n")
}

下面我们利用这个函数,试着输出一下 Go 原生类型以及自定义类型的方法集合,看下面代码:

type T struct{}

func (T) M1() {}
func (T) M2() {}

func (*T) M3() {}
func (*T) M4() {}

func main() {
    var n int
    dumpMethodSet(n)
    dumpMethodSet(&n)

    var t T
    dumpMethodSet(t)
    dumpMethodSet(&t)
}

运行这段代码,我们得到如下结果:

int's method set is empty!
*int's method set is empty!
main.T's method set:
- M1
- M2

*main.T's method set:
- M1
- M2
- M3
- M4

我们看到以 int、*int 为代表的 Go 原生类型由于没有定义方法,所以它们的方法集合都是空的。自定义类型 T 定义了方法 M1 和 M2,因此它的方法集合包含了 M1 和 M2,也符合我们预期。但 *T 的方法集合中除了预期的 M3 和 M4 之外,居然还包含了类型 T 的方法 M1 和 M2!

Go 语言规定,*T 类型的方法集合包含所有以 *T 为 receiver 参数类型的方法,以及所有以 T 为 receiver 参数类型的方法。这就是这个示例中为何 *T 类型的方法集合包含四个方法的原因。

方法集合决定接口实现的含义就是:如果某类型 T 的方法集合与某接口类型的方法集合相同,或者类型 T 的方法集合是接口类型 I 方法集合的超集,那么我们就说这个类型 T 实现了接口 I。或者说,方法集合这个概念在 Go 语言中的主要用途,就是用来判断某个类型是否实现了某个接口。

选择 receiver 参数类型的第三个原则

理解了方法集合后,我们再理解第三个原则的内容就不难了。这个原则的选择依据就是 T 类型是否需要实现某个接口,也就是是否存在将 T 类型的变量赋值给某接口类型变量的情况。

如果 T 类型需要实现某个接口,那我们就要使用 T 作为 receiver 参数的类型,来满足接口类型方法集合中的所有方法。

如果 T 不需要实现某一接口,但 *T 需要实现该接口,那么根据方法集合概念,*T 的方法集合是包含 T 的方法集合的,这样我们在确定 Go 方法的 receiver 的类型时,参考原则一和原则二就可以了。

此文章为3月Day16学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。