购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.6 接口类型

接口概念在计算机科学领域已被广泛应用。在不同的软件模块间进行交互时,通常先定义接口,然后各自实现,这样可以避免模块间的具体实现相互耦合。

在Go语言中,接口是实现多态的关键工具,是面向接口编程的核心。Go语言的接口有一个显著特性,即不需要类型显式声明实现某个接口,只要某个类型实现了接口的所有方法,编译器就自动识别该类型作为接口。接下来我们来详细分析Go语言接口的使用方式和实现原理。

2.6.1 变量的定义

在Go语言中定义接口可以使用关键字interface,它允许定义一组方法(即方法集)。接口类型的变量可以存储任何实现该接口方法集的类型实例。简而言之,任何类型只要实现了接口所定义的方法集,就可以将其实例赋值给对应接口类型的变量。下面展示了如何定义一个接口:

接口的方法集可以为空,根据这个特点,接口可分为空接口和非空接口。空接口是接口的一种特殊形式。本书提到的“接口”默认都指非空接口。

1.非空接口

非空接口指的是包含至少一个方法的接口。我们定义一个名为TestAPI的接口类型和一个名为Tester的结构体。TestAPI接口和Tester结构体的定义如代码清单2-38所示。

代码清单2-38 TestAPI接口和Tester结构体的定义

TestAPI接口包含Method1和Method2两个方法。Tester结构体实现了这两个方法。因此,Tester结构体的实例可以赋值给TestAPI接口类型的变量。这样一来,我们就能通过TestAPI接口来调用Tester结构体的Method1和Method2方法,具体使用方式如下:

在使用TestAPI接口进行操作时,我们无须关注其背后代表的具体类型,体现了多态本质。后续的业务逻辑都可以基于TestAPI接口进行开发,这正是面向接口编程的精髓。

2.空接口

空接口是一种没有定义任何方法的特殊接口类型。例如,我们可以定义一个空接口类型:

空接口变量可以接受任何类型的值,使用非常灵活。看看以下示例:

空接口由于没有定义任何方法,可以承载任何类型的数据。这一点让空接口在处理多种数据类型时显得尤为有用。如果我们希望一个函数能接受多种不同类型的参数,就可以使用空接口来实现。标准库fmt包的Printf函数就是一个典型的应用实例。fmt.Printf函数能接受任何类型的参数,就是因为它使用了空接口作为参数类型。这样的设计极大地提高了函数的通用性和灵活性。

2.6.2 实现原理

Go语言中的接口类型是实现多态性的关键。接下来深入剖析实现原理。

首先,让我们将Go语言的接口与C++的“接口”概念相比较。C++通过抽象类代表接口,从而实现多态,这一机制要求开发者显式定义继承和组合关系,并在编译时构造虚函数表来实现多态。虚函数表的数量和内容在编译期间就已确定。每个类都拥有自己的虚函数表,不同类的对象会将虚函数表指针指向各自类的虚函数表。尽管在运行时不知道对象属于哪类,但可以通过头部的虚函数表指针找到对应的虚函数表,并通过偏移地址获取具体的方法地址,从而调用实际对象实现,最终实现多态。C++这种“侵入式”的方式,并不算灵活。

相比之下,Go语言的接口实现则更加简便。接口的定义和具体类型的实现完全解耦。开发者不需要显式声明接口和具体实现类型的关系,只要具体类型实现了接口定义的方法,就能被自动识别为该接口的实现。这是一种“非侵入式”的实现方式。

接下来详细分析当一个具体类型的实例被赋值给接口变量之后,接口如何在调用方法时找到正确的方法实现。

Go接口的底层实现依赖于两个核心结构体——iface和eface。它们的定义如代码清单2-39所示。

代码清单2-39 iface和eface的定义

iface和eface都是和接口相关的结构体,它们的区别在于iface对应包含了方法的接口类型,eface对应空接口(interface{})类型。

空接口可以被任何类型赋值。其实存储任何对象都只需内存地址和类型这两个信息。因此eface用_type字段来存储具体对象的类型,用data字段存储具体对象的内存地址。

让我们再来看看iface,它是数据和方法的结合,也是接口真正的精髓所在。当创建出一个接口的变量,就会分配一个iface结构体,并对其进行初始化——把具体对象的地址与对应的itab表赋值给data和tab字段。当调用接口方法时,通过查询itab表来定位实际的方法地址。TestAPI接口方法的调用如代码清单2-40所示。

代码清单2-40 TestAPI接口方法的调用

在iface结构体中,tab字段的类型是itab,这个类型是Go语言实现接口的核心组成部分。深入理解itab有助于我们更透彻地掌握接口的工作原理。itab结构体定义如代码清单2-41所示。

代码清单2-41 itab结构体定义

在itab类型的结构体中,inter字段用于描述接口类型,而_type字段用来描述具体实现的类型。fun字段则是用来存储具体实现类型方法的地址,这是一个可变数组,由编译器决定数组的大小。这些方法按照方法名称的字典序进行排列。当接口调用方法时,实际执行的操作如下:

以上述代码中api.Method2( )的方法调用为例。这个调用的过程首先是获取正确的itab(TestAPI,Tester),然后调用itab.fun数组的第二个函数,即等价于:

图2-13直观展示了iface、itab和interfacetype结构的关联。

图2-13 iface、itab和interfacetype结构的关联

