4.2 接口

Kesa...大约 12 分钟golang

4.2.1 概述

在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。

接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。

golang-interface
golang-interface

例如:

  1. 可移植操作系统接口(Portable Operating System Interface,POSIX),定义了应用程序接口和命令行等标准,为计算机软件带来了可移植性 — 只要操作系统实现了 POSIX,计算机软件就可以直接在不同操作系统上运行。
  2. SQL ,使用 SQL 语句查询数据时,无需关心底层数据库的具体实现,只在乎 SQL 返回的结果是否符合预期。

隐式接口

Golang 中的接口式隐式的,即无需声明要实现的接口,只需实现接口定义的方法即可。

类型

接口在Golang中是一种类型,有两类不同的接口:

  1. runtime.ifaceopen in new window, 带有方法的接口
  2. runtime.efaceopen in new window, 没有方法的接口,又称空接口interface{}

注意:空接口不是任意类型,将其他类型赋值给空接口,将会进行隐式类型转换

指针和接口

在实现接口方法时,根据接收者不同会有两种:

  1. 接收者为类型
  2. 接收者为指针类型

当实现接口的类型的变量转换成接口类型时,在调用接口方法时会有不同的情况:

变量类型\接口方法的接收者类型指针
值类型不可
指针类型

可以看出,当变量转换成接口类型后:

  1. 若变量是类型,只能调用接受者为类型的方法
  2. 若变量是指针类型,则接受者为类型和指针类型的方法均可调用
golang-interface-method-receive
golang-interface-method-receive

产生差异的原因是:Golang 中参数的传递是 值 传递;传参时只会复制参数的值。

  1. 变量指针类型,转换成接口类型后,接口变量持有的是原变量的指针,在调用值类型的方法时,会自动解引用
  2. 变量类型,转换成接口类型后,接口变量持有的是原变量的拷贝,在调用指针类型方法时,无法获取原变量的指针

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.efaceopen in new window:

type eface struct {
	_type *_type
	data  unsafe.Pointer
}
  • _type:类型信息
  • data:指向底层数据

从数据结构可以看出,任何变量都可以转换成空接口类型。

类型结构体runtime._typeopen in new window

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.ifaceopen in new window:

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

其中runtime.itabopen in new window:

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"}相关的代码,可以分为三部分:

  1. 结构体Cat的初始化
  2. 赋值触发的类型转换过程
  3. 调用接口的方法 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.newobjectopen in new window上为变量分配内存,并将其指针返回到SP+8上
  • 将SP+8 存储到 DI 上
  • Cat类型的变量分别赋值其字符串长度 6 和 字符串的值的指针(因为Cat只有一个string类型的字段)
golang-new-struct-pointer
golang-new-struct-pointer

类型转换

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

接口类型的结构包含和类型相关的itab和指向原始数据的指针,此处将编译期生成的runtime.itabopen in new window复制到SP上:

golang-struct-pointer-to-iface
golang-struct-pointer-to-iface

此时SP~SP+16就共同组成了runtime.ifaceopen in new window结构体。

方法调用

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()
}

关键的汇编代码依然分为三个部分:

  1. 初始化Cat结构体
  2. 类型转换
  3. 调用方法

初始化变量

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.convT2Iopen in new window函数,以 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.ifaceopen in new window类型,其中包含runtime.itabopen in new window指针和指向Cat类型的指针(在堆上新分配的Cat类型,不是原变量)。

golang-struct-to-iface
golang-struct-to-iface

此时, 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), AXruntime.itabopen in new window 结构体中取出 Cat.Quack 方法指针作为 CALL 指令调用时的参数。

接口变量的第 24 字节是 itab.fun 数组开始的位置,由于 Duck 接口只包含一个方法,所以 itab.fun[0] 中存储的就是指向 Quack 方法的指针。

4.2.4 类型断言

将接口转换成具体类型,要用到类型断言。类型断言有两种方式:

  1. v := varI.(T)
  2. 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()
	}
}

将汇编指令分为两部分:

  1. 变量的初始化
  2. 类型断言

变量初始化

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.ifaceopen in new window 的构建过程.

类型断言

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
    1. 获取Cat结构体指针
    2. 将指针拷贝到栈顶
    3. 调用方法
    4. 恢复函数栈,返回
  • 不同,直接恢复栈顶指针并返回
golang-interface-to-struct
golang-interface-to-struct

空接口

func main() {
	var c interface{} = &Cat{Name: "draven"}
	switch c.(type) {
	case *Cat:
		cat := c.(*Cat)
		cat.Quack()
	}
}

如果不关闭 Go 语言编译器的优化选项,生成的汇编指令和接口的断言差不多,编译器会省略将 Cat 结构体转换成 runtime.efaceopen in new window 的过程。

如果禁用编译器优化,会在类型断言时不直接获取变量中具体类型的 runtime._typeopen in new window,而是从 eface._type 中获取,汇编指令仍然会使用目标类型的 hash 与变量的类型比较。

4.2.5 动态派发

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程。

Go 接口的引入带来了动态派发特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,会在运行期间决定具体调用该方法的哪个实现。

以下面的代码为例:

func main() {
	var c Duck = &Cat{Name: "draven"}
	c.Quack()
	c.(*Cat).Quack()
}

在关闭编译器的优化后,将其编译成汇编,在初始化过程之后栈上的数据如下:

stack-after-initialize
stack-after-initialize

动态派发

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()
  1. 从接口变量中获取保存 Cat.Quack 方法指针的 tab.func[0]
  2. 接口变量在 runtime.ifaceopen in new window 中的数据会被拷贝到栈顶
  3. 方法指针会被拷贝到寄存器中并通过汇编指令 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

下面两个方法 BenchmarkDirectCallBenchmarkDynamicDispatch 分别会调用结构体方法和接口方法,在接口上调用方法时会使用动态派发机制,以直接调用作为基准分析动态派发带来了多少额外开销:

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 语言在函数调用时是传值的,动态派发的过程只是放大参数拷贝带来的影响

Reference

  1. https://draveness.me/golang/docs/part2-foundation/ch04-basic/golang-interface/#结构体类型open in new window
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2