浅入浅出 Go 语言接口的原理

阅读数:20 2019 年 12 月 3 日 15:10

浅入浅出 Go 语言接口的原理

2.2 接口

接口是 Go 语言的重要组成部分,它在 Go 语言中通过一组方法指定了一个对象的行为,接口 interface 的引入能够让我们在 Go 语言更好地组织并写出易于测试的代码。然而很多使用 Go 语言的工程师其实对接口的了解都非常有限,对于它的底层实现也一无所知,这其实成为了我们使用和理解 interface 的最大阻碍。

在这一节中,我们就会介绍 Go 语言中这个重要类型 interface 的一些常见问题以及它底层的实现,包括接口的基本原理、类型断言和转换的过程以及动态派发机制,帮助各位 Go 语言开发者更好地理解 interface 类型。

__ 1. 概述

接口是计算机系统中多个组件共享的边界,通过定义接口,具体的实现可以和调用方完全分离,其本质就是引入一个中间层对不同的模块进行解耦,上层的模块就不需要依赖某一个具体的实现,而是只需要依赖一个定义好的接口,这种面向接口的编程方式有着非常强大的生命力,无论是从框架还是操作系统中我们都能够看到使用接口带来的便利。

浅入浅出 Go 语言接口的原理

POSIX(可移植操作系统接口)就是一个典型的例子,它定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,没有使用操作系统或者 CPU 架构特定功能的计算机软件就可以无需修改在不同操作系统上运行。

Go 语言中的接口 interface 不仅是一组方法,还是一种内置的类型,我们在这一节中将介绍接口相关的几个基本概念以及常见的问题,为我们之后介绍它的实现原理进行一些简单的铺垫,帮助各位读者更好地理解 Go 语言中的接口类型。

__ 1.1. 方法

很多面向对象语言其实也有接口这一概念,例如 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") // typecheck1
err := AsErr(rpcErr) // typecheck2
println(err)
}
func NewRPCError(code int64, msg string) error {
return &RPCError{ // typecheck3
Code: code,
Message: msg,
}
}
func AsErr(err error) error {
return err
}

Go 语言会 编译期间 对上述代码进行类型检查,这里总共触发了三次类型检查:

  1. *RPCError 类型的变量赋值给 error 类型的变量 rpcErr
  2. *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 errorAsErr 函数;
  3. *RPCError 类型的变量从函数签名的返回值类型为 errorNewRPCError 函数中返回;

从编译器类型检查的过程来看,编译器仅在需要时才会对类型进行检查,类型实现接口时其实也只需要隐式的实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。

__ 1.2. 类型

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中其实有两种略微不同的接口,其中一种是带有一组方法的接口,另一种是不带有任何方法的 interface{} 类型:

浅入浅出 Go 语言接口的原理

在 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{} 类型,我们会在这一节的后面介绍类型转换发生的过程和原理。

__ 1.3. 指针和接口

Go 语言是一个有指针类型的编程语言,当指针和接口同时出现时就会遇到一些让人困惑或者感到诡异的问题,接口在定义一组方法时其实没有对实现的接受者做限制,所以我们其实会在一个类型上看到以下两种不同的实现方式:

浅入浅出 Go 语言接口的原理

这两种不同的实现不可以同时存在,Go 语言的编译器会在遇到这种情况时报错 method redeclared

对于 Cat 结构体来说,它不仅在实现时可以选择将接受者的类型 — 结构体和结构体指针,在初始化时也可以初始化成结构体或者指针:

浅入浅出 Go 语言接口的原理

我们会在这时得到两个不同维度的『编码方式』,实现接口的接受者类型和初始化时返回的类型,这两个维度总共会产生如下的四种不同情况:

浅入浅出 Go 语言接口的原理

在这四种不同情况中,只有一种会发生编译不通过的问题,也就是方法接受者是指针类型,变量初始化成结构体类型,其他的三种情况都可以正常通过编译,下面两种情况能够通过编译其实非常好理解:

  • 方法接受者和初始化类型都是结构体;
  • 方法接受者和初始化类型都是结构体指针;

而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,也就是方法的接受者是结构体,而初始化的变量是指针类型:

复制代码
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 结构体指针其实是能够直接调用 WalkQuack 方法的,因为作为指针它能够隐式获取到对应的底层结构体,我们可以将这里的调用理解成 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 语言在进行 参数传递 时都是值传递的。

浅入浅出 Go 语言接口的原理

当代码中的变量是 Cat{} 时,调用函数其实会对参数进行复制,也就是当前函数会接受一个新的 Cat{} 变量,由于方法的参数是 *Cat,而编译器没有办法根据结构体找到一个唯一的指针,所以编译器会报错;当代码中的变量是 &Cat{} 时,在方法调用的过程中也会发生值的拷贝,创建一个新的 Cat 指针,这个指针能够指向一个确定的结构体,所以编译器会隐式的对变量解引用(dereference)获取指针指向的结构体完成方法的正常调用。

__ 1.4. 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,我们会在接下来详细介绍结构的实现原理。

__ 2. 实现原理

相信通过上一节的内容,我们已经对 Go 语言中的接口有了一定的了解,接下来就会从 Golang 的源代码和汇编指令层面介绍接口的底层数据结构、类型转换、动态派发等过程的实现原理。

__ 2.1. 数据结构

在上一节中其实介绍过 Go 语言中的接口类型会根据『是否包含一组方法』被分成两种不同的类型,包含方法的接口被实现成 iface 结构体,不包含任何方法的 interface{} 类型在底层其实就是 eface 结构体,我们先来看 eface 结构体的组成:

复制代码
type eface struct { // 16 bytes
_type *_type
data unsafe.Pointer
}

由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针,从这里的结构我们也就能够推断出 — 任意的类型都可以转换成 interface{} 类型。

复制代码
type iface struct { // 16 bytes
tab *itab
data unsafe.Pointer
}

另一个用于表示接口 interface 类型的结构体就是 iface 了,在这个结构体中也有指向原始数据的指针 data,在这个结构体中更重要的其实是 itab 类型的 tab 字段。

__ itab 结构体

itab 结构体是接口类型的核心组成部分,每一个 itab 都占 32 字节的空间,其中包含的 _type 字段是 Go 语言类型在运行时的内部结构,每一个 _type 结构体中都包含了类型的大小、对齐以及哈希等信息:

复制代码
type itab struct { // 32 bytes
inter *interfacetype
_type *_type
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

除此之外 itab 结构体中还包含另一个表示接口类型的 interfacetype 字段,它就是一个对 _type 类型的简单封装。

hash 字段其实是对 _type.hash 的拷贝,它会在从 interface 到具体类型的切换时用于快速判断目标类型和接口中类型是否一致;最后的 fun 数组其实是一个动态大小的数组,如果如果当前数组中内容为空就表示 _type 没有实现 inter 接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的。

__ _type 结构体

_type 类型表示的就是 Go 语言中类型的运行时表示,下面其实就是类型在运行期间的结构,我们可以看到其中包含了非常多的原信息 — 类型的大小、哈希、对齐以及种类等字段。

复制代码
type _type struct {
size uintptr
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte
str nameOff
ptrToThis typeOff
}

我们在这里其实也还需要简单了解一下 _type 的结构,这一节中后面的内容将详细介绍该结构体中一些字段的作用和意义。

__ 2.2. 基本原理

既然我们已经对接口在运行时的数据结构已经有所了解,接下来我们就会通过几个例子来深入理解接口类型是如何初始化和传递的,我们会分别介绍在实现接口时使用指针类型和结构体类型的区别。

这两种不同类型的接口实现方式其实会导致 Go 语言编译器底层生成的汇编代码不同,在具体的执行过程上也会有一些差异,接下来就会介绍接口常见操作的基本原理。

__ 指针类型

首先我们重新回到这一节开头提到的 Duck 接口的例子,简单修改一下前面提到的这段代码,删除 Duck 接口中的 Walk 方法并将 Quack 方法设置成禁止内联编译:

复制代码
package main
type Duck interface {
Quack()
}
type Cat struct {
Name string
}
//go:noinline
func (c *Cat) Quack() {
println(c.Name + " meow")
}
func main() {
var c Duck = &Cat{Name: "grooming"}
c.Quack()
}

将上述代码编译成汇编语言之后,我们删掉其中一些对理解接口原理无用的指令,只保留与赋值语句 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"
  1. 获取 Cat 结构体类型指针并将其作为参数放到栈 SP 上;
  2. 通过 CALL 指定调用 runtime.newobject 函数,这个函数会以 Cat 结构体类型指针作为入参,分配一片新的内存空间并将指向这片内存空间的指针返回到 SP+8 上;
  3. SP+8 现在存储了一个指向 Cat 结构体的指针,我们将栈上的指针拷贝到寄存器 DI 上方便操作;
  4. 由于 Cat 中只包含一个字符串类型的 Name 变量,所以在这里会分别将字符串地址 &"grooming" 和字符串长度 8 设置到结构体上,最后三行汇编指令的作用就等价于 cat.Name = "grooming"

字符串在运行时的表示其实就是指针加上字符串长度,在前面的章节 字符串 已经介绍过它的底层表示和实现原理,但是我们这里要看一下初始化之后的 Cat 结构体在内存中的表示是什么样的:

浅入浅出 Go 语言接口的原理

每一个 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 上:

浅入浅出 Go 语言接口的原理

我们会发现 SPSP+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 = DI = &Cat{}
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
}
//go:noinline
func (c Cat) Quack() {
println(c.Name + " meow")
}
func main() {
var c Duck = Cat{Name: "grooming"}
c.Quack()
}

