「读书笔记」了解接口类型变量的内部表示

304 阅读8分钟

接口

26 了解接口类型变量的内部表示

接口是 Go 这门静态类型语言中唯一“动静兼备”的语言特性。

  • 接口的静态特性
    • 接口类型变量具有静态类型,比如:var e error 中变量 e 的静态类型为 error。
    • 支持在编译阶段的类型检查:当一个接口类型变量被赋值时,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。
  • 接口的动态特性
    • 接口类型变量兼具动态类型,即在运行时存储在接口类型变量中的值的真实类型。比如:var i interface{} = 13 中接口变量 i 的动态类型为 int。
    • 接口类型变量在程序运行时可以被赋值为不同的动态类型变量,从而支持运行时多态

接口的动态类型让 Go 语言可以像纯动态语言(如 Python)中那样拥有使用“鸭子类型”的灵活性。

鸭子类型是动态编程语言用来实现多态的一种方式。他的原意是:如果一只鸟走起来像鸭子,游泳起来像鸭子,叫起来也像鸭子,那么它就是一只鸭子。引申为:只关心事物的外部行为而非内部结构。

nil error 值 != nil

package main

import (
   "errors"
   "fmt"
)

type MyError struct {
   error
}

var ErrBad = MyError{
   error: errors.New("bad error"),
}

func bad() bool {
   return false
}

func returnsError() error {
   var p *MyError = nil
   if bad() {
      p = &ErrBad
   }
   return p
}

func main() {
   e := returnsError()
   if e != nil {
      fmt.Printf("error: %+v\n", e)
      return
   }
   fmt.Println("ok")
}

上面的程序输出结果并非预期的 ok,而是满足了 e != nil 的条件进入错误处理分支,最终输出结果为 error: 。

接口类型变量的内部表示

接口类型变量在运行时的表示:

// $GOROOT/src/runtime/runtime2.go
type iface struct {
	tab  *itab
	data unsafe.Pointer
}

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

在运行时层面,接口类型变量有两种表示——eface 和 iface,这两种表示分别用于不同接口类型的变量:

  • eface:用于表示没有方法的空接口(empty interface)类型变量,即 interface{} 类型的变量。
  • iface:用于表示其余拥有方法的接口(interface)类型变量。

这两种结构的共同点是都有两个指针字段,并且第二个指针字段的功用相同,都指向当前赋值给该接口类型变量的动态类型变量的值。

不同点在于 eface 所表示的空接口类型并无方法列表,因此其第一个指针字段指向一个 _type 类型结构,该结构为该接口类型变量的动态类型的信息:

// $GOROOT/src/runtime/type.go
type _type struct {
   size       uintptr
   ptrdata    uintptr // size of memory prefix holding all pointers
   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) -> ==?
   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.
   gcdata    *byte
   str       nameOff
   ptrToThis typeOff
}

而 iface 除了要存储动态类型信息之外,还要存储接口本身的信息(接口的类型信息、方法列表信息等)以及动态类型所实现的方法的信息,因此 iface 的第一个字段指向一个 itab 类型的结构:

type itab struct {
   inter *interfacetype
   _type *_type
   hash  uint32 // copy of _type.hash. Used for type switches.
   _     [4]byte
   fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

上面 itab 结构中的第一个字段 inter 指向的 interfacetype 结构存储着该接口类型自身的信息。interfacetype 类型定义如下,该 interfacetype 结构由类型信息(typ)、包路径名(pkgpath)和接口方法集合切片(mhdr)组成。

type interfacetype struct {
   typ     _type
   pkgpath name
   mhdr    []imethod
}

itab 结构中的字段 _type 则存储着该接口类型变量的动态类型的信息,字段 fun 则是动态类型已实现的接口方法的调用地址数组。

虽然 eface(_type, data) 和 iface(tab, data) 的第一个字段有所差别,但 tab 和 _type 可统一看作动态类型的类型信息。Go 语言中每种类型都有唯一的 _type 信息,无论是内置原生类型,还是自定义类型。Go 运行时会为程序内的全部类型建立只读的共享 _type 信息表,因此拥有相同动态类型的同类接口类型变量的 _type/tab 信息是相同的。而接口类型变量的 data 部分则指向一个动态分配的内存空间,该内存空间存储的是赋值给接口类型变量的动态类型变量的值。未显式初始化的接口类型变量的值为 nil,即该变量的 _type/tab 和 data 都为 nil。这样,我们要判断两个接口类型变量是否相同,只需判断 _type/tab 是否相同以及 data 指针所指向的内存空间所存储的数据值是否相同(注意:不是 data 指针的值)。

我们可以使用 println 输出各类接口类型变量的内部表示信息:

  • nil 接口变量:未赋初始值的接口类型变量的值是 nil。无论是空接口类型变量还是非空接口类型变量,一旦变量值为 nil,那么它们内部表示均为 (0x0,0x0),即类型信息和数据信息均为空。

    func printNilInterface() {
       // nil接口变量
       var i interface{}                 // 空接口类型
       var err error                     // 非空接口类型
       println(i)                        // (0x0,0x0)
       println(err)                      // (0x0,0x0)
       println("i = nil:", i == nil)     // i = nil: true
       println("err = nil:", err == nil) // err = nil: true
       println("i = err:", i == err)     // i = err: true
    }
    
  • 空接口类型变量:只有在 _type 和 data 所指数据内容一致(注意:不是数据指针的值一致)的情况下,两个空接口类型变量之间才能画等号。Go 在创建 eface 时一般会为 data 重新分配内存空间,将动态类型变量的值复制到这块内存空间,并将 data 指针指向这块内存空间。因此我们在多数情况下看到的 data 指针值是不同的。但 Go 对于 data 的分配是有优化的,也不是每次都分配新内存空间,如下面的 0x1099828,显然是直接指向了一块事先创建好的静态数据区。

    func printEmptyInterface() {
       // empty接口变量
       var eif1 interface{} // 空接口类型
       var eif2 interface{} // 空接口类型
       var n, m int = 17, 18
    
       eif1 = n
       eif2 = m
    
       println("eif1:", eif1)                // eif1: (0x10726a0,0xc00006ef68)
       println("eif2:", eif2)                // eif2: (0x10726a0,0xc00006ef60)
       println("eif1 = eif2:", eif1 == eif2) // eif1 = eif2: false
    
       eif2 = 17
       println("eif1:", eif1)                // eif1: (0x10726a0,0xc00006ef68)
       println("eif2:", eif2)                // eif2: (0x10726a0,0x1099828)
       println("eif1 = eif2:", eif1 == eif2) // eif1 = eif2: true
    
       eif2 = int64(17)
       println("eif1:", eif1)                // eif1: (0x10726a0,0xc00006ef68)
       println("eif2:", eif2)                // eif2: (0x1072760,0x1099828)
       println("eif1 = eif2:", eif1 == eif2) // eif1 = eif2: false
    }
    
  • 非空接口类型变量:只有在 tab 和 data 所指数据内容一致的情况下,两个非空接口类型变量之间才能画等号。

    func printNonEmptyInterface() {
       var err1 error // 非空接口类型
       var err2 error // 非空接口类型
       err1 = (*T)(nil)
       println("err1:", err1)              // err1: (0x10c0708,0x0)
       println("err1 = nil:", err1 == nil) // err1 = nil: false
    
       err1 = T(5)
       err2 = T(6)
       println("err1:", err1)                // err1: (0x10c0768,0x10c0210)
       println("err2:", err2)                // err2: (0x10c0768,0x10c0218)
       println("err1 = err2:", err1 == err2) // err1 = err2: false
    
       err2 = fmt.Errorf("%d\n", 5)
       println("err1:", err1)                // err1: (0x10c0768,0x10c0210)
       println("err2:", err2)                // err2: (0x10c0688,0xc000010250)
       println("err1 = err2:", err1 == err2) // err1 = err2: false
    }
    
  • 空接口类型变量与非空接口类型变量的等值比较:空接口类型变量和非空接口类型变量内部表示的结构有所不同(第一个字段:_type vs tab),似乎一定不能相等。但 Go 在进行等值比较时,类型比较使用的是 eface 的 _type 和 iface 的 tab._type,因此如下所示,当 eif 和 err 都被赋值为 T(5) 时,两者之间是可以画等号的。

    func printEmptyInterfaceAndNonEmptyInterface() {
       var eif interface{} = T(5)
       var err error = T(5)
       println("eif:", eif)              // eif: (0x1007ff7c0,0x1007f4f78)
       println("err:", err)              // err: (0x10080b3e8,0x1007f4f78)
       println("eif = err:", eif == err) // eif = err: true
    
       err = T(6)
       println("eif:", eif)              // eif: (0x1007ff7c0,0x1007f4f78)
       println("err:", err)              // err: (0x10080b3e8,0x1007f4f80)
       println("eif = err:", eif == err) // eif = err: false
    }
    

接口类型的装箱原理

装箱(boxing)是指把值类型转换成引用类型,比如在 Java 中将一个 int 变量转换成 Integer 对象就是一个装箱操作。在 Go 语言中,将任意类型赋值给一个接口类型变量都是装箱操作,其实接口类型的装箱就是创建一个 eface 或 iface 的过程。

在将动态类型变量赋值给接口类型变量语句过程中,用到了 convT2E 和 convT2I 两个 runtime 包的函数。convT2E 用于将任意类型转换为一个 eface,convT2I 用于将任意类型转换为一个 iface,实现逻辑主要是根据传入的类型信息(convT2E 的 _type 和 convT2I 的 tab._type)分配一块内存空间,并将 elem 指向的数据复制到这块内存空间中,最后传入的类型信息作为返回值结构中的类型信息,返回值结构中的数据指针(data)指向新分配的那块内存空间。

经过装箱后,箱内的数据(存放在新分配的内存空间中)与原变量便无瓜葛了,除非是指针类型。

var n int = 61
var ei interface{} = n
n = 62
fmt.Println("data in box:", ei) // 61

var m int = 51
ei = &m
m = 52
p := ei.(*int)
fmt.Println("data in box:", *p) // 52

装箱是一个有性能损耗的操作,因此 Go 在不断对装箱操作进行优化,包括对常见类型(如整型、字符串、切片等)提供一系列快速转换函数,这些函数去除了 typedmemmove 操作,增加了零值快速返回等。

同时 Go 建立了 staticbytes 区域,对 byte 大小的值进行装箱操作时不再分配新内存,而是利用 staticbytes 区域的内存空间,如 bool 类型等。

// $GOROOT/src/runtime/iface.go
var staticuint64s = [...]uint64{
   0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
   0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
   ...
}

关注我

掘金:XQGang

Github: XQ-Gang

参考

《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明