您的位置 首页 golang

go语言编程:接口interface详解-深度好文值得一读

什么是interface

interface即接口一词,在面向对象程序编程中,我们经常会听到“接口”这个名词。例如在java中,一个class要实现一个接口,需要显示使用 implement 关键字。在 golang 中,接口这个概念与 java 等其他语言有些差别。golang语言中的接口是一组方法的集合, interface是一组method签名的集合。interface在golang中是一个关键词,也表示接口类型。定义一个Animal的接口

 type Animal interface{
    Say() string
}
  

通过interface关键字,定义了Animal这个接口,这个接口包含有一个Say方法。当然也可以定义一个不包含任何方法的接口,下面这个Animal2接口不含任何方法,称它为一个空接口。那接口中除了有函数,还能别的东西吗?能不能放变量,不能在里面定义变量,内嵌非接口类型也不可以,但是可以内嵌接口,像下面的第一组至第五组都是不可以的,直接编译不通过,只有第六次可以。golang中的接口是“一等公民”, 可以作为结构体的字段,可以做函数参数和返回值, 但不可做方法的recevier。

 type Animal2 interface{

}
  
 // 第一组
type i interface {
 idata int
}

// 第二组
type i interface {
 int
}

// 第三组
type i interface {
 st struct1
}

// 第四组
type i interface {
 struct1
}

// 第五组
type i interface {
 animal Animal
}

// 第六组
type i interface {
 Animal
}
  

golang中如何实现interface

在golang中,如果类型实现了接口中定义的所有方法,我们就说该类型实现了接口。并不需要显示将该类型与接口关联,才能实现接口,即不需要像 java那样需要通过implement关键字显示申明实现接口。golang中采用的是“鸭子模型”, 如果一个动物走起路来像鸭子,叫声像鸭子,那么可以将它视为鸭子。空接口不包含任何方法,所以说所有类型都实现了空接口,因为任何类型都至少含有0个或多个方法。

Dog和Cat结构体类型都实现了Say方法和Walk方法,这种不需要显示申明实现了某某接口,写起来很方便。特别是在实现包内的接口的时候,不用引用包,只要实现了包内的接口就可以。 go 会自动进行 interface 的检查,并在运行时执行从其他类型到 interface 的自动转换,即使实现了多个 interface,go 也会在使用对应 interface 时实现自动转换。在运行进行的时候进行检查和转换,会引入一些问题:

  1. 性能会降低。使用interface作为参赛,在运行时会动态的确定行为,相比具体类型在编译的时候就确定类型,性能有所下降。
  2. 阅读代码的时候会增大难度,不能清楚地看到类型实现了哪些接口,goland IDE提供了好的查看方法,需要借助工具。
 type Animal interface{
   Say() string
   Walk()
}

type Dog struct{}

type Cat struct{}

func (d Dog) Say() string{
   return "我是一只小狗,汪汪汪..."
}

func (d *Dog) Walk(){
   fmt.Println("我走起路来大摇大摆")
}

func (c Cat) Say() string{
   return "我是一只小猫,喵喵喵..."
}

func (c Cat) Walk(){
   fmt.Println("我走起路来很小声")
}

func do(animal Animal){
   animal.Say()
   animal.Walk()
}

func main(){
   dog:=Dog{}
   do(dog)
   
   dog2:=&Dog{}
   do(dog2)
   
   cat:=Cat{}
    do (cat)
   
   cat2:=&Cat{}
   do(cat2)
}
  

现在对上面定义的接口测试一把,定义一个do函数,参数为Animal接口类型,定义4个对象,dog和dog2,cat和cat2, 分别传给do函数,都能正确编译运行嘛?不行,do(dog) 编译不通过,不对呀?为啥do(dog2)可以编译通过。区别在于dog是值类型,dog2是指针类型,在回头看Dog类型的实现,Say的实现类型是dog, Walk的实现类型是*dog, 是有区别的。对于值类型,编译器不会帮我们自动生成接收者为指针类型的方法,但对指针类型,编译器会自动生成接收者为值类型的方法,一个可以合理的解释时,接收者为指针类型可能会对类型有修改,会影响到接收者。所以dog为值类型,并没有实现Walk方法,编译不通过。Cat实现的方法接收者都是值类型,指针对象调用的时候编译器会自动帮我们产生接收者为指针类型的方法。do(cat)和do(cat2)都是可以编译的。

