一文吃透 Go 语言解密之接口 interface

大家好,我是煎鱼。

自古流传着一个传言...在 Go 语言面试的时候必有人会问接口(interface)的实现原理。这又是为什么?为何对接口如此执着?

实际上,Go 语言的接口设计在整体扮演着非常重要的角色,没有他,很多程序估计都跑的不愉快了。

在 Go 语言的语义上,只要某个类型实现了所定义的一组方法集,则就认为其就是同一种类型,是一个东西。大家常常称其为鸭子类型(Duck typing),因为其与鸭子类型类型的定义相对吻合。

图来自网络后重整

在维基百科中,鸭子类型的谚语定义为 ”If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.“,翻译过来就是 ”如果它看起来像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那他就可以认为是鸭子“。

回归到 Go 语言,在接口之下,接口又蕴含了怎么样的底层结构,其设计原理和思考又是什么呢?我们不能只看表面,接下来在这一章节中都会进行一一分析和道来。看看其深层到底是何 “物”。

本文目录:

什么是 interface

Go 语言中的接口声明:

type Human interface {
 Say(s string) error
}

关键字主体为 type xxx interface,紧接着可以在方括号中编写方法集,用于声明和定义该接口所包含的方法集。

更进一步的代码演示:

type Human interface { Say(s string) error}

type TestA string

func (t TestA) Say(s string) error { fmt.Printf('煎鱼:%s\n', s) return nil}

func main() { var h Human var t TestA _ = t.Say('炸鸡翅') h = t _ = h.Say('烤羊排')}

输出结果:

煎鱼:炸鸡翅
煎鱼:烤羊排

我们在上述代码中,声明了一个名为 Human 的 interface,其包含一个 Say 方法。同时我们声明了一个 TestA 类型,也有自己的一个 Say 方法。他们两者的方法入参和出参类型均为一样。

而与此同时,我们在主函数 main 中通过声明和赋值,成功将类型为 TestA 的变量 t 赋给了类型为 Human 的变量 h,也就是说两者只因有了个 Say 方法,在 Go 语言的编译器中就认为他们是 “一样” 的了,这也就是业界中常说的鸭子类型。

数据结构

通过上面的功能代码一看,似乎 Go 语言非常优秀。一个接口,不同的类型,2 个包含相同的方法,也能够对标到一起。

接口到底是怎么实现的呢?底层数据结构又是什么?带着问题,我们开始深挖细节之路。

在 Go 语言中,接口的底层数据结构在运行时一共分为两类结构体(struct),分别是:

  • runtime.eface 结构体:表示不包含任何方法的空接口,也称为 empty interface。
  • runtime.iface 结构体:表示包含方法的接口。

runtime.eface

首先我们来介绍 eface,看看 “他” 到底是何许人也。源码如下:

type eface struct { _type *_type data  unsafe.Pointer}

其表示不包含任何方法的空接口。在结构上来讲 eface 非常简单,就两个属性,分别是 _type 和 data 属性,分别代表底层的指向的类型信息和指向的值信息指针。

再进一步到 type 属性里看看,其包含的类型信息更多:

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:类型的大小。
  • ptrdata:包含所有指针的内存前缀的大小。
  • hash:类型的 hash 值。此处提前计算好,可以避免在哈希表中计算。
  • tflag:额外的类型信息标志。此处为类型的 flag 标志,主要用于反射。
  • align:对应变量与该类型的内存对齐大小。
  • fieldAlign:对应类型的结构体的内存对齐大小。
  • kind:类型的枚举值。包含 Go 语言中的所有类型,例如:kindBoolkindIntkindInt8kindInt16 等。
  • equal:用于比较此对象的回调函数。
  • gcdata:存储垃圾收集器的 GC 类型数据。

总结一句,就是类型信息所需的信息都会存储在这里面,其中包含字节大小、类型标志、内存对齐、GC 等相关属性。而在 eface 来讲,其由于没有方法集的包袱,因此只需要存储类型和值信息的指针即可,非常简单。

runtime.iface

其次就是我们日常在应用程序中应用的较多的 iface,源码如下:

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

与 eface 结构体类型一样,主要也是分为类型和值信息,分别对应 tab 和 data 属性。但是我们再加思考一下,为什么 iface 能藏住那么多的方法集呢,难道施了黑魔法?

为了解密,我们进一步深入看看 itab 结构体。源码如下:

