2.2 接口
接口是 Go 语言的重要组成部分,它在 Go 语言中通过一组方法指定了一个对象的行为,接口 interface
的引入能够让我们在 Go 语言更好地组织并写出易于测试的代码。然而很多使用 Go 语言的工程师其实对接口的了解都非常有限,对于它的底层实现也一无所知,这其实成为了我们使用和理解 interface
的最大阻碍。
在这一节中,我们就会介绍 Go 语言中这个重要类型 interface
的一些常见问题以及它底层的实现,包括接口的基本原理、类型断言和转换的过程以及动态派发机制,帮助各位 Go 语言开发者更好地理解 interface
类型。
__1. 概述
接口是计算机系统中多个组件共享的边界,通过定义接口,具体的实现可以和调用方完全分离,其本质就是引入一个中间层对不同的模块进行解耦,上层的模块就不需要依赖某一个具体的实现,而是只需要依赖一个定义好的接口,这种面向接口的编程方式有着非常强大的生命力,无论是从框架还是操作系统中我们都能够看到使用接口带来的便利。
POSIX(可移植操作系统接口)就是一个典型的例子,它定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,没有使用操作系统或者 CPU 架构特定功能的计算机软件就可以无需修改在不同操作系统上运行。
Go 语言中的接口 interface
不仅是一组方法,还是一种内置的类型,我们在这一节中将介绍接口相关的几个基本概念以及常见的问题,为我们之后介绍它的实现原理进行一些简单的铺垫,帮助各位读者更好地理解 Go 语言中的接口类型。
__1.1. 方法
很多面向对象语言其实也有接口这一概念,例如 Java 中也有 interface
接口,这里的接口其实不止包含一组方法的签名,还可以定义一些变量,这些变量可以直接在实现接口的类中使用:
上述 Java 代码就定义了一个必须要实现的方法 sayHello
和一个会被注入到实现类中的变量 hello
,下面的 MyInterfaceImpl
类型就是一个 MyInterface
的实现:
Java 中的类都必须要通过上述方式显式地声明实现的接口并实现其中的方法,然而 Go 语言中的接口相比之下就简单了很多。
如果想在 Go 语言中定义一个接口,我们也需要使用 interface
关键字,但是在接口中我们只能定义需要实现的方法,而不能包含任何的变量或者字段,所以一个常见的 Go 语言接口是这样的:
任意类型只要实现了 Error
方法其实就实现了 error
接口,然而在 Go 语言中所有接口的实现都是隐式的,我们只需要实现 Error
就相当于隐式的实现了 error
接口:
当我们使用上述 RPCError
结构体时,其实并不关心它实现了哪些接口,Go 语言只会在传递或者返回参数以及变量赋值时才会对某个结构是否实现接口进行检查,我们可以简单举几个例子来演示发生接口类型检查的时机:
Go 语言会 编译期间 对上述代码进行类型检查,这里总共触发了三次类型检查:
将
*RPCError
类型的变量赋值给error
类型的变量rpcErr
;将
*RPCError
类型的变量rpcErr
传递给签名中参数类型为error
的AsErr
函数;将
*RPCError
类型的变量从函数签名的返回值类型为error
的NewRPCError
函数中返回;
从编译器类型检查的过程来看,编译器仅在需要时才会对类型进行检查,类型实现接口时其实也只需要隐式的实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。
__1.2. 类型
接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中其实有两种略微不同的接口,其中一种是带有一组方法的接口,另一种是不带有任何方法的 interface{}
类型:
在 Go 语言的源代码中,我们将第一种接口表示成 iface
结构体,将第二种不需要任何方法的接口表示成 eface
结构体,两种不同的接口虽然都使用 interface
进行声明,但是后者由于在 Go 语言中非常常见,所以在实现时也将它实现成了一种特殊的类型。
需要注意的是,与 C 语言中的 void *
不同,interface{}
类型并不表示任意类型,interface{}
类型的变量在运行期间的类型只是 interface{}
。
上述函数也不接受任意类型的参数,而是只接受 interface{}
类型的值,在调用 Print
函数时其实会对参数 v
进行类型转换,将原来的 Test
类型转换成 interface{}
类型,我们会在这一节的后面介绍类型转换发生的过程和原理。
__1.3. 指针和接口
Go 语言是一个有指针类型的编程语言,当指针和接口同时出现时就会遇到一些让人困惑或者感到诡异的问题,接口在定义一组方法时其实没有对实现的接受者做限制,所以我们其实会在一个类型上看到以下两种不同的实现方式:
这两种不同的实现不可以同时存在,Go 语言的编译器会在遇到这种情况时报错
method redeclared
。
对于 Cat
结构体来说,它不仅在实现时可以选择将接受者的类型 — 结构体和结构体指针,在初始化时也可以初始化成结构体或者指针:
我们会在这时得到两个不同维度的『编码方式』,实现接口的接受者类型和初始化时返回的类型,这两个维度总共会产生如下的四种不同情况:
在这四种不同情况中,只有一种会发生编译不通过的问题,也就是方法接受者是指针类型,变量初始化成结构体类型,其他的三种情况都可以正常通过编译,下面两种情况能够通过编译其实非常好理解:
方法接受者和初始化类型都是结构体;
方法接受者和初始化类型都是结构体指针;
而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,也就是方法的接受者是结构体,而初始化的变量是指针类型:
上述代码中的 Cat
结构体指针其实是能够直接调用 Walk
和 Quack
方法的,因为作为指针它能够隐式获取到对应的底层结构体,我们可以将这里的调用理解成 C 语言中的 d->Walk()
和 d->Speak()
,先获取底层结构体再执行对应的方法。
如果我们将上述代码中的接受者和初始化时的类型进行交换,就会发生编译不通过的问题:
编译器会提醒我们『Cat
类型并没有实现 Duck
接口,Quack
方法的接受者是指针』,这两种情况其实非常让人困惑,尤其是对于刚刚接触 Go 语言接口的开发者,想要理解这个问题,首先要知道 Go 语言在进行 参数传递 时都是值传递的。
当代码中的变量是 Cat{}
时,调用函数其实会对参数进行复制,也就是当前函数会接受一个新的 Cat{}
变量,由于方法的参数是 *Cat
,而编译器没有办法根据结构体找到一个唯一的指针,所以编译器会报错;当代码中的变量是 &Cat{}
时,在方法调用的过程中也会发生值的拷贝,创建一个新的 Cat
指针,这个指针能够指向一个确定的结构体,所以编译器会隐式的对变量解引用(dereference)获取指针指向的结构体完成方法的正常调用。
__1.4. nil 和 non-nil
我们可以再通过一个例子理解『Go 语言的接口类型不是任意类型』这一句话,下面的代码在 main
函数中初始化了一个 *TestStruct
结构体指针,由于指针的零值是 nil
,所以变量 s
在初始化之后也是 nil
:
但是当我们将 s
变量传入 NilOrNot
时,该方法却打印出了 non-nil
字符串,这主要是因为调用 NilOrNot
函数时其实会发生隐式的类型转换,变量 nil
会被转换成 interface{}
类型,interface{}
类型是一个结构体,它除了包含 nil
变量之外还包含变量的类型信息,也就是 TestStruct
,所以在这里会打印出 non-nil
,我们会在接下来详细介绍结构的实现原理。
__2. 实现原理
相信通过上一节的内容,我们已经对 Go 语言中的接口有了一定的了解,接下来就会从 Golang 的源代码和汇编指令层面介绍接口的底层数据结构、类型转换、动态派发等过程的实现原理。
__2.1. 数据结构
在上一节中其实介绍过 Go 语言中的接口类型会根据『是否包含一组方法』被分成两种不同的类型,包含方法的接口被实现成 iface
结构体,不包含任何方法的 interface{}
类型在底层其实就是 eface
结构体,我们先来看 eface
结构体的组成:
由于 interface{}
类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针,从这里的结构我们也就能够推断出 — 任意的类型都可以转换成 interface{}
类型。
另一个用于表示接口 interface
类型的结构体就是 iface
了,在这个结构体中也有指向原始数据的指针 data
,在这个结构体中更重要的其实是 itab
类型的 tab
字段。
__itab
结构体
itab
结构体是接口类型的核心组成部分,每一个 itab
都占 32 字节的空间,其中包含的 _type
字段是 Go 语言类型在运行时的内部结构,每一个 _type
结构体中都包含了类型的大小、对齐以及哈希等信息:
除此之外 itab
结构体中还包含另一个表示接口类型的 interfacetype
字段,它就是一个对 _type
类型的简单封装。
hash
字段其实是对 _type.hash
的拷贝,它会在从 interface
到具体类型的切换时用于快速判断目标类型和接口中类型是否一致;最后的 fun
数组其实是一个动态大小的数组,如果如果当前数组中内容为空就表示 _type
没有实现 inter
接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的。
___type
结构体
_type
类型表示的就是 Go 语言中类型的运行时表示,下面其实就是类型在运行期间的结构,我们可以看到其中包含了非常多的原信息 — 类型的大小、哈希、对齐以及种类等字段。
我们在这里其实也还需要简单了解一下 _type
的结构,这一节中后面的内容将详细介绍该结构体中一些字段的作用和意义。
__2.2. 基本原理
既然我们已经对接口在运行时的数据结构已经有所了解,接下来我们就会通过几个例子来深入理解接口类型是如何初始化和传递的,我们会分别介绍在实现接口时使用指针类型和结构体类型的区别。
这两种不同类型的接口实现方式其实会导致 Go 语言编译器底层生成的汇编代码不同,在具体的执行过程上也会有一些差异,接下来就会介绍接口常见操作的基本原理。
__指针类型
首先我们重新回到这一节开头提到的 Duck
接口的例子,简单修改一下前面提到的这段代码,删除 Duck
接口中的 Walk
方法并将 Quack
方法设置成禁止内联编译:
将上述代码编译成汇编语言之后,我们删掉其中一些对理解接口原理无用的指令,只保留与赋值语句 var c Duck = &Cat{Name: "grooming"}
相关的代码,先来了解一下结构体指针被装到接口变量 c
的过程:
这段代码的第一部分其实就是对 Cat
结构体的初始化,我们直接展示上述汇编语言对应的伪代码,帮助我们更快地理解这个过程:
获取
Cat
结构体类型指针并将其作为参数放到栈SP
上;通过
CALL
指定调用runtime.newobject
函数,这个函数会以Cat
结构体类型指针作为入参,分配一片新的内存空间并将指向这片内存空间的指针返回到SP+8
上;SP+8
现在存储了一个指向Cat
结构体的指针,我们将栈上的指针拷贝到寄存器DI
上方便操作;由于
Cat
中只包含一个字符串类型的Name
变量,所以在这里会分别将字符串地址&"grooming"
和字符串长度8
设置到结构体上,最后三行汇编指令的作用就等价于cat.Name = "grooming"
;
字符串在运行时的表示其实就是指针加上字符串长度,在前面的章节 字符串 已经介绍过它的底层表示和实现原理,但是我们这里要看一下初始化之后的 Cat
结构体在内存中的表示是什么样的:
每一个 Cat
结构体在内存中的大小都是 16 字节,这是因为其中只包含一个字符串字段,而字符串在 Go 语言中总共占 16 字节,初始化 Cat
结构体之后就进入了将 *Cat
转换成 Duck
类型的过程了:
Duck
作为一个包含方法的接口,它在底层就会使用 iface
结构体进行表示,iface
结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab
字段,我们已经通过上一段代码在栈上的 SP+8
初始化了 Cat
结构体指针,这段代码其实只是将编译期间生成的 itab
结构体指针复制到 SP
上:
我们会发现 SP
和 SP+8
总共 16 个字节共同组成了 iface
结构体,栈上的这个 iface
结构体也就是 Quack
方法的第一个入参。
到这里已经完成了对 Cat
指针转换成 iface
结构体并调用 Quack
方法过程的分析,我们再重新回顾一下整个调用过程的汇编代码和伪代码,其中的大部分内容都是对 Cat
指针和 iface
的初始化,调用 Quack
方法时其实也只执行了一个汇编指令,调用的过程也没有经过动态派发的过程,这其实就是 Go 语言编译器帮我们做的优化了,我们会在后面详细介绍动态派发的过程。
__结构体类型
我们将上一小节中的代码稍作修改 — 使用结构体类型实现 Quack
方法并在初始化变量时也使用结构体类型:
编译上述的代码其实会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不会影响具体的执行过程:
如果我们在初始化变量时使用指针类型
&Cat{Name: "grooming"}
也能够通过编译,不过生成的汇编代码和上一节中的几乎完全相同,都会通过runtime.newobject
创建新的Cat
结构体指针并设置它的变量,在最后也会使用同样的方式调用Quack
方法,所以这里也就不做额外的分析了。
我们先来看一下上述汇编代码中用于初始化 Cat
结构体的部分:
这段汇编指令的工作其实与上一节中的差不多,这里会在栈上占用 16 字节初始化 Cat
结构体,不过而上一节中的代码在堆上申请了 16 字节的内存空间,栈上只是一个指向 Cat
结构体的指针。
初始化了结构体就进入了类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck
的地址和指向 Cat
结构体的指针一并传入 runtime.convT2I
函数:
这个函数会获取 itab
中存储的类型,根据类型的大小申请一片内存空间并将 elem
指针中的内容拷贝到目标的内存空间中:
convT2I
在函数的最后会返回一个 iface
结构体,其中包含 itab
指针和拷贝的 Cat
结构体,在当前函数返回值之后,main
函数的栈上就会包含以下的数据:
SP
和 SP+8
中存储的 itab
和 Cat
指针就是 runtime.convT2I
函数的入参,这个函数的返回值位于 SP+16
,是一个占 16 字节内存空间的 iface
结构体,SP+32
存储的就是在栈上的 Cat
结构体,它会在 runtime.convT2I
执行的过程中被拷贝到堆上。
在最后,我们会通过以下的操作调用 Cat
实现的接口方法 Quack()
:
这几个汇编指令中的大多数还是非常好理解的,其中的 MOVQ 24(AX), AX
应该是最重要的指令,它从 itab
结构体中取出 Cat.Quack
方法指针,作为 CALL
指令调用时的参数,第 24 字节是 itab.fun
字段开始的位置,由于 Duck
接口只包含一个方法,所以 itab.fun[0]
中存储的就是指向 Quack
的指针了。
__2.3. 类型断言
上一节主要介绍的内容其实是我们如何把某一个具体类型转换成一个接口类型,也就是 协变 的过程,而这一节主要想介绍的是如何将一个接口类型转换成具体类型,也就是从 Duck
转换回 Cat
,这也就是 逆变 的过程:
当我们编译了上述代码之后,会得到如下所示的汇编指令,这里截取了从创建结构体到执行 switch/case
结构的代码片段:
我们可以直接跳过初始化 Duck
变量的过程,从 0058
开始分析随后的汇编指令,需要注意的是 SP+8
~ SP+24
16 个字节的位置存储了 Cat
结构体,Go 语言的编译器做了一些优化,所以我们没有看到 iface
结构体的构建过程,但是对于这里要介绍的类型断言和转换其实没有太多的影响:
switch/case
语句生成的汇编指令会将目标类型的 hash
与接口变量中的 itab.hash
进行比较,如果两者完全相等就会认为接口变量的具体类型是 Cat
,这时就会进入 0080
所在的分支,开始类型转换的过程,我们会获取 SP+8
存储的 Cat
结构体指针、将其拷贝到 SP
上、调用 Quack
方法,最终恢复当前函数的堆栈后返回,不过如果接口中存在的具体类型不是 Cat
,就会直接恢复栈指针并返回到调用方。
当我们使用如下所示的代码,将 Cat
结构体转换成 interface{}
空接口类型并通过 switch/case
语句进行类型的断言时,如果不关闭 Go 语言编译器的优化选项,生成的代码是差不多的,它们都会省略从 Cat
结构体转换到 iface
和 eface
的过程:
如果我们不使用编译器优化,这两者的区别也只是分别从 iface.tab._type
和 eface._type
中获取当前接口变量的类型,汇编指令仍然会通过类型的 hash
对它们进行比较。
__2.4. 动态派发
动态派发是在运行期间选择具体的多态操作执行的过程,它其实是一种在面向对象语言中非常常见的特性,但是 Go 语言中接口的引入其实也为它带来了动态派发这一特性,也就是对于一个接口类型的方法调用,我们会在运行期间决定具体调用该方法的哪个实现。
假如我们有以下的代码,主函数中调用了两次 Quack
方法,其中第一次调用是以 Duck
接口类型的方式进行调用的,这个调用的过程需要经过运行时的动态派发,而第二次调用是以 *Cat
类型的身份调用该方法的,最终调用的函数在编译期间就已经确认了:
在这里我们需要使用 -N
的编译参数指定编译器不要优化生成的汇编指令,如果不指定这个参数,编译器会对很多能够推测出来的结果进行优化,与我们理解的执行过程会有一些偏差,例如:
由于接口类型中的
tab
参数并没有被使用,所以优化从Cat
转换到Duck
接口类型的一些编译指令;由于变量的类型是确定的,所以删除从
Duck
接口类型转换到*Cat
具体类型时可能会发生panic
的分支;…
在具体分析调用 Quack
方法的两种姿势之前,我们首先要先了解 Cat
结构体究竟是如何初始化的,以及初始化完成后的栈上有哪些数据:
这段代码的初始化过程其实和上两节中的初始化过程没有太多的差别,它先初始化了 Cat
结构体指针,再将 Cat
和 tab
打包成了一个 iface
类型的结构体,我们直接来看初始化过程结束之后的堆栈数据:
SP
是运行时方法 runtime.newobject
的参数,而 SP+8
是该方法的返回值,即指向刚初始化的 Cat
结构体指针,SP+32
、SP+40
和 SP+56
是对 SP+8
的拷贝,这两个指针都会指向栈上的 Cat
结构体,SP+56
的 Cat
结构体指针和 SP+48
的 tab
结构体指针共同构成了接口变量 iface
结构体。
接下来我们进入 c.Quack()
语句展开后的汇编指令,下面的代码从接口变量中获取了 tab.func[0]
,其中保存了 Cat.Quack
的方法指针,接口变量在中的数据会被拷贝到 SP
上,而方法指针会被拷贝到寄存器中并通过汇编指令 CALL
触发:
另一个调用 Quack
方法的语句 c.(*Cat).Quack()
生成的汇编指令看起来会有一些复杂,但是其中前半部分都是在做类型的转换,将接口类型转换成 *Cat
类型,只有最后的两行代码是函数调用相关的指令:
这两行代码将 Cat
指针拷贝到了 SP
上并直接调用 Quack
方法,对于这一次的方法调用,待执行的函数其实在编译期间就已经确定了,所以运行期间就不需要再动态查找方法地实现:
两次方法调用的汇编指令差异其实就是动态派发带来的额外开销,我们需要了解一下这些额外的编译指令对性能造成的影响。
__性能测试
下面代码中的两个方法 BenchmarkDirectCall
和 BenchmarkDynamicDispatch
分别会调用结构体方法和接口方法,我们以直接调用作为基准看一下动态派发带来了多少额外的性能开销:
直接运行下面的命令,使用 1 个 CPU 运行上述代码,其中的每一个基准测试都会被执行 3 次:
如果是直接调用结构体的方法,三次基准测试的平均值其实在 ~3.03ns
左右(关闭编译器优化),而使用动态派发的方式会消耗 ~3.58ns
,动态派发生成的指令会带来 ~18%
左右的额外性能开销。
这些性能开销在一个复杂的系统中其实不会带来太多的性能影响,因为一个项目中不可能只存在动态派发的调用,所以 ~18%
的额外开销相比使用接口带来的好处其实没有太大的影响,除此之外如果我们开启默认的编译器优化之后,动态派发的额外开销会降低至 ~5%
左右,对应用性能的整体影响就更小了。
上面的性能测试其实是在实现和调用接口方法的都是结构体指针,当我们将结构体指针换成结构体又会有比较大的差异:
当我们重新执行相同的命令时,能得到如下所示的结果:
直接调用方法需要消耗时间的平均值和使用指针实现接口时差不多,大概在 ~3.09ns
左右,而使用动态派发调用方法却需要 ~6.98ns
相比直接调用额外消耗了 ~125%
的时间,同时从生成的汇编指令我们也能看出后者的额外开销会高很多。
:-------:|:-------:|:-------:
Pointer | ~3.03ns | ~3.58ns
Struct | ~3.09ns | ~6.98ns
最后我们重新看一下调用和实现方式的差异组成的耗时矩阵,从这个矩阵我们可以看到使用结构体来实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也是我们在使用接口时应当尽量避免的 — 不要使用结构体类型实现接口。
这其实不只是接口的问题,由于 Go 语言的函数调用是传值的,所以会发生参数的拷贝,对于一个大的结构体,参数的拷贝会消耗非常多的资源,我们应该使用指针来传递一些大的结构。
__3. 总结
重新回顾一下这一节介绍的内容,我们在开头简单介绍了不同编程语言接口实现上的区别以及在使用时的一些常见问题,例如使用不同类型实现接口带来的差异、函数调用时发生的隐式类型转换,随后我们介绍了接口的基本原理、类型断言和转换的过程以及接口相关方法调用时的动态派发机制,这对我们理解 Go 语言的内部实现有着非常大的帮助。
__4. Reference
__5. 其他
__5.1. 关于图片和转载
文章未经许可均禁止转载,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。
**本文转载自 Draveness 技术博客。
原文链接:https://draveness.me/golang/basic/golang-interface.html
评论