interface底层实现

前面说了go中有些接口是定义有方法,还有一种是没有方法的接口,根据这个,go语言接口有两种表现形式,对应到底层实现也是用两种不用的数据结构表示的。对没有定义方法的接口,底层用 用eface结构表示,对定义的有方法的接口,底层用iface结构表示。这里两种结构定义在 runtime/runtime2.go中 eface和iface都占16字节,eface只有2个字段,因为它代表的是没有方法的接口,只需要存储被赋值对象的类型和数据即可,正好对应到这里的_type 和 data字段。iface代表含有方法的接口,定义里面的 data字段也是表示被存储对象的值,注意这里的值是原始值的一个拷贝,如果原始值是一个值类型,这里的data是执行的数据时原始数据的一个副本。

 // 空接口,表示不含有method的interface结构
type eface struct {
   // 赋值给空接口变量的类型型,_type是基础数据类型的抽象
   _type *_type
   // 指向数据的指针,对于值类型变量,指向的是值拷贝一份后的地址
   // 对于指针类型变量,指向的是原数据地址
   data unsafe.Pointer
}
  

// 非空接口,含有method的interface结构 type iface struct { // itab描述信息有接口的类型和赋值给接口变量的类型,大小等 tab *itab // 指向数据的地址 data unsafe.Pointer }

 type itab struct {
   // 描述接口的类型,接口有哪些方法,接口的包名
   inter *interfacetype
   // 描述赋值变量的类型
   _type *_type
   // hash值,用在接口断言时候
   hash  uint32 // copy of _type.hash. Used for type switches.
   _     [4]byte
   // 赋值变量,即接口实现者的方法地址,这里虽然定义了数组长度为1,并不表示只能有1个方法
   // fun是第一个方法的地址,因为方法的地址是一个指针地址,占用固定的8个字节,所以后面的
   // 方法的地址可以根据fun偏移计算得到
   fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
  

itab比较简单,只有5个字段, inter存储的是interface自己本身的类型,_type存储的是接口被赋值对象的类型,也就一个具体对象的类型,对应前面的Animal对象,inter存在的是Animal类型,inter含有 一个_type类型,同时包含package名pkgpath,mhdr描述Animal含有哪些方法信息。hash值在类型断言的时候用,这里的hash值与*type里面的hash值是一样的。fun表示接口被赋值对象的方法,这里虽然 定义了长度为1的数组,并不表示只有1个方法,uintptr是一个指针地址,占用固定的8个字节,方法地址从fun开始是连续存储的,所以后面的方法地址可以根据fun偏移计算得到。

 // 接口的变量的类型
type interfacetype struct {
   // golang 基础类型,struct, array, slice,map...
   typ _type
   // 变量类型定义的结构所在的包位置信息
   pkgpath name
   // method信息
   mhdr []imethod
}
  
 // Needs to be in sync with ../cmd/link/internal/ld/decodesym.go:/^func.commonsize,
// ../cmd/compile/internal/gc/reflect.go:/^func.dcommontype and
// ../reflect/type.go:/^type.rtype.
// ../internal/reflectlite/type.go:/^type.rtype.
type _type struct {
   // 类型占用的内存大小
   size uintptr
   // 包含所有指针的内存前缀大小
   ptrdata uintptr // size of memory prefix holding all pointers
   // 类型的hash值
   hash uint32
   // 标记值,在反射的时候会用到
   tflag tflag
   // 字节对齐方式
   align uint8
   // 结构体字段对齐的字节数
   fieldAlign uint8
   // 基础类型,见下面文中的说明
   kind uint8
   // function for comparing objects of this type
   // (ptr to object A, ptr to object B) -> ==?
   // 比较2个形参对象的类型是否相同 对象A和对象B的类型是否相同
   equal func(unsafe.Pointer, unsafe.Pointer) bool
   // gcdata stores the GC type data for the garbage collector.
   // If the KindGCProg bit is set in kind, gcdata is a GC program.
   // Otherwise it is a ptrmask bitmap. See mbitmap.go for details.

   // gc信息
   gcdata *byte
   // 类型名称字符串在可执行二进制文件段中的偏移量
   str nameOff
   // 类型的元数据信息在可执行二进制文件段中的偏移量
   ptrToThis typeOff
}
  

_type类型定义如上,我们关注几个核心字段,size类型占用的内存大小,hash值与itab中的hash值一致,在类型断言的时候用,kind表示基础类型,描述类型的元素数据信息,是对具体类型的一种抽象。kind具体类型如下,定义在 runtime/typekind.go文件中

 // golang基础数量类型
const (
   kindBool = 1 + iota
   kindInt
   kindInt8
   kindInt16
   kindInt32
   kindInt64
   kindUint
   kindUint8
   kindUint16
   kindUint32
   kindUint64
   kindUintptr
   kindFloat32
   kindFloat64
   kindComplex64
   kindComplex128
   // 数组
   kindArray
   // 通道
   kindChan
   // 函数
   kindFunc
   // 接口
   kindInterface
   // map
   kindMap
   // 指针
   kindPtr
   // 切片
   kindSlice
   // 字符串
   kindString
   // 结构体
   kindStruct
   // Pointer指针
   kindUnsafePointer

   kindDirectIface = 1 << 5
   kindGCProg      = 1 << 6
   kindMask        = (1 << 5) - 1
)
  

下面通过一个例子,进行反汇编,看一看是有eface和iface生成。

 package main

import "fmt"

type Animal interface {
   Say() string
   Walk()
}

type dog struct{}

func (d *dog) Say() string {
   return "我是一只小狗,旺旺旺..."
}

func (d *dog) Walk() {
   fmt.Println("我走起路来非常可爱")
}

func main() {
   var in interface{}
   i := 10
   in = i
   fmt.Println(in)

   var animal Animal = &dog{}
   animal.Say()
   animal.Walk()
}
  

执行 go tool compile -S -N -l main.go, 可以看到main函数对应的汇编中调用了如下函数,左右滑动可以完整查看哦。

 func convT2E64(t *_type, elem unsafe.Pointer) (e eface)
func convT2I(tab *itab, elem unsafe.Pointer) (i iface)
  

这两个可以看成是eface和iface的构造函数,函数的入参与结构定义的字段是一对一的。

interface nil值

iface和eface结构都有2个字段,分别是类型和数据信息,他们被分别称为动态类型和动态值。所以一个接口包含动态类型和动态值, 接口的nil值是指动态类型和动态值都为nil, 除此任意一个不为nil, 接口就是非nil的。下面通过一个例子加强认识。

 package main

import "fmt"

type Animal interface {
   Say() string
   Walk()
}

type dog struct{}

func (d *dog) Say() string {
   return "我是一只小狗,旺旺旺..."
}

func (d *dog) Walk() {
   fmt.Println("我走起路来非常可爱")
}

func main() {
   var in interface{}
   fmt.Println(in == nil) //true

   var d *dog
   fmt.Println(d == nil) //true

   var id Animal
   fmt.Printf("id:type:%T, data:%v\n", id, id)

   id = d
   fmt.Println(id == nil) //false
   fmt.Printf("id:type:%T, data:%v\n", id, id)
}
  

输出键截图如下:

in是一个空接口,没有被赋值,它的_type和data都是空的,所以in==nil成立。d是一个结构体指针,它没有初始化,所以d==nil也是成立的。id是一个Animal类型接口,它 没有被赋值,分别打印出它的类型和数据信息,可以看到都是type:nil, data:nil,在将id=d赋值d后,id==nil不成立了, 因为id的类型不为空,通过打印也可以看得出来 type:*main.dog, data:nil。

打印出接口的_type和data内容

 package main

import (
 "fmt"
 "unsafe"
)

type myface struct {
 itab uintptr
 data uintptr
}

func main() {
 var ia interface{} = nil
 var ib interface{} = (*int)(nil)
 i := 10

 var ic interface{} = i

 iap := *(*myface)(unsafe.Pointer(&ia))
 ibp := *(*myface)(unsafe.Pointer(&ib))
 icp := *(*myface)(unsafe.Pointer(⁣))

 fmt.Println(iap)
 fmt.Println(ibp)
 fmt.Println(icp)

 fmt.Println(*(*int)(unsafe.Pointer(icp.data)))
}
  

输出见上面的截图,自定义一个与iface一样的结构体,然后通过unsafe.Pointer将接口转换成自定义的结构体myface,然后输出myface的内容。 iap 是空接口,打印出来的itab和data都为空,ibp类型为*int,数据为空,打印出来的与预期一致。icp类型和数据都不为空,打印出来的也是都为非空,打印出icp.data值也是10,与i的值是一样的。

interface的创建过程

还是从前面的的代码Animal接口入手,执行下面2行代码,涉及到3个步骤:

  • 初始化dog对象
  • 将&dog{}转成Animal接口
  • 调用Animal接口的Say方法
 var animal Animal = &dog{}
animal.Say()
  

前面说了iface的产生通过convT2I函数,下面看下个函数的实现 convT2I定义在 runtime/iface.go文件中, convT2I根据传入的类型*itab和数据elem构造一个iface, 类型赋值很简单,直接将入参 tab赋值给i.tab, 对于i.data, 先是mallocgc分配一块内存,类型大小来做tab.size, 然后执行typedmemove将elem的值拷贝到新分配的内存x中,最后将x赋值给i.data返回。

 func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
   t := tab._type
   if raceenabled {
      raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I))
   }
   if msanenabled {
      msanread(elem, t.size)
   }
   x := mallocgc(t.size, t, true)
   typedmemmove(t, x, elem)
   i.tab = tab
   i.data = x
   return
}
  

