函数
是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。
方法
却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。
接收者声明就是在关键字func和方法名称
之间的圆括号
包裹起来的内容,其中必须包含确切的名称和类型字面量。
接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。
方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。
一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复。
我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。
Go 语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go 语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。
type AnimalCategory struct {
kingdom string // 界。
phylum string // 门。
class string // 纲。
order string // 目。
family string // 科。
genus string // 属。
species string // 种。
}
category := AnimalCategory{species: "cat"}
type Animal struct {
scientificName string // 学名。
AnimalCategory // 动物基本分类。
}
animal := Animal{
scientificName: "American Shorthair",
AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)
上述代码在后面使用fmt.Printf
函数和%s
占位符试图打印animal
的字符串表示形式,相当于调用animal
的String
方法。虽然我们还没有为Animal
类型编写String
方法,但这样做是没问题的。因为在这里,嵌入字段AnimalCategory
的String
方法会被当做animal
的方法调用。
那如果我也为Animal类型编写一个String方法呢?这里会调用哪一个呢?
答案是,animal的String方法会被调用。这时,我们说,嵌入字段AnimalCategory的String方法被“屏蔽”了。注意,只要名称相同,无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法。
类似的,由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。
正因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。
不过,即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在Category
方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个Animal
类型的String
方法的实现:
func (a Animal) String() string {
return fmt.Sprintf("%s (category: %s)",
a.scientificName, a.AnimalCategory)
}
在这里,我们把对嵌入字段的String
方法的调用结果融入到了Animal
类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。
最后,还要提一下多层嵌入的问题。也就是说,嵌入字段本身也有嵌入字段的情况。请看我声明的Cat类型:
type Cat struct {
name string
Animal
}
func (cat Cat) String() string {
return fmt.Sprintf("%s (category: %s, name: %q)",
cat.scientificName, cat.Animal.AnimalCategory, cat.name)
}
结构体类型Cat
中有一个嵌入字段Animal
,而Animal
类型还有一个嵌入字段AnimalCategory
。
在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。
例如,当我们调用Cat
类型值的String
方法时,如果该类型确有String
方法,那么嵌入字段Animal
和AnimalCategory
的String
方法都会被“屏蔽”。
如果该类型没有String
方法,那么嵌入字段Animal
的String
方法会被调用,而它的嵌入字段AnimalCategory
的String
方法仍然会被屏蔽。
只有当Cat
类型和Animal
类型都没有String
方法的时候,AnimalCategory
的String
方法才被调用。
最后的最后,如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。
这里强调一下,Go 语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合。Go语言官网有关于这样的说明。
简单来说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。
类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。
同时,类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化。
另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。
这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go 语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。
接口类型之间也可以组合。在 Go 语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。
值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。
而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。
一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。
严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。
比如,在Cat类型的变量cat之上,之所以我们可以通过cat.SetName(“monster”)修改猫的名字,是因为 Go 语言把它自动转译为了(&cat).SetName(“monster”),即:先取cat的指针值,然后在该指针值上调用SetName方法。
在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。
比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。
文章学习自郝林老师的《Go语言36讲》