type itab struct {
 inter *interfacetype
 _type *_type
 hash  uint32 
 _     [4]byte
 fun   [1]uintptr 
}
  • inter:接口的类型信息。
  • _type:具体类型信息
  • hash_type.hash 的副本,用于目标类型和接口变量的类型对比判断。
  • fun:底层数组,存储接口的方法集的具体实现的地址,其包含一组函数指针,实现了接口方法的动态分派,且每次在接口发生变更时都会更新。

对应 func 属性会在后面的章节进一步展开讲解,便于大家对于接口中的函数指针管理的使用和理解,在此可以先行思考长度为 1 的 uintptr 数组是如何做到存储多方法的?

接下来我们进一步展开 interfacetype 结构体。源码如下:

type nameOff int32type typeOff int32

type imethod struct { name nameOff ityp typeOff}

type interfacetype struct { typ     _type pkgpath name mhdr    []imethod}
  • _type:接口的具体类型信息。
  • pkgpath:接口的包(package)名信息。
  • mhdr:接口所定义的函数列表。

而相对应 interfacetype,还有各种类型的 type。例如:maptypearraytypechantypeslicetype 等,都是针对具体的类型做的具体类型定义:

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

type chantype struct {
 typ  _type
 elem *_type
 dir  uintptr
}
...

若有兴趣自行翻看 runtime 里相应源码即可,都是一些基本数据结构信息的存储和配套方法,就不在此一一展开讲解了。

小结

总结来讲,接口的数据结构基本表示形式比较简单,就是类型和值描述。再根据其具体的区别,例如是否包含方法集,具体的接口类型等进行组合使用。

值接收者和指针接收者

在接口的具体应用使用场景中,有一个是大家常常会碰到,甚至会对其产生较大纠结心里的东西。那就是到底用值接收者,又或是用指针接收者来声明。

演示说明

演示代码如下:

type Human interface { Say(s string) error Eat(s string) error}

type TestA struct{}

func (t TestA) Say(s string) error { fmt.Printf('说煎鱼:%s\n', s) return nil}

func (t *TestA) Eat(s string) error { fmt.Printf('吃煎鱼:%s\n', s) return nil}

func main() { var h Human = &TestA{} _ = h.Say('催更') _ = h.Eat('真香')}

在 Human 接口中,其包含 Say 和 Eat 方法,并且在 TestA 结构体中我们进行了针对性的实现。

具体的区别就是:

  • 在 Say 方法中是值接收对象,如:(t TestA)
  • 在 Eat 方法中是指针接收对象,如:(t *TestA)

最终的输出结果:

说煎鱼:催更
吃煎鱼:真香

值和指针

如果我们将演示代码的主函数 main 改成下述这样:

func main() { var h Human = TestA{} _ = h.Say('催更') _ = h.Eat('真香')}

你觉得这段代码还能正常运行吗?在编译时会出现如下报错信息:

# command-line-arguments
./main.go:23:6: cannot use TestA literal (type TestA) as type Human in assignment:
 TestA does not implement Human (Eat method has pointer receiver)

显然是不能的。因为接口校验不对,编译器过不了。其根本原因在于 Eat 是指针接收者。而当声明改为 TestA{} 后,其就会变成值对象,所以不匹配。

这时候又会出现新的问题,为什么在上面代码声明为 &TestA{} 时,那肯定是指针引用了,那为什么 Say 方法又能正常运行,不会报错呢?

其实 TestA{} 实现了 Say 方法,那么 &TestA{} 也能自动拥有该方法。显然,这是 Go 语言自身在背后做了一些事情。

因此如果我们实现了一个值对象的接收者时,也会相应拥有了一个指针接收者。两者并不会互相影响,因为值对象会产生值拷贝,对象会独立开来。

而指针对象的接收者不行,因为指针引用的对象,在应用上是期望能够直接对源接收者的值进行修改,若又支持值接收者,显然是不符合其语义的。

两者怎么用

既然支持值接收,又支持指针接收。那平时在工程应用开发中,到底用谁?还是说随便用?

其实问题的答案,在前面就有提到。本质上还是要看你业务逻辑所期望修改的是什么?还是说程序很严谨,每次都重新 new 一个,是值又或是指针引用对于程序逻辑的结果都没有任何的影响。

