分享
  1. 首页
  2. 文章

Go接口详解

pax0s · · 9831 次点击 · · 开始浏览
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

Go接口的设计和实现是Go整个类型系统的一大特点。接口组合和嵌入、duck typing等实现了优雅的代码复用、解耦、模块化的特性,而且接口是方法动态分派、反射的实现基础(当然更基础的是编译期为运行时提供的类型信息)。理解了接口的实现之后,就不难理解"著名"的nil返回值问题以及反射、type switch、type assertion等原理。本文主要基于Go1.8.1的源码介绍接口的内部实现及其使用相关的问题。


1. 接口的实现

(1) 下面是接口在runtime中的实现,注意其中包含了接口本身和实际数据类型的类型信息:

// src/runtime/runtime2.go
type iface struct {
 // 包含接口的静态类型信息、数据的动态类型信息、函数表
 tab *itab
 // 指向具体数据的内存地址比如slice、map等,或者在接口
 // 转换时直接存放小数据(一个指针的长度)
 data unsafe.Pointer
}
type itab struct {
 // 接口的类型信息
 inter *interfacetype
 // 具体数据的类型信息
 _type *_type
 link *itab
 hash uint32
 bad bool 
 inhash bool
 unused [2]byte
 // 函数地址表,这里放置和接口方法对应的具体数据类型的方法地址
 // 实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时
 // 会更新此表,或者直接拿缓存的itab
 fun [1]uintptr // variable sized
}

(2) 另外,需要注意与接口相关的两点优化,会影响到反射等的实现:

  • 空接口(interface{})的itab优化

当将某个类型赋值给空接口时,由于空接口没有方法,所以空接口eface的tab会直接指向数据的具体类型。在Go的reflect包中,reflect.TypeOf和reflect.ValueOf的参数都是空接口,因此所有参数都会先转换为空接口类型。这样反射就实现了对所有参数类型获取实际数据类型的统一。这在后面反射的基本实现中会分析到。

  • 发生"接口转换"时data字段相关的优化

当被转换为接口的数据的类型长度不超过一个指针的长度时(比如pointer、map、func、chan、[1]int等类型),接口转换时会将数据直接拷贝存放到接口的data字段中(DirectIface),而不再额外分配内存并拷贝。另外,从go1.8+的源码来看除了DirectIface的优化以外,还对长度较小(不超过64字节,未初始化数据内存的array,空字符串等)的零值做了优化,也不会重新分配内存,而是直接指向一个包级全局数组变量zeroVal的首地址。注意这里的优化发生在接口转换时生成的临时接口上,而不是被赋值的接口左值上。


(3) 再者,在Go中只有值传递(包括接口类型),与具体的类型实现无关,但是某些类型具有引用的属性。典型的9种非基础类型中:

  • array传递会拷贝整块数据内存,传递长度为len(arr) * Sizeof(elem)
  • string、slice、interface传递的是其runtime的实现,所以长度是固定的,分别为16、24、16字节(amd64)
  • map、func、chan、pointer传递的是指针,所以长度固定为8字节(amd64)
  • struct传递的是所有字段的内存拷贝,所以长度是所有字段的长度和
  • 详细的测试可以参考[这段程序](pass_by_value_main.go)

2. runtime中接口的转换操作

接口相关的操作主要在于对其内部字段itab的操作,因为接口转换最重要的是类型信息。这里简单分析几个runtime中相关的函数。主要实现在`src/runtime/iface.go`中。值得注意的是,接口的类型转换在编译期会生成一个函数调用的语法树节点(OCALL),调用runtime提供的相应接口转换函数完成接口的类型设置,所以接口的转换是在运行时发生的,其具体类型的方法地址表也是在运行时填写的,这一点和C++的虚函数表不太一样。另外,由于在运行时转换会产生开销,所以对转换的itab做了缓存。

type MyReader struct {
}
func (r MyReader) Read(b []byte) (n int, err error) {
}
// 接口的相关转换编译成对相关runtime函数的调用,比如convI2I/assertI2I等
var i io.Reader = MyReader{}
realReader := i.(MyReader)
var ei interface{} = interface{}(realReader)