编译上述的代码其实会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不会影响具体的执行过程:

复制代码
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)

这个函数会获取 itab 中存储的类型,根据类型的大小申请一片内存空间并将 elem 指针中的内容拷贝到目标的内存空间中:

复制代码
func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type
x := mallocgc(t.size, t, true)
typedmemmove(t, x, elem)
i.tab = tab
i.data = x
return
}

convT2I 在函数的最后会返回一个 iface 结构体,其中包含 itab 指针和拷贝的 Cat 结构体,在当前函数返回值之后,main 函数的栈上就会包含以下的数据:

浅入浅出 Go 语言接口的原理

SPSP+8 中存储的 itabCat 指针就是 runtime.convT2I 函数的入参,这个函数的返回值位于 SP+16,是一个占 16 字节内存空间的 iface 结构体,SP+32 存储的就是在栈上的 Cat 结构体,它会在 runtime.convT2I 执行的过程中被拷贝到堆上。

在最后,我们会通过以下的操作调用 Cat 实现的接口方法 Quack()

复制代码
MOVQ 16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck)
MOVQ 24(SP), CX ;; CX = &Cat{Name: "grooming"}
MOVQ 24(AX), AX ;; AX = AX.fun[0] = Cat.Quack
MOVQ CX, (SP) ;; SP = CX
CALL AX ;; CX.Quack()

这几个汇编指令中的大多数还是非常好理解的,其中的 MOVQ 24(AX), AX 应该是最重要的指令,它从 itab 结构体中取出 Cat.Quack 方法指针,作为 CALL 指令调用时的参数,第 24 字节是 itab.fun 字段开始的位置,由于 Duck 接口只包含一个方法,所以 itab.fun[0] 中存储的就是指向 Quack 的指针了。

__ 2.3. 类型断言

上一节主要介绍的内容其实是我们如何把某一个具体类型转换成一个接口类型,也就是 协变 的过程,而这一节主要想介绍的是如何将一个接口类型转换成具体类型,也就是从 Duck 转换回 Cat,这也就是 逆变 的过程:

复制代码
package main
type Duck interface {
Quack()
}
type Cat struct {
Name string
}
//go:noinline
func (c *Cat) Quack() {
println(c.Name + " meow")
}
func main() {
var c Duck = &Cat{Name: "grooming"}
switch c.(type) {
case *Cat:
cat := c.(*Cat)
cat.Quack()
}
}

当我们编译了上述代码之后,会得到如下所示的汇编指令,这里截取了从创建结构体到执行 switch/case 结构的代码片段:

