Go语言学习 - interface

521 阅读5分钟

Introduction

Go语言interface使用非常普遍, 比较常见的玩法是interface{}, 没那么常见的玩法是真的去写了一个type interface struct{}, 然后等着去实现这个接口. 这篇文章介绍以上两种玩法的实现, 内容包含有:

  • interface变量,到底存了些啥 ------- 跳转
  • 一个interface实例化的例子 -------- 跳转
  • 隐式转换与显式转换 -------------- 跳转
  • 空interface变量又存了些啥 -------- 跳转
  • 实体实现?指针实现?疯了 ----------- 跳转

interface变量里存啥了

type iface struct {
    tab   *itab            // 类型
    data  unsafe.Pointer   // 值
}

var interface v = implementation{}

如上所示, 假设你创建了某个结构体实现了一个接口, 现在你创建了一个v来实例化这个接口,编译器检查到这个变量的主体是一个interface(接口变量), 于是创建了一个iface绑定在变量v上, 其中的itab表示接口详细信息, data则表示实际承载的数据. 所有的故事都开始在一个叫做iface的内置结构体上.

type itab struct {
    inner  *interfacetype   // 接口类型
    typ    *_type           // 数据类型
    hash   uint32           // 类型hash
    fn     []uintptr        // 实际的函数集
}
type interfacetype struct {
  type    _type      // 接口类型
  mhdr    []imethod  // 接口定义出的方法
}
type _type struct {
	size       uintptr   // 大小
	hash       uint32    // 类型hash
	kind       uint8     // 基础类型
}
  • 我们用interfaceType表示接口类型详情, 用_type表示实际数据类型详情
    • 本质上interfaceType主要也记录_type, 但是接口的一个重要特性, 接口方法也要记录, 因此我们把type[]imethod打包在一起变成interfaceType记录接口详情
    • _type中有一个重要数据hash, 一个整数, 用于快速判断两个_type是不是相同的
  • []imethodfn []uinptr都表示函数集(方法集), 区别是imethod表示接口要求实现的所有函数, 而fn则表示实际承载数据包含那些函数, 在动态派发中我们不知这个接口实际应该执行什么函数, 就是用这种类型虚函数表的东西去定位并找到实际应该执行的函数的
  • hash最常见的用法在switch v.(type) {}中, 为了判断这个接口是什么类型, 用的就是hash值来判断的

一个instance实例化的小例子

有了上面的内容作为基础, 我们来聊聊给出的这个例子, 我用face变量去承载impl, 就这种情况你认为变量v里存了些什么?

  • 首先我们分配内存并生产一个impl{}结构体
  • 然后就到了类型转换的阶段, 编译器会将刚刚生产出来的结构体, 连同face类型传递到runtime.convT2I方法里去, 这个函数负责生产出一个iface, 这个iface会被用于绑定到v变量上去. 里面记录了它的接口类型, 与实际承载的数据类型

到这里编译期的任务已经执行完了, 编译期间主要负责判断"是否实现",以及"类型转换"两项工作, 然后我们将任务扩展一下, 分析一下下面两个例子:

face.call()
face.(*impl).call()
  • 上面一种调用方式叫动态派发, 是一种运行期间才能确定具体调用什么函数的手段, 等到运行调用的时候, 会通过iface中的fn列表找到实际要运行的函数
  • 下面一种本质上还是在搞类型转换, 刚刚也说了只要是类型转换都是在编译期就搞好的事情, 因此如果是走下面一种调法, 编译期就能确定要执行什么函数

隐式/显式转换

我们在上面的例子中通过v.(impl)的方式将v从接口变量变成了实例化的对象, 这种你手动去写的方式叫做显式转换. 隐式转换其实发生的更常见, 比如:

func f(v interface{}){}
f(&person)

函数定义上要求的是空接口, 然而你却送来了一个&person, 编译同样是能通过的, 这是因为你的指针被隐式转换成了空接口, 但是很多人对此的理解是: "interface{}相当于void*", 这是两件事.

空接口的内部实现

空接口使用eface来承载数据. 我们在iface中使用interfaceType来表示接口类型, 因为我们需要记录这个接口定义了那些方法需要实现, 然而空接口并没有任何方法, 自然也用不上interafaceType, 直接用type表示类型就好了.

空接口只关心实例类型, 实例值是什么. 相似的, 空接口承载实例的过程本质上也是eface的生成过程. 空接口并不是空的, 因为它保留了实例的类型, 同时值拷贝了一份副本过来

type eface struct {
    _type *_type
    data unsafe.Pointer
}

即使空接口没有任何方法, 但是关于类型的一系列操作, 也都还是有的, 你还是可以通过type.hash来搞类型断言, 比如:

var v interface = &cat{}

switch v.(type) {
  case *cat:
  		// ...
  case *dog:
  		// ...
}

一些关于接口的练习

看一下上面的例子, 上面的例子不是一定都能通过编译的, 首先很容易理解的一点就是, 方法的主体是一个结构体, 那么结构体impl{}自然是实现了这个接口, 同样如果方法的主体是一个指针, 那么指针&impl{}自然也是实现了这个接口, 没问题.

但为什么指针也能调用结构体定义的方法? 但反过来不行呢? 结构体不能调用指针的方法呢? 理解这个现象要知道Go语言在函数调用的时候一定会复制参数, 那么区别无非就是复制一个指针还是复制一个结构体.

  • 如果你传来指针, 我复制指针, 那么我可以隐式解引用, 指向的是同一个结构体
  • 但如果你传来一个结构体, 我也复制一个结构体, 但我不可能无中生有帮你创造一个指针出来, 即使我帮你创造了, 因为复制过, 指针指向的也不是最初的那个结构体了