Go语言36讲笔记--14接口类型的合理运用

796 阅读13分钟

对接口的基本理解

  1. 在 Go 语言的语境中,当我们在谈论“接口”的时候,一定指的是接口类型。因为接口类型与其他数据类型不同,它是没法被实例化的。具体地讲,我们既不能通过调用new函数或make函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。

  2. 对于某一个接口类型来说,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。

  3. 通过关键字typeinterface,可以声明出接口类型。接口类型的类型字面量与结构体类型的看起来有些相似,它们都用花括号包裹一些核心信息。只不过,结构体类型包裹的是它的字段声明,而接口类型包裹的是它的方法定义。需要注意的是:接口类型声明中的这些方法所代表的就是该接口的方法集合。一个接口的方法集合就是它的全部特征

  4. 对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征(即全部的方法),那么它就一定是这个接口的实现类型。

  5. 怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法

两个条件,一个是“两个方法的签名需要完全一致”,另一个是“两个方法的名称要一模一样”。
方法签名:是入参列表+返回值列表,与名称无关

本篇文章需要了解的几个知识点
  1. Go 语言的接口常用于代表某种能力或某类特征。所以在设计的时候尽量精简化。
  2. 需要弄清楚,接口变量的动态值、动态类型和静态类型都代表了什么。这些是正确使用接口变量的基础。
  3. 当我们给接口变量赋值时,接口变量会持有被赋予值的副本,而不是它本身。
  4. 接口变量的值并不等同于这个可被称为动态值的副本。它会包含两个指针,一个指针指向动态值,一个指针指向类型信息。
  5. 基于4,即使我们把一个值为nil的某个实现类型的变量赋给了接口变量,后者的值也不可能是真正的nil。虽然这时它的动态值会为nil,但它的动态类型确是存在的。So,除非我们只声明而不初始化,或者显式地赋给它nil,否则接口变量的值就不会为nil

demo

package main

import "fmt"

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

type Dog struct {
	name string // 名字。
}

func (dog *Dog) SetName(name string) { //指针方法
	dog.name = name
}

func (dog Dog) Name() string { //值方法
	return dog.name
}

func (dog Dog) Category() string { //值方法
	return "dog"
}
//这就意味着,`Dog`类型本身的方法集合中只包含了 2 个方法,也就是所有的值方法。
//而它的指针类型`*Dog`方法集合却包含了 3 个方法

//`*Dog`类型拥有`Dog`类型附带的所有值方法和指针方法。
//由于这些方法是`Pet`接口中所有方法的实现,所以`*Dog`类型就成为了`Pet`接口的实现类型。

func main() {
	// 示例1。
	dog := Dog{"little pig"}
	_, ok := interface{}(dog).(Pet)
	fmt.Printf("Dog implements interface Pet: %v\n", ok)
	_, ok = interface{}(&dog).(Pet)
	fmt.Printf("*Dog implements interface Pet: %v\n", ok)
	fmt.Println()

	// 示例2。
	var pet Pet = &dog // &dog代表的是一个指针值,把该指针值赋给类型为Pet的变量pet
        
	fmt.Printf("This pet is a %s, the name is %q.\n",
		pet.Category(), pet.Name())
}

---
Dog implements interface Pet: false
*Dog implements interface Pet: true

This pet is a dog, the name is "little pig".

知识点2的理解

👆.go中,变量pet,我们赋给它的值可以被叫做它的实际值(也称动态值),而该值的类型可以被叫做这个变量的实际类型(也称动态类型)。

比如,我们把取址表达式&dog的结果值赋给了变量pet,这时这个结果值就是变量pet的动态值,而此结果值的类型*Dog就是该变量的动态类型。

dog := Dog{"little pig"},此时&dog的类型是*Dog

动态类型这个叫法是相对于静态类型而言的。对于变量pet来讲,它的静态类型就是Pet,并且永远是Pet,但是它的动态类型却会随着我们赋给它的动态值而变化

比如,只有我把一个*Dog类型的值赋给变量pet之后,该变量的动态类型才会是*Dog。如果还有一个Pet接口的实现类型*Fish,并且我又把一个此类型的值赋给了pet,那么它的动态类型就会变为*Fish。 在我们给一个接口类型的变量赋予实际的值之前,它的动态类型是不存在的。


当我们为一个接口变量赋值时会发生什么?(解析知识点3)

为了突出问题,把Pet接口的声明简化一下。

type Pet interface {
	Name() string
	Category() string
}

去掉了Pet接口的那个名为SetName的方法。这样一来,Dog类型也就变成Pet接口的实现类型了。

dog := Dog{"little pig"}
var pet Pet = dog
dog.SetName("monster")

声明并初始化了一个Dog类型的变量dog,这时它的name字段的值是"little pig"
然后,该变量赋给了一个Pet类型的变量pet
最后通过调用dog的方法SetName把它的name字段的值改成了"monster"

