2.接口

Go 语言中的接口是一组方法的签名。接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。

实现上,interface 实际结构为动态类型 + 动态值,根据是否包含方法,又分为 ifaceeface 两种,eface 多带一个函数地址列表

这种面向接口的编程方式有着非常强大的生命力,无论是在框架还是操作系统中我们都能够找到接口的身影。可移植操作系统接口(Portable Operating System Interface,POSIX)就是一个典型的例子,它定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,计算机软件就可以直接在不同操作系统上运行。

除了解耦有依赖关系的上下游,接口还能够帮助我们隐藏底层实现,减少关注点。人能够同时处理的信息非常有限,定义良好的接口能够隔离底层的实现,让我们将重点放在当前的代码片段中。SQL 就是接口的一个例子,当我们使用 SQL 语句查询数据时,其实不需要关心底层数据库的具体实现,我们只在乎 SQL 返回的结果是否符合预期。

隐式接口

很多面向对象语言都有接口这一概念,例如 Java 和 C#。这里简单介绍一下 Java 中的接口:

// 接口定义
public interface MyInterface {
    public void sayHello();
}

// 实现接口的类
public class MyInterfaceImpl implements MyInterface {
    public void sayHello() {
        System.out.println("Hello");
    }
}

Java 中的类必须通过上述方式显式地声明实现的接口,但是在 Go 语言中实现接口就不需要使用类似的方式。一个常见的 Go 语言接口是这样的:

// 接口定义
type error interface {
	  Error() string
}

// 实现接口的类
type RPCError struct {
	  Code    int64
	  Message string
}

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

Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式地声明接口 (implements 关键字) 并实现所有方法;

  • 在 Go 中:实现接口的所有方法就隐式地实现了接口;

我们使用上述 RPCError 结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查,这里举几个例子来演示发生接口类型检查的时机:

func main() {
    var rpcErr error = NewRPCError(400, "unknown err") // typecheck1
    err := AsErr(rpcErr) // typecheck2
    println(err)
}

func NewRPCError(code int64, msg string) error {
    return &RPCError{ // typecheck3
        Code:    code,
        Message: msg,
    }
}

func AsErr(err error) error {
    return err
}

Go 语言在编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:

  1. *RPCError 类型的变量赋值给 error 类型的变量 rpcErr

  2. *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 errorAsErr 函数;

  3. *RPCError 类型的变量从函数签名的返回值类型为 errorNewRPCError 函数中返回;

从类型检查的过程来看,编译器仅在需要时才检查类型,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。

数据结构

Go 语言中有两种略微不同的接口,一种是带方法的,Go语言中使用 runtime.iface 结构;一种是不带方法的,使用 runtime.eface 结构。两者都为动态类型 + 动态值,eface 还会多一个方法列表

eface (empty face)

type eface struct { // 16 字节
    _type *_type
    data  unsafe.Pointer
}

// 类型的运行时表示
// 包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等
type _type struct {
    size       uintptr // 类型占用的内存空间,为内存空间的分配提供信息
	  ptrdata    uintptr 
  	hash       uint32  // 快速确定类型是否相等
	  tflag      tflag
  	align      uint8
	  fieldAlign uint8
  	kind       uint8
	  equal      func(unsafe.Pointer, unsafe.Pointer) bool // 判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的
  	gcdata     *byte
	  str        nameOff
  	ptrToThis  typeOff
}

iface

// runtime/runtime2.go
type iface struct { // 16 字节
    tab  *itab
    data unsafe.Pointer
}

type itab struct { // 32 字节
    inter *interfacetype // 接口定义的类型信息
    _type *_type         // 接口实际指向值的类型信息
    hash  uint32         // 用于快速判断目标类型和具体类型 runtime._type 是否一致
    _     [4]byte
    fun   [1]uintptr     // 接口方法实现列表,即函数地址列表,按字典序排序
}

// runtime/type.go
type interfacetype struct {
    typ     _type
    pkgpath name
    mhdr    []imethod      // 接口方法声明列表,按字典序排序
}
// 接口的方法声明 
type imethod struct {
    name nameOff           // 方法名
    ityp typeOff           // 描述方法参数返回值等细节
}

类型转换

以如下代码为例:

var c Duck = &Cat{Name: "draven"}
c.Quack()

可以拆解为三步:

  1. 结构体 Cat 的初始化 (&Cat{Name: "draven"}`)

  2. 赋值触发的类型转换过程

    1. 先构建 itab 对象,其内容包含 Cat 的类型、函数方法

    2. 再调用 runtime.convT2I 方法构建 iface 对象,其 itab 字段为上面一步的结果,data 字段为指向 Cat 的指针

  3. 调用接口的方法 Quack()

    1. itab 结构内的 fun 字段存储了 Cat 的函数索引

类型断言

func main() {
    var c Duck = &Cat{Name: "draven"}
    switch c.(type) {
    case *Cat:
        cat := c.(*Cat)
        cat.Quack()
	}
}
00058 CMPL  go.itab.*"".Cat,"".Duck+16(SB), $593696792
                                        ;; if (c.tab.hash != 593696792) {
00068 JEQ   80                          ;;
00070 MOVQ  24(SP), BP                  ;;      BP = SP+24
00075 ADDQ  $32, SP                     ;;      SP += 32
00079 RET                               ;;      return
                                        ;; } else {
00080 LEAQ  ""..autotmp_4+8(SP), AX     ;;      AX = &Cat{Name: "draven"}
00085 MOVQ  AX, (SP)                    ;;      SP = AX
00089 CALL  "".(*Cat).Quack(SB)         ;;      SP.Quack()
00094 JMP   70                          ;;      ...
                                        ;;      BP = SP+24
                                        ;;      SP += 32
                                        ;;      return
                                        ;; }

switch语句生成的汇编指令会将目标类型的 hash 与接口变量中的 itab.hash 进行比较:

  • 如果两者相等意味着变量的具体类型是 Cat,我们会跳转到 0080 所在的分支完成类型转换。

    1. 获取 SP+8 存储的 Cat 结构体指针;

    2. 将结构体指针拷贝到栈顶;

    3. 调用 Quack 方法;

    4. 恢复函数的栈并返回;

  • 如果接口中存在的具体类型不是 Cat,就会直接恢复栈指针并返回到调用方

动态派发

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是面向对象语言中的常见特性6。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。

在如下所示的代码中,main 函数调用了两次 Quack 方法:

  1. 第一次以 Duck 接口类型的身份调用,调用时需要经过【运行时】的动态派发;

  2. 第二次以 *Cat 具体类型的身份调用,【编译期】就会确定调用的函数:

func main() {
	var c Duck = &Cat{Name: "draven"}
	c.Quack()
	c.(*Cat).Quack()
}

reflect

reflect 包基本是依赖 interface 来实现的。里面定义了一个接口和一个结构体,即 reflect.Typereflect.Value,提供很多函数来获取存储在接口里的类型信息。

reflect.Type 主要提供关于类型相关的信息,所以它和 _type 关联比较紧密;reflect.Value 则结合 _typedata 两者,因此程序员可以获取甚至改变类型的值。

reflect 包中提供了两个基础的关于反射的函数来获取上述的接口和结构体:

func TypeOf(i interface{}) Type 
func ValueOf(i interface{}) Value

调用这两个函数时,实参会先被值拷贝,转为 interface{} 类型。这样,实参的类型信息、方法集、值信息都存储到 interface{} 变量里了。

参考

draveness - 4.2 接口

Last updated