接口是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中的接口除了定义方法签名之外,还可以定义变量,在实现了此接口的类中可以直接使用这些变量。
1 | csharp复制代码public interface HumanInterface{ |
Java中的类必须显式的声明实现的接口,但是在Go语言中接口是隐式实现的,只需要实现了接口中定义的全部方法及实现了接口。
数据结构
使用
1 | go复制代码type myinterface interface { |
从上面的代码中我们发现MyStruct的实现中并没有找到myinterface的身影,就像上面提到的Go语言中的接口实现都是隐式的。如果我们在上述实现中去掉Func2 方法的实现,如果在具体的使用代码中没有涉及到变量赋值(变量类型为myinterface)、传递参数(接收者为myinterface)以及返回参数(返回参数类型为myinterface)并不会出现编译出错的情况。这是因为Go语言只会在上述三种情况下才会检查类型是否实现了对应的接口。
iface 和eface
Go语言中接口分为两种类型,分别是包含一组的方法的接口和空接口。在src/runtime/runtime2.go文件中分别使用iface和eface两个结构体来描述。空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无需实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中转换出原值。两种接口都是用interface声明。但是由于空接口在Go语言中非常常见,所以使用特殊类型实现。
1 | go复制代码type iface struct { |
在上面得代码中我们给出了_type和itab类型字段得解释,当然我们并不需要对每个字段都了解其用途,只需要有个大概的概念。
_type结构体相对较为简单,并没有太多可说之处,相信各位读者对照着注释就可以轻松理解。所以接下来我们就聊聊itab结构体。首先itab除了_type字段外多了interfacetype。interfacetype从字面上来说可以轻易得知它代表的是当前的接口类型,那么_type对应的则必然是接口所指向值的类型信息,
hash则是_type.hash的拷贝,fun数组持有组成该interface虚函数表的函数的指针,所以fun数组保存的元素数量和具体类型相关联而无法设置成固定大小。
1 | rust复制代码type interfacetype struct { |
interfacetype定义于src/runtime/type.go文件中,由三个字段组成,除了typ这个Go语言类型的runtime表示,还有pkgpath和mhdr两个字段,其主要作用就是interface的公共描述,类似的还有maptype、arraytype、chantype等,这些都在type.go文件中由定义,可以理解成Go语言类型的runtime外在的表现信息。
变量是如何转变成interface的
在上一部分内容中我们已经了解了interface的数据结构,接下来让我们通过下面的代码来了解它们时如何被初始化的
1 | go复制代码func main(){ |
使用go tool compile -N -S -l test.go查看生成的汇编代码。在此我们只需要关心 var temp myinterface = MyStruct{ID:1} 这一行代码的细节,其他暂时忽略。生成的汇编代码如下
1 | scss复制代码0x0024 00036 (test.go:8) PCDATA $0, $0 |
将上述过程分成三个部分
1. 分配空间
1 | bash复制代码MOVQ $1, ""..autotmp_1+48(SP) |
1对应的是MyStruct的ID,,被存储在当前栈帧的自底向上+48偏移量的位置,。后续编译器可以根据它的存储位置来用地址对其进行引用。
2. 创建itab
1 | go复制代码 LEAQ go.itab."".MyStruct,"".myinterface(SB), AX |
看上去编译器已经为提前创建了必需的itab来表示iface,并且通过全局符号提供给我们使用。编译器这么做的原因不言而喻,毕竟不管在运行时创建了多少iface<myinterface,MyStruct>,只需要一个itab,从itab内的定义也可以看出其并不会和运行时所初始化的变量由任何关系。
在本文中并不会继续深入了解 go.itab.””.MyStruct,””.myinterface符号
,感兴趣的同学看这篇文章
,非常的深入细致
3. 分配数据
1 | scss复制代码CALL runtime.convT2I(SB) |
在1、2中我们看到了解到目前栈顶(SP)保存着 go.itab.””.MyStruct,””.myinterface 的地址,8(sp)则保存着变量的地址。上面两个指针会作为参数传给convT2I函数,此函数会创建并返回interface。 src/runtime/iface.go
1 | scss复制代码func convT2I(tab *itab, elem unsafe.Pointer) (i iface) { |
上述代码做了4件事情:
- 它创建了一个 iface 的结构体 i。
- 它将我们刚给 i.tab 赋的值赋予了 itab 指针。
- 它 在堆上分配了一个 i.tab._type 的新对象 i.tab._type,然后将第二个参数 elem 指向的值拷贝到这个新对象上。
- 将最后的 interface 返回。
现在我们终于得到了完整的interface
动态派发实现
下面是第一行实例化的汇编代码
1 | scss复制代码MOVQ $1, ""..autotmp_1+48(SP) |
接着是对方法间接调用的汇编代码
1 | scss复制代码MOVQ "".temp+32(SP), AX |
AX中保存的是itab的指针,实际上是指向go.itab.””.MyStruct,””.myinterface的指针.对其解饮用并offset 24个字节,上面itab的结构体定义我们可以得知此时指向的itab.fun . 并且我们已经知道了fun[0]实际上指向的是main.(MyStruct).Func1的指针. 因为方法本身没有参数,所以在入参的时候只需要传入receiver,并通过CALL指令即可完成函数调用.
如果我们修改代码为如下形式
1 | scss复制代码temp.Func2() |
这是再查看汇编代码,则和最初的有所不同
1 | scss复制代码MOVQ "".temp+32(SP), AX |
轻易可以得知其获取到的函数指针相对第一次的增加了8字节的偏移,这个很容易理解,因为上面提到过fun字段是接口方法实现列表是按照字典序排序的.
本文转载自: 掘金