Go语言接口的原理
概述
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。
如下图所示,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。
可移植操作系统接口(Portable Operating System Interface,POSIX)就是一个典型的例子,它定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,计算机软件就可以直接在不同操作系统上运行。
除了解耦有依赖关系的上下游,接口还能够帮助我们隐藏底层实现,减少关注点。SQL 就是接口的一个例子,当我们使用 SQL 语句查询数据时,其实不需要关心底层数据库的具体实现,我们只在乎 SQL 返回的结果是否符合预期。
隐式接口
很多面向对象语言都有接口这一概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用,这里简单介绍一下 Java 中的接口:
1 | public interface MyInterface { |
上述代码定义了一个必须实现的方法 sayHello
和一个**会注入到实现类的变量 hello
**。在下面的代码中,MyInterfaceImpl
实现了 MyInterface
接口:
1 | public class MyInterfaceImpl implements MyInterface { |
Java 中的类必须通过上述方式显式地声明实现的接口,
但是在 Go 语言中实现接口就不需要使用类似的方式。首先,我们简单了解一下在 Go 语言中如何定义接口。定义接口需要使用 interface
关键字,在接口中我们只能定义方法签名,不能包含成员变量,一个常见的 Go 语言接口是这样的:
1 | type error interface { |
如果一个类型需要实现 error
接口,那么它只需要实现 Error() string
方法,下面的 RPCError
结构体就是 error
接口的一个实现:
1 | type RPCError struct { |
细心的读者可能会发现上述代码根本就没有 error
接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 Error() string
方法就实现了 error
接口。Go 语言实现接口的方式与 Java 完全不同:
- 在 Java 中:实现接口需要显式地声明接口并实现所有方法;
- 在 Go 中:实现接口的所有方法就隐式地实现了接口;
接口类型检查的时机
我们使用上述 RPCError
结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查,这里举几个例子来演示发生接口类型检查的时机:
1 | func main() { |
Go 语言在编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:
- 将
*RPCError
类型的变量赋值给error
类型的变量rpcErr
; - 将
*RPCError
类型的变量rpcErr
传递给签名中参数类型为error
的AsErr
函数; - 将
*RPCError
类型的变量从函数签名的返回值类型为error
的NewRPCError
函数中返回;
从类型检查的过程来看,编译器仅在需要时才检查类型。
类型
接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,
- 一种是带有一组方法的接口:
runtime.iface
- 另一种是不带任何方法的
interface{}
:runtime.eface
两种接口虽然都使用 interface
声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。
需要注意的是,与 C 语言中的
void *
不同,interface{}
类型不是任意类型。如果我们将类型转换成了interface{}
类型,变量在运行期间的类型也会发生变化,获取变量类型时会得到interface{}
。
1
2
3
4
5
6
7
8
9
10
11 package main
func main() {
type Test struct{}
v := Test{}
Print(v)
}
func Print(v interface{}) {
println(v)
}上述函数不接受任意类型的参数,只接受
interface{}
类型的值,在调用v
进行类型转换,将原来的Test
类型转换成interface{}
类型,本节会在后面介绍类型转换的实现原理。
指针和接口
在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式:
虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。
对 Cat
结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。
1 | type Cat struct {} |
实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:
结构体实现接口 | 结构体指针实现接口 | |
---|---|---|
结构体初始化变量 | 通过 | 不通过 |
结构体指针初始化变量 | 通过 | 通过 |
四种中只有使用指针实现接口,使用结构体初始化变量无法通过编译,其他的三种情况都可以正常执行。当实现接口的类型和初始化变量时返回的类型时相同时,代码通过编译是理所应当的:
- 方法接受者和初始化类型都是结构体;
- 方法接受者和初始化类型都是结构体指针;
而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,即方法的接受者是结构体,而初始化的变量是结构体指针:
1 | type Cat struct{} |
作为指针的 &Cat{}
变量能够隐式地获取到指向的结构体,所以能在结构体上调用 Walk
和 Quack
方法。我们可以将这里的调用理解成 C 语言中的 d->Walk()
和 d->Speak()
,它们都会先获取指向的结构体再执行对应的方法。
但是如果我们将上述代码中方法的接受者和初始化的类型进行交换,代码就无法通过编译了:
1 | type Duck interface { |
编译器会提醒我们:Cat
类型没有实现 Duck
接口,Quack
方法的接受者是指针。这两个报错对于刚刚接触 Go 语言的开发者比较难以理解,如果我们想要搞清楚这个问题,首先要知道 Go 语言在传递参数时都是传值的。
如上图所示,无论上述代码中初始化的变量 c
是 Cat{}
还是 &Cat{}
,使用 c.Quack()
调用方法时都会发生值拷贝:
- 如上图左侧,对于
&Cat{}
来说,这意味着拷贝一个新的&Cat{}
指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体; - 如上图右侧,对于
Cat{}
来说,这意味着Quack
方法会接受一个全新的Cat{}
,因为方法的参数是*Cat
,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;
上面的分析解释了指针类型的现象,当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。当然这并不意味着我们应该一律使用结构体实现接口,这个问题在实际工程中也没那么重要,在这里我们只想解释现象背后的原因。
nil 和 non-nil
我们可以通过一个例子理解Go 语言的接口类型不是任意类型这一句话,下面的代码在 main
函数中初始化了一个 *TestStruct
类型的变量,由于指针的零值是 nil
,所以变量 s
在初始化之后也是 nil
:
1 | package main |
出现上述现象的原因是 —— 调用 NilOrNot
函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,***TestStruct
类型会转换成 interface{}
类型**,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct
,所以转换后的变量与 nil
不相等。
数据结构
从源代码和汇编指令层面介绍接口的底层数据结构。
Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:
- 使用
runtime.iface
结构体表示包含方法的接口 - 使用
runtime.eface
结构体表示不包含任何方法的interface{}
类型;
runtime.eface
结构体在 Go 语言中的定义是这样的:
1 | type eface struct { // 16 字节 |
由于 interface{}
类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go 语言的任意类型都可以转换成 interface{}
。
另一个用于表示接口的结构体是 runtime.iface
,这个结构体中有指向原始数据的指针 data
,不过更重要的是 runtime.itab
类型的 tab
字段。
1 | type iface struct { // 16 字节 |
接下来我们将详细分析 Go 语言接口中的这两个类型,即 runtime._type
和 runtime.itab
。
类型结构体
runtime._type
是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。
1 | type _type struct { |
size
字段存储了类型占用的内存空间,为内存空间的分配提供信息;hash
字段能够帮助我们快速确定类型是否相等;equal
字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从typeAlg
结构体中迁移过来的;
我们只需要对 runtime._type
结构体中的字段有一个大体的概念,不需要详细理解所有字段的作用和意义。
itab 结构体
runtime.itab
结构体是接口类型的核心组成部分,每一个 runtime.itab
都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter
和 _type
两个字段表示:
1 | type itab struct { // 32 字节 |
除了 inter
和 _type
两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用:
hash
是对_type.hash
的拷贝,当我们想将interface
类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型runtime._type
是否一致;fun
是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以fun
数组中保存的元素数量是不确定的;
我们会在类型断言中介绍 hash
字段的使用,在动态派发一节中介绍 fun
数组中存储的函数指针是如何被使用的。
类型转换
既然我们已经了解了接口在运行时的数据结构,接下来会通过几个例子来深入理解接口类型是如何初始化和传递的,本节会介绍在实现接口时使用指针类型和结构体类型的区别。这两种不同的接口实现方式会导致 Go 语言编译器生成不同的汇编代码,进而影响最终的处理过程。
指针类型
首先回到这一节开头提到的 Duck
接口的例子,我们使用 //go:noinline
指令禁止 Quack
方法的内联编译:
1 | package main |
我们使用编译器将上述代码编译成汇编语言、删掉一些对理解接口原理无用的指令并保留与赋值语句 var c Duck = &Cat{Name: "draven"}
相关的代码,这里将生成的汇编指令拆分成三部分分析:
- 结构体
Cat
的初始化; - 赋值触发的类型转换过程;
- 调用接口的方法
Quack()
;
我们先来分析结构体 Cat
的初始化过程:
1 | LEAQ type."".Cat(SB), AX ;; AX = &type."".Cat |
- 获取
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 | LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = *itab(go.itab.*"".Cat,"".Duck) |
类型转换的过程比较简单,Duck
作为一个包含方法的接口,它在底层使用 runtime.iface
结构体表示。runtime.iface
结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab
字段,我们已经通过上一段代码 SP+8 初始化了 Cat
结构体指针,这段代码只是将编译期间生成的 runtime.itab
结构体指针复制到 SP 上:
到这里,我们会发现 SP ~ SP+16 共同组成了 runtime.iface
结构体,而栈上的这个 runtime.iface
也是 Quack
方法的第一个入参。
1 | CALL "".(*Cat).Quack(SB) ;; SP.Quack() |
上述代码会直接通过 CALL
指令完成方法的调用,细心的读者可能会发现一个问题 —— 为什么在代码中我们调用的是 Duck.Quack
但生成的汇编是 *Cat.Quack
呢?Go 语言的编译器会在编译期间将一些需要动态派发的方法调用改写成对目标方法的直接调用,以减少性能的额外开销。如果在这里禁用编译器优化,就会看到动态派发的过程,我们会在后面分析接口的动态派发以及性能上的额外开销。
结构体类型
在这里我们继续修改上一节中的代码,使用结构体类型实现 Duck
接口并初始化结构体类型的变量:
1 | package main |
如果我们在初始化变量时使用指针类型 &Cat{Name: "draven"}
也能够通过编译,不过生成的汇编代码和上一节中的几乎完全相同,所以这里也就不分析这个情况了。
编译上述代码会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不影响具体的执行过程。与上一节一样,我们将汇编代码的执行过程分成以下几个部分:
- 初始化
Cat
结构体; - 完成从
Cat
到Duck
接口的类型转换; - 调用接口的
Quack
方法;
我们先来看一下上述汇编代码中用于初始化 Cat
结构体的部分:
1 | XORPS X0, X0 ;; X0 = 0 |
这段汇编指令会在栈上初始化 Cat
结构体,而上一节的代码在堆上申请了 16 字节的内存空间,栈上只有一个指向 Cat
的指针。
初始化结构体后会进入类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck
的地址和指向 Cat
结构体的指针作为参数一并传入 runtime.convT2I
函数:
1 | LEAQ go.itab."".Cat,"".Duck(SB), AX ;; AX = &(go.itab."".Cat,"".Duck) |
这个函数会获取 runtime.itab
中存储的类型,根据类型的大小申请一片内存空间并将 elem
指针中的内容拷贝到目标的内存中:
1 | func convT2I(tab *itab, elem unsafe.Pointer) (i iface) { |
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 | MOVQ 16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck) |
这几个汇编指令还是非常好理解的,MOVQ 24(AX), AX
是最关键的指令,它从 runtime.itab
结构体中取出 Cat.Quack
方法指针作为 CALL
指令调用时的参数。接口变量的第 24 字节是 itab.fun
数组开始的位置,由于 Duck
接口只包含一个方法,所以 itab.fun[0]
中存储的就是指向 Quack
方法的指针了。
类型断言
上一节介绍是如何把具体类型转换成接口类型,而这一节介绍的是如何将一个接口类型转换成具体类型。本节会根据接口中是否存在方法分两种情况介绍类型断言的执行过程。
非空接口
首先分析接口中包含方法的情况,Duck
接口一个非空的接口,我们来分析从 Duck
转换回 Cat
结构体的过程:
1 | func main() { |
我们将编译得到的汇编指令分成两部分分析,第一部分是变量的初始化,第二部分是类型断言,第一部分的代码如下:
1 | 00000 TEXT "".main(SB), ABIInternal, $32-0 |
0037 ~ 0049 三个指令初始化了 Duck
变量,Cat
结构体初始化在 SP+8 ~ SP+24 上。因为 Go 语言的编译器做了一些优化,所以代码中没有runtime.iface
的构建过程,不过对于这一节要介绍的类型断言和转换没有太多的影响。下面进入类型转换的部分:
1 | 00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792 |
switch语句生成的汇编指令会将目标类型的 hash
与接口变量中的 itab.hash
进行比较:
如果两者相等意味着变量的具体类型是
1
Cat
,我们会跳转到 0080 所在的分支完成类型转换。
- 获取 SP+8 存储的
Cat
结构体指针; - 将结构体指针拷贝到栈顶;
- 调用
Quack
方法; - 恢复函数的栈并返回;
- 获取 SP+8 存储的
如果接口中存在的具体类型不是
Cat
,就会直接恢复栈指针并返回到调用方;
上图展示了调用 Quack
方法时的堆栈情况,其中 Cat
结构体存储在 SP+8 ~ SP+24 上,Cat
指针存储在栈顶并指向上述结构体。
空接口
当我们使用空接口类型 interface{}
进行类型断言时,如果不关闭 Go 语言编译器的优化选项,生成的汇编指令是差不多的。编译器会省略将 Cat
结构体转换成 runtime.eface
的过程:
1 | func main() { |
如果禁用编译器优化,上述代码会在类型断言时就不是直接获取变量中具体类型的 runtime._type
,而是从 eface._type
中获取,汇编指令仍然会使用目标类型的 hash
与变量的类型比较。
动态派发
动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是面向对象语言中的常见特性6。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。
在如下所示的代码中,main
函数调用了两次 Quack
方法:
- 第一次以
Duck
接口类型的身份调用,调用时需要经过运行时的动态派发; - 第二次以
*Cat
具体类型的身份调用,编译期就会确定调用的函数:
1 | func main() { |
因为编译器优化影响了我们对原始汇编指令的理解,所以需要使用编译参数 -N
关闭编译器优化。如果不指定这个参数,编译器会对代码进行重写,与最初生成的执行过程有一些偏差,例如:
- 因为接口类型中的
tab
参数并没有被使用,所以优化从Cat
转换到Duck
的过程; - 因为变量的具体类型是确定的,所以删除从
Duck
接口类型转换到*Cat
具体类型时可能会发生崩溃的分支; - …
在具体分析调用 Quack
方法的两种姿势之前,我们要先了解 Cat
结构体究竟是如何初始化的,以及初始化后的栈上有哪些数据:
1 | LEAQ type."".Cat(SB), AX |
这段代码的初始化过程其实和上两节中的过程没有太多的差别,它先初始化了 Cat
结构体指针,再将 Cat
和 tab
打包成了一个 runtime.iface
类型的结构体,我们直接来看初始化结束后的栈情况:
- SP 是
Cat
类型,它也是运行时runtime.newobject
方法的参数; - SP+8 是
runtime.newobject
方法的返回值,即指向堆上的Cat
结构体的指针; - SP+32、SP+40 是对 SP+8 的拷贝,这两个指针都会指向堆上的
Cat
结构体; - SP+48 ~ SP+64 是接口变量
runtime.iface
结构体,其中包含了tab
结构体指针和*Cat
指针;
初始化过程结束后,就进入到了动态派发的过程,c.Quack()
语句展开的汇编指令会在运行时确定函数指针。
1 | MOVQ "".c+48(SP), AX ;; AX = iface(c).tab |
这段代码的执行过程可以分成以下三个步骤:
- 从接口变量中获取保存
Cat.Quack
方法指针的tab.func[0]
; - 接口变量在
runtime.iface
中的数据会被拷贝到栈顶; - 方法指针会被拷贝到寄存器中并通过汇编指令
CALL
触发:
另一个调用 Quack
方法的语句 c.(*Cat).Quack()
生成的汇编指令看起来会有一些复杂,但是代码前半部分都是在做类型转换,将接口类型转换成 *Cat
类型,只有最后两行代码才是函数调用相关的指令:
1 | MOVQ "".c+56(SP), AX ;; AX = iface(c).data = &Cat{...} |
下面的几行代码只是将 Cat
指针拷贝到了栈顶并调用 Quack
方法。这一次调用的函数指针在编译期就已经确定了,所以运行时就不需要动态查找方法的实现:
1 | MOVQ "".c+48(SP), AX ;; AX = iface(c).tab |
两次方法调用对应的汇编指令差异就是动态派发带来的额外开销,这些额外开销在有低延时、高吞吐量需求的服务中是不能被忽视的,我们来详细分析一下产生的额外汇编指令对性能造成的影响。
基准测试
下面代码中的两个方法 BenchmarkDirectCall
和 BenchmarkDynamicDispatch
分别会调用结构体方法和接口方法,在接口上调用方法时会使用动态派发机制,我们以直接调用作为基准分析动态派发带来了多少额外开销:
1 | func BenchmarkDirectCall(b *testing.B) { |
我们直接运行下面的命令,使用 1 个 CPU 运行上述代码,每一个基准测试都会被执行 3 次:
1 | $ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s -bench=. |
- 调用结构体方法时,每一次调用需要 ~3.03ns;
- 使用动态派发时,每一调用需要 ~3.58ns;
在关闭编译器优化的情况下,从上面的数据来看,动态派发生成的指令会带来 ~18% 左右的额外性能开销。
这些性能开销在一个复杂的系统中不会带来太多的影响。一个项目不可能只使用动态派发,而且如果我们开启编译器优化后,动态派发的额外开销会降低至 ~5%,这对应用性能的整体影响就更小了,所以与使用接口带来的好处相比,动态派发的额外开销往往可以忽略。
上面的性能测试建立在实现和调用方法的都是结构体指针上,当我们将结构体指针换成结构体又会有比较大的差异:
1 | func BenchmarkDirectCall(b *testing.B) { |
当我们重新执行相同的基准测试时,会得到如下所示的结果:
1 | $ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s . |
直接调用方法需要消耗时间的平均值和使用指针实现接口时差不多,约为 ~3.09ns,而使用动态派发调用方法却需要 ~6.98ns 相比直接调用额外消耗了 ~125% 的时间,从生成的汇编指令我们也能看出后者的额外开销会高很多。
直接调用 | 动态派发 | |
---|---|---|
指针 | ~3.03ns | ~3.58ns |
结构体 | ~3.09ns | ~6.98ns |
表 4-2 直接调用和动态派发的性能对比
从上述表格我们可以看到使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口。
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。