关于golang:Golang中interface的简单分析

8次阅读

共计 4951 个字符,预计需要花费 13 分钟才能阅读完成。

原文地址

版本: GO1.17

接口

Go 语言中的接口,是一组办法的签名,它是 Go 语言的重要组成部分。应用接口能让咱们写出易于测试的代码。
然而很多工程师对 Go 接口的理解都十分无限,也不分明其底层原理的实现。这成为了开发高性能服务的妨碍。

本文会介绍应用接口时遇到的一些常见问题,以及它的设计与实现,包含接口的类型转换、类型断言以及动静派发机制,
帮忙读者更好地了解接口类型。

概述

在计算机科学中,接口是多个组件共享的边界,不同的组件能在边界上替换信息。
如下图所示,接口的实质是引入一个新的中间层。调用方能够通过接口与具体的实现拆散。
接触上下游的耦合,下层的模块不在须要依赖上层的具体模块。只须要依赖一个约定好的的接口。

                   GOLANG INTERFACE
                                             ┌────────┐
                            ┌───────────────►│ module │
                            │                └────────┘
                            │
┌────────┐        ┌─────────┴─┐              ┌────────┐
│ module ├───────►│ interface ├─────────────►│ module │
└────────┘        └─────────┬─┘              └────────┘
                            │
                            │                ┌────────┐
                            └───────────────►│ module │
                                             └────────┘

图 1 上下游通过接口解耦

这种面向接口的编程形式有着弱小的生命力,无论是在框架中,还是在操作系统中,都能看到接口的身影。
可移植操作系统接口(Portable Operating System Interface, POSIX)就是一个典型的例子,
它定义了利用程序接口和命令行等规范,为计算机软件带来了可移植性,只有操作系统实现了 POSIX,
计算机软件就能够在不同操作系统上运行。

除理解耦有依赖关系的上下游,接口还能帮忙咱们暗藏底层实现,缩小关注点。
人可能同时解决的信息十分无限,定义良好的接口可能隔离底层实现,让咱们将重点放在以后的代码片段中。
SQL 就是接口的一个例子。当咱们应用 SQL 查问数据时,其实不须要关怀底层数据库的具体实现,
咱们只在乎 SQL 返回的后果是否合乎预期。

                   SQL AND DATABASE
                                             ┌────────┐
                            ┌───────────────►│ MYSQL  │
                            │                └────────┘
                            │
                  ┌─────────┴─┐              ┌────────┐
                  │     SQL   ├─────────────►│ SQLITE │
                  └─────────┬─┘              └────────┘
                            │
                            │                ┌────────────┐
                            └───────────────►│ POSTGRESQL │
                                             └────────────┘

图 2 SQL 和不同数据库

类型

接口也是 GO 语言中的一种类型,它可能呈现在变量的定义,函数的入参和放回值上。
GO 语言中有两种稍微不同的接口,一种是带一组办法的接口,另一种是不带任何办法的接口。

                   GOLANG DIFFERENT INTERFACE
                           
                  ┌─────────┐              ┌────────┐
                  │   iface │              │ eface  │
                  └─────────┘              └────────┘
                            

图 3 Go 语言中的两种接口

Go 语言应用 runtime.iface 示意带有一组办法的接口,应用 runtime.eface 示意不带任何办法的接口。
须要留神的是 interface{} 不是任意类型,如果咱们将类型转换成了 interface{} 类型,
变量在运行期间的类型也会发生变化。

咱们能够通过一个例子了解 Go 语言的接口类型不是任意类型 这一句话,上面的代码在 main 函数中初始化了一个 *Test 类型的变量,因为指针的零值是 nil,所以变量 s 在初始化之后也是 nil

package main

type Test struct{}

func main() {
    var v *Test
    println(v == nil) // true
    var i interface{} = v
    println(i == nil) // false
}

由此可见,变量的赋值会触发隐式类型转换,在类型转换时,*Test会被转换成 interface{}
转换后的变量,不仅蕴含转换前的变量,还蕴含变量的类型信息。所以转换后的变量不等于nil

数据结构

咱们从源代码和汇编的角度剖析一下接口的底层数据结构。
Go 语言依据接口是否蕴含一组办法,将接口分为两类:

  1. 应用 runtime.iface 示意蕴含办法的接口
  2. 应用 runtime.eface 示意不蕴含办法的接口

runtime.eface在 Go 语言中的定义如下:

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

这个构造绝对简略,只蕴含类型和数据,从上述构造咱们能推断出
Go 语言的任意类型能转都能换成 runtime.eface

另一个用于示意接口的构造体是 runtime.iface, 这个构造体也有指向原始数据的指针 data
不过更重要的是 runtime.itab 类型的 tab 字段

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

接下来咱们将剖析 Go 语言中的这两个接口类型

类型构造体

runtime._type是 Go 语言类型的运行时示意,上面是 runtime 包中的构造体,
其中蕴含了很多类型的元信息,例如类型的大小、哈希、对齐以及品种等

type _type struct {
    size       uintptr // 存储了类型的占用空间,为内存空间的调配提供信息
    ptrdata    uintptr
    hash       uint32  // 字段可能帮忙咱们疾速确定类型是否相等
    tflag      tflag
    align      uint8
    fieldAlign uint8
    kind       uint8
    equal      func(unsafe.Pointer, unsafe.Pointer) bool // 字段用于判断以后类型的多个对象是否相等
    gcdata     *byte
    str        nameOff
    ptrToThis  typeOff
}