复制代码
00000 TEXT "".main(SB), ABIInternal, $32-0
...
00029 XORPS X0, X0
00032 MOVUPS X0, ""..autotmp_4+8(SP)
00037 LEAQ go.string."grooming"(SB), AX
00044 MOVQ AX, ""..autotmp_4+8(SP)
00049 MOVQ $8, ""..autotmp_4+16(SP)
00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792
00068 JEQ 80
00070 MOVQ 24(SP), BP
00075 ADDQ $32, SP
00079 RET
00080 LEAQ ""..autotmp_4+8(SP), AX
00085 MOVQ AX, (SP)
00089 CALL "".(*Cat).Quack(SB)
00094 JMP 70

我们可以直接跳过初始化 Duck 变量的过程,从 0058 开始分析随后的汇编指令,需要注意的是 SP+8 ~ SP+24 16 个字节的位置存储了 Cat 结构体,Go 语言的编译器做了一些优化,所以我们没有看到 iface 结构体的构建过程,但是对于这里要介绍的类型断言和转换其实没有太多的影响:

复制代码
00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792
;; if (c.tab.hash != 593696792) {
00068 JEQ 80 ;;
00070 MOVQ 24(SP), BP ;; BP = SP+24
00075 ADDQ $32, SP ;; SP += 32
00079 RET ;; return
;; } else {
00080 LEAQ ""..autotmp_4+8(SP), AX ;; AX = &Cat{Name: "grooming"}
00085 MOVQ AX, (SP) ;; SP = AX
00089 CALL "".(*Cat).Quack(SB) ;; SP.Quack()
00094 JMP 70 ;; ...
;; BP = SP+24
;; SP += 32
;; return
;; }

switch/case 语句生成的汇编指令会将目标类型的 hash 与接口变量中的 itab.hash 进行比较,如果两者完全相等就会认为接口变量的具体类型是 Cat,这时就会进入 0080 所在的分支,开始类型转换的过程,我们会获取 SP+8 存储的 Cat 结构体指针、将其拷贝到 SP 上、调用 Quack 方法,最终恢复当前函数的堆栈后返回,不过如果接口中存在的具体类型不是 Cat,就会直接恢复栈指针并返回到调用方。

浅入浅出 Go 语言接口的原理

当我们使用如下所示的代码,将 Cat 结构体转换成 interface{} 空接口类型并通过 switch/case 语句进行类型的断言时,如果不关闭 Go 语言编译器的优化选项,生成的代码是差不多的,它们都会省略从 Cat 结构体转换到 ifaceeface 的过程:

复制代码
package main
type Anything interface{}
type Cat struct {
Name string
}
//go:noinline
func (c *Cat) Quack() {
println(c.Name + " meow")
}
func main() {
var c Anything = &Cat{Name: "grooming"}
switch c.(type) {
case *Cat:
cat := c.(*Cat)
cat.Quack()
}
}

如果我们不使用编译器优化,这两者的区别也只是分别从 iface.tab._typeeface._type 中获取当前接口变量的类型,汇编指令仍然会通过类型的 hash 对它们进行比较。

__ 2.4. 动态派发

动态派发是在运行期间选择具体的多态操作执行的过程,它其实是一种在面向对象语言中非常常见的特性,但是 Go 语言中接口的引入其实也为它带来了动态派发这一特性,也就是对于一个接口类型的方法调用,我们会在运行期间决定具体调用该方法的哪个实现。

假如我们有以下的代码,主函数中调用了两次 Quack 方法,其中第一次调用是以 Duck 接口类型的方式进行调用的,这个调用的过程需要经过运行时的动态派发,而第二次调用是以 *Cat 类型的身份调用该方法的,最终调用的函数在编译期间就已经确认了:

复制代码
package main
type Duck interface {
Quack()
}
type Cat struct {
Name string
}
//go:noinline
func (c *Cat) Quack() {
println(c.Name + " meow")
}
func main() {
var c Duck = &Cat{Name: "grooming"}
c.Quack()
c.(*Cat).Quack()
}

在这里我们需要使用 -N 的编译参数指定编译器不要优化生成的汇编指令,如果不指定这个参数,编译器会对很多能够推测出来的结果进行优化,与我们理解的执行过程会有一些偏差,例如:

  • 由于接口类型中的 tab 参数并没有被使用,所以优化从 Cat 转换到 Duck 接口类型的一些编译指令;
  • 由于变量的类型是确定的,所以删除从 Duck 接口类型转换到 *Cat 具体类型时可能会发生 panic 的分支;