在以上代码执行后,pet变量的字段name的值会是什么?

回答是pet变量的字段name的值依然是"little pig"

过程解析

首先,由于dogSetName方法是指针方法,所以该方法持有的接收者就是指向dog的指针值的副本,因而其中对接收者的name字段的设置就是对变量dog的改动。

那么当dog.SetName("monster")执行之后,dogname字段的值就一定是"monster"

// 示例1。
dog := Dog{"little pig"}
fmt.Printf("The dog's name is %q.\n", dog.Name())
var pet Pet = dog
dog.SetName("monster")
fmt.Printf("The dog's name is %q.\n", dog.Name())
fmt.Printf("This pet is a %s, the name is %q.\n", pet.Category(), pet.Name())
fmt.Println()
---
The dog's name is "little pig".
The dog's name is "monster".
This pet is a dog, the name is "little pig".

如果理解到了这一层,那么需要小心此处的陷阱。

为什么👆.go中dogname字段值变了,而pet的却没有呢?

这里有一条通用的规则:如果我们使用一个变量给另外一个变量赋值,那么真正赋给后者的,并不是前者持有的那个值,而是该值的一个副本。

例如,我声明并初始化了一个Dog类型的变量dog1,这时它的name"little pig"。然后,我在把dog1赋给变量dog2之后,修改了dog1name字段的值。这时,dog2name字段的值是什么?

dog1 := Dog{"little pig"}	
dog2 := dog1	//dog1与dog2都不是引用数据类型,存储的都是数据本身
dog1.name = "monster"

这时的dog2name仍然会是"little pig"。这就是我刚刚告诉你的那条通用规则的又一个体现。

这条规则是原因之一。关于另一半原因,如下:

从接口类型值的存储方式和结构说起。我在前面说过,接口类型本身是无法被值化的。在我们赋予它实际的值之前,它的值一定会是nil,这也是它的零值。

反过来讲,一旦它被赋予了某个实现类型的值,它的值就不再是nil了。不过要注意,即使我们像前面那样把dog的值赋给了petpet的值与dog的值也是不同的。这不仅仅是副本与原值的那种不同。

当我们给一个接口变量赋值的时候,该变量的动态类型会与它的动态值一起被存储在一个专用的数据结构中。

严格来讲,这样一个变量的值其实是这个专用数据结构的一个实例,而不是我们赋给该变量的那个实际的值。所以我才说,pet的值与dog的值肯定是不同的,无论是从它们存储的内容,还是存储的结构上来看都是如此。不过,我们可以认为,这时pet的值中包含了dog值的副本。

我们就把这个专用的数据结构叫做iface吧,在 Go 语言的runtime包中它其实就叫这个名字。

iface的实例会包含两个指针,一个是指向类型信息的指针,另一个是指向动态值的指针。这里的类型信息是由另一个专用数据结构的实例承载的,其中包含了动态值的类型,以及使它实现了接口的方法和调用它们的途径,等等。

总之,接口变量被赋予动态值的时候,存储的是包含了这个动态值的副本的一个结构更加复杂的值。


知识扩展(知识点4、5)

问题 1:接口变量的值在什么情况下才真正为nil
demo

package main

import (
	"fmt"
	"reflect"
)

type Pet interface {
	Name() string
	Category() string
}

type Dog struct {
	name string // 名字。
}

func (dog *Dog) SetName(name string) { //指针方法
	dog.name = name
}

func (dog Dog) Name() string { //值方法
	return dog.name
}

func (dog Dog) Category() string {
	return "dog"
}

func main() {
	// 示例1。
	var dog1 *Dog //声明一个指针类型变量,并没有被初始化,初始值是nil
	fmt.Println("The first dog is nil.")
	dog2 := dog1
	fmt.Println("The second dog is nil.")
        
        //当我们把`dog2`的值赋给变量`pet`的时候,`dog2`的值会先被复制,
        //不过由于在这里它的值是`nil`,所以就没必要复制了。
	var pet Pet = dog2
        //Go 语言会用我上面提到的那个专用数据结构`iface`的实例包装这个`dog2`的值的副本,这里是`nil`。
        //虽然被包装的动态值是`nil`,但是`pet`的值却不会是`nil`,因为这个动态值只是`pet`值的一部分而已。
        //这时的`pet`的动态类型就存在了,是`*Dog`。
        //我们可以通过`fmt.Printf`函数和占位符`%T`来验证这一点,另外`reflect`包的`TypeOf`函数也可以起到类似的作用。
        
        
	if pet == nil {
		fmt.Println("The pet is nil.") //必执行这条
	} else {
		fmt.Println("The pet is not nil.")
	}
        
	fmt.Printf("The type of pet is %T.\n", pet)
	fmt.Printf("The type of pet is %s.\n", reflect.TypeOf(pet).String())
	fmt.Printf("The type of second dog is %T.\n", dog2)
	fmt.Println()

	// 示例2。
	wrap := func(dog *Dog) Pet {
		if dog == nil {
			return nil
		}
		return dog
	}
	pet = wrap(dog2)
	if pet == nil {
		fmt.Println("The pet is nil.")
	} else {
		fmt.Println("The pet is not nil.")
	}
}