下面以convI2I为例来说明,编译时生成OCALL语法树节点的过程。

// src/cmd/compile/internal/gc/walk.go
func convFuncName(from, to *types.Type) string {
	tkind := to.Tie()
	switch from.Tie() {
 // 将接口转换为另一接口,返回需要在runtime中调用的函数名
	case 'I':
		switch tkind {
		case 'I':
			return "convI2I"
		}
	case 'T':
		/* ... */
}
// src/cmd/compile/internal/gc/walk.go
// 这里只给出节点操作类型为OCONVIFACE(即inerface转换)的处理逻辑
func walkexpr(n *Node, init *Nodes) *Node {
 case OCONVIFACE:
 n.Left = walkexpr(n.Left, init)
 /* 这里省略了很多特殊的处理逻辑,比如空接口相关的优化 */
 // 到这里开始进入一般的接口转换
 	// 查找需要调用的runtime的函数,在Runtimepkg中查找
 fn := syslook(convFuncName(n.Left.Type, n.Type))
	fn = substArgTypes(fn, n.Left.Type, n.Type)
	dowidth(fn.Type)
 	// 生成函数调用节点
	n = nod(OCALL, fn, nil)
	n.List.Set(ll)
	n = typecheck(n, Erv)
	n = walkexpr(n, init)	
}

一旦itab的函数表设置后,后面的接口的方法调用只需要一次间接调用的开销,不需要反复查找方法的地址。关于接口的实现,Russ Cox写过一篇很好的文章


下面分析runtime中接口相关的几个主要函数:

  • getitab
// 根据接口类型和实际数据类型生成itab
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
 // 先从缓存中找
 h := itabhash(inter, typ)
 // look twice - once without lock, once with.
 // common case will be no lock contention.
 var m *itab
 var locked int
 for locked = 0; locked < 2; locked++ {
 if locked != 0 {
 lock(&ifaceLock)
 }
 for m = (*itab)(atomic.Loadp(unsafe.Pointer(&hash[h]))); m != nil; m = m.link {
 // 找到
 if m.inter == inter && m._type == typ {
 if m.bad {
 if !canfail {
 // 检查并绑定方法地址表
 additab(m, locked != 0, false)
 }
 m = nil
 }
 if locked != 0 {
 unlock(&ifaceLock)
 }
 return m
 }
 }
 }
 // 缓存中没找到则分配itab的内存: itab结构本身内存 + 末尾存方法地址表的可变长度
 m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
 m.inter = inter // 设置接口类型信息
 m._type = typ // 设置实际数据类型信息
 additab(m, true, canfail) // 设置itab函数调用表
 unlock(&ifaceLock)
 if m.bad {
 return nil
 }
 return m
}
  • additab
// 检查具体类型是否实现了接口规定的方法,并使用具体类型的方法
// 地址填充方法表。
func additab(m *itab, locked, canfail bool) {
 inter := m.inter
 typ := m._type
 x := typ.uncommon()
 ni := len(inter.mhdr) // 接口方法数量
 nt := int(x.mcount) // 实际数据类型方法数量
 xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
 j := 0
 for k := 0; k < ni; k++ {
 // 对每个接口方法的地址
 i := &inter.mhdr[k]
 // 使用接口的类型信息获取实际类型, 函数名字,包名字
 itype := inter.typ.typeOff(i.ityp)
 name := inter.typ.nameOff(i.name)
 iname := name.name()
 ipkg := name.pkgPath()
 if ipkg == "" {
 ipkg = inter.pkgpath.name()
 }
 for ; j < nt; j++ {
 // 对每个具体类型的方法
 t := &xmhdr[j]
 tname := typ.nameOff(t.name)
 // 具体类型的方法类型和接口方法的类型相同,并且名字相同,则匹配成功
 if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
 pkgPath := tname.pkgPath()
 if pkgPath == "" {
 pkgPath = typ.nameOff(x.pkgpath).name()
 }
 if tname.isExported() || pkgPath == ipkg {
 if m != nil {
 // 具体类型的某个方法地址
 ifn := typ.textOff(t.ifn)
 // 填充itab的func表地址
 *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = ifn
 }
 goto nextimethod
 }
 }
 }
 // didn't find method
 // 不匹配panic
 if !canfail {
 if locked {
 unlock(&ifaceLock)
 }
 panic(&TypeAssertionError{"", typ.string(), inter.typ.string(), iname})
 }
 // 或者设置失败标识
 m.bad = true
 break
 nextimethod:
 }
 if !locked {
 throw("invalid itab locking")
 }
 h := itabhash(inter, typ)
 m.link = hash[h]
 m.inhash = true
 // 存到itab的hash表缓存
 atomicstorep(unsafe.Pointer(&hash[h]), unsafe.Pointer(m))
}

  • convI2I
