接口是 Go 语言的重要组成部分,它在 Go 语言中通过一组方法指定了一个对象的行为,接口 interface 的引入能够让我们在 Go 语言更好地组织并写出易于测试的代码。然而很多使用 Go 语言的工程师其实对接口的了解都非常有限,对于它的底层实现也一无所知,这其实成为了我们使用和理解 interface 的最大阻碍。
在这一节中,我们就会介绍 Go 语言中这个重要类型 interface 的一些常见问题以及它底层的实现,包括接口的基本原理、类型断言和转换的过程以及动态派发机制,帮助各位 Go 语言开发者更好地理解 interface 类型。
概述
接口是计算机系统中多个组件共享的边界,通过定义接口,具体的实现可以和调用方完全分离,其本质就是引入一个中间层对不同的模块进行解耦,上层的模块就不需要依赖某一个具体的实现,而是只需要依赖一个定义好的接口,这种面向接口的编程方式有着非常强大的生命力,无论是从框架还是操作系统中我们都能够看到使用接口带来的便利。
POSIX(可移植操作系统接口)就是一个典型的例子,它定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,没有使用操作系统或者 CPU 架构特定功能的计算机软件就可以无需修改在不同操作系统上运行。
Go 语言中的接口 interface 不仅是一组方法,还是一种内置的类型,我们在这一节中将介绍接口相关的几个基本概念以及常见的问题,为我们之后介绍它的实现原理进行一些简单的铺垫,帮助各位读者更好地理解 Go 语言中的接口类型。
方法
很多面向对象语言其实也有接口这一概念,例如 Java 中也有 interface 接口,这里的接口其实不止包含一组方法的签名,还可以定义一些变量,这些变量可以直接在实现接口的类中使用:
public interface MyInterface {
public String hello = "Hello";
public void sayHello();
}
上述 Java 代码就定义了一个必须要实现的方法 sayHello 和一个会被注入到实现类中的变量 hello ,下面的 MyInterfaceImpl 类型就是一个 MyInterface 的实现:
public class MyInterfaceImpl implements MyInterface {
public void sayHello() {
System.out.println(MyInterface.hello);
}
}
Java 中的类都必须要通过上述方式显式地声明实现的接口并实现其中的方法,然而 Go 语言中的接口相比之下就简单了很多。
如果想在 Go 语言中定义一个接口,我们也需要使用 interface 关键字,但是在接口中我们只能定义需要实现的方法,而不能包含任何的变量或者字段,所以一个常见的 Go 语言接口是这样的:
type error interface {
Error() string
}
任意类型只要实现了 Error 方法其实就实现了 error 接口,然而在 Go 语言中所有接口的实现都是隐式的,我们只需要实现 Error 就相当于隐式的实现了 error 接口:
type RPCError struct {
Code int64
Message string
}
func (e *RPCError) Error() string {
return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}
当我们使用上述 RPCError 结构体时,其实并不关心它实现了哪些接口,Go 语言只会在传递或者返回参数以及变量赋值时才会对某个结构是否实现接口进行检查,我们可以简单举几个例子来演示发生接口类型检查的时机:
func main() {
var rpcErr error = NewRPCError(400, "unknown err")
Go 语言会 编译期间 对上述代码进行类型检查,这里总共触发了三次类型检查:
- 将
*RPCError 类型的变量赋值给 error 类型的变量 rpcErr ;
- 将
*RPCError 类型的变量 rpcErr 传递给签名中参数类型为 error 的 AsErr 函数;
- 将
*RPCError 类型的变量从函数签名的返回值类型为 error 的 NewRPCError 函数中返回;
从编译器类型检查的过程来看,编译器仅在需要时才会对类型进行检查,类型实现接口时其实也只需要隐式的实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。
类型
接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中其实有两种略微不同的接口,其中一种是带有一组方法的接口,另一种是不带有任何方法的 interface{} 类型:
在 Go 语言的源代码中,我们将第一种接口表示成 iface 结构体,将第二种不需要任何方法的接口表示成 eface 结构体,两种不同的接口虽然都使用 interface 进行声明,但是后者由于在 Go 语言中非常常见,所以在实现时也将它实现成了一种特殊的类型。
需要注意的是,与 C 语言中的 void * 不同,interface{} 类型并不表示任意类型,interface{} 类型的变量在运行期间的类型只是 interface{} 。
package main
func main() {
type Test struct{}
v := Test{}
Print(v)
}
func Print(v interface{}) {
println(v)
}
上述函数也不接受任意类型的参数,而是只接受 interface{} 类型的值,在调用 Print 函数时其实会对参数 v 进行类型转换,将原来的 Test 类型转换成 interface{} 类型,我们会在这一节的后面介绍类型转换发生的过程和原理。
指针和接口
Go 语言是一个有指针类型的编程语言,当指针和接口同时出现时就会遇到一些让人困惑或者感到诡异的问题,接口在定义一组方法时其实没有对实现的接受者做限制,所以我们其实会在一个类型上看到以下两种不同的实现方式:
这两种不同的实现不可以同时存在,Go 语言的编译器会在遇到这种情况时报错 method redeclared 。
对于 Cat 结构体来说,它不仅在实现时可以选择将接受者的类型 — 结构体和结构体指针,在初始化时也可以初始化成结构体或者指针:
我们会在这时得到两个不同维度的『编码方式』,实现接口的接受者类型和初始化时返回的类型,这两个维度总共会产生如下的四种不同情况:
在这四种不同情况中,只有一种会发生编译不通过的问题,也就是方法接受者是指针类型,变量初始化成结构体类型,其他的三种情况都可以正常通过编译,下面两种情况能够通过编译其实非常好理解:
- 方法接受者和初始化类型都是结构体;
- 方法接受者和初始化类型都是结构体指针;
而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,也就是方法的接受者是结构体,而初始化的变量是指针类型:
type Cat struct{}
func (c Cat) Walk() {
fmt.Println("catwalk")
}
func (c Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = &Cat{}
c.Walk()
c.Quack()
}
上述代码中的 Cat 结构体指针其实是能够直接调用 Walk 和 Quack 方法的,因为作为指针它能够隐式获取到对应的底层结构体,我们可以将这里的调用理解成 C 语言中的 d->Walk() 和 d->Speak() ,先获取底层结构体再执行对应的方法。
如果我们将上述代码中的接受者和初始化时的类型进行交换,就会发生编译不通过的问题:
type Duck interface {
Walk()
Quack()
}
type Cat struct{}
func (c *Cat) Walk() {
fmt.Println("catwalk")
}
func (c *Cat) Quack() {
fmt.Println("meow")
}
func main() {
var c Duck = Cat{}
c.Walk()
c.Quack()
}
$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
Cat does not implement Duck (Quack method has pointer receiver)
编译器会提醒我们『Cat 类型并没有实现 Duck 接口,Quack 方法的接受者是指针』,这两种情况其实非常让人困惑,尤其是对于刚刚接触 Go 语言接口的开发者,想要理解这个问题,首先要知道 Go 语言在进行 参数传递 时都是值传递的。
当代码中的变量是 Cat{} 时,调用函数其实会对参数进行复制,也就是当前函数会接受一个新的 Cat{} 变量,由于方法的参数是 *Cat ,而编译器没有办法根据结构体找到一个唯一的指针,所以编译器会报错;当代码中的变量是 &Cat{} 时,在方法调用的过程中也会发生值的拷贝,创建一个新的 Cat 指针,这个指针能够指向一个确定的结构体,所以编译器会隐式的对变量解引用(dereference)获取指针指向的结构体完成方法的正常调用。
nil 和 non-nil
我们可以再通过一个例子理解『Go 语言的接口类型不是任意类型』这一句话,下面的代码在 main 函数中初始化了一个 *TestStruct 结构体指针,由于指针的零值是 nil ,所以变量 s 在初始化之后也是 nil :
package main
type TestStruct struct{}
func NilOrNot(v interface{}) {
if v == nil {
println("nil")
} else {
println("non-nil")
}
}
func main() {
var s *TestStruct
NilOrNot(s)
}
$ go run main.go
non-nil
但是当我们将 s 变量传入 NilOrNot 时,该方法却打印出了 non-nil 字符串,这主要是因为调用 NilOrNot 函数时其实会发生隐式的类型转换,变量 nil 会被转换成 interface{} 类型,interface{} 类型是一个结构体,它除了包含 nil 变量之外还包含变量的类型信息,也就是 TestStruct ,所以在这里会打印出 non-nil ,我们会在接下来详细介绍结构的实现原理。
实现原理
相信通过上一节的内容,我们已经对 Go 语言中的接口有了一定的了解,接下来就会从 Golang 的源代码和汇编指令层面介绍接口的底层数据结构、类型转换、动态派发等过程的实现原理。
数据结构
在上一节中其实介绍过 Go 语言中的接口类型会根据『是否包含一组方法』被分成两种不同的类型,包含方法的接口被实现成 iface 结构体,不包含任何方法的 interface{} 类型在底层其实就是 eface 结构体,我们先来看 eface 结构体的组成:
type eface struct {
由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针,从这里的结构我们也就能够推断出 — 任意的类型都可以转换成 interface{} 类型。
type iface struct {
另一个用于表示接口 interface 类型的结构体就是 iface 了,在这个结构体中也有指向原始数据的指针 data ,在这个结构体中更重要的其实是 itab 类型的 tab 字段。
itab 结构体
itab 结构体是接口类型的核心组成部分,每一个 itab 都占 32 字节的空间,其中包含的 _type 字段是 Go 语言类型在运行时的内部结构,每一个 _type 结构体中都包含了类型的大小、对齐以及哈希等信息:
type itab struct {
除此之外 itab 结构体中还包含另一个表示接口类型的 interfacetype 字段,它就是一个对 _type 类型的简单封装。
hash 字段其实是对 _type.hash 的拷贝,它会在从 interface 到具体类型的切换时用于快速判断目标类型和接口中类型是否一致;最后的 fun 数组其实是一个动态大小的数组,如果如果当前数组中内容为空就表示 _type 没有实现 inter 接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的。
_type 结构体
_type 类型表示的就是 Go 语言中类型的运行时表示,下面其实就是类型在运行期间的结构,我们可以看到其中包含了非常多的原信息 — 类型的大小、哈希、对齐以及种类等字段。
type _type struct {
size uintptr
ptrdata uintptr
我们在这里其实也还需要简单了解一下 _type 的结构,这一节中后面的内容将详细介绍该结构体中一些字段的作用和意义。
基本原理
既然我们已经对接口在运行时的数据结构已经有所了解,接下来我们就会通过几个例子来深入理解接口类型是如何初始化和传递的,我们会分别介绍在实现接口时使用指针类型和结构体类型的区别。
这两种不同类型的接口实现方式其实会导致 Go 语言编译器底层生成的汇编代码不同,在具体的执行过程上也会有一些差异,接下来就会介绍接口常见操作的基本原理。
指针类型
首先我们重新回到这一节开头提到的 Duck 接口的例子,简单修改一下前面提到的这段代码,删除 Duck 接口中的 Walk 方法并将 Quack 方法设置成禁止内联编译:
package main
type Duck interface {
Quack()
}
type Cat struct {
Name string
}
将上述代码编译成汇编语言之后,我们删掉其中一些对理解接口原理无用的指令,只保留与赋值语句 var c Duck = &Cat{Name: "grooming"} 相关的代码,先来了解一下结构体指针被装到接口变量 c 的过程:
LEAQ type."".Cat(SB), AX
MOVQ AX, (SP)
CALL runtime.newobject(SB)
MOVQ 8(SP), DI
MOVQ $8, 8(DI)
LEAQ go.string."grooming"(SB), AX
MOVQ AX, (DI)
LEAQ go.itab.*"".Cat,"".Duck(SB), AX
TESTB AL, (AX)
MOVQ DI, (SP)
这段代码的第一部分其实就是对 Cat 结构体的初始化,我们直接展示上述汇编语言对应的伪代码,帮助我们更快地理解这个过程:
LEAQ type."".Cat(SB), AX ;; AX = &type."".Cat
MOVQ AX, (SP) ;; SP = &type."".Cat
CALL runtime.newobject(SB) ;; SP + 8 = &Cat{}
MOVQ 8(SP), DI ;; DI = &Cat{}
MOVQ $8, 8(DI) ;; StringHeader(DI.Name).Len = 8
LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming"
MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"grooming"
- 获取
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 类型的过程了:
LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = *itab(go.itab.*"".Cat,"".Duck)
MOVQ DI, (SP) ;; SP = AX
CALL "".(*Cat).Quack(SB) ;; SP.Quack()
Duck 作为一个包含方法的接口,它在底层就会使用 iface 结构体进行表示,iface 结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab 字段,我们已经通过上一段代码在栈上的 SP+8 初始化了 Cat 结构体指针,这段代码其实只是将编译期间生成的 itab 结构体指针复制到 SP 上:
我们会发现 SP 和 SP+8 总共 16 个字节共同组成了 iface 结构体,栈上的这个 iface 结构体也就是 Quack 方法的第一个入参。
LEAQ type."".Cat(SB), AX ;; AX = &type."".Cat
MOVQ AX, (SP) ;; SP = &type."".Cat
CALL runtime.newobject(SB) ;; SP + 8 = &Cat{}
MOVQ 8(SP), DI ;; DI = &Cat{}
MOVQ $8, 8(DI) ;; StringHeader(DI.Name).Len = 8
LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming"
MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"grooming"
LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = &(go.itab.*"".Cat,"".Duck)
MOVQ DI, (SP) ;; SP = AX
CALL "".(*Cat).Quack(SB) ;; SP.Quack()
到这里已经完成了对 Cat 指针转换成 iface 结构体并调用 Quack 方法过程的分析,我们再重新回顾一下整个调用过程的汇编代码和伪代码,其中的大部分内容都是对 Cat 指针和 iface 的初始化,调用 Quack 方法时其实也只执行了一个汇编指令,调用的过程也没有经过动态派发的过程,这其实就是 Go 语言编译器帮我们做的优化了,我们会在后面详细介绍动态派发的过程。
结构体类型
我们将上一小节中的代码稍作修改 — 使用结构体类型实现 Quack 方法并在初始化变量时也使用结构体类型:
package main
type Duck interface {
Quack()
}
type Cat struct {
Name string
}
编译上述的代码其实会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不会影响具体的执行过程:
XORPS X0, X0
MOVUPS X0, ""..autotmp_1+32(SP)
LEAQ go.string."grooming"(SB), AX
MOVQ AX, ""..autotmp_1+32(SP)
MOVQ $8, ""..autotmp_1+40(SP)
LEAQ go.itab."".Cat,"".Duck(SB), AX
MOVQ AX, (SP)
LEAQ ""..autotmp_1+32(SP), AX
MOVQ AX, 8(SP)
CALL runtime.convT2I(SB)
MOVQ 16(SP), AX
MOVQ 24(SP), CX
MOVQ 24(AX), AX
MOVQ CX, (SP)
CALL AX
如果我们在初始化变量时使用指针类型 &Cat{Name: "grooming"} 也能够通过编译,不过生成的汇编代码和上一节中的几乎完全相同,都会通过 runtime.newobject 创建新的 Cat 结构体指针并设置它的变量,在最后也会使用同样的方式调用 Quack 方法,所以这里也就不做额外的分析了。
我们先来看一下上述汇编代码中用于初始化 Cat 结构体的部分:
XORPS X0, X0 ;; X0 = 0
MOVUPS X0, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = 0
LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming"
MOVQ AX, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = AX
MOVQ $8, ""..autotmp_1+40(SP) ;; StringHeader(SP+32).Len =8
这段汇编指令的工作其实与上一节中的差不多,这里会在栈上占用 16 字节初始化 Cat 结构体,不过而上一节中的代码在堆上申请了 16 字节的内存空间,栈上只是一个指向 Cat 结构体的指针。
初始化了结构体就进入了类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck 的地址和指向 Cat 结构体的指针一并传入 runtime.convT2I 函数:
LEAQ go.itab."".Cat,"".Duck(SB), AX ;; AX = &(go.itab."".Cat,"".Duck)
MOVQ AX, (SP) ;; SP = AX
LEAQ ""..autotmp_1+32(SP), AX ;; AX = &(SP+32) = &Cat{Name: "grooming"}
MOVQ AX, 8(SP) ;; SP + 8 = AX
CALL runtime.convT2I(SB) ;; runtime.convT2I(SP, SP+8)
|
请发表评论