咱们只须要对 runtime._type 构造体中的字段有个大体的概念,不须要具体了解每个字段的作用和意义。

itab 构造体

runtime.itab构造体是接口类型的外围组成部分,共占 32 字节,咱们能够把其看成是接口类型和具体类型的组合
inter 字段示意接口类型,_type字段示意具体类型

type itab struct { // 32 字节
    inter *interfacetype
    _type *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}

除了 inter_type两个字段外,上述构造体的另外两个字段也有本人的作用:

  • hash是对 _type.hash 的拷贝,当咱们想将 interface 类型转成具体类型时, 能够应用该字段疾速判断指标类型和具体类型的 runtime._type 是否统一
  • fun是一组办法的首地址, 配合 inter 中的办法集应用,能够不便的定位到 _type 实现的办法地址

咱们会在类型断言中介绍 hash 的应用,在动静派发中介绍 fun 的应用

类型断言

本节会依据接口是否蕴含办法分两种状况介绍类型断言的执行过程。

非空接口

首先剖析非空接口,Person是一个蕴含办法的非空接口,咱们来剖析从
Person 转换回 Chinese 构造体的过程

func main() {var p Person = &Chinese{Name: "chinese"}
    switch p.(type) {
    case *Chinese:
        chinese := p.(*Chinese)
        chinese.GetName()}
}

咱们将编译失去的汇编指令分成两局部,第一局部是变量的初始化,第二部门是
类型断言。
第一局部代码如下:

     00000     TEXT    "".main(SB), ABIInternal, $136-0
     ......
     00038    MOVUPS    X15, ""..autotmp_6+112(SP)               ;; 清空(112-128)(SP)
     ......
     00056    MOVQ    $7, ""..autotmp_6+120(SP)                ;; +120(SP) = 7
     00065    LEAQ    go.string."chinese"(SB), SI              ;; SI = &("chinese")
     00072    MOVQ    SI, ""..autotmp_6+112(SP)                ;; +112(SP) = SI = &("chinese")
     ......
     00082     LEAQ    go.itab.*"".Chinese,"".Person(SB), SI    ;; SI = &(go.itab.*"".Chinese,"".Person(SB))
     00089     MOVQ    SI, "".p+80(SP)                          ;; +80(SP) = SI = &(go.itab.*"".Chinese,"".Person(SB))
     00044     LEAQ    ""..autotmp_6+112(SP), DX                ;; DX = &(+112(SP))
     00094     MOVQ    DX, "".p+88(SP)                          ;; +88(SP) = DX = &(+112(SP))

下面的代码初始化了 Person 变量,Chinese构造体初始化在 (112-128)(SP) 上。
(112-120)(SP)上存的是 go.string.”chinese”(SB) . 也就是字符串 ”chinese” 的地址
(120-128)(SP) 上存的是 长度 7
Person变量初始化在 (80-96)(SP) 上。

上面进入类型转换的局部:

    00099     MOVQ    "".p+80(SP), DX        ;; DX = &(go.itab.*"".Chinese,"".Person(SB))
    00104     MOVQ    "".p+88(SP), SI        ;; SI = &(+112(SP)) ="chinese"00126     MOVL    16(DX), DX             ;; DX = &(go.itab.*"".Chinese,"".Person(SB)).hash
    00133     CMPL    DX, $-1430607797       ;; if (p.hash == *"".Chinese.hash) 

switch 语句生成的汇编指令会将指标类型的 hash 与接口变量中的 itab.hash 进行比拟:
如果二者相等,阐明断言胜利, 能够走入分支,如果不相等,阐明 p 变量不是 *Chinese 类型。

空接口

当咱们应用空接口类型 interface{} 进行类型断言时, 编译器从 eface._type 中获取,汇编指令依然会应用指标类型的 hash 与变量的类型比拟

func main() {var p interface{} = &Chinese{Name: "chinese"}
    switch p.(type) {
    case *Chinese:
        chinese := p.(*Chinese)
        chinese.GetName()}
}

动静派发

动静派发是在运行期间抉择具体方法执行的过程。调用接口类型的办法时,如果编译期不能确认接口的类型,Go 语言会在运行期决定调用该办法的哪个实现。

func main() {var p Person = &Chinese{Name: "chinese"}
    PrintName(p)
}

func PrintName(p Person) {name1 := p.GetName()
    fmt.Println(name1)
}

次要来看动静派发的过程

    00000     TEXT    "".PrintName(SB), ABIInternal, $208-16
    00038     MOVQ    AX, "".p+216(SP)        ;;"".p+216(SP) = iface.tab
    00046     MOVQ    BX, "".p+224(SP)        ;;"".p+224(SP) = iface.data
    00056     MOVQ    24(AX), CX              ;; CX = iface(p).tab.fun[0] = *Chinese.GetName
    00060     MOVQ    BX, AX                  ;; AX = iface(p).data = (&Chinese{Name: "chinese"})
    00064     CALL    CX                      ;; (&Chinese{Name: "chinese"}).GetName()

PrintName 函数承受参数为 Person 接口 p , 也就是一个 iface 构造体实例,依据 1.17 的调用规约,
寄存器 AX,BX 别离存的是 iface.tab 以及 iface.data,【00056】的 24(AX) 是 iface.tab.fun[0]
【00064】理论就是接口办法实在调用的中央。
至于【00038】【00046】为什么要把参数存起来,是因为调用接口办法后,返回值会笼罩 AX,BX 的值。

传送门

正文完
 0