总结一下,如果你想使用指针接收者,可以想想是否有以下诉求:

  • 期望接收者直接修改能够直接修改源值。
  • 期望在大结构体的情况下,性能更好,可以在理论上避免每次值拷贝,但也会有增加别的开销,需要具体情况具体权衡。

但若应用场景没什么区别,只是个人习惯问题就不用过于纠结了,适度统一也是很重要的一环。

类型断言

在 Go 语言中使用接口,必搭配一个 “技能”。那就是进行类型断言(type assertion):

var i interface{} = '吃煎鱼'

// 进行变量断言,若不判断容易出现 panics := i.(string)

// 进行安全断言s, ok := i.(string)

在 switch case 中,还有另外一种写法:

var i interface{} = '炸煎鱼'

// 进行 switch 断言
switch i.(type) {
case string:
    // do something...
case int:
    // do something...
case float64:
    // do something...
}

采取的是 (变量).(type) 的调用方式,再给予 case 不同的类型进行判断识别。在 Go 语言的背后,类型断言其实是在编译器翻译后,根据 iface 和 eface 分别对应了下述方法:

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}func assertI2I(inter *interfacetype, i iface) (r iface)

func assertE2I2(inter *interfacetype, e eface) (r iface, b bool)func assertE2I(inter *interfacetype, e eface) (r iface)

主要是根据接口的类型信息进行一轮判断和识别,基本就完成了。主要核心在于 getitab 方法,会在后面进行统一介绍和说明。

类型转换

演示代码如下:

func main() {
 x := '煎鱼'
 var v interface{} = x
 fmt.Println(v)
}

查看汇编代码:

 0x0021 00033 (main.go:9) LEAQ go.string.'煎鱼'(SB), AX 0x0028 00040 (main.go:9) MOVQ AX, (SP) 0x002c 00044 (main.go:9) MOVQ $6, 8(SP) 0x0035 00053 (main.go:9) PCDATA $1, $0 0x0035 00053 (main.go:9) CALL runtime.convTstring(SB) 0x003a 00058 (main.go:9) MOVQ 16(SP), AX 0x003f 00063 (main.go:10) XORPS X0, X0

主要对应了 runtime.convTstring 方法。同时很显然其是根据类型来区分来方法:

func convTstring(val string) (x unsafe.Pointer) {
 if val == '' {
  x = unsafe.Pointer(&zeroVal[0])
 } else {
  x = mallocgc(unsafe.Sizeof(val), stringType, true)
  *(*string)(x) = val
 }
 return
}

func convT16(val uint16) (x unsafe.Pointer)
func convT32(val uint32) (x unsafe.Pointer)
func convT64(val uint64) (x unsafe.Pointer)
func convTstring(val string) (x unsafe.Pointer) 
func convTslice(val []byte) (x unsafe.Pointer)
func convT2Enoptr(t *_type, elem unsafe.Pointer) (e eface)
func convT2I(tab *itab, elem unsafe.Pointer) (i iface)
...

动态分派

前面有提到接口中的 fun [1]uintptr 属性会可以存储接口的方法集,但不知道为什么。

接下来我们将进行具体的分析,演示代码:

type Human interface { Say(s string) error Eat(s string) error Walk(s string) error}

type TestA string

func (t TestA) Say(s string) error { fmt.Printf('煎鱼:%s\n', s) return nil}func (t TestA) Eat(s string) error { fmt.Printf('煎鱼:%s\n', s) return nil}

func (t TestA) Walk(s string) error { fmt.Printf('煎鱼:%s\n', s) return nil}

func main() { var h Human var t TestA h = t _ = h.Eat('烤羊排') _ = h.Say('炸鸡翅') _ = h.Walk('去炸鸡翅')}

存储方式

执行 go build -gcflags '-l' -o awesomeProject . 编译后,再次执行 go tool objdump -s 'main' awesomeProject

