<Go语言学习笔记> 接口

275 阅读8分钟

本文为极客时间《Go语言核心36讲》的学习笔记,梳理了索引相关的知识点。

接口介绍

接口(硬件类接口)是指同一计算机不同功能层之间的通信规则称为接口。 接口(软件类接口)是指对协定进行定义的引用类型。其他类型实现接口,以保证它们支持某些操作。接口指定必须由类提供的成员或实现它的其他接口。与类相似,接口可以包含方法、属性、 索引器和事件作为成员。

从根本上讲:接口是一个中间层,使得调用和实现完全分离,解除了上下游的耦合,调用方不再依赖于某一个具体的模块,而是依赖于一个接口。举个例子:

装修的时候,只要在墙上留好适当的位置、插座、出水口就能适配市面上几乎所有的空调柜机。这里位置、插座、出水口就是规定好的接口内容,空调厂商是能力提供商,也即是具体的实现,我们自己就是调用方。这样的好处显而易见的。以此类推:手机电池,键盘鼠标,我们的数据线都是接口的思想。

回到我们计算机领域,同样的解决问题的思路在我们这里称为面向接口编程。推而广之,我们日常使用的很多工具本身都是一种接口:

  1. SQL语句。同样的语句可以在MySQL,SQL Server,SQLLite等上面运行,并且能得到同样预期的结果。
  2. 我们使用的不同公司的APP,只要他们实现了操作系统提供的接口,都可以在运行起来。
  3. 各种通信协议本质上都是接口思想的具体实现。

使用接口有一个显而易见的好处:容易理解。”代码必须能够被人阅读,只是机器恰好可以执行“。为什么我们会说大多数项目代码都是屎山屎坑,他们的代码能执行,但是没有人看得懂了。

GO的接口

不同于Java中的接口,GO语言的接口只包含具体方法,没有属性。在实现一个具体的方法时,也不需要显式的声明,只需要实现对应的方法就算是实现了某个接口。如下:

//在Java中声明 并实现一个接口
public interface Pet {
    public String Name = "Pet"; //可以包含属性
    public void getName(); //定义方法
}

//实现一个具体的接口,需要用的关键字 implements
public class Dog implements Pet { 
    public void getName() {
        System.out.println(Dog. getName);
    }
}
//Go官方包中对于 error 接口的定义
type error interface {
	Error() string //只有这么一个方法,只要实现这个方法都可以认为实现了这个接口
}

//具体实现
type HttpError struct {
	Code    int
	Message string
	Data    struct
}

func (e * HttpError) Error() string {
	return fmt.Sprintf("err:%s, code:%d", e.Message, e.Code)
}

通过名字我们还能看出来这个方法有error的样子,比较好理解他其实是实现了一个Error的接口。后面我们来看个比较离谱的:

type Cat struct {
	Name string
	Age int64
	string
}

func (c *Cat)String()string  {
	return fmt.Sprintf("String:%#v",c)
}

func (c Cat)String1() string {
	return fmt.Sprintf("String:%#v",c)
}

func (c *Cat)Error() string  { //在这里声明一个 Error() string
	return fmt.Sprintf("error:%#v",c)
}

func main() {
	C := Cat{
		Name: "abc1",
		Age:  12,
		string:"sss",
	}

	Test3(&C) //可以正常传入
}

func Test3(e error) string {
	return e.Error()
}

看上去Cat结构体和Error接口八竿子打不到,没事关系。因为Cat实现了Error() string方法,就可以认为他实现了Error接口。这个思路就是我们之前说的:鸭子模型

这里引出一个问题:怎样判定一个数据类型的某一个方法实现的就是某个接口类型中的某个方法呢? 这里有两个充要条件: 1. 两个方法的签名要需要完全一致。 2. 两个方法的名称要一模一样。

函数签名就是函数的声明信息,包括参数、返回值、调用约定之类

引入一个概念:静态类型动态类型

dog := Dog{"little pig"}
//此时,变量dog是一个具体的实现。
var pet Pet = &dog
//此时,变量pet的静态类型是interface{},动态类型是接口Pet 此时pet的值是变量dog的指针

指针与值的区别

先看例子

直接来看下郝琳老师给的案例:

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"
}

func main() {
	// 示例1。
	dog := Dog{"little pig"}// 一个指针方法,两个值方法。
	_, ok := interface{}(dog).(Pet) //反射的用法,判断dog是否是Pet类型,值类型
	fmt.Printf("Dog implements interface Pet: %v\n", ok) //false
	_, ok = interface{}(&dog).(Pet) //判断&dog是否是Pet类型,指针类型
	fmt.Printf("*Dog implements interface Pet: %v\n", ok) //true
	fmt.Println()

	// 示例2。
	var pet Pet = &dog // pet 是一个接口类型,然后把dog的指针赋值过去。
	fmt.Printf("This pet is a %s, the name is %q.\n",
		pet.Category(), pet.Name())//dog的这两个方法都是值方法, 这里依然可以正常调用。
}

