概述

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

Java的接口不仅可以定义方法签名,还可以定义变量。并且Java中的类必须显示地声明实现的接口,但Golang则不同。① 在接口中我们只能定义方法签名,不能包含成员变量;② Golang 接口的实现都是隐式的,我们只需要实现 Error() string 方法,就实现了 error接口。

在Java中,实现接口需要显示地声明接口,并实现所有方法,

Golang 中,实现接口的所有方法就隐式地实现了接口。

Golang只会在传递参数、返回参数以及变量赋值的时候,才会对某个类型是否实现了接口进行检查。


Go 语言在编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:

*RPCErrorerrorrpcErr*RPCErrorrpcErrerrorAsErr*RPCErrorerrorNewRPCError

从类型检查的过程来看,编译器仅在需要时才检查类型,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。

结构体和指针实现接口

因为,和C++不同,Go 语言在传递参数时都是传值的。即使通过指针调用,编译器可以隐式地对变量解引用获取指针指向的结构体对象。所以,对Golang来说,无论初始化变量c是 Cat{} 还是 &Cat{},使用 c.Quack() 调用方法时,都会发生值拷贝。--- C++就可以选择引用传参,避免一次深拷贝

  • 结构体指针实现接口
Duck.Quack*Cat.Quack
CatQuack()
  • 结构体实现接口

动态派发

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

mainQuack
Duck*Cat

在关闭编译器优化的情况下,从数据来看,动态派发生成的指令会带来 ~18% 左右的额外性能开销。

这些性能开销在一个复杂的系统中不会带来太多的影响。一个项目不可能只使用动态派发,而且如果我们开启编译器优化后,动态派发的额外开销会降低至 ~5%,这对应用性能的整体影响就更小了,所以与使用接口带来的好处相比,动态派发的额外开销往往可以忽略。


上面的性能测试,建立在实现和调用方法的都是结构体指针上,当我们将结构体指针换成结构体又会有比较大的差异:

直接调用方法,需要消耗时间的平均值和使用指针实现接口时差不多,约为 ~3.09ns,而使用动态派发调用方法却需要 ~6.98ns 相比直接调用额外消耗了 ~125% 的时间,从生成的汇编指令我们也能看出后者的额外开销会高很多。

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

从上述表格我们可以看到,使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口

使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为 Go 语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。

你们的评论和点赞是我写作的最大动力,蟹蟹


参考资料: