Go语言基础结构 —— Interface 接口

19 篇文章 0 订阅
1 篇文章 0 订阅

概述

接口是Go语言编程中数据类型的关键。在Go语言的实际编程中,几乎所有的数据结构都围绕接口展开,接口是Go语言中所有数据结构的核心。Go语言中的接口实际上是一组方法的集合,接口和gomock配合使用可以使得我们写出易于测试的代码.但是除了在反射等使用场景中我们很难直接感知到接口的存在(虽然大多数人使用反射的时候也没有感知到接口在其中发挥的作用),但是想要深入理解Go语言,我们必须对接口有足够的了解.接下来我们将从接口的数据结构、结构体如何转变成interfaceGo语言中动态派发的实现这些方面来一起学习Go语言中的接口。

隐身接口

很多面向对象语言都有接口这一概念,例如 JavaJava 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用,这里简单介绍一下 Java 中的接口:

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

上述代码定义了一个必须实现的方法 sayHello 和一个会注入到实现类的变量 hello。在下面的代码中,MyInterfaceImpl 实现了 MyInterface 接口:

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

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

Go 语言中如何定义接口。定义接口需要使用 interface 关键字,在接口中我们只能定义方法签名,不能包含成员变量,一个常见的 Go 语言接口是这样的:

type Animal interface {
	Say() string
	Name() string
}

如果一个类型需要实现 Animal 接口,那么它只需要实现 Say() stringName() string 方法,下面的 Duck 结构体就是接口的一个实现:

type Duck struct {
	Name  string
	Sound string
}

func (a *Duck) MySay() string {
	return fmt.Sprintf("My Sound is: %s", a.Sound)
}

func (a *Duck) MyName() string {
	return fmt.Sprintf("My Name is: %s", a.Name)
}

上述代码根本就没有 Animal 接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 MySay() stringName() string 方法就实现了 Animal 接口。

指针和结构体接收者

我们经常能看到两种实现接口的接收方式:指针和结构体,看下面缩略代码:

type Animal interface {
	MySay() string
	MyName() string
}

type Duck struct {...}

//指针方式
func (a *Duck) MySay() string {...}
func (a *Duck) MyName() string {...}

//结构体方式
func (a Duck) MySay() string {...}
func (a Duck) MyName() string {...}

因为结构体类型和指针类型是不同的,但是上面两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 method redeclared

实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:

结构体接收者- func (a Duck) MySay()指针接收者 - func (a *Duck) MySay()
结构体指针方式初始化
var Duck Animal = &Duck{}
通过检查通过检查
结构体方式初始化
var Duck Animal = Duck{}
通过检查不通过

