阅读 958

深入理解golang中的接口

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

简述

在我们揭开interface的面纱前,先让我们一起去了解下在开发中使用接口能够给我们带来哪些好处。提到接口则不得不提面向对象设计中的依赖倒置原则,由Object Mentor 公司总裁罗伯特·马丁(Robert C.Martin)于 1996 年在 C++ Report 上发表的文章首先提出。依赖倒置的原始定义为:

High level modules shouldnot depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details. Details should depend upon abstractions

其核心思想是:要面向接口编程,而不是面向实现编程。由于在软件设计中,细节具有多边形,而设计良好的抽象层则更加稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多. 在Java和C#这些面向对象的程序语言中都有接口的概念。以Java为例,Java中的接口除了定义方法签名之外,还可以定义变量,在实现了此接口的类中可以直接使用这些变量。

public interface HumanInterface{
    public String name="test";
    public void eat();
}

public class Man implements HumanInterface{
    public void eat(){
        System.out.println(name+"eat a lot")
    }
}
public class Women implements HumanInterface{
    public void eat(){
        System.out.println(name+"eat very little")
    }
}
复制代码

Java中的类必须显式的声明实现的接口,但是在Go语言中接口是隐式实现的,只需要实现了接口中定义的全部方法及实现了接口。

数据结构

使用

type myinterface interface {
	Func1() string
	Func2() string
}
type MyStruct struct {
}
func (m *MyStruct) Func1() string {
	return fmt.Sprintf("Func1 implement")
}
func (m *MyStruct) Func2() string {
	return fmt.Sprintf("Func2 implement")
}
复制代码

从上面的代码中我们发现MyStruct的实现中并没有找到myinterface的身影,就像上面提到的Go语言中的接口实现都是隐式的。如果我们在上述实现中去掉Func2 方法的实现,如果在具体的使用代码中没有涉及到变量赋值(变量类型为myinterface)、传递参数(接收者为myinterface)以及返回参数(返回参数类型为myinterface)并不会出现编译出错的情况。这是因为Go语言只会在上述三种情况下才会检查类型是否实现了对应的接口。

iface 和eface

Go语言中接口分为两种类型,分别是包含一组的方法的接口和空接口。在src/runtime/runtime2.go文件中分别使用iface和eface两个结构体来描述。空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无需实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中转换出原值。两种接口都是用interface声明。但是由于空接口在Go语言中非常常见,所以使用特殊类型实现。

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

type itab struct {
	inter *interfacetype //接口定义的类型信息
	_type *_type //接口实际指向值得类型信息
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // 接口方法实现列表,即函数地址列表,按字典序排序
}

type eface struct {
	_type *_type //类型
	data  unsafe.Pointer //底层数据的指针
}

type _type struct {
	size       uintptr  //存储了类型需要占用的内存空间,主要在初始化时内存分配使用
	ptrdata    uintptr // size of memory prefix holding all pointers
	hash       uint32 //用于判断类型相等
	tflag      tflag //类型的Tags
	align      uint8 //结构体内对齐
	fieldAlign uint8 //结构体作为field时的对齐
	kind       uint8 //类型编号,定义域runtime/typekind.go
	// function for comparing objects of this type
	// (ptr to object A, ptr to object B) -> ==?
	equal func(unsafe.Pointer, unsafe.Pointer) bool
	// gcdata stores the GC type data for the garbage collector.
	// If the KindGCProg bit is set in kind, gcdata is a GC program.
	// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
	gcdata    *byte
	str       nameOff
	ptrToThis typeOff
    
    //nameOff 和 typeOff 类型是 int32 ,这两个值是链接器负责嵌入的,相对于可执行文件的元信息的偏移量。元信息会在运行期,加载到 runtime.moduledata 结构体中 (src/runtime/symtab.go)。
runtime 提供了一些 helper 函数,这些函数能够帮你找到相对于 moduledata 的偏移量,比如 resolveNameOff (src/runtime/type.go) and resolveTypeOff (src/runtime/type.go)
}
复制代码

