Go语言对象模型 之 类型断言

类型断言是Go语言中应用在接口值上的一个神奇特性,它有两种典型的使用方式:

1)判断接口值的动态类型是否为某个具体类型,如果是则进一步从接口值中提取出相应具体类型的值;
这里说的“具体类型”指的就是像intstringslice以及struct这样有特定存储结构的类型。与“具体类型”相对的自然就是“抽象类型”,在Go语言中就是具有方法的接口类型,其抽象了对象行为而隐藏了背后实现。

2)判断接口值的动态类型是否满足某个目标接口类型,如果满足则进一步将其转换为目标接口值;
这里说的“目标接口类型”指的是具有方法的接口类型,如io.Readerfmt.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,用来存储具体类型值的地址,如果datanil,相应的接口值就为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语言中类型断言的一种实现形式。

场景1interface{}断言为某种具体类型:

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和方法列表mhdrlen(mhdr)等于接口中方法的个数。方法列表中存储的是接口定义的所有方法的命子和类型信息。

itabfun字段是一个指针数组,长度等于接口中方法的个数,里面存储的是各个接口方法的地址,就像C++的虚函数表。itab结构有两种分配方式:一是被编译器静态生成,二是由运行时动态分配,两种情况都会为fun数组分配合适大小的空间。

我们根据itabinter字段,即接口的元数据信息,能够知道接口定义了哪些方法;根据itab_type字段,即具体类型的元数据信息,能够知道具体类型实现了哪些方法,以及方法的地址;最后我们从具体类型的方法列表里逐一查找到接口定义的方法,并将方法的地址保存到fun数组相应的位置。这样我们就完成了一个itab的初始化,就像编译器和运行时所做的那样。

和C++的虚函数机制不同的是:一,不像C++的虚函数表完全在编译阶段生成,Go语言能够在运行时分配并初始化itab;二,为了提高函数查找效率,_type中具体类型实现的方法列表,以及interfacetype中接口定义的方法列表mhdr都是经过排序的,查找时只需遍历一次。

运行时负责分配和初始化itab的函数是runtime.getitab,如下函数声明:

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab

该函数的主要逻辑:

  1. 检查参数的合法性:要求len(inter.mhdr)大于0,即接口至少定义1个方法;要求typ描述的具体类型是“自定义”类型,只有自定义类型才能有方法;
  2. 首先检查itabTable里有没有缓存对应于这对(inter, typ)*itab,有则直接从缓存中取出来,跳到第6步;
  3. 没有缓存的情况下,持久内存分配itab对象,赋值itab.inter, itab._type = inter, typ,然后调用itab对象的init方法;
  4. itab.init方法会遍历匹配inter_type的方法列表,并将找到的方法地址填写到itab.fun数组对应位置,因为两个列表都是排序过的,所以只遍历一次效率很高。如果能够匹配到接口定义的所有方法,则返回空字符串"";如果有某个接口方法在_type实现的方法列表中未找到,则赋值itab.fun[0] = 0,然后返回未找到方法的name
  5. itab添加到itabTable中,使用(inter, typ)作为key
  6. getitab根据fun[0] != 0判断是否成功,成功则返回*itab,失败则根据canfail参数决定返回nil或者panic
getitab.png

由此看来,如果断言的目标类型是一个“非空”接口类型,或者直接说存储结构是iface,那么断言逻辑需要检查源具体类型实现的方法列表能否满足接口定义的方法列表。从集合的角度来看,要求具体类型实现的方法集能够包含接口定义的方法集,用A表示接口定义的方法集,B表示具体类型实现的方法集,则有:

A \subset 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

场景2interface{}断言为某种“非空”接口类型

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.efaceruntime.iface,以及具体类型元数据_type和“非空”接口类型元数据interfacetype在类型断言中起到的作用。在探讨类型断言的具体实现时,按照源类型是“空”或“非空”接口类型、目标类型是具体类型或者“非空”接口类型,分成了4种情况来研究。在目标类型为具体类型时,编译器可以在编译阶段生成需要的结构,运行阶段只需比较指针相等性;目标类型为“非空”接口类型时,需要通过runtime.assert系列4个函数进行处理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容