如上表所示,无论上述代码中初始化的变量 是 Duck{} 还是 &Duck{},使用 MySay() 调用方法时都会发生值拷贝:

  • 对于 &Duck{} 来说,这意味着拷贝一个新的 &Duck{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;

  • 对于 Duck{} 来说,这意味着 MySay 方法会接受一个全新的 Duck{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;

总结起来:当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。

接口嵌套

Go语言中,不仅结构体与结构体之间可以嵌套,接口与接口间也可以通过嵌套创造出新的接口。

一个接口可以包含一个或多个其他的接口,这相当于直接将这些内嵌接口的方法列举在外层接口中一样。只要接口的所有方法被实现,则这个接口中的所有嵌套接口的方法均可以被调用。

Go语言的 io 包中定义了写入器(Writer)、关闭器(Closer)和写入关闭器(WriteCloser)3 个接口,代码如下:

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

type WriteCloser interface {
    Writer
    Closer
}

在代码中使用 io.Writerio.Closerio.WriteCloser 这 3 个接口时,只需要按照接口实现的规则实现 io.Writer 接口和 io.Closer 接口即可。而 io.WriteCloser 接口在使用时,编译器会根据接口的实现者确认它们是否同时实现了 io.Writerio.Closer 接口,详细实现代码如下:

package main
import (
    "io"
)
// 声明一个设备结构
type device struct {
}
// 实现io.Writer的Write()方法
func (d *device) Write(p []byte) (n int, err error) {
    return 0, nil
}
// 实现io.Closer的Close()方法
func (d *device) Close() error {
    return nil
}
func main() {
    // 声明写入关闭器, 并赋予device的实例
    var wc io.WriteCloser = new(device)
    // 写入数据
    wc.Write(nil)
    // 关闭设备
    wc.Close()
    // 声明写入器, 并赋予device的新实例
    var writeOnly io.Writer = new(device)
    // 写入数据
    writeOnly.Write(nil)
}

nil 与 non-nil

Go 语言的接口类型不是任意类型

我们可以通过一个例子理解这句话,下面的代码在 main 函数中初始化了一个 *NilStruct 类型的变量,由于指针的零值是 nil,所以变量 t 在初始化之后也是 nil

package main

import "fmt"

type NilStruct struct {
}

func NilOrNot(v interface{}) bool {
	if v == nil {
		return true
	}
	return false
}

func main() {
	var t *NilStruct
	fmt.Println(t == nil)
	fmt.Println(NilOrNot(t))
}

$ go run main.go
true
false

我们简单总结一下上述代码执行的结果:

  • 将上述变量与 nil 比较会返回 true
  • 将上述变量传入 NilOrNot 方法并与 nil 比较会返回 false

出现上述现象的原因是 —— 调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*NilStruct 类型会转换成 interface{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 NilStruct,所以转换后的变量与 nil 不相等。

数据结构

类型

Go 语言中,类型(Type)是用来描述数据的属性和操作的概念。它定义了数据的内部表示以及对数据进行操作的方法。类型在编程语言中起到了限制和约束数据的作用,它决定了数据的取值范围、可用的操作,以及数据在内存中的布局方式。

Go 语言中,每个值都有一个明确的类型。例如,整数类型(int)、浮点数类型(float64)、布尔类型(bool)、字符串类型(string)等都是 Go 语言中的内置类型。此外,我们还可以自定义结构体类型(struct)和接口类型(interface),以及通过类型别名(type)来创建自定义类型。

在类型中都有一些公有属性,例如类型的大小、对齐方式、哈希值、标志位、种类、相等性函数、垃圾回收数据、名称和指针等,是所有类型最原始的元信息。这些元信息,记录在位于src/runtime/type.go_type结构体中,作为每个类型元数据的Header

//go 1.20.3 path: /src/runtime/type.go
type _type struct {
	size       uintptr                                   //表示类型的大小,即占用内存的字节数
	ptrdata    uintptr                                   //类型中指针数据的大小,以字节为单位。这个字段用于垃圾回收器识别类型中哪些部分是指针,哪些部分是非指针
	hash       uint32                                    //类型的哈希值,用于在运行时比较两个类型是否相同
	tflag      tflag                                     //类型的标志位,用于存储一些额外的信息,如是否有名称、是否有不可比较的字段等
	align      uint8                                     //类型的对齐方式,以字节为单位。这个字段决定了类型在内存中的布局和对齐方式
	fieldAlign uint8                                     //类型的字段对齐方式,以字节为单位。这个字段决定了类型中的字段在内存中的布局和对齐方式
	kind       uint8                                     //类型的种类,用于区分不同的基本类型,如 int、string、struct 等
	equal      func(unsafe.Pointer, unsafe.Pointer) bool //类型的相等性函数,用于在运行时比较两个值是否相等。如果为 nil,则表示该类型没有定义相等性函数,或者该类型是不可比较的
	gcdata     *byte                                     //类型的垃圾回收数据,用于存储一些与垃圾回收相关的信息,如指针位图等
	str        nameOff                                   //类型的名称偏移量,用于在运行时获取类型的名称。这个字段是一个相对于 _type 结构体起始地址的偏移量,可以通过它找到一个 nameOff 结构体,进而找到一个 name 结构体,其中存储了类型的名称
	ptrToThis  typeOff                                   //类型的指针偏移量,用于在运行时获取指向该类型的指针类型。这个字段也是一个相对于 _type 结构体起始地址的偏移量,可以通过它找到一个 typeOff 结构体,进而找到一个 _type 结构体,其中存储了指向该类型的指针类型
}

_type 结构体是 Go 语言中实现反射机制和接口机制的基础。反射机制可以让我们在运行时动态地获取和操作任何值和类型的信息。接口机制可以让我们实现多态性和抽象性,让不同的类型可以实现相同的行为。

有个别字段需要详细说明下,其他字段直接备注:

  • kind 基础类型,在 Go 语言中,基础类型是一个枚举常量,有 26 个基础类型,枚举值通过 kindMask 取出特殊标记位

    const (
    	kindBool = 1 + iota
    	kindInt
    	kindInt8
    	kindInt16
    	kindInt32
    	kindInt64
    	kindUint
    	kindUint8
    	kindUint16
    	kindUint32
    	kindUint64
    	kindUintptr
    	kindFloat32
    	kindFloat64
    	kindComplex64
    	kindComplex128
    	kindArray
    	kindChan
    	kindFunc
    	kindInterface
    	kindMap
    	kindPtr
    	kindSlice
    	kindString
    	kindStruct
    	kindUnsafePointer
    	kindDirectIface = 1 << 5
    	kindGCProg      = 1 << 6
    	kindMask        = (1 << 5) - 1
    )
    
  • strptrToThis,对应的类型是 nameoff typeOff。分表nametype针对最终输出文件所在段内的偏移量。在编译的链接步骤中,链接器将各个 .o 文件中的段合并到输出文件,会进行段合并,有的放入.text段,有的放入 .data 段,有的放入 .bss 段。nameofftypeoff就是记录了对应段的偏移量。

对于intstringbool等单一的基础结构,元信息存储于_type结构体内已经够用了,但对于 arraychanslicefunc等复合型的结构体,它们除了基础的元信息,还需要存储一些额外的元数据,比如键和值类型、参数和返回数量、结构体字段等,为了存储这些信息,golang设定了很多内置类型,来处理不同类型需要存储不同信息的需求。

image-20230614164456190

对于内置类型,大部分也都在runtime.type文件里面,些内置类型都是在 _type 基础上进一步封装而来,列举个别例子说明:

//go 1.20.3 path: /src/runtime/type.go

//用于表示数组类型
type arraytype struct {
	typ   _type     // 类型描述符
	elem  *_type    // 数组元素类型的指针
	slice *_type    // 切片类型的指针
	len   uintptr   // 数组长度
}

//用于表示通道类型
type chantype struct {
	typ  _type    // 类型描述符
	elem *_type   // 通道元素类型的指针
	dir  uintptr  // 通道的方向
}

//表示切片类型
type slicetype struct {
	typ  _type    // 类型描述符
	elem *_type   // 切片元素类型的指针
}

//用于表示函数类型
type functype struct {
	typ      _type    // 类型描述符
	inCount  uint16   // 输入参数数量
	outCount uint16   // 输出参数数量
}

//用于表示指针类型
type ptrtype struct {
	typ  _type    // 类型描述符
	elem *_type   // 指针指向的类型
}

//用于表示结构体类型
type structtype struct {
	typ     _type
	pkgPath name          // 结构体所属包的路径
	fields  []structfield //结构体的字段列表
}

大家可能疑问,上面都是go语言里面的内置类型,那我们在代码中自己定义的类型是什么样的呢?

还有如果是自定义类型,后面还会有一个uncommontype结构体,uncommontype是指向一个函数指针的数组,收集了这类型的实现的所有方法:

//自定义类型元数据
type uncommontype struct {
	pkgpath nameOff
	mcount  uint16 // number of methods
	xcount  uint16 // number of exported methods
	moff    uint32 // offset from this uncommontype to [mcount]method
	_       uint32 // unused
}
  • pkgpath 记录类型所在的包路径
  • mcount 记录该类型关联到多少个方法
  • xcount 记录该类型的导出型多少个方法
  • moff 记录的是这些方法元数据组成的数组,相对于这个uncommontype 结构体偏移了多少字节

例如,我们基于[]string定义一个新类型myslice,它就是一个自定义类型,可以给它定义两个方法LenCap

myslice的类型元数据中,首先是slicetype类型描述信息,然后在后面加上uncommontype结构体。注意通过uncommontype这里记录的信息,我们就可以找到myslice的方法元数据列表了。如下图所示:

image-20220829152709907

接口实现

对于golang来说有两种接口的结构,一种是有方法定义的接口,另外一种是空接口,分别对应两种实现。

  • 使用 runtime.iface结构体表示包含方法的接口
  • 使用 runtime.eface 结构体表示不包含任何方法的 interface{} 类型;

eface — 空Interface

空接口通过eface结构体定义实现:

//go1.20.1  path: src/runtime/runtime2.go
type eface struct {
	_type *_type         //类型元数据
	data  unsafe.Pointer //数据信息,指向数据指针
}

eface 包含了2个元素:

  • _type,指向对象的类型元数据,在编译的时候生成在可执行文件后随着可执行文件加载进入.text.rodata 区域内;

  • data,指向数据指针。

举个例子:

package main

import "fmt"

//Student结构体
type Student struct {
	name string
}

//Student方法setName
func (s *Student) setName(name string) {
	s.name = name
}

//Student方法getName
func (s *Student) getName() string {
	return s.name
}

func main() {
	//声明一个空接口变量a
	var a interface{}
	s := &Student{"Jack"}
	a = s
	fmt.Println(a)
}

我们把Student类型的变量s赋给a。那么变量a的结构就如下图所示:

image-20230615112700989

我们通过 gdb 调试信息查看验证是否正确:

(gdb) info locals
a = {_type = 0x10c0980 <type:*+46208>, data = 0xc000014270}
s = 0xc000014270

interface相对比较简单,介绍到此。

iface — 非空Interface

非空接口使用的是runtime.iface这个结构体 :

//go1.20.1  path: src/runtime/runtime2.go
type iface struct {
	tab  *itab
	data unsafe.Pointer  //指向原始数据指针
}

同样包含两个字段:

  • tab,存放的是类型、方法等信息;

    tab所对应的结构体是 runtime.itab,该结构体是接口类型的核心组成部分;每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter_type 两个字段表示:

    //go1.20.1  path: src/runtime/runtime2.go
    type itab struct {
    	inter *interfacetype // 接口自身定义的类型信息,用于定位到具体interface类型
    	_type *_type         // 接口的具体类型,指向实际对象类型
    	hash  uint32         //_type.hash的拷贝,用于快速查询和判断目标类型和接口中类型是一致
    	_     [4]byte        // 填充字段,保证对齐用
    	fun   [1]uintptr     //动态数组,接口方法实现列表(方法集),即函数地址列表,按字典序排序,如果数组中的内容为空表示 _type 没有实现 inter 接口
    }
    
    • inter interfacetype的类型元数据;它里面记录了这个接口类型的描述信息,接口要求的方法列表就记录在interfacetype.mhdr,结构体代码如下:

      type interfacetype struct {
      	typ     _type	     //接口类型
      	pkgpath name       //包路径
      	mhdr    []imethod  //接口中的方法表
      }
      

      其中 interfacetype 结构体的 mhdr字段涉及到 imethod类型,imethod 结构体用于描述接口类型的方法;method 结构体用于描述类型的方法。

      我们来看下其结构:

      //接口类型的方法
      type imethod struct {
      	name nameOff  // 方法名称在名称表中的偏移量
      	ityp typeOff  // 方法类型在类型表中的偏移量
      }
      
      //非接口类型的方法,它是一个压缩格式的结构,每个字段的值都是一个相对偏移量
      type method struct {
      	name nameOff  // 方法名称在名称表中的偏移量
      	mtyp typeOff  // 方法类型在类型表中的偏移量
      	ifn  textOff  // 接口方法的实现函数在代码段中的偏移量
      	tfn  textOff  // 普通方法的实现函数在代码段中的偏移量
      }
      
    • hashitab._type中拷贝来的,是类型的哈希值,用于快速判断类型是否相等时使用;

    • fun 它是一个用于动态派发的虚函数表,存储了一组函数指针; 记录的是动态类型实现的那些接口要求的方法的地址,是从方法元数据中拷贝来的,为的是快速定位到方法。如果itab._type对应的类型没有实现这个接口,则itab.fun[0]=0 ; 当fun[0]为0时,说明_type并没有实现该接口,当有实现接口时,fun存放了第一个接口方法的地址,其他方法一次往下存放,这里就简单用空间换时间,其实方法都在_type字段中能找到,实际在这记录下,每次调用的时候就不用动态查找了

  • data 指针指向绑定对象的原始数据;

下面我们看一个例子,代码如下:

package main

import (
	"fmt"
)

type Square interface {
	Area() float64
	Perimeter() float64
}

type Sdata struct {
	x, y float64
}

func (s *Sdata) Area() float64 {
	return s.x * s.y
}

func (s *Sdata) Perimeter() float64 {
	return (s.x + s.y) * 2
}

func NewSdata(x, y float64) *Sdata {
	return &Sdata{
		x: x,
		y: y,
	}
}

func main() {
	var s Square
	Object := NewSdata(1, 2)
	s = Object
	fmt.Println(s)
}

通过 gdb 调试信息,我们看下变量s赋值前的结果:

-----------  赋值前 ---------------
(gdb) p s
$2 = {tab = 0x0, data = 0x0}

-----------  赋值后 ---------------
(gdb) p s
$1 = {tab = 0x10dd9b8 <go:itab.*main.Sdata,main.Square>, data = 0xc0000b4010}
(gdb) ptype s
type = struct runtime.iface {
    runtime.itab *tab;
    void *data;
}
(gdb) p Object
$2 = (main.Sdata *) 0xc0000b4010

从调试结果来看, s为非空接口,赋值前对应结构和数据:{tab = 0x0, data = 0x0}, 赋值后对应的数据和结构:{tab = 0x10dd9b8 <go:itab.*main.Sdata,main.Square>, data = 0xc0000b4010},用图表示关系如下:

image-20220825175647238

至此,非空接口的关系以及逻辑关联已经讲清楚了,后续将对itab进行详细讲解。

itab结构

itab缓存(itabTable)

对于itab来说,既然一个非空的接口类型(itab.inter) 和一个动态类型(itab._type) 就可以确定一个itab的内容,那么这个itab结构体自然是可以被接口类型和动态类型均相同的接口变量进行复用的。这就是itabTable, itabTable结构如下:

const itabInitSize = 512
type itabTableType struct {
	size    uintptr             // length of entries array. Always a power of 2.
	count   uintptr             // current number of filled entries.
	entries [itabInitSize]*itab // really [size] large
}
  • size 数组的大小,即 entries 数组的大小,长度总数为 2n
  • count 记录当前实际的itab数量,即entries数组使用了多少量
  • entries 一个*itab数组,初始大小是 itabInitSize(512)个, 即利用空间换时间的思路,存放所有的itab

itab初始化

go的启动过程中,schedinit里面会调用的itabsinit进行初始化,代码如下:

func schedinit() {
    ......
    itabsinit()     // uses activeModules
    ......
}

func itabsinit() {
	lockInit(&itabLock, lockRankItab)
	lock(&itabLock)
	for _, md := range activeModules() {
		for _, i := range md.itablinks {
			itabAdd(i)
		}
	}
	unlock(&itabLock)
}

简单理解 itabsinit() 这个方法的作用是将各模块间用于缓存运行时类型转换的接口表初始化到itabTable中。

itabAdd

itabAdd()方法是将单条 itab数据 存入到 itabTable中,代码如下:

const itabInitSize = 512

var (
  ......
	itabTable     = &itabTableInit                    
	itabTableInit = itabTableType{size: itabInitSize}
)


//itabAdd将给定的itab添加到itab哈希表中
func itabAdd(m *itab) {

	// 检查是否存在 malloc 死锁
	if getg().m.mallocing != 0 {
		throw("malloc deadlock")
	}

	t := itabTable
	if t.count >= 3*(t.size/4) { //当itab哈希表中使用率超过75%时,需要扩容处理

		//以原来2倍的空间去申请新的itab哈希表空间
		t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true))
		t2.size = t.size * 2

		//将旧的itab哈希表数据复制到新的itab哈希表中
		iterate_itabs(t2.add)
		if t2.count != t.count {
			throw("mismatched count during itab table copy")
		}

		//发布新的哈希表。使用原子写入
		atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
		//采用新表作为作为当前的itab表
		t = itabTable
	}
	//将m添加进itab表
	t.add(m)
}

// add将给定的itab添加到itab表t中
func (t *itabTableType) add(m *itab) {

	/**
	通过与运算法来确定新itab在itab表中的index
	与运算法公式:index = hash&(m-1) 其中m-1为数组长度减1,具体参考hashmap章节
	*/
	mask := t.size - 1 //掩码,用于计算哈希值对应的索引
	h := itabHashFunc(m.inter, m._type) & mask // 获取当前索引位置的条目

	for i := uintptr(1); ; i++ {
		//获取h位置地址
		p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize))
		//取值
		m2 := *p
		//如果m信息已经存在,直接返回
		if m2 == m {
			return
		}
		//如果找到的位置为空位置,则使用原子插入
		if m2 == nil {
			atomic.StorepNoWB(unsafe.Pointer(p), unsafe.Pointer(m))
			//使用值+1
			t.count++
			return
		}
		/**
		如果查找的位置已经存在非m值的其余itab信息,则使用开放寻址法,进行重新寻找位置
		开放寻址法公式为: h(i) = h0 + i*(i+1)/2 mod 2^k
		*/
		h += i
		h &= mask
	}
}