上面代码执行 go tool compile -S -N -l main.go 汇编出来的结果如下,

可能有人会产生好奇,为什么汇编代码里看不到对runtime.convT2I() 方法调用呢?笔者认为是编译器做了优化处理,上述实验环境是在mac系统下,采用的go版本是1.14版。下面看看itab的构造,是不是与前面分析的一致,直接在上面反汇编的文本搜索go.itab会查到, 可以看到itab的内存布局,第一个8字节的指针描述的是接口自身的类型,也就是Animal类型,从汇编中也可以看到这一点,第二个8字节指针描述的是被赋值对象的实际类型,这里赋值的是*dog类型,与汇编显示的是一致,后面2项是dog的方法Say和Walk。

interface类型断言

在c语言中,我们可以将一个byte类型的变量直接赋值给一个int类型变量,但是在go语言中是不行的,在go语言中不允许隐式转换。在赋值(=)操作时候,两边的类型必须一致(接口除外) 类型转换和类型断言本质都是将一个类型转成另外一种类型,类型断言是对接口变量进行的操作,对一个非接口变量进行断言直接编译会不过,像下面这样。

非空接口类型断言

对下面的代码执行反汇编操作, go tool compile -S -N -l main.go, 关注反汇编中一个核心操作,runtime.assertI2I2

 func main() {
   var animal Animal = &dog{}
   v, ok := animal.(Animal)
   if !ok {
      fmt.Println("animal is not Animal type")
   }
   fmt.Println(v)
}
  