在具体分析调用 Quack 方法的两种姿势之前,我们首先要先了解 Cat 结构体究竟是如何初始化的,以及初始化完成后的栈上有哪些数据:

复制代码
LEAQ type."".Cat(SB), AX
MOVQ AX, (SP)
CALL runtime.newobject(SB) ;; SP + 8 = new(Cat)
MOVQ 8(SP), DI ;; DI = SP + 8
MOVQ DI, ""..autotmp_2+32(SP) ;; SP + 32 = DI
MOVQ $8, 8(DI) ;; StringHeader(cat).Len = 8
LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming"
MOVQ AX, (DI) ;; StringHeader(cat).Data = AX
MOVQ ""..autotmp_2+32(SP), AX ;; AX = &Cat{...}
MOVQ AX, ""..autotmp_1+40(SP) ;; SP + 40 = &Cat{...}
LEAQ go.itab.*"".Cat,"".Duck(SB), CX ;; CX = &go.itab.*"".Cat,"".Duck
MOVQ CX, "".c+48(SP) ;; iface(c).tab = SP + 48 = CX
MOVQ AX, "".c+56(SP) ;; iface(c).data = SP + 56 = AX

这段代码的初始化过程其实和上两节中的初始化过程没有太多的差别,它先初始化了 Cat 结构体指针,再将 Cattab 打包成了一个 iface 类型的结构体,我们直接来看初始化过程结束之后的堆栈数据:

浅入浅出 Go 语言接口的原理

SP 是运行时方法 runtime.newobject 的参数,而 SP+8 是该方法的返回值,即指向刚初始化的 Cat 结构体指针,SP+32SP+40SP+56 是对 SP+8 的拷贝,这两个指针都会指向栈上的 Cat 结构体,SP+56Cat 结构体指针和 SP+48tab 结构体指针共同构成了接口变量 iface 结构体。

接下来我们进入 c.Quack() 语句展开后的汇编指令,下面的代码从接口变量中获取了 tab.func[0],其中保存了 Cat.Quack 的方法指针,接口变量在中的数据会被拷贝到 SP 上,而方法指针会被拷贝到寄存器中并通过汇编指令 CALL 触发:

复制代码
MOVQ "".c+48(SP), AX ;; AX = iface(c).tab
MOVQ 24(AX), AX ;; AX = iface(c).tab.fun[0] = Cat.Quack
MOVQ "".c+56(SP), CX ;; CX = iface(c).data
MOVQ CX, (SP) ;; SP = CX = &Cat{...}
CALL AX ;; SP.Quack()

另一个调用 Quack 方法的语句 c.(*Cat).Quack() 生成的汇编指令看起来会有一些复杂,但是其中前半部分都是在做类型的转换,将接口类型转换成 *Cat 类型,只有最后的两行代码是函数调用相关的指令:

复制代码
MOVQ "".c+56(SP), AX ;; AX = iface(c).data = &Cat{...}
MOVQ "".c+48(SP), CX ;; CX = iface(c).tab
LEAQ go.itab.*"".Cat,"".Duck(SB), DX ;; DX = &&go.itab.*"".Cat,"".Duck
CMPQ CX, DX ;; CMP(CX, DX)
JEQ 163
JMP 201
MOVQ AX, ""..autotmp_3+24(SP) ;; SP+24 = &Cat{...}
MOVQ AX, (SP) ;; SP = &Cat{...}
CALL "".(*Cat).Quack(SB) ;; SP.Quack()

这两行代码将 Cat 指针拷贝到了 SP 上并直接调用 Quack 方法,对于这一次的方法调用,待执行的函数其实在编译期间就已经确定了,所以运行期间就不需要再动态查找方法地实现:

复制代码
MOVQ "".c+48(SP), AX ;; AX = iface(c).tab
MOVQ 24(AX), AX ;; AX = iface(c).tab.fun[0] = Cat.Quack
MOVQ "".c+56(SP), CX ;; CX = iface(c).data