itabAdd() 代码总结下:

  • itabTable 使用量达到75%时,将会以原大小的双倍大小进行扩容

  • 新的 itab 数据插入 itabTable 时候,存放位置采取与运算法 : h = hash & (m-1); m为数组长度; 当取位存在其他数据时,则使用开放寻址法寻找下一个存放位置;而hash算法使用了 itabHashFunc方法,即 取itab中的接口类型与实际类型,分别哈希后取异或

    func itabHashFunc(inter *interfacetype, typ *_type) uintptr {
    	return uintptr(inter.typ.hash ^ typ.hash)
    }
    

getitab & find

getitab() 函数是根据inter以及 typ数据 从 itabTable中获取 itab的方法,代码如下:

//根据非空的接口类型和动态类型获取itab内容
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {

	//如果inter.mhdr为空,则表示该接口没有方法和元数据,为空接口,不存在itab
	if len(inter.mhdr) == 0 {
		throw("internal error - misuse of itab")
	}

	/**
	简单情况
	tflagUncommon 标识是否有uncommon内容,即记录pkgpath和方法的内容
	目前看来只有匿名结构体或者reflect动态创建的struct没有methods时,该值为0
	大致猜测意思就是当前查询的动态类型没有methods时,则直接返回nil
	*/
	if typ.tflag&tflagUncommon == 0 {
		if canfail {
			return nil
		}
		name := inter.typ.nameOff(inter.mhdr[0].name)
		panic(&TypeAssertionError{nil, typ, &inter.typ, name.name()})
	}

	var m *itab
	/**
	首先,查看现有表以查看是否可以找到所需的itab。
	这是迄今为止最常见的情况,因此请不要使用锁。
	使用atomic确保我们看到该线程完成的所有先前写入更新itabTable字段(在itabAdd中使用atomic.Storep)。
	*/
	t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
	if m = t.find(inter, typ); m != nil {
		goto finish
	}

	// 如果任然没有找到数据,则加锁重试
	lock(&itabLock)
	if m = itabTable.find(inter, typ); m != nil {
		unlock(&itabLock)
		goto finish
	}

	// 如果条目不存在,则进行输入并添加
	m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
	m.inter = inter
	m._type = typ
	m.hash = 0
	m.init()
	itabAdd(m)
	unlock(&itabLock)
finish:
	//如果非m.fun[0] == 0,则表明有方法被实现,返回m
	if m.fun[0] != 0 {
		return m
	}
	if canfail {
		return nil
	}
	//如果数据类型并没有实现接口,那么根据调用方式,该报错报错,该panic panic。
	panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}