----
The first dog is nil.
The second dog is nil.
The pet is not nil.
The type of pet is *main.Dog.
The type of pet is *main.Dog.
The type of second dog is *main.Dog.

The pet is nil.

在 Go 语言中,我们把由字面量nil表示的值叫做无类型nil。这是真正的nil,因为它的类型也是nil。虽然dog2的值是真正的nil,但是当我们把这个变量赋给pet的时候,Go 语言会把它的类型和值放在一起考虑。

也就是说,这时 Go 语言会识别出赋予pet的值是一个*Dog类型的nil。然后,Go 语言就会用一个iface的实例包装它,包装后的产物肯定就不是nil了。

只要我们把一个有类型的nil赋给接口变量,那么这个变量的值就一定不会是那个真正的nil。因此,当我们使用判等符号==判断pet是否与字面量nil相等的时候,答案一定会是false

怎样才能让一个接口变量的值真正为nil呢?

要么只声明它但不做初始化,要么直接把字面量nil赋给它。


问题 2:怎样实现接口之间的组合?

接口类型间的嵌入也被称为接口的组合。

接口类型间的嵌入不会涉及方法间的“屏蔽”。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名彼此不同也会是如此。因此,接口的组合根本不可能导致“屏蔽”现象的出现。 demo

type Animal interface {
	ScientificName() string
	Category() string
}
 
type Pet interface {
	Animal //此时,Animal接口包含的所有方法也就成为了Pet接口的方法。
	Name() string
}

Go 语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。

这是因为相比于包含很多方法的大接口而言,小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。

Go 语言标准库代码包io中的ReadWriteCloser接口和ReadWriter接口就是这样的例子,它们都是由若干个小接口组合而成的。

io.ReadWriteCloser接口为例,它是由io.Readerio.Writerio.Closer这三个接口组成的。

这三个接口都只包含了一个方法,是典型的小接口。它们中的每一个都只代表了一种能力,分别是读出、写入和关闭。我们编写这几个小接口的实现类型通常都会很容易。并且,一旦我们同时实现了它们,就等于实现了它们的组合接口io.ReadWriteCloser

即使我们只实现了io.Readerio.Writer,那么也等同于实现了io.ReadWriter接口,因为后者就是前两个接口组成的。可以看到,这几个io包中的接口共同组成了一个接口矩阵。它们既相互关联又独立存在。

demo,体现接口组合优势

type Animal interface {
	// ScientificName 用于获取动物的学名。
	ScientificName() string
	// Category 用于获取动物的基本分类。
	Category() string
}

type Named interface {
	// Name 用于获取名字。
	Name() string
}

type Pet interface { //接口的组合
	Animal
	Named
}

type PetTag struct {
	name  string
	owner string
}

func (pt PetTag) Name() string { //值方法
	return pt.name
}

func (pt PetTag) Owner() string { //值方法
	return pt.owner
}

type Dog struct { //结构体之间的组合,注意屏蔽问题
	PetTag
	scientificName string
}

func (dog Dog) ScientificName() string {
	return dog.scientificName
}

func (dog Dog) Category() string {
	return "dog"
}

func main() {
	petTag := PetTag{name: "little pig"}
        
	_, ok := interface{}(petTag).(Named) //判断结构体类型变量petTag是否实现了Named接口
        
	fmt.Printf("PetTag implements interface Named: %v\n", ok)
        
	dog := Dog{
		PetTag:         petTag,
		scientificName: "Labrador Retriever",
	}
        
	_, ok = interface{}(dog).(Animal)
	fmt.Printf("Dog implements interface Animal: %v\n", ok)
        
	_, ok = interface{}(dog).(Named)
	fmt.Printf("Dog implements interface Named: %v\n", ok)
        
	_, ok = interface{}(dog).(Pet)
	fmt.Printf("Dog implements interface Pet: %v\n", ok)
}
----
PetTag implements interface Named: true
Dog implements interface Animal: true
Dog implements interface Named: true
Dog implements interface Pet: true

思考题

如果我们把一个值为nil的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?如果可以,有哪些注意事项?如果不可以,原因是什么?

可以调用。

但是请注意,这个被调用的方法在此时所持有的接收者的值是nil。因此,如果该方法引用了其接收者的某个字段,那么就会引发 panic!

上面的demo中有例子。