在上面得代码中我们给出了_type和itab类型字段得解释,当然我们并不需要对每个字段都了解其用途,只需要有个大概的概念。

_type结构体相对较为简单,并没有太多可说之处,相信各位读者对照着注释就可以轻松理解。所以接下来我们就聊聊itab结构体。首先itab除了_type字段外多了interfacetype。interfacetype从字面上来说可以轻易得知它代表的是当前的接口类型,那么_type对应的则必然是接口所指向值的类型信息,

hash则是_type.hash的拷贝,fun数组持有组成该interface虚函数表的函数的指针,所以fun数组保存的元素数量和具体类型相关联而无法设置成固定大小。

type interfacetype struct {
	typ     _type
	pkgpath name
	mhdr    []imethod
}

type imethod struct {
	name nameOff
	ityp typeOff
}
复制代码

interfacetype定义于src/runtime/type.go文件中,由三个字段组成,除了typ这个Go语言类型的runtime表示,还有pkgpath和mhdr两个字段,其主要作用就是interface的公共描述,类似的还有maptype、arraytype、chantype等,这些都在type.go文件中由定义,可以理解成Go语言类型的runtime外在的表现信息。

变量是如何转变成interface的

在上一部分内容中我们已经了解了interface的数据结构,接下来让我们通过下面的代码来了解它们时如何被初始化的

func main(){
	var temp myinterface = MyStruct{ID:1}
	temp.Func1()

}
type myinterface interface {
	Func1() string
	Func2() string
}

type MyStruct struct {
	ID int64
	ptr *int64
}
//go:noinline
func (m MyStruct) Func1() string {
	return fmt.Sprintf("Func1 implement")
}
//go:noinline
func (m MyStruct) Func2() string {
	return fmt.Sprintf("Func2 implement")
}
复制代码

使用go tool compile -N -S -l test.go查看生成的汇编代码。在此我们只需要关心 var temp myinterface = MyStruct{ID:1} 这一行代码的细节,其他暂时忽略。生成的汇编代码如下

0x0024 00036 (test.go:8)        PCDATA  $0, $0
        0x0024 00036 (test.go:8)        PCDATA  $1, $1
        0x0024 00036 (test.go:8)        XORPS   X0, X0
        0x0027 00039 (test.go:8)        MOVUPS  X0, ""..autotmp_1+48(SP)
        0x002c 00044 (test.go:8)        MOVQ    $1, ""..autotmp_1+48(SP)
        0x0035 00053 (test.go:8)        PCDATA  $0, $1
        0x0035 00053 (test.go:8)        LEAQ    go.itab."".MyStruct,"".myinterface(SB), AX
        0x003c 00060 (test.go:8)        PCDATA  $0, $0
        0x003c 00060 (test.go:8)        MOVQ    AX, (SP)
        0x0040 00064 (test.go:8)        PCDATA  $0, $1
        0x0040 00064 (test.go:8)        PCDATA  $1, $0
        0x0040 00064 (test.go:8)        LEAQ    ""..autotmp_1+48(SP), AX
        0x0045 00069 (test.go:8)        PCDATA  $0, $0
        0x0045 00069 (test.go:8)        MOVQ    AX, 8(SP)
        0x004a 00074 (test.go:8)        CALL    runtime.convT2I(SB)
        0x004f 00079 (test.go:8)        PCDATA  $0, $1
        0x004f 00079 (test.go:8)        MOVQ    24(SP), AX
        0x0054 00084 (test.go:8)        MOVQ    16(SP), CX
        0x0059 00089 (test.go:8)        PCDATA  $1, $2
        0x0059 00089 (test.go:8)        MOVQ    CX, "".temp+32(SP)
        0x005e 00094 (test.go:8)        PCDATA  $0, $0
        0x005e 00094 (test.go:8)        MOVQ    AX, "".temp+40(SP)