//根据信息查找itab信息
func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab {
	/**
	通过与运算法来确定在itabTable中的index
	与运算法公式:index = hash&(m-1) 其中m-1为数组长度减1,具体参考hashmap章节
	hash函数为:itabHashFunc
	*/
	mask := t.size - 1
	h := itabHashFunc(inter, typ) & mask
	for i := uintptr(1); ; i++ {
		p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize))
		// 在这里使用atomic read,所以如果我们看到m!= nil,我们也会看到m字段的初始化
		// m := *p
		m := (*itab)(atomic.Loadp(unsafe.Pointer(p)))
		if m == nil {
			return nil
		}
		if m.inter == inter && m._type == typ {
			return m
		}
		/**
		使用二次探测
		探测公式为h(i)= h0 + i *(i + 1)/ 2 mod 2 ^ k
		*/
		h += i
		h &= mask
	}
}

总结下流程:

  • 先用t保存全局itabTable的地址,然后使用t.find去查找,这样是为了防止查找过程中,itabTable被替换导致查找错误

  • 如果没找到,那么就会上锁,然后使用itabTable.find去查找,这样是因为在第一步查找的同时,另外一个协程写入,可能导致实际存在却查找不到,这时上锁避免itabTable被替换,然后直接在itaTable中查找。

  • 再没找到,说明确实没有,那么就根据接口类型、数据类型,去生成一个新的itab,然后插入到itabTable中,这里可能会导致hash表扩容,如果数据类型并没有实现接口,那么根据调用方式,该报错报错,该panic panic

    这里我们可以看到申请新的itab空间时,内存空间的大小是: unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize

    参照前面接受的结构,len(inter.mhdr)就是接口定义的方法数量,因为字段fun是一个大小为1的数组,所以len(inter.mhdr)-1,在fun字段下面其实隐藏了其他方法接口地址。