从上述汇编中可以看到,在栈顶放入了3个参数,*interfacetype, *itab和unsafe.Pointer之后,调用了runtime.assertI2I2方法。runtime.assertI2I2方法的源代码如下:

 func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) {
   tab := i.tab
   if tab == nil {
      return
   }
   if tab.inter != inter {
      tab = getitab(inter, tab._type, true)
      if tab == nil {
         return
      }
   }
   r.tab = tab
   r.data = i.data
   b = true
   return
}
  

assertI2I2的处理逻辑是,如果iface中的itab.inter和第一个传入参数 interfacetype相同,说明类型相同,直接返回入参iface的相同类型,如果 iface中的itab.inter和第一个入参 interfacetype不相同,则根据*interfacetype和iface.tab去构造tab。构造的过程中会查找itab表,如果类型不匹配, 或者不是属于同一个 interface 类型,都会失败。上述断言的是对象是否是Animal类型,Animal是一个接口类型,即断言animal是否Animal类型,如果断言的是一个具体 类型,例如 animal.( dog) 这里断言是一个具体类型 dog,编译器会直接构造出 iface,而不会调用 runtime.assertI2I2()构造 iface

空接口类型断言

下面将*dog赋值给一个空接口,然后对其进行断言,同上对其反汇编,得到汇编关键代码如下

 func main() {
   var iface interface{} = &dog{}
   v, ok := iface.(*dog)
   if !ok {
      fmt.Println("animal is not Animal type")
   }
   fmt.Println(v)
}
  