通过这个例子,就可以解释为什么实际工作中,绝大多数的类的方法都是指针类型。

这里再来看一个例子:


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

//例子2
dog1 := Dog{"little pig"}
dog2 := dog1
dog1.name = "monster"

//例子3
dog := Dog{"little pig"}
pet := &dog
//dog.SetName("monster")
dog.name = "monster"

其中,例1和例2 都不会导致Pet的Name发生变化,此时只是修改了Dog的值。例3的两种赋值方式都会改变Pet的值。 这里的表现情况和之前讲的结构体的表现保持一致。底层原理上,接口要稍微复杂一点。

总而言之:无论方法是值还是指针,初始化实例的指针一定可以满足要求。 具体工作中,最好使用指针方法。

interface{} says nothing

这是GO谚语之一,简单翻译就是interface{}啥也没说。 首先,interface{}绝对不是任意的意思,不能什么都往里塞。比如

func Func1(v interface{}){...} //最好不要这样写,容易出事故。

其次,声明一个interface之后,要给一个明确的定义,它到底包含了哪些具体的方法,没有方法就多写点注释,面得后期维护困难。

最后,一个类型为interface{}的变量,它不是一个空值,而是一个空结构。绝对不是nil,两者不能画等号。具体解释可以看底层实现。

底层原理

Go的指针在底层使用了两个结构体:


//通常接口类型是这个接口体,包含方法的接口
type iface struct {
	tab  *itab //指向类型信息的指针
	data unsafe.Pointer //指向具体的数据的指针
}

//比较少见的,没有任何方法的接口。也就是interface{},隐式转换时得到就是这个结构体
type eface struct {
	_type *_type
	data  unsafe.Pointer
}

其中itab_type的具体实现可以去看源码,我们就不展开了。他们是接口在具体使用中能够被互相转化,互相识别的关键点。

问题引申

  1. 接口变量的值在什么情况下才真正为nil?
    • 接口变量是一个接口体永远不会是nil,编辑器会有异常提示!
    • 接口变量的值可以为nil,只要声明不实现,或者直接赋值为nil

var dog1 *Dog
fmt.Println("The first dog is nil. [wrap1]")
dog2 := dog1
fmt.Println("The second dog is nil. [wrap1]")
var pet Pet = dog2
if pet == nil { //这一行代码会报出提示,pet 永远不会是 nil
  fmt.Println("The pet is nil. [wrap1]")
} else {
  fmt.Println("The pet is not nil. [wrap1]")
}
  1. 怎样实现接口之间的组合? 接口类型间的嵌入也被称为接口的组合。接口的嵌入要比结构体简单的多,它不存在”屏蔽问题“。多个嵌入时,只要出现同名方法,哪怕出入参不一样也会直接报错,无法编译。具体的使用:
type Animal interface {
  ScientificName() string
  Category() string
}

type Pet interface {
  Animal
  Name() string
}

在具体的使用过程中,尽量多的使用小接口。Go 语言团队鼓励我们声明体量较小的接口,并建议我们通过这种接口间的组合来扩展程序、增加程序的灵活性。小接口可以更加专注地表达某一种能力或某一类特征,同时也更容易被组合在一起。

Go谚语:The bigger the interface, the weaker the abstraction (接口越大,抽象越差)

具体可以参考Go 语言标准库代码包io中的ReadWriteCloser接口和ReadWriter接口,他们就是有一系列的小接口组合而成的。

  1. 如果我们把一个值为nil的某个实现类型的变量赋给了接口变量,那么在这个接口变量上仍然可以调用该接口的方法吗?

如果你完全理解了上面写的东西,那么答案就很简单了。类型信息保存在interface{}中的类型指针里,所以这个时候是可以访问当方法的。但是具体的属性,因为没有初始化,所以不存在,访问的时候会Panic。比如:

var dog *Dog //此时dog为指针,默认为nil
fmt.Printf("dog:%+v\n",dog)
var pet Pet = dog //此时pet 依然是接口,类型为Pet 只不过没有实现
fmt.Println("pet.Name()", pet.Name()) //运行是报错,panic: runtime error: invalid memory address or nil pointer dereference

如果pet.Name()是值方法,那么在这一行就会报错,如果是指针方法,则会在pet.Name()里面报错。

引申阅读:Go 语言接口的原理 | Go 语言设计与实现