init

itab.init()方法是初始化一个itab的函数,在getitab() 函数中,我们能看到它的身影,如下:

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
    ......
    m.inter = inter
	  m._type = typ
	  m.hash = 0
	  m.init()
    itabAdd(m)
    ......
}

其实 itab需要初始化之后才能插入itabTable, 这也就是为什么它出现在 itabAdd函数之前的原因,遍历接口类型与具体类型比较具体类型是否实现了所有接口类型, 理论上时间复杂度为O(n2),下面我们来看看 init 到底干了些什么,看代码:

//go 1.20.3 path: /src/runtime/iface.go

//初始化itab,填充itab.fun数组
func (m *itab) init() string {
	//赋值接口类型
	inter := m.inter
	//赋值动态类型
	typ := m._type
	//返回组合的uncommontype类型
	x := typ.uncommon()

	ni := len(inter.mhdr) //接口类型的方法数量
	nt := int(x.mcount)   //具体类型的方法数量
	//指向具体类型的方法数组
	xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
	j := 0
	//指向具体itab.fun
	methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni]
	var fun0 unsafe.Pointer
imethods:
	// 这里看似双重遍历,由于方法数组都是有序的,所以其实时间复杂度为ni+nt, 而不是ni*nt
	for k := 0; k < ni; k++ { //遍历接口方法
		i := &inter.mhdr[k]                //获取接口方法i
		itype := inter.typ.typeOff(i.ityp) //i方法type
		name := inter.typ.nameOff(i.name)  //i方法name字段
		iname := name.name()               // name转化为string
		ipkg := name.pkgPath()             // 路径转为string
		if ipkg == "" {
			ipkg = inter.pkgpath.name()
		}
		for ; j < nt; j++ { // 遍历具体类型的方法
			t := &xmhdr[j]
			tname := typ.nameOff(t.name)
			if typ.typeOff(t.mtyp) == itype && tname.name() == iname { //判等
				pkgPath := tname.pkgPath()
				if pkgPath == "" {
					pkgPath = typ.nameOff(x.pkgpath).name()
				}
				if tname.isExported() || pkgPath == ipkg {
					if m != nil {
						ifn := typ.textOff(t.ifn)
						if k == 0 {
							fun0 = ifn // we'll set m.fun[0] at the end
						} else {
							methods[k] = ifn
						}
					}
					continue imethods // 存在i方法就continue到外层for
				}
			}
		}
		m.fun[0] = 0 // 没有找到i方法, 将m.fun[0] 置为0
		return iname
	}
	m.fun[0] = uintptr(fun0)
	return ""
}