复制代码

将上述过程分成三个部分

1. 分配空间

MOVQ    $1, ""..autotmp_1+48(SP)
...
LEAQ    ""..autotmp_1+48(SP), AX
MOVQ    AX, 8(SP)
复制代码

1对应的是MyStruct的ID,,被存储在当前栈帧的自底向上+48偏移量的位置,。后续编译器可以根据它的存储位置来用地址对其进行引用。

2. 创建itab

 LEAQ    go.itab."".MyStruct,"".myinterface(SB), AX
 MOVQ    AX, (SP)
复制代码

看上去编译器已经为提前创建了必需的itab来表示iface,并且通过全局符号提供给我们使用。编译器这么做的原因不言而喻,毕竟不管在运行时创建了多少iface<myinterface,MyStruct>,只需要一个itab,从itab内的定义也可以看出其并不会和运行时所初始化的变量由任何关系。 在本文中并不会继续深入了解 go.itab."".MyStruct,"".myinterface符号 ,感兴趣的同学看这篇文章 ,非常的深入细致

3. 分配数据

CALL    runtime.convT2I(SB)
MOVQ    24(SP), AX
MOVQ    16(SP), CX
复制代码

在1、2中我们看到了解到目前栈顶(SP)保存着 go.itab."".MyStruct,"".myinterface 的地址,8(sp)则保存着变量的地址。上面两个指针会作为参数传给convT2I函数,此函数会创建并返回interface。 src/runtime/iface.go

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
	t := tab._type
	if raceenabled {
		raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
	}
	if msanenabled {
		msanread(elem, t.size)
	}
	x := mallocgc(t.size, t, true)
	typedmemmove(t, x, elem)
	i.tab = tab
	i.data = x
	return
}
复制代码

上述代码做了4件事情:

  1. 它创建了一个 iface 的结构体 i。
  2. 它将我们刚给 i.tab 赋的值赋予了 itab 指针。
  3. 它 在堆上分配了一个 i.tab._type 的新对象 i.tab._type,然后将第二个参数 elem 指向的值拷贝到这个新对象上。
  4. 将最后的 interface 返回。

现在我们终于得到了完整的interface

动态派发实现

下面是第一行实例化的汇编代码

MOVQ    $1, ""..autotmp_1+48(SP)
LEAQ    go.itab."".MyStruct,"".myinterface(SB), AX
MOVQ    AX, (SP)
LEAQ    ""..autotmp_1+48(SP), AX
MOVQ    AX, 8(SP)
CALL    runtime.convT2I(SB)
MOVQ    24(SP), AX
MOVQ    16(SP), CX
MOVQ    CX, "".temp+32(SP)
MOVQ    AX, "".temp+40(SP)
复制代码

接着是对方法间接调用的汇编代码

MOVQ    "".temp+32(SP), AX
MOVQ    24(AX), AX
MOVQ    "".temp+40(SP), CX
MOVQ    CX, (SP)
CALL    AX
复制代码

AX中保存的是itab的指针,实际上是指向go.itab."".MyStruct,"".myinterface的指针.对其解饮用并offset 24个字节,上面itab的结构体定义我们可以得知此时指向的itab.fun . 并且我们已经知道了fun[0]实际上指向的是main.(MyStruct).Func1的指针. 因为方法本身没有参数,所以在入参的时候只需要传入receiver,并通过CALL指令即可完成函数调用.

如果我们修改代码为如下形式

temp.Func2()
复制代码

这是再查看汇编代码,则和最初的有所不同

MOVQ    "".temp+32(SP), AX
MOVQ    32(AX), AX
MOVQ    "".temp+40(SP), CX
MOVQ    CX, (SP)
CALL    AX
复制代码

轻易可以得知其获取到的函数指针相对第一次的增加了8字节的偏移,这个很容易理解,因为上面提到过fun字段是接口方法实现列表是按照字典序排序的.

文章分类
后端
文章标签