类型断言是Go语言中应用在接口值上的一个神奇特性,它有两种典型的使用方式:
1)判断接口值的动态类型是否为某个具体类型,如果是则进一步从接口值中提取出相应具体类型的值;
这里说的“具体类型”指的就是像int
、string
、slice
以及struct
这样有特定存储结构的类型。与“具体类型”相对的自然就是“抽象类型”,在Go语言中就是具有方法的接口类型,其抽象了对象行为而隐藏了背后实现。
2)判断接口值的动态类型是否满足某个目标接口类型,如果满足则进一步将其转换为目标接口值;
这里说的“目标接口类型”指的是具有方法的接口类型,如io.Reader
、fmt.Stringer
,而不是不含任何方法的接口类型interface{}
。我们称不包含任何方法的interface{}
为“空接口类型”,这里说的“空”是类型而不是值,注意不要和nil
混淆。
在Go语言的实现中,“空”接口类型的值和“非空”接口类型的值在存储结构的定义上是不相同的,空接口类型的值用runtime.eface
结构来存储,而非空接口类型的值用runtime.iface
结构来存储。下面是两种结构的具体定义:
runtime.eface
type eface struct {
_type *_type
data unsafe.Pointer
}
runtime.iface
type iface struct {
tab *itab
data unsafe.Pointer
}
从具体定义可以发现,两种结构的大小是相等的,都是由两个指针组成。两种结构都有一个unsafe.Pointer
类型的成员data
,用来存储具体类型值的地址,如果data
为nil
,相应的接口值就为nil
。
eface
结构中的另一个成员是指向_type
类型的指针,名字也是_type
。在Go程序的编译阶段,编译器会为每种在代码中被使用到的具体类型生成类型元数据,元数据里包含类型名、占用存储空间大小、自定义类型的方法列表等信息。在最终生成的可执行文件中,每种具体类型的元数据都是唯一的,并且以一个_type
类型的结构作为入口,也就意味着每种具体类型的_type
结构拥有唯一的地址。eface
的_type
字段存储的就是data
指针指向的值所属具体类型的类型元数据地址。
var i interface{}
// 此行实际上分配了一个runtime.eface结构,等价于如下代码:
// var i runtime.eface
//
// 你可能会疑惑,runtime.eface类型没有被导出,但这只是对用户代码的限制
// 编译器总是有权限做任何事情,就像g++能够调用initializer_list的私有构造函数
a := 10
i = a
// 此行实际上做了两件事情
// 1.将存储int类型元数据的runtime._type对象地址赋给i._type
// 2.将变量a的地址赋给i.data
//
// i._type = &go.types.int
// i.data = unsafe.Pointer(&a)
所以说eface
结构,本质上就是将具体类型值的地址和类型元数据地址打包在一起。在判断eface
接口值是否为某种具体类型时,直接比较_type
指针是否相等即可,这也是Go语言中类型断言的一种实现形式。
场景1 从interface{}
断言为某种具体类型:
func assert(v interface{}) {
if i, ok := v.(int); ok {
// todo ...
}
}
// v的实际类型为runtime.eface,上述断言实际上就是指针的相等性比较:
// ok := v._type == &go.types.int
//
// 这里如果不使用‘comma ok’ idiom,就会直接panic
iface
中的tab
字段是一个runtime.itab
类型的指针,runtime.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
类型被设计出来,最主要的目的就是实现Go语言的接口方法机制。要想通过接口值调用具体类型的方法,必须能够从接口值出发,找到对应方法的地址。如果像eface
那样先找到_type
,再通过查询元数据找到对应方法,运行时效率实在低下。所以itab
结构就是用来解决这个问题。
itab
的_type
字段同eface._type
一样,存储的是接口值具体类型的元数据地址,而interfacetype
结构存储的是“非空”接口类型的类型元数据,其定义如下:
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}
其中除了_type
结构外,还包含包路径pkgpath
和方法列表mhdr
,len(mhdr)
等于接口中方法的个数。方法列表中存储的是接口定义的所有方法的命子和类型信息。
itab
的fun
字段是一个指针数组,长度等于接口中方法的个数,里面存储的是各个接口方法的地址,就像C++的虚函数表。itab
结构有两种分配方式:一是被编译器静态生成,二是由运行时动态分配,两种情况都会为fun
数组分配合适大小的空间。
我们根据itab
的inter
字段,即接口的元数据信息,能够知道接口定义了哪些方法;根据itab
的_type
字段,即具体类型的元数据信息,能够知道具体类型实现了哪些方法,以及方法的地址;最后我们从具体类型的方法列表里逐一查找到接口定义的方法,并将方法的地址保存到fun
数组相应的位置。这样我们就完成了一个itab
的初始化,就像编译器和运行时所做的那样。
和C++的虚函数机制不同的是:一,不像C++的虚函数表完全在编译阶段生成,Go语言能够在运行时分配并初始化itab
;二,为了提高函数查找效率,_type
中具体类型实现的方法列表,以及interfacetype
中接口定义的方法列表mhdr
都是经过排序的,查找时只需遍历一次。
运行时负责分配和初始化itab
的函数是runtime.getitab
,如下函数声明:
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab
该函数的主要逻辑:
- 检查参数的合法性:要求
len(inter.mhdr)
大于0,即接口至少定义1个方法;要求typ
描述的具体类型是“自定义”类型,只有自定义类型才能有方法; - 首先检查
itabTable
里有没有缓存对应于这对(inter, typ)
的*itab
,有则直接从缓存中取出来,跳到第6步; - 没有缓存的情况下,持久内存分配
itab
对象,赋值itab.inter, itab._type = inter, typ
,然后调用itab
对象的init
方法; -
itab.init
方法会遍历匹配inter
和_type
的方法列表,并将找到的方法地址填写到itab.fun
数组对应位置,因为两个列表都是排序过的,所以只遍历一次效率很高。如果能够匹配到接口定义的所有方法,则返回空字符串"";如果有某个接口方法在_type
实现的方法列表中未找到,则赋值itab.fun[0] = 0
,然后返回未找到方法的name
; - 将
itab
添加到itabTable
中,使用(inter, typ)
作为key
; -
getitab
根据fun[0] != 0
判断是否成功,成功则返回*itab
,失败则根据canfail
参数决定返回nil
或者panic
。
由此看来,如果断言的目标类型是一个“非空”接口类型,或者直接说存储结构是iface
,那么断言逻辑需要检查源具体类型实现的方法列表能否满足接口定义的方法列表。从集合的角度来看,要求具体类型实现的方法集能够包含接口定义的方法集,用A表示接口定义的方法集,B表示具体类型实现的方法集,则有:
到了程序运行阶段,通过iface
调用接口方法时,就可以直接从iface.itab.fun
中按下标取到对应方法的地址,这点还是和虚函数机制很类似的。就像C++中同一个类的所有对象共享虚函数表一样,Go语言中拥有相同interfacetype
和_type
的所有iface
共享itab
,运行时有个全局的哈希表itabTable
,用来缓存已经初始化过的itab
。
Go语言的编译器总是尝试在编译阶段做尽可能多的事情,这样就能让运行时更高效。在某些情况下,编译器能够在上下文中获得足够的信息,来静态生成itab
,就像如下代码所示:
f, _ := os.Open("test.txt")
var rw io.ReadWriter
// 此行实际上分配了一个runtime.iface结构:
// var rw runtime.iface
rw = f
// 此行也是做了两件事情,用伪代码表示:
// rw.tab = &go.itab.*os.File,io.ReadWriter
// rw.data = unsafe.Pointer(&f)
//
// 编译器有足够的信息在编译阶段静态生成itab:目标接口类型和源具体类型均可从上下文得到
// 这里rw.tab.fun数组的大小为2,存储的分别是*os.File的Read和Write方法的地址
在某些情况下,编译器从上下文中无法得到足够的信息,所以就只能依靠运行时分配并初始化itab
。
场景2 从interface{}
断言为某种“非空”接口类型
func assert(v interface{}) {
if w, ok := v.(io.Writer); ok {
// todo ...
}
}
// v的实际类型为runtime.eface,v.data动态类型在编译阶段不可知,无法静态生成itab
//
// 从eface到iface,’comma ok’形式,通过runtime.assertE2I2函数进行处理
在Go语言运行时中,有4个函数用来处理目标类型为“非空”接口的断言:
// 判断iface的具体类型是否实现了inter要求的方法集,否则panic
func assertI2I(inter *interfacetype, i iface) (r iface)
// 判断iface的具体类型是否实现了inter要求的方法集,否则返回false,'comma ok'形式
func assertI2I2(inter *interfacetype, i iface) (r iface, b bool)
// 判断eface的具体类型是否实现了inter要求的方法集,否则panic
func assertE2I(inter *interfacetype, e eface) (r iface)
// 判断eface的具体类型是否实现了inter要求的方法集,否则返回false,'comma ok'形式
func assertE2I2(inter *interfacetype, e eface) (r iface, b bool)
这4个函数主要逻辑都是通过runtime.getitab
来实现的。
场景3 从一种“非空”接口类型断言为另一种“非空”接口类型
func assert(w io.Writer) {
if rw, ok := w.(io.ReadWriter); ok {
// todo ...
}
}
// w的实际类型为runtime.iface
// 本质上就是检查w.itab._type是否实现了io.ReadWriter定义的两个方法
// 通过runtime.assertI2I2函数进行处理
而对于源为“非空”接口类型,目标为具体类型的断言,编译器是能够在编译阶段生成itab
的,所以不需要运行时动态处理。
场景4 从“非空”接口类型断言为某种具体类型
func assert(w io.Writer) {
if f, ok := w.(*os.File); ok {
// todo ...
}
}
// v的实际类型为runtime.iface
// 具体类型为*os.File,接口类型为io.Writer,两个都能确定可以在编译阶段生成itab
//
// 本质上上述断言被实现为指针的相等性比较:
// ok := v.itab == &go.itab.*os.File,io.Writer
上面之所以把4个场景的示例代码都放在单独的函数中,是为了避免额外的上下文信息造成编译器的进一步优化,从而干扰实验效果。例如如下代码是不会用到runtime.assertI2I
的:
f, _ := os.Open("test.txt")
var w io.Writer = f
var rw io.ReadWriter = w.(io.ReadWriter)
// 编译器可以通过分析上下文,得知w的动态类型为*os.File,从而静态生成rw.itab
本文介绍了“空”接口类型值和“非空”接口类型值的不同存储结构runtime.eface
和runtime.iface
,以及具体类型元数据_type
和“非空”接口类型元数据interfacetype
在类型断言中起到的作用。在探讨类型断言的具体实现时,按照源类型是“空”或“非空”接口类型、目标类型是具体类型或者“非空”接口类型,分成了4种情况来研究。在目标类型为具体类型时,编译器可以在编译阶段生成需要的结构,运行阶段只需比较指针相等性;目标类型为“非空”接口类型时,需要通过runtime.assert
系列4个函数进行处理。