接口

1、概述

​ 接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要下层的具体模块,只需要依赖一个约定好的接口。

上下游通过接口

举个例子:我们定义了一个 Shape 接口,它规定了一个 Area() 方法,然后我们创建了两个结构体 CircleRectangle 分别实现了这个接口的 Area 方法。我们可以实现一个calculateArea 函数计算它们的面积,参数就设置为 Shape 接口,我们就可以通过接口来调用它们的方法获取到面积,而不用关心是什么形状。

这就是接口的作用:它定义了一个规范,不同的对象可以根据这个规范来实现自己的功能,而不需要关心其他对象的具体实现,从而降低了代码的耦合度。

​ 这种面向接口的编程方式有着非常大的生命力,不论在框架还是操作系统中我们都能够找到接口的身影。

​ 除了解耦有依赖关系的上下游,接口还能够帮助我们隐藏底层实现,减少关注点。

​ 人能够同时处理的信息非常有限,定义良好的接口能够隔离底层的实现,让我们将重点放在当前的代码片段中。SQL 就是接口的一个例子,当我们使用 SQL 语句查询数据的时候,其实不需要关心底层数据库的实现,我们只在乎 SQL 返回的结果是否符合预期。

SQL和不同数据库

​ 计算机科学中的接口是比较抽象的概念,但编程语言中接口的概念就比较具体。Go 语言中的接口是一种内置的类型,它定义了以一组方法的签名,下面介绍几个基本的概念以及常见问题。

隐式接口

​ 很多面向对象语言都有接口这个概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用:

1
2
3
4
public interface MyInterface {
public String hello = "Hello";
public void sayHello();
}

​ 上述代码定义了一个必须实现的方法 sayHello 和一个会注入到实现类的变量 hello。在下面的代码中,MyInterfaceImpl 实现了 MyInterface 接口:

1
2
3
4
5
public class MyInterfaceImpl implements MyInterface {
public void sayHello() {
System.out.println(MyInterface.hello);
}
}

​ Java 中必须使用这种显示声明接口的方法,但 Go 语言中不需要这样的方式。

​ Go 语言中定义接口需要使用 interface 关键字,在接口中我们只能定义方法签名,不能包含成员变量,如下:

1
2
3
type error interface {
Error() string
}

​ 如果一个类型需要实现 error 接口,那么它只需要实现 Error() string 方法,如下:

1
2
3
4
5
6
7
8
type RPCError struct {
Code int64
Message string
}

