Go接口详解

Go接口详解

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使用中的很多问题。

编辑于 2018-01-10

文章被以下专栏收录