两次方法调用的汇编指令差异其实就是动态派发带来的额外开销,我们需要了解一下这些额外的编译指令对性能造成的影响。

__ 性能测试

下面代码中的两个方法 BenchmarkDirectCallBenchmarkDynamicDispatch 分别会调用结构体方法和接口方法,我们以直接调用作为基准看一下动态派发带来了多少额外的性能开销:

复制代码
//go:noinline
func (c *Cat) Quack() string {
return c.Name
}
func BenchmarkDirectCall(b *testing.B) {
c := &Cat{Name: "grooming"}
for n := 0; n < b.N; n++ {
// MOVQ AX, "".c+24(SP)
// MOVQ AX, (SP)
// CALL "".(*Cat).Quack(SB)
c.Quack()
}
}
func BenchmarkDynamicDispatch(b *testing.B) {
c := Duck(&Cat{Name: "grooming"})
for n := 0; n < b.N; n++ {
// MOVQ "".d+56(SP), AX
// MOVQ 24(AX), AX
// MOVQ "".d+64(SP), CX
// MOVQ CX, (SP)
// CALL AX
c.Quack()
}
}

直接运行下面的命令,使用 1 个 CPU 运行上述代码,其中的每一个基准测试都会被执行 3 次:

复制代码
$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s -bench=.
goos: darwin
goarch: amd64
pkg: github.com/golang/playground
BenchmarkDirectCall 500000000 3.11 ns/op 0 B/op 0 allocs/op
BenchmarkDirectCall 500000000 2.94 ns/op 0 B/op 0 allocs/op
BenchmarkDirectCall 500000000 3.04 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 500000000 3.40 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 500000000 3.79 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 500000000 3.55 ns/op 0 B/op 0 allocs/op

如果是直接调用结构体的方法,三次基准测试的平均值其实在 ~3.03ns 左右(关闭编译器优化),而使用动态派发的方式会消耗 ~3.58ns,动态派发生成的指令会带来 ~18% 左右的额外性能开销。

这些性能开销在一个复杂的系统中其实不会带来太多的性能影响,因为一个项目中不可能只存在动态派发的调用,所以 ~18% 的额外开销相比使用接口带来的好处其实没有太大的影响,除此之外如果我们开启默认的编译器优化之后,动态派发的额外开销会降低至 ~5% 左右,对应用性能的整体影响就更小了。

上面的性能测试其实是在实现和调用接口方法的都是结构体指针,当我们将结构体指针换成结构体又会有比较大的差异:

复制代码
//go:noinline
func (c Cat) Quack() string {
return c.Name
}
func BenchmarkDirectCall(b *testing.B) {
c := Cat{Name: "grooming"}
for n := 0; n < b.N; n++ {
// MOVQ AX, (SP)
// MOVQ $8, 8(SP)
// CALL "".Cat.Quack(SB)
c.Quack()
}
}
func BenchmarkDynamicDispatch(b *testing.B) {
c := Duck(Cat{Name: "grooming"})
for n := 0; n < b.N; n++ {
// MOVQ 16(SP), AX
// MOVQ 24(SP), CX
// MOVQ AX, "".d+32(SP)
// MOVQ CX, "".d+40(SP)
// MOVQ "".d+32(SP), AX
// MOVQ 24(AX), AX
// MOVQ "".d+40(SP), CX
// MOVQ CX, (SP)
// CALL AX
c.Quack()
}
}

当我们重新执行相同的命令时,能得到如下所示的结果:

复制代码
$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s .
goos: darwin
goarch: amd64
pkg: github.com/golang/playground
BenchmarkDirectCall 500000000 3.15 ns/op 0 B/op 0 allocs/op
BenchmarkDirectCall 500000000 3.02 ns/op 0 B/op 0 allocs/op
BenchmarkDirectCall 500000000 3.09 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 200000000 6.92 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 200000000 6.91 ns/op 0 B/op 0 allocs/op
BenchmarkDynamicDispatch 200000000 7.10 ns/op 0 B/op 0 allocs/op

直接调用方法需要消耗时间的平均值和使用指针实现接口时差不多,大概在 ~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

评论

发布