// 将已有的接口,转换为新的接口类型,失败panic
// 比如:
// var rc io.ReadCloser
// var r io.Reader
// rc = io.ReadCloser(r)
func convI2I(inter *interfacetype, i iface) (r iface) {
 tab := i.tab
 if tab == nil {
 return
 }
 // 接口类型相同直接赋值即可
 if tab.inter == inter {
 r.tab = tab
 r.data = i.data
 return
 }
 // 否则重新生成itab
 r.tab = getitab(inter, tab._type, false)
 // 注意这里没有分配内存拷贝数据
 r.data = i.data
 return
}
  • convT2I

// 使用itab并拷贝数据,得到iface
func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
 t := tab._type
 if raceenabled {
 raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&tab)), funcPC(convT2I))
 }
 if msanenabled {
 msanread(elem, t.size)
 }
 // 注意这里发生了内存分配和数据拷贝
 x := mallocgc(t.size, t, true)
 // memmove内部的拷贝对大块内存做了优化
 typedmemmove(t, x, elem)
 i.tab = tab
 i.data = x
 return
}

从上面convX2I我们可以看到,在接口类型之间转换时,并没有分配内存和拷贝数据,但是将非接口类型转换为接口类型时,却发生了内存分配和数据拷贝。这里的原因是Go接口的数据不能被改变,所以接口之间的转换可以使用同一块内存,但是其他情况为了避免外部改变导致接口内数据改变,所以会进行内存分配和数据拷贝。另外,这也是反射非指针变量时无法直接改变变量数据的原因,因为反射会先将变量转换为空接口类型。可以参考go-nuts。这里我们用一个简单的程序测试一下。

package main
import "fmt"
type Data struct {
 n int
}
func main() {
 d := Data{10}
 fmt.Printf("address of d: %p\n", &d)
 // assign not interface type variable to interface variable
 // d will be copied
 var i1 interface{} = d
 // assign interface type variable to interface variable
 // the data of i1 will directly assigned to i2.data and will not be copied
 var i2 interface{} = i1
 fmt.Println(d)
 fmt.Println(i1)
 fmt.Println(i2)
}
// 关掉优化和inline
go build -gcflags "-N -l" interface.go
// 可以看到接口变量i1和i2的数据地址是相同的,但是d和i1的数据地址不相同
(gdb) info locals 
&d = 0xc420074168
i2 = {_type = 0x492e00, data = 0xc4200741a0}
i1 = {_type = 0x492e00, data = 0xc4200741a0}

3. type assertion与type switch

理解了接口的实现,不难猜测type assertion和type switch的实现逻辑,我们只需要取出接口的动态类型(数据类型)与目标类型做比较即可,而目标类型的信息在编译期是可以确定下来的。可以参考Effective Go中的简单例子。


4. nil接口的问题

具体的代码可参考nil接口返回值测试。理解了接口的底层实现,这个问题其实也比较好理解了。需要说明的是nil在Go中既指空值,也指空类型。这里的空值并非零值,空值是指未初始化,比如slice没有分配底层的内存。只有chan、interface、func、slice、map、pointer可直接与nil比较和用nil赋值。对于非接口类型来说,对其赋值nil的语义是将其数据变为未初始化的状态,而给接口类型来说,还会将接口的类型信息字段itab置nil。所以:

type MyReader interface {
}
var r MyReader // (nil, nil)
var n *int = nil
var r1 MyReader = n // (*int, nil)
var r2 MyReader // (nil, nil)
var inter interface{} = r2 // (nil, nil)

5. 接口与反射