可以看到,空接口的断言非常简单,直接将eface的第一个字段*_type和要比较的类型的* _type进行比较,如果相同,就是断言成功,构建相应的返回值。总结起来,非空接口的类型推断的本质是iface中 itab的对比,itab 匹配成功会在内存中组装返回值,这个返回值就是要断言目标类型对象。匹配失败,执行清空操作,返回默认值。空接口类型推断的本质是eface中 _type的对比, _type匹配成功会在内存中组装返回值,这个返回值就是要断言目标类型对象。匹配失败,执行清空操作,返回默认值。概括起来,两种类型的断言对比的都是类型,不比较数据data。

断言有2种语法,一种是: 「目标类型值,布尔值:= 表达式.(目标类型)」 , 另一种是 「目标类型值:=表达式.(目标类型)」 。前一种是安全性断言,有1个布尔值表示是否断言成功,第二种是非安全的,并不知道断言是否成功,如果失败了,继续执行目标类型值的 一些操作,会引发panic。

类型查询 switch type

类型查询也是接口独有的一种运算,语法格式为:

 switch 接口类型变量.(type){
  case 具体类型:
  case 接口:
}
  

对非接口类型变量不能调用type运算,直接编译不会通过。case 后面可以跟非接口的类型名,也可以跟接口类型名,例如在下面的代码中, iface是一个interface{}类型,直接跟第一个匹配上,输出iface is interface。如果去掉第一个匹配项,iface会跟Animal匹配上,因为它也是Animal 类型,fallthrough 语句不能在 Type Switches 中使用。强行使用,编译器会报错。

 func main() {
   var iface interface{} = &dog{}
   
   switch iface.(type) {
   case interface{}:
      fmt.Println("iface is interface")
   case Animal:
      fmt.Println("iface is animal")
   case *dog:
      fmt.Println("iface is *dog")
   case dog:
      fmt.Println("iface is dog")
   }
}
  

动态派发

go类型系统

在说动态派发之前,先讲一讲go中的类型系统。go中类型分为内置built-in类型和自定义类型。built-in类型有:int8, int16, int32, int64, int, byte, string, slice, func, channel和map等。自定义类型有:

 type T int
type T struct{
    name string
}

type I interface{
    Name() string
}
  

内置类型是不能定义方法的,前面也说了接口类型不能作为方法的接收者。所以内置类型和接口不存在方法。不管是内置类型还是自定义类型,都有描述他们的信息,称之为类型的元 数据信息,每种数据类型的元数据信息都是全局唯一的.

每种数据类型都是在_type字段的基础上,添加一些额外的字段进行管理的

 type arraytype struct { 
typ _type 
elem *_type 
slice *_type 
len uintptr
} 
type chantype struct { 
typ _type 
elem *_type 
dir uintptr
} 
type slicetype struct { 
typ _type 
elem *_type 
}
  

对于自定义类型,除了了类型共有的字段信息,还有一个uncommontype 信息,记录了包路径,方法的个数,可导出方法的个数,moff记录的是这些方法组成的相对于uncommon结构体偏移了多少字节。

 type uncommontype struct {
   pkgpath nameOff
   mcount  uint16 // number of methods
   xcount  uint16 // number of exported methods
   moff    uint32 // offset from this uncommontype to [mcount]method
   _       uint32 // unused
}
  