查看具体的汇编代码:

  LEAQ go.itab.main.TestA,main.Human(SB), AX 
  TESTB AL, 0(AX)     
  MOVQ 0x10(SP), AX    
  MOVQ AX, 0x28(SP)    
  MOVQ go.itab.main.TestA,main.Human+32(SB), CX 
  MOVQ AX, 0(SP)     
  LEAQ go.string.*+3048(SB), DX   
  MOVQ DX, 0x8(SP)    
  MOVQ $0x9, 0x10(SP)    
  CALL CX      
  MOVQ go.itab.main.TestA,main.Human+24(SB), AX 
  MOVQ 0x28(SP), CX    
  MOVQ CX, 0(SP)     
  LEAQ go.string.*+3057(SB), DX   
  MOVQ DX, 0x8(SP)    
  MOVQ $0x9, 0x10(SP)    
  CALL AX      
  MOVQ go.itab.main.TestA,main.Human+40(SB), AX 
  MOVQ 0x28(SP), CX    
  MOVQ CX, 0(SP)     
  LEAQ go.string.*+4973(SB), CX   
  MOVQ CX, 0x8(SP)    
  MOVQ $0xc, 0x10(SP)    
  CALL AX   

结合来看,虽然 fun 属性的类型是 [1]uintptr,只有一个元素,但其实就是存放了接口方法集的首个方法的地址信息,接着根据顺序往后计算并获取就好了。也就是说其是存在一定规律的。在存入方法时就决定了,所以获取也能明确。

我们进一步展开,看看 itab hash table 是如何获取和新增的。

获取 itab 元素

getitab 方法的主要作用是获取 itab 元素,若不存在则新增。源码如下:

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab { // 省略一些边界、异常处理 var m *itab

 t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable))) if m = t.find(inter, typ); m != nil {  goto finish }

 lock(&itabLock) if m = itabTable.find(inter, typ); m != nil {  unlock(&itabLock)  goto finish }

 m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys)) m.inter = inter m._type = typ m.hash = 0 m.init() itabAdd(m) unlock(&itabLock)finish: if m.fun[0] != 0 {  return m }

 panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})}
  • 调用 atomic.Loadp 方法加载并查找现有的 itab hash table,看看是否是否可以找到所需的 itab 元素。
  • 若没有找到,则调用 lock 方法对 itabLock 上锁,并进行重试(再一次查找)。
    • 若找到,则跳到 finish 标识的收尾步骤。
    • 若没有找到,则新生成一个 itab 元素,并调用 itabAdd 方法新增到全局的 hash table 中。
  • 返回 fun 属性的首位地址,继续后续业务逻辑。

新增 itab 元素

itabAdd 方法的主要作用是将所生成好的 itab 元素新增到 itab hash table 中。源码如下:

func itabAdd(m *itab) {
 // 省略一些边界、异常处理
 t := itabTable
 if t.count >= 3*(t.size/4) { // 75% load factor
  t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true))
  t2.size = t.size * 2
  iterate_itabs(t2.add)
  if t2.count != t.count {
   throw('mismatched count during itab table copy')
  }

atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
  t = itabTable
 }
 t.add(m)
}

  • 检查 itab hash table 的容量情况,查看容量情况是否已经满足大于或等于 75%。
  • 若满足扩容策略,则调用 mallocgc 方法申请内存,按既有 size 大小扩容双倍容量。
  • 若不满足扩容策略,则直接新增 itab 元素到 hash table 中。

总结

在本文中,我们先介绍了 Go 语言接口的 runtime.eface 和 runtime.iface 两个基本数据结构,其代表了一切的开端。

随后针对值接受者和指针接收者进行了详细的说明,同时日常用的较多的类型断言和转换也一一进行了描述。

最后对接口的多方法这个神秘的地方进行了基本分析和了解,相信这一番轮流吸收下来,能够打开大家对接口的一个新的理解。


分享 Go 语言、微服务和奇怪的系统设计

👆 长按关注煎鱼,在知识的海洋里遨游

(0)

