接口
1、概述
接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要下层的具体模块,只需要依赖一个约定好的接口。
举个例子:我们定义了一个
Shape
接口,它规定了一个Area()
方法,然后我们创建了两个结构体Circle
和Rectangle
分别实现了这个接口的Area
方法。我们可以实现一个calculateArea
函数计算它们的面积,参数就设置为Shape
接口,我们就可以通过接口来调用它们的方法获取到面积,而不用关心是什么形状。这就是接口的作用:它定义了一个规范,不同的对象可以根据这个规范来实现自己的功能,而不需要关心其他对象的具体实现,从而降低了代码的耦合度。
这种面向接口的编程方式有着非常大的生命力,不论在框架还是操作系统中我们都能够找到接口的身影。
除了解耦有依赖关系的上下游,接口还能够帮助我们隐藏底层实现,减少关注点。
人能够同时处理的信息非常有限,定义良好的接口能够隔离底层的实现,让我们将重点放在当前的代码片段中。SQL 就是接口的一个例子,当我们使用 SQL 语句查询数据的时候,其实不需要关心底层数据库的实现,我们只在乎 SQL 返回的结果是否符合预期。
计算机科学中的接口是比较抽象的概念,但编程语言中接口的概念就比较具体。Go 语言中的接口是一种内置的类型,它定义了以一组方法的签名,下面介绍几个基本的概念以及常见问题。
隐式接口
很多面向对象语言都有接口这个概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用:
1 |
|
上述代码定义了一个必须实现的方法 sayHello
和一个会注入到实现类的变量 hello
。在下面的代码中,MyInterfaceImpl
实现了 MyInterface
接口:
1 |
|
Java 中必须使用这种显示声明接口的方法,但 Go 语言中不需要这样的方式。
Go 语言中定义接口需要使用 interface
关键字,在接口中我们只能定义方法签名,不能包含成员变量,如下:
1 |
|
如果一个类型需要实现 error
接口,那么它只需要实现 Error() string
方法,如下:
1 |
|
这段代码中根本没有出现 error
的影子,这就是 Go 语言中实现接口的方法,是隐式的,我们只需要实现 Error() string
方法就实现了 error
接口。这种方式与 Java 实现接口的方法是完全不同的:
- Java:实现接口需要显示地声明接口并实现所有方法
- Go:实现接口的所有方法就隐式地实现了接口
我们使用上述 RPCError
结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查。
类型
Go 语言中接口也是一种类型,它能够出现在变量的定义、函数的参数和返回值中并对它们做出约束。Go 语言中有两种不同的接口:一种是带有方法的接口,另一种总是不带任何方法的接口:
Go 语言使用 runtime.iface
表示第一种接口,使用 runtime.eface
表示第二种不包含任何方法的接口 interface{}
,两种接口虽然都使用 interface
声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。
Go 语言中的 interface
与 C 语言中的 void *
不同,它不是任意类型。如果我们将类型转换成了 interface{}
类型,变量在运行期间也会发生变化,获取变量类型时会得到 interface{}
。
1 |
|
上述函数只接受 interface
类型的参数,在调用 Print
函数的时候,会对参数 v
进行类型转换,将原来的 Test
类型转换成 interface
{} 类型,于是打印出来的结果就是:
1 |
|
接口和指针
在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式:
因为结构体类型和指针类型是不同的,就像我们不能向一个接受指针的函数传递结构体一样,在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。
对 Cat
结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。
1 |
|
Go
实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:
结构体实现接口 | 结构体指针实现接口 | |
---|---|---|
结构体初始化变量 | 通过 | 不通过 |
结构体指针初始化变量 | 通过 | 通过 |
四种中只有使用指针实现接口,使用结构体初始化变量无法通过编译,其他的三种情况都可以正常执行。当实现接口的类型和初始化变量时返回的类型时相同时,代码通过编译是理所应当的:
- 方法接受者和初始化类型都是结构体;
- 方法接受者和初始化类型都是结构体指针;
而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?先说能通过编译的情况:方法的接收者是结构体,而初始化的变量是结构体指针:
1 |
|
因为指针 &Cat{}
变量能够隐式地获取到指向的结构体,所以能在结构体上调用对应的方法。
而如果方法的接收者是结构体指针,而初始化的变量是结构体,代码就无法通过编译了:
1 |
|
编译器会提醒:无法将 'Cat{}' (类型 Cat) 用作类型 Duck 类型未实现 'Duck',因为 'Quack' 方法有指针接收器。
如图所示,不论上述代码中初始化的变量 c
是 Cat{}
还是 &Cat{}
,使用 c.Quack()
方法时都会发生值拷贝:
- 上图左侧,对于
&Cat
来说,这意味着拷贝一个新的&Cat{}
指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用来获取指针指向的结构体; - 上图右侧,对于
Cat{}
来说,这意味着Quack
方法会接受一个全新的Cat{}
,因为方法的参数是*Cat
,编译器不会无中生有创建一个新的指针;即使编译器可以创建新的指针,这个指针也无法指向最初调用该方法的结构体。
因此,当我们使用指针实现接口时,只有指针类型才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。但这并不意味着我们应该统一使用结构体实现接口,这里只是做一个解释。
nil 和 non-nil
我们可以通过一个例子理解 Go 语言的接口类型不是任意类型 这句话:
1 |
|
简单总结一下上述代码:
- 将上述变量与
nil
比较会返回true
; - 将上述变量传入
NilOrNot
方法并与nil
比较会返回false
;
出现上述现象的原因是 —— 调用 NilOrNot
函数时发生了 隐式类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在发生类型转换时, *TestStruct
类型转换成了 interface{}
类型,转换后的变量不仅包含转换前的变量值 nil
,还包含变量的类型信息 TestStruct
,所以转换后的变量与 nil
不相等。
2、数据结构
从概述中,我们了解到Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:
- 使用
runtime.iface
结构体表示包含方法的接口 - 使用
runtime.eface
结构体表示不包含方法的interface{}
类型
runtime.eface
结构体在 Go 语言中的定义是这样的:
1 |
|
由于 interface{}
不包含任何方法,所以它的结构体是比较简单的,只包含指向底层数据和结构的两个指针。因此不难看出,—— Go 语言的任意类型都可以转换成 interface{}
。
另一个用于表示接口的结构体是 runtime.iface
,这个结构体中指向原始数据的指针 data
,还有一个 runtime.itab
类型的 tab
字段。
下面我们来分析一个 Go 语言接口中的这两个类型,即runtime._type
和 runtime.itab
。
类型结构体
runtime._type
是 Go 语言类型的运行时表示。它包含很多类型的元信息,例如类型大小、哈希、对齐以及种类等。
1 |
|
size
字段存储了类型占用的内存空间,为内存空间的分配提供信息;hash
字段能够帮助我们快速确定类型是否相等;equal
字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从typeAlg
结构体中迁移过来的4;
对该结构体中的字段,我们只需要有个大体的概念即可,不用详细理解所有字段的作用和意义。
itab 结构体
runtime.itab
结构体是接口类型的核心组成部分,每一个 runtime.itab
都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter
和 _type
两个字段表示:
1 |
|
hash
是对_typehash
的拷贝,当我们想将interface
类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型runtime._type
是否一致;fun
是一个动态大小的数组,用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以fun
数组中保存的元素数量是不确定的;
后面会对上述两个字段进行深入了解。
3、类型转换
下面通过几个例子深入理解接口类型是如何初始化和传递的。
指针类型
首先回到这一节开头提到的 Duck
接口的例子,我们使用 //go:noinline
指令5禁止 Quack
方法的内联编译:
1 |
|
我们使用编译器将上述代码编译成汇编语言、删掉一些对理解接口原理无用的指令并保留与赋值语句相关的代码,拆分成三部分:
- 结构体
Cat
的初始化; - 赋值触发的类型转换过程;
- 调用接口的方法
Quack()
;
我们先来分析结构体 Cat
的初始化过程:
1 |
|
- 获取
Cat
结构体类型指针并将其作为参数放到栈上; - 通过
CALL
指定调用runtime.newobject
函数,这个函数会以Cat
结构体类型指针作为入参,分配一片新的内存空间并将指向这片内存空间的指针返回到 SP+8 上; - SP+8 现在存储了一个指向
Cat
结构体的指针,我们将栈上的指针拷贝到寄存器DI
上方便操作; - 由于
Cat
中只包含一个字符串类型的Name
变量,所以在这里会分别将字符串地址&"draven"
和字符串长度 6 设置到结构体上,最后三行汇编指令等价于cat.Name = "draven"
;
字符串在运行时的表示是指针加上字符串长度,这里要看一下初始化之后的 Cat
结构体在内存中的表示是什么样的:
因为 Cat
结构体的定义中只包含一个字符串,而字符串在 Go 语言中总共占 16 字节,所以每一个 Cat
结构体的大小都是 16 字节。初始化 Cat
结构体之后就进入了将 *Cat
转换成 Duck
类型的过程了:
1 |
|
类型转换的过程比较简单,Duck
作为一个包含方法的接口,它在底层使用 runtime.iface
结构体表示。runtime.iface
结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab
字段,我们已经通过上一段代码 SP+8 初始化了 Cat
结构体指针,这段代码只是将编译期间生成的 runtime.itab
结构体指针复制到 SP 上:
到这里,我们会发现 SP ~ SP+16 共同组成了 runtime.iface
结构体,而栈上的这个 runtime.iface
也是 Quack
方法的第一个入参。
1 |
|
上述代码会直接通过 CALL
指令完成方法的调用,细心的读者可能会发现一个问题 —— 为什么在代码中我们调用的是 Duck.Quack
但生成的汇编是 *Cat.Quack
呢?Go 语言的编译器会在编译期间将一些需要动态派发的方法调用改写成对目标方法的直接调用,以减少性能的额外开销。如果在这里禁用编译器优化,就会看到动态派发的过程,我们会在后面分析接口的动态派发以及性能上的额外开销。
结构体类型
在这里我们继续修改上一节中的代码,使用结构体类型实现 Duck
接口并初始化结构体类型的变量:
1 |
|
编译上述代码会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不影响具体的执行过程。与上一节一样,我们将汇编代码的执行过程分成以下几个部分:
- 初始化
Cat
结构体; - 完成从
Cat
到Duck
接口的类型转换; - 调用接口的
Quack
方法;
我们先来看一下上述汇编代码中用于初始化 Cat
结构体的部分:
1 |
|
Go
这段汇编指令会在栈上初始化 Cat
结构体,而上一节的代码在堆上申请了 16 字节的内存空间,栈上只有一个指向 Cat
的指针。
初始化结构体后会进入类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck
的地址和指向 Cat
结构体的指针作为参数一并传入 runtime.convT2I
函数:
1 |
|
Go
这个函数会获取 runtime.itab
中存储的类型,根据类型的大小申请一片内存空间并将 elem
指针中的内容拷贝到目标的内存中:
1 |
|
Go
runtime.convT2I
会返回一个 runtime.iface
,其中包含 runtime.itab
指针和 Cat
变量。当前函数返回之后,main
函数的栈上会包含以下数据:
SP 和 SP+8 中存储的 runtime.itab
和 Cat
指针是 runtime.convT2I
函数的入参,这个函数的返回值位于 SP+16,是一个占 16 字节内存空间的 runtime.iface
结构体,SP+32 存储的是在栈上的 Cat
结构体,它会在 runtime.convT2I
执行的过程中拷贝到堆上。
在最后,我们会通过以下的指令调用 Cat
实现的接口方法 Quack()
:
1 |
|
Go
这几个汇编指令还是非常好理解的,MOVQ 24(AX), AX
是最关键的指令,它从 runtime.itab
结构体中取出 Cat.Quack
方法指针作为 CALL
指令调用时的参数。接口变量的第 24 字节是 itab.fun
数组开始的位置,由于 Duck
接口只包含一个方法,所以 itab.fun[0]
中存储的就是指向 Quack
方法的指针了。