You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

343 lines
17 KiB
Markdown

2 years ago
# 25方法方法集合与如何选择receiver类型
你好我是Tony Bai。
在上一节中我们开启了Go方法的学习了解了Go语言中方法的组成、声明和实质。可以说我们已经正式入门Go方法了。
入门Go方法后和函数一样我们要考虑如何进行方法设计的问题。由于在Go语言中**方法本质上就是函数**所以我们之前讲解的、关于函数设计的内容对方法也同样适用比如错误处理设计、针对异常的处理策略、使用defer提升简洁性等等。
但关于Go方法中独有的receiver组成部分却没有现成的、可供我们参考的内容。而据我了解初学者在学习Go方法时最头疼的一个问题恰恰就是**如何选择receiver参数的类型**。
那么在这一讲中我们就来学习一下不同receiver参数类型对Go方法的影响以及我们选择receiver参数类型时的一些经验原则。
## receiver参数类型对Go方法的影响
要想为receiver参数选出合理的类型我们先要了解不同的receiver参数类型会对Go方法产生怎样的影响。在上一节课中我们分析了Go方法的本质得出了“Go方法实质上是**以方法的receiver参数作为第一个参数的普通函数**”的结论。
对于函数参数类型对函数的影响我们是很熟悉的。那么我们能不能将方法等价转换为对应的函数再通过分析receiver参数类型对函数的影响从而间接得出它对Go方法的影响呢
我们可以基于这个思路试试看。我们直接来看下面例子中的两个Go方法以及它们等价转换后的函数
```plain
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类型实例。
据此我们可以得出结论当我们的方法M1采用类型为T的receiver参数时代表T类型实例的receiver参数以值传递方式传递到M1方法体中的实际上是**T类型实例的副本**M1方法体中对副本的任何修改操作都不会影响到原T类型实例。
* **第二当receiver参数的类型为\*T时**
当我们选择以\*T作为receiver参数类型时M2方法等价转换为`F2(t *T)`。同上面分析我们传递给F2函数的t是T类型实例的地址这样F2函数体中对参数t做的任何修改都会反映到原T类型实例上。
据此我们也可以得出结论当我们的方法M2采用类型为\*T的receiver参数时代表\*T类型实例的receiver参数以值传递方式传递到M2方法体中的实际上是**T类型实例的地址**M2方法体通过该地址可以对原T类型实例进行任何修改操作。
我们再通过一个更直观的例子证明一下上面这个分析结果看一下Go方法选择不同的receiver类型对原类型实例的影响
```plain
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类型变量调用了呢这个问题恰恰也是上节课我们遗留的一个问题。我们改造一下上面例子看一下
```plain
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参数代表的类型实例进行修改那我们要为receiver参数选择\*T类型但是如果我们不需要在方法中对类型实例进行修改呢这个时候我们是为receiver参数选择T类型还是\*T类型呢
这也得分情况。一般情况下我们通常会为receiver参数选择T类型因为这样可以缩窄外部修改类型实例内部状态的“接触面”也就是尽量少暴露可以修改类型内部状态的方法。
不过也有一个例外需要你特别注意。考虑到Go方法调用时receiver参数是以值拷贝的形式传入方法中的。那么**如果receiver参数类型的size较大**,以值拷贝形式传入就会导致较大的性能开销,这时我们选择\*T作为receiver类型可能更好些。
以上这些可以作为我们**选择receiver参数类型的第二个原则**。
到这里,你可能会发出感慨:即便有两个原则,这似乎依旧很容易掌握!不要大意,这可没那么简单,这两条只是基础原则,还有一条更难理解的原则在下面呢。
不过在讲解这第三条原则之前,我们先要了解一个基本概念:**方法集合**Method Set它是我们理解第三条原则的前提。
## 方法集合
在了解方法集合是什么之前,我们先通过一个示例,直观了解一下为什么要有方法集合,它主要用来解决什么问题:
```plain
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变量**。
可是,为什么呀?为什么\*T类型的pt可以被正常赋值给Interface类型变量i而T类型的t就不行呢如果说T类型是因为只实现了M1方法未实现M2方法而不满足Interface类型的要求那么\*T类型也只是实现了M2方法并没有实现M1方法啊
有些事情并不是表面看起来这个样子的。了解方法集合后,这个问题就迎刃而解了。同时,**方法集合也是用来判断一个类型是否实现了某接口类型的唯一手段**,可以说,“**方法集合决定了接口实现**”。更具体的分析,我们等会儿再讲。
那么,什么是类型的方法集合呢?
Go中任何一个类型都有属于自己的方法集合或者说方法集合是Go类型的一个“属性”。但不是所有类型都有自己的方法呀比如int类型就没有。所以对于没有定义方法的Go类型我们称其拥有空方法集合。
接口类型相对特殊,它只会列出代表接口的方法列表,不会具体定义某个方法,它的方法集合就是它的方法列表中的所有方法,我们可以一目了然地看到。因此,我们下面重点讲解的是非接口类型的方法集合。
为了方便查看一个非接口类型的方法集合我这里提供了一个函数dumpMethodSet用于输出一个非接口类型的方法集合
```plain
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原生类型以及自定义类型的方法集合看下面代码
```plain
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)
}
```
运行这段代码,我们得到如下结果:
```plain
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类型的方法集合包含四个方法的原因。
这个时候,你是不是也找到了前面那个示例中为何`i = pt`没有报编译错误的原因了呢我们同样可以使用dumpMethodSet工具函数输出一下那个例子中pt与t各自所属类型的方法集合
```plain
type Interface interface {
M1()
M2()
}
type T struct{}
func (t T) M1() {}
func (t *T) M2() {}
func main() {
var t T
var pt *T
dumpMethodSet(t)
dumpMethodSet(pt)
}
```
运行上述代码,我们得到如下结果:
```plain
main.T's method set:
- M1
*main.T's method set:
- M1
- M2
```
通过这个输出结果我们可以一目了然地看到T、\*T各自的方法集合。
我们看到T类型的方法集合中只包含M1没有Interface类型方法集合中的M2方法这就是Go编译器认为变量t不能赋值给Interface类型变量的原因。
在输出的结果中,我们还看到\*T类型的方法集合除了包含它自身定义的M2方法外还包含了T类型定义的M1方法\*T的方法集合与Interface接口类型的方法集合是一样的因此pt可以被赋值给Interface接口类型的变量i。
到这里,我们已经知道了所谓的**方法集合决定接口实现**的含义就是如果某类型T的方法集合与某接口类型的方法集合相同或者类型T的方法集合是接口类型I方法集合的超集那么我们就说这个类型T实现了接口I。或者说方法集合这个概念在Go语言中的主要用途就是用来判断某个类型是否实现了某个接口。
有了方法集合的概念做铺垫选择receiver参数类型的第三个原则已经呼之欲出了下面我们就来看看这条原则的具体内容。
## 选择receiver参数类型的第三个原则
理解了方法集合后,我们再理解第三个原则的内容就不难了。这个原则的选择依据就是**T类型是否需要实现某个接口**也就是是否存在将T类型的变量赋值给某接口类型变量的情况。
如果**T类型需要实现某个接口**那我们就要使用T作为receiver参数的类型来满足接口类型方法集合中的所有方法。
如果T不需要实现某一接口但\*T需要实现该接口那么根据方法集合概念\*T的方法集合是包含T的方法集合的这样我们在确定Go方法的receiver的类型时参考原则一和原则二就可以了。
如果说前面的两个原则更多聚焦于类型内部,从单个方法的实现层面考虑,那么这第三个原则则是更多从全局的设计层面考虑,聚焦于这个类型与接口类型间的耦合关系。
## 小结
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
我们前面已经知道Go方法本质上也是函数。所以Go方法设计的多数地方都可以借鉴函数设计的相关内容。唯独Go方法的receiver部分我们是没有现成经验可循的。这一讲中我们主要学习的就是如何为Go方法的receiver参数选择类型。
我们先了解了不同类型的receiver参数对Go方法行为的影响这是我们进行receiver参数选型的前提。
在这个前提下我们提出了receiver参数选型的三个经验原则虽然课程中我们是按原则一到三的顺序讲解的**但实际进行Go方法设计时我们首先应该考虑的是原则三即T类型是否要实现某一接口。**
如果T类型需要实现某一接口的全部方法那么我们就需要使用T作为receiver参数的类型来满足接口类型方法集合中的所有方法。
如果T类型不需要实现某一接口那么我们就可以参考原则一和原则二来为receiver参数选择类型了。也就是如果Go方法要把对receiver参数所代表的类型实例的修改反映到原类型实例上那么我们应该选择\*T作为receiver参数的类型。否则通常我们会为receiver参数选择T类型这样可以减少外部修改类型实例内部状态的“渠道”。除非receiver参数类型的size较大考虑到传值的较大性能开销选择\*T作为receiver类型可能更适合。
在理解原则三时我们还介绍了Go语言中的一个重要概念**方法集合**。它在Go语言中的主要用途就是判断某个类型是否实现了某个接口。方法集合像“胶水”一样将自定义类型与接口隐式地“粘结”在一起我们后面理解带有类型嵌入的类型时还会借助这个概念。
## 思考题
方法集合是一个很重要也很实用的概念,我们在下一节课还会用到这个概念帮助我们理解具体的问题。所以这里,我给你出了一道与方法集合有关的预习题。
如果一个类型T包含两个方法M1和M2
```plain
type T struct{}
func (T) M1()
func (T) M2()
```
然后我们再使用类型定义语法又基于类型T定义了一个新类型S
```plain
type S T
```
那么这个S类型包含哪些方法呢\*S类型又包含哪些方法呢请你自己分析一下然后借助dumpMethodSet函数来验证一下你的结论。
欢迎你把这节课分享给更多对Go方法感兴趣的朋友。我是Tony Bai我们下节课见。