接口
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,
...
}
关注我
参考
《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明