Go语言36讲笔记--13结构体、方法的使用(对组合的理解)

183 阅读12分钟

关于结构体类型的一些基本常识

  1. 结构体本身就是一个数据结构,其中可能包含多个字段,每个字段通常需要姓名+type。(存在无type字段情况)

  2. 空结构体类型并不是没有意义的,可以用该类型关联方法(方法可以看作是函数的特殊版本)。 关于函数与方法的区别 函数,是独立的程序实体。我们可以声明有名字的函数,也可以声明没名字的函数,还可以把它们当做普通的值传来传去。我们能把具有相同签名的函数抽象成独立的函数类型,以作为一组输入、输出(或者说一类逻辑组件)的代表。(这里不是很理解)

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

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

接收者的类型其实就是当前方法所属的类型,而接收者的名称,则用于在当前方法中引用它所属的类型的当前值。 通过struct之间的组合,替代面向对象语言的继承关系

package main

import "fmt"

// 示例1。
// AnimalCategory 代表动物分类学中的基本分类法。
type AnimalCategory struct { //该结构体七个字段,分别对应不同等级的称呼
	kingdom string // 界。
	phylum string // 门。
	class  string // 纲。
	order  string // 目。
	family string // 科。
	genus  string // 属。
	species string // 种。
}
 
func (ac AnimalCategory) String() string { //此方法隶属于AnimalCategory类型

//通过此方法receiver名称ac,我们可以引用到当前值的任一字段,or调用到当前值的任何一个方法(甚至可以包括`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) //这里哪个字段有值,就按照顺序执行出来
}

// 示例2。结构体之间的组合,来体现继承关系
type Animal struct {
	scientificName string // 学名。
	AnimalCategory        // 动物基本分类。
}

// 该方法会"屏蔽"掉嵌入字段中的同名方法。
func (a Animal) String() string {
	return fmt.Sprintf("%s (category: %s)",
		a.scientificName, a.AnimalCategory)
}

// 示例3。
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)
}

func main() {
	// 示例1。
	category := AnimalCategory{species: "cat", genus: "CAT"} //这里我添加了字段genus,我想看看背后的逻辑
	fmt.Printf("The animal category: %s\n", category)

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

	// 示例3。
	cat := Cat{
		name:   "little pig",
		Animal: animal,
	}
	fmt.Printf("The cat: %s\n", cat)
}

执行结果

The animal category: CATcat
The animal: American Shorthair (category: CATcat)
The cat: American Shorthair (category: CATcat, name: "little pig")

