[golang] 接口

523 阅读15分钟

概述

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

隐式接口

Java 的接口不仅可以定义方法签名,还可以定义变量。

public interface MyInterface {
    public String hello = "Hello";
    public void sayHello();
}

public class MyInterfaceImpl implements MyInterface {
    public void sayHello() {
        System.out.println(MyInterface.hello);
    }
}

Java 中的类必须通过上述方式显式地声明实现的接口,在 Go 定义接口需要使用 interface 关键字,在接口中只能定义方法签名,不能包含成员变量。

type error interface {
    Error() string
}

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

type RPCError struct {
    Code    int64
    Message string
}

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

Go 语言中接口的实现都是隐式的,只需要实现 Error() string 方法就实现了 error 接口。Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式地声明接口并实现所有方法
  • 在 Go 中:实现接口的所有方法就隐式地实现了接口; 上述 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 传递给签名中参数类型为 error 的 AsErr 函数;
  3. 将 *RPCError 类型的变量从函数签名的返回值类型为 error 的 NewRPCError 函数中返回;

类型

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口(runtime.iface),另一种是不带任何方法的 interface{}runtime.eface)。
需要注意的是,与 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{} 类型。

指针和接口

image.png
两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 method redeclared
对 Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。

type Cat struct {}
type Duck interface { ... }

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

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

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

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

方法的接受者是结构体,而初始化的变量是结构体指针。

type Cat struct{}

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

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

作为指针的 &Cat{} 变量能够隐式地获取到指向的结构体,所以能在结构体上调用 Walk 和 Quack 方法。可以将这里的调用理解成 C 语言中的 d->Walk() 和 d->Speak(),它们都会先获取指向的结构体再执行对应的方法。
但是如果将上述代码中方法的接受者和初始化的类型进行交换,代码就无法通过编译了。

type Duck interface {
    Quack()
}

type Cat struct{}

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

func main() {
    // cannot use Cat literal (type Cat) as type Duck in assignment:
    // Cat does not implement Duck (Quack method has pointer receiver)
    var c Duck = Cat{}
    c.Quack()
}

编译器会提醒:Cat 类型没有实现 Duck 接口,Quack 方法的接受者是指针。这两个报错是因为参数传递时都是传值的。
image.png
如上图所示,无论上述代码中初始化的变量 c 是 Cat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:

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

nil 和 non-nil

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{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等。

数据结构

Go 语言根据接口类型是否包含一组方法将接口类型分成了两类:

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

类型结构体

type eface struct { // 16 字节
    _type *_type
    data  unsafe.Pointer
}

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 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的;

itab 结构体

type iface struct { // 16 字节
    tab  *itab
    data unsafe.Pointer
}

type itab struct { // 32 字节
    inter *interfacetype
    _type *_type
    hash  uint32
    _     [4]byte
    fun   [1]uintptr
}
  • inter 和 _type 两个用于表示类型的字段;
  • hash 是对 _type.hash 的拷贝,当想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致;
  • fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的;

类型转换

指针类型

Duck 接口的例子,使用 //go:noinline 指令禁止 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()
}

使用编译器将上述代码编译成汇编语言,删掉一些对理解接口原理无用的指令,并保留与赋值语句 var c Duck = &Cat{Name: "draven"} 相关的代码,这里将生成的汇编指令拆分成三部分分析:

  1. 结构体 Cat 的初始化;
  2. 赋值触发的类型转换过程;
  3. 调用接口的方法 Quack(); 结构体 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	$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"; 字符串在运行时的表示是指针加上字符串长度。
    image.png
    因为 Cat 结构体的定义中只包含一个字符串,而字符串在 Go 语言中总共占 16 字节,所以每一个 Cat 结构体的大小都是 16 字节。初始化 Cat 结构体之后就进入了将 *Cat 转换成 Duck 类型的过程。
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 上。
image.png
SP ~ SP+16 共同组成了 runtime.iface 结构体,而栈上的这个 runtime.iface 也是 Quack 方法的第一个入参。

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

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

结构体类型

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{Name: "draven"} 也能够通过编译,不过生成的汇编代码和上一节的几乎完全相同,所以这里也就不作分析。
将汇编代码的执行过程分成以下几个部分:

  1. 初始化 Cat 结构体;
  2. 完成从 Cat 到 Duck 接口的类型转换;
  3. 调用接口的 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 结构体,而上一节的代码在堆上申请了 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: "draven"}