引申问题:type myType1 = int32 和 type myType2 int32有什么区别?myType1和int32底层元数据信息都是同一个, rune和int32就是这样的关系,myType2属于已有数据创建的新类型,它和int32是不同的类型,底层的数据元数据信息也是不同的。

什么是动态派发

go中每种类型都有自己的元数据信息,通过元数据信息可以定位到该类型关联的所有method。对比下面2种方法调用方式:

  • 通过一个*os.File类型的变量f直接调用Read方法。
  • 通过io.Reader类型的变量r调用Read方法
 func ReadFile(f *os.File, b []byte) (n int,err error){
 return f.Read(b)
}

var r io.Reader = f
n,err:=r.Read(buf)
  

从汇编的角度看,前面一种方法调用时通过CALL指令+立即数地址来实现的,方法地址在源代码编译成可执行文件之后就确定了,称这种情况为方法地址的静态绑定。这种静态绑定的方式无法支持第二种情况,因为在编译的时候还不知道r装载的动态类型是什么,只有在运行的时候才能确定,正是因为接口装载的动态类型是可以变化的, 所以通过接口调用它的方法时,需要根据它背后的动态类型来确定调用哪一种实现,这也是面向对象编程中,接口的一个核心功能,实现多态,也就是实现方法的动态派发。

go中如何实现动态派发

非空接口iface中有Itab字段,itab字段存储的是类型元数据相关的信息,实现动态派发要使用的函数地址表就是存在在itab中的fun字段。itab.inter指向当前接口的类型元数据, 记录着接口需要实现哪些方法, itab._type指向动态类型元数据,从_type到uncommontype, 在从uncommontype到[mcount]method, 可以找到该动态类型的方法集合。接口需要实现的方法列表与动态类型的方法集都是经过排序的,可以设想一下,如果没有排序,如果接口定义的有m个方法,动态类型实现的方法有n个,那么查找方法需要 时间复杂度为O(m*n), 如果是经过排序的,只需要顺序遍历一次,时间复杂度为O(m+n), 加快了查询速度。 「如果没有实现」 ,那么itab.func[0]就等于0,如果实现了,把动态类型实现的方法地址存储到itab.fun数组中。当调用方法的时候,直接到itab.fun根据下标取到函数的地址开始执行。

引申问题1:如果一个具体类型没有实现某个接口,为什么也要缓存对应的itab? 按照常理,只有具体类型实现了接口,才能够得到一个itab, 进而缓存起来。假设某个具体类似没有实现该接口,但是运行阶段有大量这样的类型断言,缓存中查不到对应的itab, 就会每次 都查询元数据的方法列表,对性能影响非常大,所以,go会把有效、无效的itab都缓存起来,通过fun[0]加以区分。动态派发的过程,其实有 两部分的性能损失,一部分是损失在动态调用方法。这是一个函数指针的间接调用,还要经过地址偏移动态计算以后的跳转。还有一部分是构造 iface 的过程。通过结构体的值类型调用动态派发的代码中,内存中构造了一个完整的 iface。而通过结构体指针类型调用的代码中,并没有构造 iface,直接把入参放入栈顶,直接调用那个方法。对于两者的性能benchmark已有不少,这里就不贴测试代码了,直接说结论。附一个draveness大神的测试对比,

  • 指针实现的动态派发造成的性能损失非常小,相对于一些复杂逻辑的处理函数,这点性能损失几乎可以忽略不计。
  • 结构体实现的动态派发性能损耗比较大。结构体在方法调用的时候需要传值,拷贝参数,这里导致性能损失比较大。

欢迎关注微信公众号-数据小冰,更多精彩内容和你一起分享

文章来源:智云一二三科技

文章标题:go语言编程:接口interface详解-深度好文值得一读

文章地址:https://www.zhihuclub.com/86541.shtml

关于作者: 智云科技

热门文章

发表评论

您的电子邮箱地址不会被公开。

网站地图