这个方法会检查interfacetype的方法是否匹配,即type有没有实现interface

interface、type的方法都按字典序排,所以O(n+m)的时间复杂度可以匹配完

在检测的过程中,匹配上了,依次往fun字段写入type中对应方法的地址。如果有一个方法没有匹配上,那么就设置fun[0]为0,在外层调用会检查fun[0]==0,即type并没有实现interface

类型转换

指针类型

先直接上代码:

package main

type People interface {
	GetName() string
}

//go:noinline
func (s *Student) GetName() string {
	return s.name
}

type Student struct {
	name string
	age  int
}

func main() {
	var p People
	var s *Student = &Student{
		name: "XJX",
		age:  15,
	}
	p = s
	p.GetName()
}

利用go tool compile命令将上述代码变成汇编代码:

go tool compile -N -l -S main.go

我们开始分析汇编代码:

  • 初始化Student对象

    0x0030 00048 (main.go:32)       LEAQ    type."".Student(SB), AX					;AX = &type."".Student
    0x0037 00055 (main.go:32)       MOVQ    AX, (SP)								;SP = &type."".Student
    0x003b 00059 (main.go:32)       PCDATA  $1, $0
    0x003b 00059 (main.go:32)       NOP
    0x0040 00064 (main.go:32)       CALL    runtime.newobject(SB)					;SP + 8 = &Student{}
    0x0045 00069 (main.go:32)       MOVQ    8(SP), DI                               ;DI = &Student{}
    0x004a 00074 (main.go:32)       MOVQ    DI, ""..autotmp_3+32(SP)                ;autotmp_3(32SP) = &Student{}
    0x004f 00079 (main.go:33)       MOVQ    $3, 8(DI)                               ;StringHeader(DI.Name).Len = 3
    0x0057 00087 (main.go:33)       PCDATA  $0, $-2
    0x0057 00087 (main.go:33)       CMPL    runtime.writeBarrier(SB), $0
    0x005e 00094 (main.go:33)       NOP
    0x0060 00096 (main.go:33)       JEQ     100
    0x0062 00098 (main.go:33)       JMP     191
    0x0064 00100 (main.go:33)       LEAQ    go.string."XJX"(SB), AX                 ;AX = &"XJX"
    0x006b 00107 (main.go:33)       MOVQ    AX, (DI)                                ;StringHeader(DI.Name).Data = &"XJX"
    0x006e 00110 (main.go:33)       JMP     112
    0x0070 00112 (main.go:32)       PCDATA  $0, $-1
    0x0070 00112 (main.go:32)       MOVQ    ""..autotmp_3+32(SP), AX                ;AX = &Student{}
    0x0075 00117 (main.go:32)       TESTB   AL, (AX)
    0x0077 00119 (main.go:34)       MOVQ    $15, 16(AX)                             ;DI.age = 15
    0x007f 00127 (main.go:32)       MOVQ    ""..autotmp_3+32(SP), AX 				;AX = &Student{}
    
    • 先将 &type."".Student 放在 (SP) 栈顶。
    • 然后调用 runtime.newobject() 在堆中生成 Student 对象并且返回地址。(SP) 栈顶的值即是 newobject() 方法的入参。
    • 后续将 name age 信息补充完整。因为 name 为字符串,字符串的结构为 StringHeaderStringHeaderLen Data两个属性值分别为 3XJX字符串存放地址; age 则为 int 基础类型,直接赋值即可。

    我们用张图表示:

    image-20220906113831943
  • 把Student对象转化为Person interface指针

    继续看代码:

    0x008e 00142 (main.go:36)       LEAQ    go.itab.*"".Student,"".People(SB), CX;AX = *itab(go.itab.*"".Student,"".People)
    0x0095 00149 (main.go:36)       MOVQ    CX, "".p+48(SP)						;p(48SP) = *itab(go.itab.*"".Student,"".People)
    0x009a 00154 (main.go:36)       MOVQ    AX, "".p+56(SP)						;p(56SP) = &Student{}
    

    经过上面几行汇编代码,成功的构造出了 itab 结构体以及iface

    如图:

    image-20220906120048418
  • 调用interface方法

    0x009f 00159 (main.go:37)       MOVQ    "".p+48(SP), AX					; AX = *itab(go.itab.*"".Student,"".People)
    0x00a4 00164 (main.go:37)       TESTB   AL, (AX)
    0x00a6 00166 (main.go:37)       MOVQ    24(AX), AX						; AX = *(GetName)
    0x00aa 00170 (main.go:37)       MOVQ    "".p+56(SP), CX					; CX = &Student{}
    0x00af 00175 (main.go:37)       MOVQ    CX, (SP)						; 移动CX到栈顶
    0x00b3 00179 (main.go:37)       CALL    AX								;call GetName func
    

    取出 *itab(go.itab.*"".Student,"".People)地址存放到AX寄存器,然后移动 +24字节,获取第一个函数的地址存入AX,调用AX即可。

结构体类型

来看下结构体类型的代码:

package main

import "fmt"

type People interface {
	GetName() string
}

//go:noinline
func (s Student) GetName() string {
	return s.name
}

type Student struct {
	name string
	age  int
}

func main() {
	var p People
	var s Student = Student{
		name: "XJX",
		age:  15,
	}
	p = s
	p.GetName()
}

这边就不详细说了,基本跟指针类型差不多,但有几点差别:

  • 编译器发现变量只是临时变量时,没有调用 runtime.newobject(),仅仅是将它的每个基本类型的字段生成好放在内存中。然后如果涉及到逃逸,则使用函数runtime.convTstring函数将数据复制拷贝到堆区一份,runtime.convTstring代码如下:

    //iface.go
    func convTstring(val string) (x unsafe.Pointer) {
    	if val == "" {
    		x = unsafe.Pointer(&zeroVal[0])
    	} else {
    		x = mallocgc(unsafe.Sizeof(val), stringType, true)
    		*(*string)(x) = val
    	}
    	return
    }
    

    在堆上分配了stringStruct并赋值,返回x为对象地址。

  • 初始化结构体后会进入类型转换的阶段,编译器会将 go.itab.""..Student,""..People 的地址和指向 Student 结构体的指针作为参数一并传入 runtime.convT2I函数:

    func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
    	t := tab._type
    	x := mallocgc(t.size, t, true)
    	typedmemmove(t, x, elem)
    	i.tab = tab
    	i.data = x
    	return
    }
    

    runtime.convT2I会返回一个runtime.iface,其中包含runtime.itab指针和 Student 变量。

  • 在汇编调试过程中,发现一个现象,如果使用如下结构体方式赋值,编译器可能会进行优化,变量基本通过临时变量存储在栈中,也并不会存在类型转换调用 runtime.convT2I函数,而跟指针类型类型直接在栈内构造完成,代码如下:

    var p People = Student{
        name: "XJX",
        age:  15,
    }
    p.GetName()
    

类型断言

我们需要知道在golang里面的函数参数可以为具体某一interface或者interface{},这就是golang的设计哲学,万物起源interface{},衍生出来specified interface,接下来就是基于衍生的specified interface衍生出来的现实生活中的实体–struct

有了这一步之后,就有一个问题需要被解决,就是有0到N个实现了某一个衍生接口的struct,但是我又想对他们进行不一样的操作,这个时候就需要通过类型确定,这时候就需要类型断言。

Go语言的interface中可以是任何类型,所以Go给出了类型断言来判断某一时刻接口中所含有的类型,例如现在给出一个接口,名为InterfaceText :

x := interfaceText.(T) //T是某一种类型,此方式断言失败会panic
or
x,err := interfaceText.(T)//T是某一种类型,此方式不会panic,错误信息会返回到err变量

上式是接口断言的一般形式,因为此方法不一定每次都可以完好运行,所以err的作用就是判断是否出错。所以一般接口断言常用以下写法:

if v,err:=InterfaceText.(T);err {//T是一种类型
    possess(v)//处理v
    return
}

如果转换合法,则vInterfaceText转换为类型T的值,errture,反之errfalse

我们知道接口分为空接口和非空接口,我们从两类接口开始分析。

空接口

先从例子出发,看一段空接口代码:

package main

import "fmt"

type Student struct {
	name string
}

type People interface {
	GetName() string
}

func (s *Student) GetName() string {
	return s.name
}

func main() {
	var s interface{} = &Student{name: "XJX"}
	v, ok := s.(int)
	if ok {
		fmt.Printf("%v\n", v)
	}
	switch s.(type) {
	case People:
	}
}

通过反编译查看源码:

go tool compile -N -l -S main.go

忽略其余代码我们从类型断言这里开始看起:

......
0x0070 00112 (main.go:17)       LEAQ    type.*"".Student(SB), CX   ;把eface _type的地址放入CX
0x0077 00119 (main.go:17)       MOVQ    CX, "".s+120(SP)		   ;把CX存放值赋值给s+120(SP)
0x007c 00124 (main.go:17)       MOVQ    AX, "".s+128(SP)		   ;把AX存放值赋值给s+128(SP)
0x0084 00132 (main.go:18)       MOVQ    "".s+120(SP), AX		   ;把s+120(SP)存放寄存器AX,即eface _type地址
0x0089 00137 (main.go:18)       MOVQ    "".s+128(SP), CX		   ;把s+128(SP)存放寄存器CX,即eface data地址
0x0091 00145 (main.go:18)       LEAQ    type.int(SB), DX           ;把int的类型type的地址放到 DX
0x0098 00152 (main.go:18)       CMPQ    DX, AX                     ;直接比较 AX DX的地址
......

我们知道类型元数据都存储在.rodata区域内,获取空接口的_type的地址位置和需要断言的类型的_type位置进行比较,如果相同则代表类型一致。

下面看看空接口对于接口断言的汇编:

0x01b2 00434 (main.go:30)       MOVL    AX, ""..autotmp_14+68(SP)
0x01b6 00438 (main.go:31)       MOVQ    ""..autotmp_12+168(SP), AX
0x01be 00446 (main.go:31)       MOVQ    ""..autotmp_12+176(SP), CX
0x01c6 00454 (main.go:31)       LEAQ    type."".People(SB), DX    ;获取People的类型元素存入DX
0x01cd 00461 (main.go:31)       MOVQ    DX, (SP)				  ;放入栈顶
0x01d1 00465 (main.go:31)       MOVQ    AX, 8(SP)				  ;放入eface
0x01d6 00470 (main.go:31)       MOVQ    CX, 16(SP)
0x01db 00475 (main.go:31)       PCDATA  $1, $0
0x01db 00475 (main.go:31)       NOP
0x01e0 00480 (main.go:31)       CALL    runtime.assertE2I2(SB)    ;判断发起调用
0x01e5 00485 (main.go:31)       MOVBLZX 40(SP), AX                ;bool值
0x01ea 00490 (main.go:31)       MOVB    AL, ""..autotmp_13+67(SP)

我们从汇编代码可以看出,空接口对于接口的断言,主要是函数 assertE2I2

func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) {
    //获取eface的类型元数据
	t := e._type
	if t == nil {
		return
	}
	tab := getitab(inter, t, true)
	if tab == nil {
		return
	}
	r.tab = tab
	r.data = e.data
	b = true
	return
}

原理也很简单,从空接口的eface_type里取出类型元数据,再根据传入的interfacetype 去判断是否有对应的接口实现,成功则断言成功,否则返回nil和false。

非空接口

非空接口还是直接从例子开始看:

package main

import "fmt"

type People interface {
	GetName() string
}

type Student struct {
	name string
}

func (s *Student) GetName() string {
	return s.name
}

func main() {
	var p People = &Student{name: "XJX"}
	v, ok := p.(People)
	if ok {
		fmt.Printf("%v\n", v)
	}
	v, ok = p.(*Student)
	if ok {
		fmt.Printf("%v\n", v)
	}
}

执行命令

go tool compile -N -l -S main.go

来看下关键的代码汇编片段:

.......
0x00a1 00161 (main.go:26)	MOVUPS	X0, ""..autotmp_4+168(SP)
0x00a9 00169 (main.go:26)	MOVQ	"".p+136(SP), AX
0x00b1 00177 (main.go:26)	MOVQ	"".p+144(SP), CX
0x00b9 00185 (main.go:26)	LEAQ	type."".People(SB), DX       ;获取type People的类型元数据地址到 DX
0x00c0 00192 (main.go:26)	MOVQ	DX, (SP)                     ;DX存入栈底
0x00c4 00196 (main.go:26)	MOVQ	AX, 8(SP)                    ;iface存入8(SP)
0x00c9 00201 (main.go:26)	MOVQ	CX, 16(SP)
0x00ce 00206 (main.go:26)	PCDATA	$1, $1
0x00ce 00206 (main.go:26)	CALL	runtime.assertI2I2(SB)       ;调用方法进行类型断言
0x00d3 00211 (main.go:26)	MOVBLZX	40(SP), AX                   ;返回参数 ok bool值
0x00d8 00216 (main.go:26)	MOVQ	24(SP), CX                   ;新的iface
0x00dd 00221 (main.go:26)	MOVQ	32(SP), DX                   ;新的iface
....... 

主要看runtime.assertI2I2函数:

func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) {
	tab := i.tab //获取变量i的iface.tab
	if tab == nil {
		return
	}
	if tab.inter != inter { //如果i的face.tab跟inter不一致
		tab = getitab(inter, tab._type, true) //使用inter类型与tab._type具体类型查询itab
		if tab == nil { //如果查询不到,则表示断言失败
			return
		}
	}
    //断言成功 赋值
	r.tab = tab
	r.data = i.data
	b = true
	return
}

通过这样的方式我们就实现了接口类型断言。

我们再来看代码具体类型的断言这里和空接口的例子比较类似编译器也通过汇编代码优化进行了实现,没有去调用runtime函数:

....... 
0x0207 00519 (main.go:30)	MOVQ	$0, ""..autotmp_3+88(SP)
0x0210 00528 (main.go:30)	MOVQ	"".p+144(SP), AX                           ;放入具体的AX
0x0218 00536 (main.go:30)	LEAQ	go.itab.*"".Student,"".People(SB), CX      ;获取具体类型的itab的地址
0x021f 00543 (main.go:30)	NOP
0x0220 00544 (main.go:30)	CMPQ	"".p+136(SP), CX							;比较两个itab的地址进行类型断言
0x0228 00552 (main.go:30)	JEQ	559
0x022a 00554 (main.go:30)	JMP	871
....... 

简单补充下 p+144(SP) 放入的是p的具体值,p+136(SP)放入的是pitab,这个在前面的汇编函数已经实现,因为代码省略的原因特殊说明下。

最后用一张图来总结下类型断言:

image-20220831170728307

空接口 编译器会根据你要判断的是具体类型还是接口去判断是使用runtime.assertE2I2还是直接使用汇编去实现。

非空接口也类似,也会根据要断言的是具体类型还是接口去判断调用runtime.assertI2I2还是汇编直接比较。

runtime.assertE2I2runtime.assertI2I2底层都调用了getitab去全局查找符合的itab,这样就完成了类型的判断。

参考文档:

【新乐于心】https://www.zhihu.com/people/chen-qiang-song/posts

【draveness】 https://draveness.me/golang/

【幼麟实验室】https://space.bilibili.com/567195437

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值