示例1的func (ac AnimalCategory) String() string {

在 Go 语言中,我们可以通过为一个类型(比如AnimalCategory)编写名为String的方法,来自定义该类型的字符串表示形式。(即,自定义printf后的对象输出格式) 写法格式规定 这个String方法不需要任何参数声明,但需要有一个string类型的结果声明 结果 正因为如此,我在调用fmt.Printf函数时,fmt.Printf("The animal category: %s\n", category),使用占位符%scategory值本身就可以打印出后者的字符串表示形式,而无需显式地调用它的String方法。 fmt.Printf函数会自己去寻找它。


方法隶属的规则

  1. 方法隶属的类型其实并不局限于结构体类型,但必须是某个自定义的数据类型并且不能是任何接口类型

  2. 一个数据类型关联的所有方法,共同组成了该类型的方法集合。同一个方法集合中的方法不能出现重名。并且,如果它们所属的是一个结构体类型,那么它们的名称与该类型中任何字段的名称也不能重复

  3. 类比封装特性。我们可以把结构体类型中的一个字段看作是它的一个属性或者一项数据,再把隶属于它的一个方法看作是附加在其中数据之上的一个能力或者一项操作。将属性及其能力(或者说数据及其操作)封装在一起,是面向对象编程(object-oriented programming)的一个主要原则。

今天的问题:示例2中,Animal类型中的字段声明AnimalCategory代表了什么?

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

更宽泛地讲,如果结构体类型的某个字段声明只有一个类型名,那么该字段代表了什么

典型回答

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

Go 语言规范规定,如果一个字段的声明中只有字段的类型名而没有字段的名称,那么它就是一个嵌入字段可以被称为匿名字段

使用方式(引用结构体中的嵌入字段)

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

我们可以通过此类型变量的名称(a)后跟“.”,再后跟嵌入字段类型(AnimalCategory)的方式引用到该字段。

也就是说,嵌入字段的类型既是类型也是名称。

进一步解析

引用结构体的嵌入字段,举例

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

Category方法的接收者类型是Animal,接收者名称是a

在该方法中,我通过表达式a.AnimalCategory选择到了a的这个嵌入字段,然后又选择了该字段的String方法并调用了它。

"."

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

这是 Go 语言规范中的说法,与“引用结构体的某某字段”或“调用结构体的某某方法”的说法是相通的 嵌入字段中的方法集合(AnimalCategory: category),全部可以被此接收者类型(Animal)调用

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

再进一步,若为Animal类型编写一个String方法,此时Animal的类型变量animal使用哪个String?

animalString方法会被调用。这时嵌入字段AnimalCategoryString方法被“屏蔽”了。

只要名称相同,无需考虑无论这两个方法的签名是否一致,被嵌入类型的方法都会“屏蔽”掉嵌入字段的同名方法。

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

如何访问被屏蔽字段中的字段和方法?:将同名方法的结果逐层“包装”

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

我们把对嵌入字段(AnimalCategory)的String方法的调用结果融入到了Animal类型的同名方法的结果中。

image.png

关于多层级嵌入字段问题

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--AnimalCategory

在这种情况下,“屏蔽”现象会以嵌入的层级为依据,嵌入层级越深的字段或方法越可能被“屏蔽”。

例如,当我们调用Cat类型值的String方法时,如果该类型确有String方法,那么嵌入字段AnimalAnimalCategoryString方法都会被“屏蔽”。

如果该类型没有String方法,那么嵌入字段AnimalString方法会被调用,而它的嵌入字段AnimalCategoryString方法仍然会被屏蔽。

只有当Cat类型和Animal类型都没有String方法的时候,AnimalCategoryString方法菜会被调用。

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


深入理解

问题 1:Go 语言是用嵌入字段实现了继承吗?

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

继承与组合的区别

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

类型之间的组合采用的是非声明的方式,我们不需要显式地声明某个类型实现了某个接口,或者一个类型继承了另一个类型。

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

组合的优势

  1. 只需要把字段嵌入进来,就可以使用嵌入字段所拥有的一切。若嵌入字段有哪里不合适,可以用“包装”或“屏蔽”的方式去调整和优化。

  2. 另外,类型间的组合也是灵活的,我们总是可以通过嵌入字段的方式把一个类型的属性和能力“嫁接”给另一个类型。这时候,被嵌入类型也就自然而然地实现了嵌入字段所实现的接口。(拥有其实现的接口方法)

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

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


问题 2:值方法和指针方法都是什么意思,有什么区别?

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

值方法,就是接收者类型是非指针的自定义数据类型的方法;

同理,指针方法就是接收者类型是指针的自定义数据类型的方法。

func (cat *Cat) SetName(name string) { // *Cat:Cat类型的指针类型。
	cat.name = name
}

方法SetName的接收者类型是*Cat

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

取值操作符* + 指针值 = 取值表达式,以获取该指针值指向的基本类型值。

取址操作符& + 可寻址的基本类型值 = 取址表达式,以获取该基本类型值的指针值。

所谓的指针方法,就是接收者类型是上述指针类型的方法。 值方法和指针方法的不同点(1.值拷贝与引用拷贝的区别。2.对于接口实现的区别)

package main

import "fmt"

type Cat struct {
	name           string // 名字。
	scientificName string // 学名。
	category       string // 动物学基本分类。
}

func New(name, scientificName, category string) Cat {
	return Cat{
		name:           name,
		scientificName: scientificName,
		category:       category,
	}
}

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

func (cat Cat) SetNameOfCopy(name string) {
	cat.name = name
}

func (cat Cat) Name() string {
	return cat.name
}

func (cat Cat) ScientificName() string {
	return cat.scientificName
}

func (cat Cat) Category() string {
	return cat.category
}

func (cat Cat) String() string {
	return fmt.Sprintf("%s (category: %s, name: %q)",
		cat.scientificName, cat.category, cat.name)
}

func main() {
	cat := New("little pig", "American Shorthair", "cat")
	cat.SetName("monster") // (&cat).SetName("monster"),这里表现了区别2中的自动转译
	fmt.Printf("The cat: %s\n", cat)

	cat.SetNameOfCopy("little pig")
	fmt.Printf("The cat: %s\n", cat)

	type Pet interface {
		SetName(name string)
		Name() string
		Category() string
		ScientificName() string
	}

	_, ok := interface{}(cat).(Pet)
	fmt.Printf("Cat implements interface Pet: %v\n", ok)
	_, ok = interface{}(&cat).(Pet)
	fmt.Printf("*Cat implements interface Pet: %v\n", ok)
}

---
The cat: American Shorthair (category: cat, name: "monster")
The cat: American Shorthair (category: cat, name: "monster") //这个结果体现区别1

Cat implements interface Pet: false //这个结果体现区别3
*Cat implements interface Pet: true
  1. 值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。

    而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内****对该副本指向的值进行修改,一定会体现在原值上

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

    严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。

    比如,在Cat类型的变量cat之上,之所以我们可以通过cat.SetName("monster")修改猫的名字,是因为 Go 语言把它自动转译为了(&cat).SetName("monster"),即:先取cat的指针值,然后在该指针值上调用SetName方法。

  3. 一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。

    比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。

思考题

  1. 我们可以在结构体类型中嵌入某个类型的指针类型吗?如果可以,有哪些注意事项?

可以。在这时,需要注意各种“屏蔽”现象。由于某个类型的指针类型会包含与前者有关联的所有方法,所以我们更要注意。

另外,我们在嵌入和引用这样的字段的时候还需要注意一些冲突方面的问题,具体请参看 Go 语言规范。

  1. 字面量struct{}代表了什么?又有什么用处?

字面量struct{}代表了空的结构体类型。这样的类型既不包含任何字段也没有任何方法。该类型的值所需的存储空间几乎可以忽略不计。

因此,我们可以把这样的值作为占位值来使用。