golang-接口
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 语言会编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:
将 *RPCError 类型的变量赋值给 error 类型的变量 rpcErr;
将 *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 error 的 AsErr 函数;
将 *RPCError 类型的变量从函数签名的返回值类型为 error 的 NewRPCError 函数中返回;
从类型检查的过程来看,编译器仅在需要时才对类型进行检查,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。
数据结构
Go 语言根据接口类型『是否包含一组方法』对类型做了不同的处理。我们使用 iface 结构体表示包含方法的接口;使用 eface 结构体表示不包含任何方法的 interface{} 类型,eface 结构体在 Go 语言的定义是这样的:
type eface struct { // 16 bytes
_type *_type
data unsafe.Pointer
}
由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go 语言中的任意类型都可以转换成 interface{} 类型。
另一个用于表示接口的结构体就是 iface,这个结构体中有指向原始数据的指针 data,不过更重要的是 itab 类型的 tab 字段。
type iface struct { // 16 bytes
tab *itab
data unsafe.Pointer
}
接下来我们将详细分析 Go 语言接口中的这两个类型,即 _type 和 itab。
动态派发
动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是一种在面向对象语言中常见的特性6。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。
在如下所示的代码中,main 函数调用了两次 Quack 方法:
第一次以 Duck 接口类型的身份调用,调用时需要经过运行时的动态派发;
第二次以 *Cat 具体类型的身份调用,编译期就会确定调用的函数:
func main() {
var c Duck = &Cat{Name: "grooming"}
c.Quack()
c.(*Cat).Quack()
}
//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.接口变量在中的数据会被拷贝到栈顶;
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()
//动态查找要调用的函数的过程(动态派发)
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
两次方法调用对应的汇编指令差异就是动态派发带来的额外开销
直接调用 动态派发
指针 ~3.03ns ~3.58ns
结构体 ~3.09ns ~6.98ns
从上述表格我们可以看到使用结构体来实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口。
使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。
引用:
https://draveness.me/golang/docs/part2-foundation/ch04-basic/golang-interface/