相关推荐

  • 详解并发编程基础之原子操作(atomic包)

    Go语言中文网 今天 以下文章来源于Golang梦工厂 ,作者AsongGo Golang梦工厂Asong是一名Golang开发工程师,专注于Golang相关技术:Golang面试.Beego.Gin ...

  • Go 是面向对象编程风格的编程语言吗?

    Go编程时光 零基础 Go入门教程连载中... 43篇原创内容 公众号 01 介绍 Golang 语言是面向对象语言吗?Golang 语言官方的回答是 Yes and no.什么意思呢?Golang ...

  • 关于PHP的方法参数类型约束

    在之前的文章PHP方法参数的那点事儿中,我们讲过关于PHP方法参数的一些小技巧.今天,我们带来的是更加深入的研究一下PHP中方法的参数类型. 在PHP5之后,PHP正式引入了方法参数类型约束.也就是如 ...

  • 如何使用 Go 中的函数类型 (function types)?

    函数类型(function types)是一种很特殊的类型,它表示着所有拥有同样的入参类型和返回值类型的函数集合. 如下这一行代码,定义了一个名叫 Greeting 的函数类型 type Greeti ...

  • Go 与 C 的指针

    C 和 Go 都是有指针概念的语言,这篇文章主要借这两者之间的异同来加深对 Go 指针的理解和使用. 运算符 C 和 Go 都相同: & 运算符取出变量所在的内存地址 * 运算符取出指针变量所 ...

  • 你真的懂string与[]byte的转换了吗

    string类型和[]byte类型是我们编程时最常使用到的数据结构.本文将探讨两者之间的转换方式,通过分析它们之间的内在联系来拨开迷雾. 两种转换方式 标准转换 go中string与[]byte的互换 ...

  • PHP方法参数的那点事儿

    在所有的编程语言中,方法或者函数,都可以传递一些参数进来进行业务逻辑的处理或者计算.这没什么可说的,但是在PHP中,方法的参数还有许多非常有意思的能力,下面我们就来说说这方面的内容. 引用参数 涉及到 ...

  • 【Golang】图解Interface

    " 被动态值.动态类型整的晕头转向?还不快来认识下接口~" 01 - 空接口interface{} 空接口类型可以接收任意类型的数据,它只要记录这个数据在哪儿,是什么类型的就足够了 ...

  • 00后“黑话”:不同于90后火星文的“新语言”

    编辑导语:随着时代的更迭变迁,每个时代的人所讲述的语言都或多或少有些差异.当下00后亦是如此,他们的某些语言逐渐形成了独属于他们的理解圈层.而00后"黑话"的背后又反映了什么?也许 ...

  • 干货:史上最全的盘口语言解密:看懂少走十年弯路,堪称股市精华

    交易时,像一个运动员,买卖皆是出于本能的反应而不是思考,技巧上身时,心就忘了它.对于真正的超短线交易者来说,交易不是一个零和游戏,这里遍地是金子,到处皆有发财的机会,对于手续费用低廉的交易者来说,他们 ...

  • 电气设备的发热量如何计算?一文吃透

    (1)高.低压盘柜发热量 (2) 变压器发热量 (3) 母线.电缆发热量 (4)电抗器发热量 (5)发电机组发热量 (6)SFC静态变频启动装置发热量 (7) 照明设备发热量

  • 凡人说税:一文吃透零散个人代开发票小事件中的大问题

    零散个人临时经营申请代开增值税发票,究竟该如何征税,虽然是实务中的一个小问题,还真不是一句话就能说清楚的事.小问题之所以复杂,还是源于不同税费的政策制度的不统一.<发票管理办法>规定,需要 ...

  • ​一文吃透现金流量表

    ​一文吃透现金流量表 格隆汇 2020-09-17 16:40:47 关注 作者 | 晨光 数据支持 | 勾股大数据 来源 | 格隆汇探雷区(ID:glh-tlq) 一.简介 现金流量表,反映一定会计 ...

  • 《盘口语言解密系列绝技之一》#股票# #...

    《盘口语言解密系列绝技之一》#股票# #...

  • 涨停板高手进阶教程 62:涨停板盘口语言解密(图解)

    涨停板盘口语言解密 对于普通短线投资者来说,在盘中挂单通常是整数,比如10手.20手.30手等等,很少挂非整数的数量.但是在实战当中,我们经常能看到当日涨停或日后涨停的个股,在买卖盘挂单.主动性买卖成 ...

  • 苏王锐文:自然的语言(市一中高一学生高考作文展示之五)

    窗外.鸟鸣. 这耳熟能详的自然的语言,自记事时起,便在烙印在心的深处. 她睁大了懵懂的眸,望向已是一片春光烂漫的窗外,侧耳聆听,分辨在每一声呼啸而过的车轮声中.每一声高亢刺耳的鸣笛声中,夹杂的细柔而婉 ...

  • 【C社】一文吃透摊余成本、实际利率法(2020版)

    侵权必究!未经授权,不得转载以及其他商业用途! 一.概述 摊余成本即为对实际现金流总额与现金流折现额差额,在后期按照折现率(实际利率)摊销后的剩余的成本,该成本系未支付的本金和利息的折现值. 摊余成本 ...