Go 数据结构 - interface


Go 数据结构 - interface

参考
Go 语言基础之接口 - 李文周
Go 语言设计与实现接口 - 面向信仰编程
Go 语言 interface - 码农桃花源
《Go 语言底层原理》第 12 章




1. 接口概述

接口

接口定义了一种规范,提供数据传输的渠道。不论是前后端交互的接口,还是程序设计中的接口,抑或是常用的 Type-A、Type-C 结构,只有符合接口的规范设计,才能让不同组件相互连接完成数据的传输。接口可以隔离底层的实现,用户只需要知道怎么用接口就行了。


1.1 显式接口

作为后端开发领域中的大哥大,Java 需要显示声明实现的接口。

public interface MyInterface {
  public String hello = "Hello";
  public void sayHello();
}

public class MyInterfaceImpl implements MyInterface {
  public void sayHello() {
    System.out.println(MyInterface.hello);
  }
}

MyInterface 接口中声明了 方法 sayHello 和 变量 hello

MyInterfaceImpl 类 使用 implements 关键字显式的声明实现了接口 MyInterface

这是一种侵入式接口设计,实现类需要明确声明自己实现了某个接口。这也意味着对接口产生了依赖,当接口被删除时程序就无法运行,耦合度较高。


1.2 隐式接口

Go 语言中接口的实现都是隐式的,只需实现接口中定义的方法,而无需显示的声明实现了该接口。

type MyInterface interface {
    sayHello()
}

type MyInterfaceImpl struct{}

func (m MyInterfaceImpl) sayHello() {
    fmt.Println("hello")
}

MyInterface 接口中声明了 方法 sayHello

MyInterfaceImpl 结构体实现了 方法 sayHello ,但是没有显示的声明。

这是一种非侵入式接口设计,只需要实现接口的所有方法就叫实现了该接口,即便该接口删掉了也不会影响。(鸭子类型,只要实现了鸭子叫、鸭子跑方法,就可以认定它是鸭子),耦合度很低。




2. 接口实例

2.1 空接口 & 带方法签名接口

Go 语言中接口有两种类型,带方法签名的接口和空接口。

因为目前 Go1.17 前都没有范型,空接口作为伪范型用的非常多,如 fmt 中打印函数的入参都是空接口。

func Empty(i interface{}) {
    fmt.Println(i)
}

func main() {
    Empty("xyz") // xyz
    Empty(123)   // 123
    Empty(true)  // true
}

下面介绍如何使用带方法签名的接口。

package main

import "fmt"

type Duck interface {
    Walk()
    Quack()
}

type Zhouhei struct{}

func (z Zhouhei) Walk() {
    fmt.Println("周黑鸭 walk...")
}

func (z Zhouhei) Quack() {
    fmt.Println("周黑鸭 quack...")
}

type Jiujiu struct{}

func (j Jiujiu) Walk() {
    fmt.Println("久久鸭 walk...")
}

func (j Jiujiu) Quack() {
    fmt.Println("久久鸭 quack...")
}

// Duck interface 作为
func DuckWalk(d Duck) {
    d.Walk()
}

func DuckQuack(d Duck) {
    d.Quack()
}

func main() {
    z := Zhouhei{}
    j := Jiujiu{}
    DuckWalk(z)  // 周黑鸭 walk...
    DuckQuack(z) // 周黑鸭 quack...
    DuckWalk(j)  // 久久鸭 walk...
    DuckQuack(j) // 久久鸭 quack...
}

上面是接口使用中很常见的例子, Duck 接口 中定义了两个方法, ZhouheiJiujiu 两个结构体实现了这两个方法,即实现了 Duck 接口 。同时 Duck 接口 作为 DuckWalkDuckQuack 函数的入参,可以接收这两个结构体。

这种设计使得代码的扩展性比较高,如果再来一个结构体,只需实现接口中的方法即可通过 DuckWalkDuckQuack 函数被调用。


2.2 值接收者 & 指针接收者

在结构体实现方法时,一般将结构体称为方法的接收者。接收者类型有两种: 值接收者指针接收者,上面例子中使用的就是值接收者。