func (e *RPCError) Error() string {
return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

​ 这段代码中根本没有出现 error 的影子,这就是 Go 语言中实现接口的方法,是隐式的,我们只需要实现 Error() string 方法就实现了 error 接口。这种方式与 Java 实现接口的方法是完全不同的:

  • Java:实现接口需要显示地声明接口并实现所有方法
  • Go:实现接口的所有方法就隐式地实现了接口

​ 我们使用上述 RPCError 结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查。

类型

​ Go 语言中接口也是一种类型,它能够出现在变量的定义、函数的参数和返回值中并对它们做出约束。Go 语言中有两种不同的接口:一种是带有方法的接口,另一种总是不带任何方法的接口:

Go语言中的两种接口

​ Go 语言使用 runtime.iface 表示第一种接口,使用 runtime.eface 表示第二种不包含任何方法的接口 interface{},两种接口虽然都使用 interface 声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。

​ Go 语言中的 interface 与 C 语言中的 void * 不同,它不是任意类型。如果我们将类型转换成了 interface{} 类型,变量在运行期间也会发生变化,获取变量类型时会得到 interface{}

1
2
3
4
5
6
7
8
9
10
11
12
package main


func main() {
v := 1
Print(v)
println(v)
}

func Print(v interface{}) {
println(v)
}

​ 上述函数只接受 interface 类型的参数,在调用 Print 函数的时候,会对参数 v 进行类型转换,将原来的 Test 类型转换成 interface{} 类型,于是打印出来的结果就是:

1
2
(0x45e020,0xc00002a748)
1
接口和指针

​ 在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式:

结构体和指针实现接口

​ 因为结构体类型和指针类型是不同的,就像我们不能向一个接受指针的函数传递结构体一样,在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。

​ 对 Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。

1
2
3
4
5
6
7
8
type Cat struct {}
type Duck interface { ... }

func (c Cat) Quack {} // 使用结构体实现接口
func (c *Cat) Quack {} // 使用结构体指针实现接口

var d Duck = Cat{} // 使用结构体初始化变量
var d Duck = &Cat{} // 使用结构体指针初始化变量

Go

实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:

结构体实现接口 结构体指针实现接口
结构体初始化变量 通过 不通过
结构体指针初始化变量 通过 通过

四种中只有使用指针实现接口,使用结构体初始化变量无法通过编译,其他的三种情况都可以正常执行。当实现接口的类型和初始化变量时返回的类型时相同时,代码通过编译是理所应当的:

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

​ 而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?先说能通过编译的情况:方法的接收者是结构体,而初始化的变量是结构体指针:

1
2
3
4
5
6
7
8
9
10
type Cat struct {}

func (c Cat) Quack() {
fmt.Println("neow")
}

func main() {
var c Duck = &Cat{}
c.Quack()
}

​ 因为指针 &Cat{} 变量能够隐式地获取到指向的结构体,所以能在结构体上调用对应的方法。

​ 而如果方法的接收者是结构体指针,而初始化的变量是结构体,代码就无法通过编译了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Duck interface {
Quack()
}

type Cat struct{}

func (c *Cat) Quack() {
fmt.Println("meow")
}

func main() {
var c Duck = Cat{}
c.Quack()
}
 编译器会提醒:无法将 'Cat{}' (类型 Cat) 用作类型 Duck 类型未实现 'Duck',因为 'Quack' 方法有指针接收器。

实现接口的接收者类型

​ 如图所示,不论上述代码中初始化的变量 cCat{} 还是 &Cat{},使用 c.Quack()方法时都会发生值拷贝:

  • 上图左侧,对于 &Cat 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用来获取指针指向的结构体;
  • 上图右侧,对于 Cat{}来说,这意味着 Quack方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新的指针,这个指针也无法指向最初调用该方法的结构体。

​ 因此,当我们使用指针实现接口时,只有指针类型才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。但这并不意味着我们应该统一使用结构体实现接口,这里只是做一个解释。

nil 和 non-nil

​ 我们可以通过一个例子理解 Go 语言的接口类型不是任意类型 这句话:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

type TestStruct struct{}

func NilOrNot(v interface{}) bool {
return v == nil
}

func main() {
var s *TestStruct
fmt.Println(s == nil) // #=> true
fmt.Println(NilOrNot(s)) // #=> false
}

​ 简单总结一下上述代码:

  • 将上述变量与 nil 比较会返回 true
  • 将上述变量传入 NilOrNot 方法并与 nil 比较会返回 false

​ 出现上述现象的原因是 —— 调用 NilOrNot 函数时发生了 隐式类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在发生类型转换时, *TestStruct 类型转换成了 interface{} 类型,转换后的变量不仅包含转换前的变量值 nil,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等。

2、数据结构

​ 从概述中,我们了解到Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:

  • 使用 runtime.iface 结构体表示包含方法的接口
  • 使用runtime.eface结构体表示不包含方法的 interface{} 类型

runtime.eface 结构体在 Go 语言中的定义是这样的:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

​ 由于 interface{} 不包含任何方法,所以它的结构体是比较简单的,只包含指向底层数据和结构的两个指针。因此不难看出,—— Go 语言的任意类型都可以转换成 interface{}

​ 另一个用于表示接口的结构体是 runtime.iface,这个结构体中指向原始数据的指针 data,还有一个 runtime.itab 类型的 tab 字段。

​ 下面我们来分析一个 Go 语言接口中的这两个类型,即runtime._typeruntime.itab

类型结构体

runtime._type 是 Go 语言类型的运行时表示。它包含很多类型的元信息,例如类型大小、哈希、对齐以及种类等。

1
2
3
4
5
6
7
8
9
10
11
12
13
type _type struct {
size uintptr // 类型的大小(以字节为单位)
ptrdata uintptr // 指针数据的大小(以字节为单位),通常用于垃圾回收
hash uint32 // 可能用于标识类型的散列值
tflag tflag // 类型的标志(flag),描述类型的属性,例如是否可比较等
align uint8 // 类型的对齐方式(以字节为单位)
fieldAlign uint8 // 结构体字段的对齐方式(以字节为单位)
kind uint8 // 类型的种类,例如,不同值可以表示不同的类型,如字符串、整数等
equal func(unsafe.Pointer, unsafe.Pointer) bool // 用于比较两个该类型的值是否相等的函数
gcdata *byte // 可能与垃圾回收有关的数据
str nameOff // 类型的名称,通常通过字符串表的索引来引用类型名称
ptrToThis typeOff // 用于引用类型自身的偏移
}
  • size 字段存储了类型占用的内存空间,为内存空间的分配提供信息;
  • hash 字段能够帮助我们快速确定类型是否相等;
  • equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的4

​ 对该结构体中的字段,我们只需要有个大体的概念即可,不用详细理解所有字段的作用和意义。

itab 结构体

runtime.itab 结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter_type 两个字段表示:

1
2
3
4
5
6
7
type itab struct {
inter *interfacetype // 指向接口类型的指针
_type *_type // 指向具体类型的指针
hash uint32 // 散列值,可能用于快速类型检查
_ [4]byte // 未使用的 4 字节填充
fun [1]uintptr // 包含一个指向类型方法的指针的数组
}
  • hash 是对 _typehash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型runtime._type 是否一致;
  • fun 是一个动态大小的数组,用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;

​ 后面会对上述两个字段进行深入了解。

3、类型转换

​ 下面通过几个例子深入理解接口类型是如何初始化和传递的。

指针类型

​ 首先回到这一节开头提到的 Duck 接口的例子,我们使用 //go:noinline 指令5禁止 Quack 方法的内联编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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: "draven"}
c.Quack()
}