MOVQ	AX, 8(SP)                          ;; SP + 8 = AX
CALL	runtime.convT2I(SB)                ;; runtime.convT2I(SP, SP+8)

这个函数会获取 runtime.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
}

runtime.convT2I 会返回一个 runtime.iface,其中包含 runtime.itab 指针和 Cat 变量。当前函数返回之后,main 函数的栈上会包含以下数据。
image.png
SP 和 SP+8 中存储的 runtime.itab 和 Cat 指针是 runtime.convT2I 函数的入参,这个函数的返回值位于 SP+16,是一个占 16 字节内存空间的 runtime.iface 结构体,SP+32 存储的是在栈上的 Cat 结构体,它会在 runtime.convT2I 执行的过程中拷贝到堆上。
通过以下的指令调用 Cat 实现的接口方法 Quack()

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 方法的指针了。

类型断言

非空接口

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)

0037 ~ 0049 三个指令初始化了 Duck 变量,Cat 结构体初始化在 SP+8 ~ SP+24 上。因为 Go 语言的编译器做了一些优化,所以代码中没有 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,我们会跳转到 0080 所在的分支完成类型转换。
    1. 获取 SP+8 存储的 Cat 结构体指针;
    2. 将结构体指针拷贝到栈顶;
    3. 调用 Quack 方法;
    4. 恢复函数的栈并返回;
  • 如果接口中存在的具体类型不是 Cat,就会直接恢复栈指针并返回到调用方; image.png
    上图展示了调用 Quack 方法时的堆栈情况,其中 Cat 结构体存储在 SP+8 ~ SP+24 上,Cat 指针存储在栈顶并指向上述结构体。

空接口

当使用空接口类型 interface{} 进行类型断言时,如果不关闭 Go 语言编译器的优化选项,生成的汇编指令是差不多的。编译器会省略将 Cat 结构体转换成 runtime.eface 的过程。

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

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

动态派发

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是面向对象语言中的常见特性。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。
在如下所示的代码中,main 函数调用了两次 Quack 方法:

  1. 第一次以 Duck 接口类型的身份调用,调用时需要经过运行时的动态派发;
  2. 第二次以 *Cat 具体类型的身份调用,编译期就会确定调用的函数;
func main() {
    var c Duck = &Cat{Name: "draven"}
    c.Quack()
    c.(*Cat).Quack()
}

因为编译器优化影响了我们对原始汇编指令的理解,所以需要使用编译参数 -N 关闭编译器优化。如果不指定这个参数,编译器会对代码进行重写,与最初生成的执行过程有一些偏差,例如:

  • 因为接口类型中的 tab 参数并没有被使用,所以优化从 Cat 转换到 Duck 的过程;
  • 因为变量的具体类型是确定的,所以删除从 Duck 接口类型转换到 *Cat 具体类型时可能会发生崩溃的分支;
  • … 在具体分析调用 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	$6, 8(DI)                          ;; StringHeader(cat).Len = 6
LEAQ	go.string."draven"(SB), AX         ;; AX = &"draven"
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 结构体指针,再将 Cat 和 tab 打包成了一个 runtime.iface 类型的结构体。
image.png

  • SP 是 Cat 类型,它也是运行时 runtime.newobject 方法的参数;
  • SP+8 是 runtime.newobject 方法的返回值,即指向堆上的 Cat 结构体的指针;
  • SP+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()

这段代码的执行过程可以分成以下三个步骤:

  1. 从接口变量中获取保存 Cat.Quack 方法指针的 tab.func[0]
  2. 接口变量在 runtime.iface 中的数据会被拷贝到栈顶;
  3. 方法指针会被拷贝到寄存器中并通过汇编指令 CALL 触发; 另一个调用 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 指针拷贝到了栈顶并调用 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

两次方法调用对应的汇编指令差异就是动态派发带来的额外开销,这些额外开销在有低延时、高吞吐量需求的服务中是不能被忽视的,我们来详细分析一下产生的额外汇编指令对性能造成的影响。

基准测试

下面代码中的两个方法 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++ {
        // MOVQ	AX, (SP)
        // MOVQ	$6, 8(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	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% 的时间,从生成的汇编指令也能看出后者的额外开销会高很多。

直接调用动态派发
指针~3.03ns~3.58ns
结构体~3.09ns~6.98ns

从上述表格可以看到使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒应当尽量避免使用结构体类型实现接口。
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。

参阅

Go 语言设计与实现