// ============== 值接收 ===============
func (z Zhouhei) Walk() {
    fmt.Println("周黑鸭 walk...")
}

z1 := Zhouhei{}
z2 := &Zhouhei{}
DuckWalk(z1) // 周黑鸭 walk...
DuckWalk(z2) // 周黑鸭 walk...

// ============== 指针接收 ===============
func (z *Zhouhei) Walk() {
    fmt.Println("周黑鸭 walk...")
}

z1 := Zhouhei{}
z2 := &Zhouhei{}
DuckWalk(z1) // 报错
// cannot use z1 (type Zhouhei) as type Duck in argument to DuckWalk:
//   Zhouhei does not implement Duck (Walk method has pointer receiver)

DuckWalk(z2) // 周黑鸭 walk...
  • 当接收者为值类型: Zhouhei 值对象和指针对象都可以赋值给该接口变量。
    因为 Go 语言中有对指针类型变量求值的语法糖,通过指针可以获取到对象,指针 &Zhouhei{} 内部会自动求值 Zhouhei{}
  • 当接收者为指针类型:值对象无法赋值给该接口变量。
    因为值对象 Zhouhei{} 与方法接收者 指针对象 *Zhouhei 类型不一致

用值接收者还是指针接收者?

  • 如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;使用指针作为方法的接收者的理由:
    • 方法能够修改接收者指向的值。
    • 避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。
  • 如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。

是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的 本质

如果类型具备 “原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就 ** 定义值接收者类型的方法 **。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。

如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就 ** 定义指针接收者的方法 **。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份 实体


2.3 接口类型断言

一个接口的值(简称接口值)是由 一个具体类型具体类型的值 两部分组成的。这两部分分别称为接口的 动态类型动态值

类型断言

可以使用语法 i.(Type) 在运行时获取存储在接口中的动态值。其中 i 代表接口,Type 代表实现此接口的动态类型。

package main

import "fmt"

type Duck interface {
    Walk()
    Quack()
}

type Zhouhei struct{}

func (z Zhouhei) Walk() {
    fmt.Println("周黑鸭 walk...")
}

func (z Zhouhei) Quack() {
    fmt.Println("周黑鸭 quack...")
}

type Jiujiu struct{}

func (j Jiujiu) Walk() {
    fmt.Println("久久鸭 walk...")
}

func main() {
    var d Duck
    d = Jiujiu{}
    value := d.(Zhouhei)
    // cannot use Jiujiu{} (type Jiujiu) as type Duck in assignment:
    //   Jiujiu does not implement Duck (missing Quack method)

    fmt.Println(value)
}

首先声明一个接口变量作为 Jiujiu 对象的载体,然后通过类型断言语句判断是否为 Zhouhei 类型,产生错误信息:未实现 Quack 方法。

这种写法不够优雅,一般接口类型断言语句这样写: value, ok := i.(Type)

断言语句经常会和 switch 一起用

package main

import "fmt"

func check(arg interface{}) {
    switch f := arg.(type) {
    case bool:
        fmt.Println("bool")
    case string:
        fmt.Println("string")
    default:
        fmt.Println("未识别类型", f)
    }
}

func main() {
    check("xyz") // string
    check(true)  // bool
    check(123)   // 未识别类型 123
}



3. 接口原理

Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:

  • 使用 runtime.iface 结构体表示包含方法的接口
  • 使用 runtime.eface 结构体表示不包含任何方法的 interface{} 类型,它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针;
接口结构体

data 字段 存储了接口中动态类型的函数指针。

tab 字段 存储了接口的类型、接口中的动态数据类型、动态数据类型的函数指针等。其类型为 *itab,这也是接口的核心。

itab 结构体

hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致;

fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的

接口底层结构

3.1 类型结构体

itab 中的 _type 字段 代表接口存储的动态类型。Go 语言的各种数据类型都是在_type 字段的基础上通过增加额外字段来管理的。

下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。

_type结构体
  • size 字段存储了类型占用的内存空间,为内存空间的分配提供信息;
  • hash 字段能够帮助我们快速确定类型是否相等;
  • equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的;



文章作者: xuxiangfei
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 xuxiangfei !
  目录