​ 我们使用编译器将上述代码编译成汇编语言、删掉一些对理解接口原理无用的指令并保留与赋值语句相关的代码,拆分成三部分:

  1. 结构体 Cat 的初始化;
  2. 赋值触发的类型转换过程;
  3. 调用接口的方法 Quack()

我们先来分析结构体 Cat 的初始化过程:

1
2
3
4
5
6
7
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 $6, 8(DI) ;; StringHeader(DI.Name).Len = 6
LEAQ go.string."draven"(SB), AX ;; AX = &"draven"
MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"draven"
  1. 获取 Cat 结构体类型指针并将其作为参数放到栈上;
  2. 通过 CALL 指定调用 runtime.newobject 函数,这个函数会以 Cat 结构体类型指针作为入参,分配一片新的内存空间并将指向这片内存空间的指针返回到 SP+8 上;
  3. SP+8 现在存储了一个指向 Cat 结构体的指针,我们将栈上的指针拷贝到寄存器 DI 上方便操作;
  4. 由于 Cat 中只包含一个字符串类型的 Name 变量,所以在这里会分别将字符串地址 &"draven" 和字符串长度 6 设置到结构体上,最后三行汇编指令等价于 cat.Name = "draven"

​ 字符串在运行时的表示是指针加上字符串长度,这里要看一下初始化之后的 Cat 结构体在内存中的表示是什么样的:

结构体指针

因为 Cat 结构体的定义中只包含一个字符串,而字符串在 Go 语言中总共占 16 字节,所以每一个 Cat 结构体的大小都是 16 字节。初始化 Cat 结构体之后就进入了将 *Cat 转换成 Duck 类型的过程了:

1
2
LEAQ	go.itab.*"".Cat,"".Duck(SB), AX    ;; AX = *itab(go.itab.*"".Cat,"".Duck)
MOVQ DI, (SP) ;; SP = AX

类型转换的过程比较简单,Duck 作为一个包含方法的接口,它在底层使用 runtime.iface 结构体表示。runtime.iface 结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab 字段,我们已经通过上一段代码 SP+8 初始化了 Cat 结构体指针,这段代码只是将编译期间生成的 runtime.itab 结构体指针复制到 SP 上:

cat类型转换

到这里,我们会发现 SP ~ SP+16 共同组成了 runtime.iface 结构体,而栈上的这个 runtime.iface 也是 Quack 方法的第一个入参。

1
CALL    "".(*Cat).Quack(SB)                ;; SP.Quack()

​ 上述代码会直接通过 CALL 指令完成方法的调用,细心的读者可能会发现一个问题 —— 为什么在代码中我们调用的是 Duck.Quack 但生成的汇编是 *Cat.Quack 呢?Go 语言的编译器会在编译期间将一些需要动态派发的方法调用改写成对目标方法的直接调用,以减少性能的额外开销。如果在这里禁用编译器优化,就会看到动态派发的过程,我们会在后面分析接口的动态派发以及性能上的额外开销。

结构体类型

在这里我们继续修改上一节中的代码,使用结构体类型实现 Duck 接口并初始化结构体类型的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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: "draven"}
c.Quack()
}

编译上述代码会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不影响具体的执行过程。与上一节一样,我们将汇编代码的执行过程分成以下几个部分:

  1. 初始化 Cat 结构体;
  2. 完成从 CatDuck 接口的类型转换;
  3. 调用接口的 Quack 方法;

我们先来看一下上述汇编代码中用于初始化 Cat 结构体的部分:

1
2
3
4
5
XORPS   X0, X0                          ;; X0 = 0
MOVUPS X0, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = 0
LEAQ go.string."draven"(SB), AX ;; AX = &"draven"
MOVQ AX, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = AX
MOVQ $6, ""..autotmp_1+40(SP) ;; StringHeader(SP+32).Len = 6

Go

这段汇编指令会在栈上初始化 Cat 结构体,而上一节的代码在堆上申请了 16 字节的内存空间,栈上只有一个指向 Cat 的指针。

初始化结构体后会进入类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck 的地址和指向 Cat 结构体的指针作为参数一并传入 runtime.convT2I 函数:

1
2
3
4
5
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: "draven"}
MOVQ AX, 8(SP) ;; SP + 8 = AX
CALL runtime.convT2I(SB) ;; runtime.convT2I(SP, SP+8)

Go

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

1
2
3
4
5
6
7
8
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
}

Go

runtime.convT2I 会返回一个 runtime.iface,其中包含 runtime.itab 指针和 Cat 变量。当前函数返回之后,main 函数的栈上会包含以下数据:

结构体到指针

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

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

1
2
3
4
5
MOVQ	16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck)
MOVQ 24(SP), CX ;; CX = &Cat{Name: "draven"}
MOVQ 24(AX), AX ;; AX = AX.fun[0] = Cat.Quack
MOVQ CX, (SP) ;; SP = CX
CALL AX ;; CX.Quack()

Go

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

4、类型断言

5、动态派发


接口
http://example.com/2023/10/30/Go/接口/
作者
Feng Tao
发布于
2023年10月30日
更新于
2023年10月30日
许可协议