每定义一个接口变量就会对应创建一个iface类型变量。每创建一个itab类型变量就对应一个<接口类型、具体实现类型>的二元组。iface类型和itab类型相互配合,在不同层面上协同实现了接口的抽象封装。下面来看接口定义和使用示例,如代码清单2-42所示。

代码清单2-42 接口定义和使用示例

在上述代码示例中,定义了一个TestAPI的接口,具体类型Tester1、Tester2都实现了这种接口。这使Tester1和Tester2类型的实例都可以赋值给TestAPI接口类型的变量,并通过这个接口变量执行对应的方法。这个过程就涉及iface和itab类型。接口与相关结构的联系如图2-14所示。

图2-14 接口与相关结构的联系

从图2-14可以看出,接口变量的创建由用户触发,如变量api1、api2、api3分别对应着一个iface类型变量。而itab是全局性质的,通常Go运行时会维护一个全局的itab散列表(itabTable类型),程序中的itab类型变量会缓存到这个表里,以实现快速查找。

总的来说,接口背后实现机制的关键在于构造正确的iface类型变量,而创建iface类型的关键在于得到一个正确的itab类型变量。有两种方式可以构建itab类型变量。

❑ 第一种方式是在编译时就可以确定itab类型变量的内容。如果通过具体类型到接口的赋值就能推断出itab类型,那么编译时就能把这部分信息保存起来。

❑ 第二种方式是编译期间无法确定itab,那么此时必须在运行时通过动态的查询和构造过程获取itab类型变量。

接下来对构建itab的两种情况进行详细分析。

1.编译期静态的itab

在大多数情况下,当具体类型赋值给接口时,编译期间就能通过分析得到相应的itab结构体的变量,itab结构体的变量的内容会被存储在二进制文件的.rodata只读区域,从而在程序启动时可立即构建。

以代码清单2-42为例,编译代码并执行,我们使用gdb反汇编来查看相关的结构内容,如下所示:

由于api1=&t1是具体类型到接口的赋值,编译器可以直接确定这种语句的接口类型(TestAPI)和具体类型(Tester1)。因此,它对应的itab(TestAPI,Tester1)也就确定了,请注意看汇编代码中0x46ff48和0x46ff70这两个地址。

接下来可以用objdump工具分析二进制文件,输出结果如下:

这证明该itab结构体的变量确实是在编译期间构造好的,并保存在二进制文件的.rodata数据段。这样,在程序运行时它们可以被直接使用,避免运行时查找和构建itab结构体的变量的开销,从而提供更优的性能。

2.运行时动态的itab

在很多场景中,编译期间都无法确认itab结构体的变量的值,诸如动态查找、获取、生成itab类型结构体,这一系列的开销可能很大。

出于对性能的考虑,为了避免每次都产生这些开销,通常会把构造好的itab结构体的变量缓存起来,使用一个全局的散列表来管理。这个全局散列表用于快速查找是否有指定的<接口类型,具体类型>的itab类型变量。以下是一个可动态构建itab的例子,如代码清单2-43所示。

代码清单2-43 动态构建itab的示例

在上述代码中,api1=obj是具体类型到接口的赋值,编译器能够直接确定变量api1和obj的类型和内容,并构造出静态的itab类型结构。而api2=api1是接口到接口的赋值,编译器在编译期间无法确定itab的内容,只能等到运行时获取。

我们使用go build-gcflags"-N-l"编译上述代码得到二进制文件,并用objdump工具观察该二进制文件,如下所示:

在二进制文件中,我们找到了itab<API1, Object>,但是没有发现itab<API2, Object>。

当我们使用gdb执行disassemble命令进行反汇编时,可以看到接口api1到接口api2的赋值被转换成了convI2I函数的调用。以下是反汇编代码示例:

在程序运行时,convI2I函数用于动态生成itab<API2,Object>结构。在convI2I函数内部,实际上会调用getitab函数从全局的itab散列表中查询<接口类型,具体类型>是否有对应的itab类型结构变量。如果不存在,它会去创建并初始化一个itab,并使用itabAdd函数将其加入全局的散列表中,以便后续快速查找。getitab函数的实现逻辑,如代码清单2-44所示。

代码清单2-44 getitab函数的实现

getitab函数的查找过程非常直接:它通过比较itab类型的itab.inter和itab._type字段,来验证是否有匹配项。如果找到匹配的itab,则成功返回。如果没有找到则尝试构造itab类型变量,并且通过itab.init()来构造itab.fun数组。构建成功之后,为了实现后续的快速查找,需要将itab类型变量缓存到全局散列表中。

2.6.3 接口nil赋值和判断

接口在Go语言中是由iface结构体来实现,该结构体大小是16字节,前8字节是itab类型的指针,后8字节是具体对象的指针。判断一个接口是否为nil,是通过判断前8字节是否为零值来完成的。

然而,存在一种特殊情况,即一个具体的类型未被赋值,但赋值了类型变量。代码清单2-45展示了这一特殊场景示例。

代码清单2-45 接口nil的赋值示例

上面的例子看似简单,却可能在不经意间出现在开发者的代码中,极易犯错。在将具体类型赋值给接口时,即使具体类型的值是nil,也会导致接口非nil。这是因为它已经接收了对应具体类型的itab变量的值。接口变量是否为nil,是根据iface.itab字段来判断,而不是iface.data字段。 gJY5rUrPXOoBON+157iNNWyauXTJj/ULW4j7IbKPeh31fF8cZaSFzGS7tMKnK9HS

点击中间区域
呼出菜单
上一章
目录
下一章
×