4.2 接口
4.2.1 概述
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。
接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。
例如:
- 可移植操作系统接口(Portable Operating System Interface,POSIX),定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,计算机软件就可以直接在不同操作系统上运行。
- SQL ,使用 SQL 语句查询数据时,无需关心底层数据库的具体实现,只在乎 SQL 返回的结果是否符合预期。
隐式接口
Golang 中的接口式隐式的,即无需声明要实现的接口,只需实现接口定义的方法即可。
类型
接口在Golang中是一种类型,有两类不同的接口:
runtime.iface
, 带有方法的接口runtime.eface
, 没有方法的接口,又称空接口,interface{}
注意:空接口不是任意类型,将其他类型赋值给空接口,将会进行隐式的类型转换。
指针和接口
在实现接口方法时,根据接收者不同会有两种:
- 接收者为值类型
- 接收者为指针类型
当实现接口的类型的变量转换成接口类型时,在调用接口方法时会有不同的情况:
变量类型\接口方法的接收者类型 | 值 | 指针 |
---|---|---|
值类型 | 可 | 不可 |
指针类型 | 可 | 可 |
可以看出,当变量转换成接口类型后:
- 若变量是值类型,只能调用接受者为值类型的方法
- 若变量是指针类型,则接受者为值类型和指针类型的方法均可调用
产生差异的原因是:Golang 中参数的传递是 值 传递;传参时只会复制参数的值。
- 当变量是指针类型,转换成接口类型后,接口变量中持有的是原变量的指针,在调用值类型的方法时,会自动解引用
- 当变量是值类型,转换成接口类型后,接口变量中持有的是原变量的拷贝,在调用指针类型方法时,无法获取原变量的指针
nil 和 non-nil
空接口不是任意类型,可以通过下例来证明:
func NilOrNot(v interface{}) bool {
return v == nil
}
func main() {
var p *int
fmt.Println(p == nil)
fmt.Println(NilOrNot(p))
}
true
false
p
是指针类型的零值,nil
- 在将
p
传给函数时,会进行隐式的类型转换,此时类型为空接口,不再和nil
相等了
4.2.2 数据结构
_type
结构体
空接口 和 空接口interface{}
的数据结构为runtime.eface
:
type eface struct {
_type *_type
data unsafe.Pointer
}
_type
:类型信息data
:指向底层数据
从数据结构可以看出,任何变量都可以转换成空接口类型。
类型结构体runtime._type
:
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
size
:类型占用的内存空间hash
:快速确定类型是否相等equal
:判断相同类型的变量是否相等
itab
结构体
接口 和 接口类型的数据结构为runtime.iface
:
type iface struct {
tab *itab
data unsafe.Pointer
}
其中runtime.itab
:
type itab struct {
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}
inter
:表示接口类型_type
:表示具体类型hash
:是_type.hash
的拷贝,用于快速判断类型fun
:是一个动态的数组,存储了一组函数指针。在使用时通过原始指针获取数据,所以元素数量是不确定的
4.2.3 类型转换
指针类型 -> 接口
以下列代码为例(使用//go:noinline
禁止方法的内联编译):
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()
}
将其编译成汇编,然后分析和赋值语句var c Duck = &Cat{Name: "draven"}
相关的代码,可以分为三部分:
- 结构体
Cat
的初始化 - 赋值触发的类型转换过程
- 调用接口的方法
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 $6, 8(DI) ;; StringHeader(DI.Name).Len = 6
LEAQ go.string."draven"(SB), AX ;; AX = &"draven"
MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"draven"
- 获取
Cat
类型的指针,将其放到栈上 - 调用
runtime.newobject
在堆上为变量分配内存,并将其指针返回到SP+8上 - 将SP+8 存储到 DI 上
- 为
Cat
类型的变量分别赋值其字符串长度 6 和 字符串的值的指针(因为Cat
只有一个string
类型的字段)
类型转换
LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = *itab(go.itab.*"".Cat,"".Duck)
MOVQ DI, (SP)
接口类型的结构包含和类型相关的itab
和指向原始数据的指针,此处将编译期生成的runtime.itab
复制到SP上:
此时SP~SP+16就共同组成了runtime.iface
结构体。
方法调用
CALL "".(*Cat).Quack(SB) ;; SP.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: "draven"}
c.Quack()
}
关键的汇编代码依然分为三个部分:
- 初始化
Cat
结构体 - 类型转换
- 调用方法
初始化变量
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
此处在栈上初始化结构体变量Cat
。
类型转换
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
函数,以 go.itab."".Cat,"".Duck
的地址和指向 Cat
结构体的指针作为参数:
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
}
函数根据类型大小在堆上分配内存并返回runtime.iface
类型,其中包含runtime.itab
指针和指向Cat
类型的指针(在堆上新分配的Cat
类型,不是原变量)。
此时, SP+32 位置的时原始的Cat
类型变量,会将其拷贝到堆上(也就是发生了参数的拷贝)。
方法调用
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()
MOVQ 24(AX), AX
从 runtime.itab
结构体中取出 Cat.Quack
方法指针作为 CALL
指令调用时的参数。
接口变量的第 24 字节是 itab.fun
数组开始的位置,由于 Duck
接口只包含一个方法,所以 itab.fun[0]
中存储的就是指向 Quack
方法的指针。
4.2.4 类型断言
将接口转换成具体类型,要用到类型断言。类型断言有两种方式:
v := varI.(T)
v, ok := varI.(T)
,ok
表示类型是否转换成功
接口
下例表示Duck
接口一个非空的接口,分析从 Duck
转换回 Cat
结构体的过程:
func main() {
var c Duck = &Cat{Name: "draven"}
switch c.(type) {
case *Cat:
cat := c.(*Cat)
cat.Quack()
}
}
将汇编指令分为两部分:
- 变量的初始化
- 类型断言
变量初始化
00000 TEXT "".main(SB), ABIInternal, $32-0
...
00029 XORPS X0, X0
00032 MOVUPS X0, ""..autotmp_4+8(SP)
00037 LEAQ go.string."draven"(SB), AX
00044 MOVQ AX, ""..autotmp_4+8(SP)
00049 MOVQ $6, ""..autotmp_4+16(SP)
因为 编译器做了优化,代码中没有runtime.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: "draven"}
00085 MOVQ AX, (SP) ;; SP = AX
00089 CALL "".(*Cat).Quack(SB) ;; SP.Quack()
00094 JMP 70 ;; ...
;; BP = SP+24
;; SP += 32
;; return
;; }
switch语句生成的汇编指令会将目标类型的 hash
与接口变量中的 itab.hash
进行比较:
- 若相同,则表示接口变量的具体类型是
Cat
:- 获取
Cat
结构体指针 - 将指针拷贝到栈顶
- 调用方法
- 恢复函数栈,返回
- 获取
- 若不同,直接恢复栈顶指针并返回
空接口
func main() {
var c interface{} = &Cat{Name: "draven"}
switch c.(type) {
case *Cat:
cat := c.(*Cat)
cat.Quack()
}
}
如果不关闭 Go 语言编译器的优化选项,生成的汇编指令和接口的断言差不多,编译器会省略将 Cat
结构体转换成 runtime.eface
的过程。
如果禁用编译器优化,会在类型断言时不直接获取变量中具体类型的 runtime._type
,而是从 eface._type
中获取,汇编指令仍然会使用目标类型的 hash
与变量的类型比较。
4.2.5 动态派发
动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程。
Go 接口的引入带来了动态派发特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,会在运行期间决定具体调用该方法的哪个实现。
以下面的代码为例:
func main() {
var c Duck = &Cat{Name: "draven"}
c.Quack()
c.(*Cat).Quack()
}
在关闭编译器的优化后,将其编译成汇编,在初始化过程之后栈上的数据如下:
- SP 是 Cat 类型,是运行时
runtime.newobject
的参数 - SP+8 是
runtime.newobject
方法的返回值,即指向堆上的Cat
结构体的指针 - P+32、SP+40 是对 SP+8 的拷贝,这两个指针都会指向堆上的
Cat
结构体 - SP+48 ~ SP+64 是接口变量
runtime.iface
结构体,其中包含了tab
结构体指针和*Cat
指针
动态派发
c.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
MOVQ CX, (SP) ;; SP = CX = &Cat{...}
CALL AX ;; SP.Quack()
- 从接口变量中获取保存
Cat.Quack
方法指针的tab.func[0]
- 接口变量在
runtime.iface
中的数据会被拷贝到栈顶 - 方法指针会被拷贝到寄存器中并通过汇编指令
CALL
触发
类型转换后直接调用
c.(*Cat).Quack()
:
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
类型调用方法。
综上可以看出,动态派发会额外执行函数查找的过程。
Benchmark
下面两个方法 BenchmarkDirectCall
和 BenchmarkDynamicDispatch
分别会调用结构体方法和接口方法,在接口上调用方法时会使用动态派发机制,以直接调用作为基准分析动态派发带来了多少额外开销:
func BenchmarkDirectCall(b *testing.B) {
c := &Cat{Name: "draven"}
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: "draven"})
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% 左右的额外性能开销,但是在开启编译器优化后,动态派发的额外开销会降低至 ~5%,这对应用性能的整体影响就更小,所以与使用接口带来的好处相比,动态派发的额外开销往往可以忽略。
值类型和指针类型
func BenchmarkDirectCall(b *testing.B) {
c := Cat{Name: "draven"}
for n := 0; n < b.N; n++ {
c.Quack()
}
}
func BenchmarkDynamicDispatch(b *testing.B) {
c := Duck(Cat{Name: "draven"})
for n := 0; n < b.N; n++ {
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.03ns | ~3.58ns |
结构体 | ~3.09ns | ~6.98ns |
可以看到使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,所以应当尽量避免使用结构体类型实现接口。
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。