反射实现的一个基本前提是编译期为运行时提供足够的类型信息,一般来说都会使用一个基本类型(比如Go中的interface、Java中的Object)来存放具体类型的信息,以便在运行时使用。C++到目前为止也没有比较成熟的反射库,大部分原因就是没有比较好的方法提供运行时所需的类型信息,typeid等运行时信息远远不够。Go的反射的实现就是基于interface的。这里简单分析两个常用方法`reflect.TypeOf, reflect.ValueOf`的实现。

// src/reflect/value.go
// 注意: 从前面的分析可知当转换为空接口的时候,itab指针会直接
// 指向数据的实际类型,所以反射的入口函数参数类型是interface{},
// 转换后,emptyInterface的rtype字段会直接指向数据类型,所以
// 整个反射才能直接得到数据类型,不然itab指向内存的前面部分包含
// 的是接口的静态类型信息
type emptyInterface struct {
 typ *rtype
 word unsafe.Pointer
}
// src/reflect/type.go
func TypeOf(i interface{}) Type {
 // 参数i已经是空接口类型
 eface := *(*emptyInterface)(unsafe.Pointer(&i))
 return toType(eface.typ)
}
// src/reflect/value.go
func ValueOf(i interface{}) Value {
 if i == nil {
 return Value{}
 }
 escapes(i)
 return unpackEface(i)
}
// src/reflect/value.go
func unpackEface(i interface{}) Value {
 // 参数i已经是空接口类型
 e := (*emptyInterface)(unsafe.Pointer(&i))
 t := e.typ
 if t == nil {
 return Value{}
 }
 f := flag(t.Kind())
 if ifaceIndir(t) {
 f |= flagIndir
 }
 return Value{t, e.word, f}
}

6. 接口与duck typing

严格说来,Go的接口可能并不算真正的duck typing,看一个Python和Go对比的例子。在这个例子中我们并不管传入的类型是什么,也不用在乎Say方法返回的类型是什么。而在Go中,实现接口的Say方法的返回值类型也必须相同。但是,这两个例子中都不需要显式指定实现的接口,这对于代码的重构极其有利,这也是Go的接口相对于Java等接口的优势。

# python duck typing
def callSay(a):
 ret = a.Say()
 print ret
class SayerInt(object):
 def Say():
 return 1
class SayerString(object):
 def Say():
 return "string"
si = SayerInt()
ss = SayerString()
callSay(si)
callSay(ss)
// Go
type Sayer interface {
 Say() int
}
func callSay(sayer Sayer) {
 sayer.Say()
}
type Say1Struct struct {
}
func (s Say1Struct) Say() int {
 return 1
}
type Say2Struct struct {
}
func (s Say2Struct) Say() int {
 return 2
}
s1 := &Say1Struct{}
s2 := &Say2Struct{}
callSay(s1)
callSay(s2)

7. 总结

综上,接口在Go的整个类型系统起到重要的作用,而且是反射、方法动态分派、type switch、type assertion等的实现基础。另外,接口组合和duck typing特性也让整个类型层次变得更加扁平,写起来更加简洁且有利于重构。理解了接口的底层实现,也更容易避免Go使用中的很多问题。


有疑问加站长微信联系(非本文作者)

本文来自:知乎专栏

感谢作者:pax0s

查看原文:Go接口详解

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

关注微信
9831 次点击
添加一条新回复 (您需要 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传

用户登录

没有账号?注册
(追記) (追記ここまで)

今日阅读排行

    加载中
(追記) (追記ここまで)

一周阅读排行

    加载中

关注我

  • 扫码关注领全套学习资料 关注微信公众号
  • 加入 QQ 群:
    • 192706294(已满)
    • 731990104(已满)
    • 798786647(已满)
    • 729884609(已满)
    • 977810755(已满)
    • 815126783(已满)
    • 812540095(已满)
    • 1006366459(已满)
    • 692541889

  • 关注微信公众号
  • 加入微信群:liuxiaoyan-s,备注入群
  • 也欢迎加入知识星球 Go粉丝们(免费)

给该专栏投稿 写篇新文章

每篇文章有总共有 5 次投稿机会

收入到我管理的专栏 新建专栏