结构体

结构体类型表示的是实实在在的数据结构。一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型。

函数是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。

方法却不同,它需要有名字,不能被当作值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者(receiver)声明体现出来。

接收者声明就是在关键字func和方法名称之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。

接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值

// AnimalCategory 代表动物分类学中的基本分类法。
type AnimalCategory struct {
  kingdom string // 界。
  phylum string // 门。
  class  string // 纲。
  order  string // 目。
  family string // 科。
  genus  string // 属。
  species string // 种。
}

func (ac AnimalCategory) String() string {
  return fmt.Sprintf("%s%s%s%s%s%s%s",
    ac.kingdom, ac.phylum, ac.class, ac.order,
    ac.family, ac.genus, ac.species)
}

这里,有个名叫 String 的方法,从它的接收者声明可以看出它隶属于 AnimalCategory 类型。

通过该方法的接收者名称 ac,我们可以在其中引用到当前值的任何一个字段,或者调用到当前值的任何一个方法(也包括String方法自己)。

String 方法的功能是提供当前值的字符串表示形式,其中的各个等级分类会按照从大到小的顺序排列。我们可以用如下方式使用:

category := AnimalCategory{species: "cat"}
fmt.Printf("The animal category: %s\n", category)

这里,字面量初始化了一个 AnimalCategory 类型的值,并把它赋给了变量 category。为了不喧宾夺主,我只为其中的species 字段指定了字符串值"cat",该字段代表最末级分类“种”。

在 Go 语言中,我们可以通过为一个类型编写名为 String 的方法,来自定义该类型的字符串表示形式。这个 String 方法不需要任何参数声明,但需要有一个 string 类型的结果声明。

因此,在调用 fmt.Printf 函数时,使用占位符 %s 和 category 值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的 String 方法,fmt.Printf 函数会自己去寻找它。此时的打印内容会是 The animal category: cat。显而易见,category 的 String 方法成功地引用了当前值的所有字段。

方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型并且不能是任何接口类型一个数据类型关联的所有方法,共同组成了该类型的方法集合同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。Go 语言摄取了面向对象编程中的很多优秀特性,同时也推荐这种封装的做法。从这方面看,Go 语言其实是支持面向对象编程的,但它选择摒弃了一些在实际运用过程中容易引起程序开发者困惑的特性和规则。

Case:

type Animal struct {
  scientificName string // 学名。
  AnimalCategory    // 动物基本分类。
}

字段声明 AnimalCategory 代表了 Animal 类型的一个嵌入字段

Go 语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段,也可以被称为匿名字段。可以通过此类型变量的名称后跟“.”,再后跟嵌入字段类型的方式引用到该字段。也就是说,嵌入字段的类型既是类型也是名称。

func (a Animal) Category() string {
  return a.AnimalCategory.String()
}

Category 方法的接收者类型是 Animal,接收者名称是 a。在该方法中,我通过表达式 a.AnimalCategory 选择到了 a 的这个嵌入字段,然后又选择了该字段的 String 方法并调用了它。

在某个代表变量的标识符的右边加“.”,再加上字段名或方法名的表达式被称为选择表达式,它用来表示选择了该变量的某个字段或者方法。

实际上,嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。例如下面这种:

animal := Animal{
  scientificName: "American Shorthair",
  AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)

我声明了一个 Animal 类型的变量 animal 并对它进行初始化。我把字符串值 "American Shorthair" 赋给它的字段scientificName,并把前面声明过的变量 category 赋给它的嵌入字段 AnimalCategory。

在后面使用 fmt.Printf 函数和%s占位符试图打印 animal 的字符串表示形式,相当于调用 animal 的 String 方法。虽然我们还没有为 Animal 类型编写 String 方法,但这样做是没问题的。因为在这里,嵌入字段 AnimalCategory 的 String 方法会被当做 animal 的方法调用

如果也为Animal类型编写一个 String 方法,嵌入字段 AnimalCategory 的 String 方法将会被“屏蔽”,animal 的String 方法会被调用。

由于我们同样可以像访问被嵌入类型的字段那样,直接访问嵌入字段的字段,所以如果这两个结构体类型里存在同名的字段,那么嵌入字段中的那个字段一定会被“屏蔽”。这与我们在前面讲过的,可重名变量之间可能存在的“屏蔽”现象很相似。

嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,这种“屏蔽”现象依然会存在。

即使被屏蔽了,我们仍然可以通过链式的选择表达式,选择到嵌入字段的字段或方法,就像我在 Category 方法中所做的那样。这种“屏蔽”其实还带来了一些好处。我们看看下面这个 Animal 类型的 String 方法的实现:

func (a Animal) String() string {
  return fmt.Sprintf("%s (category: %s)",
    a.scientificName, a.AnimalCategory)
}

在这里,把对嵌入字段的 String 方法的调用结果融入到了 Animal 类型的同名方法的结果中。这种将同名方法的结果逐层“包装”的手法是很常见和有用的,也算是一种惯用法了。

嵌入字段本身也有嵌入字段的情况

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 方法菜会被调用。

如果处于同一个层级的多个嵌入字段拥有同名的字段或方法,那么从被嵌入类型的值那里,选择此名称的时候就会引发一个编译错误,因为编译器无法确定被选择的成员到底是哪一个。

值方法和指针方法

方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。

func (cat *Cat) SetName(name string) {
  cat.name = name
}

方法 SetName 的接收者类型是*Cat。Cat 左边再加个*代表的就是Cat类型的指针类型。

Cat可以被叫做*Cat的基本类型。可以认为这种指针类型的值表示的是指向某个基本类型值的指针。

我们可以通过把取值操作符 *放在这样一个指针值的左边来组成一个取值表达式,以获取该指针值指向的基本类型值,也可以通过把取址操作符 & 放在一个可寻址的基本类型值的左边来组成一个取址表达式,以获取该基本类型值的指针值。

所谓的指针方法,就是接收者类型是上述指针类型的方法。

二者的区别

  • 值方法的接收者是该方法所属的那个类型值的一个副本我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。

  • 一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。

  • 一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的如果一个基本类型和它的指针类型的方法集合是不同的那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。(划重点!!!!)

严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。比如,在 Cat 类型的变量 cat 之上,之所以我们可以通过 cat.SetName("monster") 修改猫的名字,是因为 Go 语言把它自动转译为了(&cat).SetName("monster"),即:先取cat的指针值,然后在该指针值上调用SetName方法。

拓展

Go 语言中根本没有继承的概念,它所做的是通过嵌入字段的方式实现了类型之间的组合。

面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的。

类型组合也是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合

我们要做的只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。如果嵌入字段有哪里不合心意,我们还可以用“包装”或“屏蔽”的方式去调整和优化。

类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。

被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。再者,组合要比继承更加简洁和清晰,Go 语言可以轻而易举地通过嵌入多个字段来实现功能强大的类型,却不会有多重继承那样复杂的层次结构和可观的管理成本。

接口类型之间也可以组合。在 Go 语言中,接口类型之间的组合甚至更加常见,我们常常以此来扩展接口定义的行为或者标记接口的特征。

最后更新于