Golang底层原理剖析之类型系统,接口与类型断言

简介: Golang底层原理剖析之类型系统,接口与类型断言

前言

关于interface源代码及使用相关点击浅谈Golang接口interface


前导


如果我们自定义一个结构体类型T,并给它关联一个方法F1,这个方法调用之前也介绍过,这个方法本质上就是函数,只不过在调用时接收者会作为第一个参数传入。这在编译阶段自然行得通。

但是到了执行阶段,反射,接口动态派发,类型断言,这些语言特性或机制,又该如何动态的获取数据类型信息呢?

类型系统


Go语言中,左边这些属于内置类型,而我们通过自己定义的类型,属于自定义类型,给内置类型定义方法是不允许的,而接口类型是无效的方法接收者。所以我们不能像类型T这样给内置类型和接口定义方法。


数据类型虽然多,但是不管是内置类型还是自定义类型,都有对应的类型描述信息,称为它的类型元数据。而且每种类型元数据都是全局唯一的,这些类型元数据共同构成了Go语言中的“类型系统”


而类型元数据这里,像类型名称,大小,对齐边界,是否为自定义类型等,是每个类型元数据都要记录的信息


所以被放到了runtime._type结构体中,作为每个类型元数据的Header,在_type之后存储的是各种类型格外需要描述的信息。例如slice的类型元数据在_type结构体后面,记录着一个*_tpye,指向其存储的元素的类型元数据,如果是string类型的slice,这个指针就指向string类型的元数据

如果是自定义类型,这后面还会有个uncommontype结构体,pkgpath记录类型所在的包路径,mcount记录了该类型关联到多少个方法,moff记录的是这些方法元数据组成的数组,相对于这个uncommontype结构体偏移了多少字节

例如我们基于[]string定义一个新类型myslice,它就是一个自定义类型,可以给它定义两个方法len和cap,myslice的类型元数据,首先是[]string的类型描述信息,然后在后面加上uncommontype结构体,注意通过uncommontype这里记录的信息,我们就可以找到给myslice定义的方法元数据在哪里了,如果uncommontype的地址的addrA,加上moff字节的偏移,就是myslice关联的方法元数据数组


接下来我们可以利用类型元数据,来解释别名和自定义类型的区别,MyType1这种写法,叫做给类型int32取别名,实际上MyType1和int32会关联到同一个类型元数据,属于同一中类型,rune和int32就是这样的关系,而MyType2这种写法,属于基于已有类型创建新类型,MyType2会自立门户,拥有自己的类型元数据。即使MyType2相对于int32来说没有任何改变,它们两个对应的类型元数据也已经不同了。

既然每种类型都有唯一对应的类型元数据,而类型定义的方法能通过类型元数据找到,那么很多问题就变得好解释了,例如下面要介绍的接口

认识接口

空接口

我们先来看看空接口长什么样子,空接口可以接收任意类型的数据,它只要记录这个数据在哪里,是什么类型的就足够了,这个_type就指向接口的动态类型元数据,data就指向接口的动态值


一个空接口类型的变量e,在它被赋值以前,_type和data都为nil。现在我们有一个os.File类型的变量f,了解类型系统之后,我们知道os.File对应这样的类型元数据,如果把f赋值给e,因为f本身就是个指针,所以这个data就等于f,而_type就指向os.File的类型元数据,我们已经知道,类型元数据这里可以找到os.File的方法元数据数组,里面就有我们常用的Read和Write这些方法的描述信息。好了,这就是空接口类型变量赋值前后的变化。

非空接口


来看看非空接口,非空接口就是有方法列表的接口类型,一个变量想要赋值给一个非空接口类型,必须要实现该接口要求的所有方法才行。与空接口类型一样,这个data字段指向接口的动态值,所有接口要求的方法列表,以及接口动态类型信息,一定存储在这个itab结构体里。


  • 第一个字段指向interface的类型元数据,接口要求的方法列表(mhdr)就记录在这里
  • 第二个字段指向接口的动态类型元数据
  • hash是从动态类型元数据中拷贝来的类型哈希值,用于快速判断类型是否相等时使用
  • fun记录的是这个动态类型实现的这个接口要求的这些方法的地址,它会从动态类型元数据中拷贝接口要求的那些方法的地址,以便通过接口快速定位到方法,而无需再去类型元数据那里查找



如果我们声明一个io.ReadWriter类型的变量rw,它被赋值以前,data为nil,tab也为nil,下面我们把一个os.File类型的变量f赋值给rw,此时rw的动态之就是f,而这个tab会指向一个itab结构体,它的接口类型为io.ReadWriter,动态类型为os.File,同时要注意itab这里的fun,它会从动态类型元数据中拷贝接口要求的那些方法的地址,以便通过rw快速定位到方法,而无需再去类型元数据那里查找,这就是非空接口的结构


关于itab,还要额外关注一点,我们知道一旦接口类型确定了,动态类型也确定了,那么itab的内容就不会改变了,所以这个不会改变的这个itab结构体是可复用的



实际上Go语言会把用到的itab结构体缓存起来,并且以接口类型和动态类型的组合为key,以itab结构体指针为value,构造一个哈希表,用于存储与查询itab缓存信息。需要一个itab时,会首先去这里查找。这里的哈希表和map底层的哈希表不同,是一种更为简便的设计

key的哈希值是这样计算的,用接口类型的类型哈希值,与动态类型的类型哈希值,进行异或运算,如果已经有对应的itab指针,就直接拿来使用

若itab缓存中没有,就要创建一个itab结构体,然后添加到这个哈希表中

ok,明确了空接口和非空接口的数据结构,理解了接口动态值与动态类型在赋值前后的变化,接下来就可以看看类型断言了

类型断言


我们知道接口可以分为空接口和非空接口两类,相对于接口这种抽象类型,int,string,slice等等,都可以被称为具体类型


类型断言作用在接口值之上,可以是空接口,也可以是非空接口。而断言的目标类型,可以是具体类型,也可以是非空接口类型。这样就组合出了四种类型断言,接下来我们就逐一看看,它们究竟是怎样断言的。

空接口.(具体类型)

e.(os.File)是要判断e的动态类型是否为os.File,这只需要确定这个_type是否指向os.File的类型元数据就好,之前介绍过,Go语言里每种类型的类型元数据都是全局唯一的,如果像这样给e赋值,e的动态之就是f,动态类型就是os.File,所以断言成功,ok为true,r被赋值为e的动态值f。


如果像这样赋值,e的动态类型就是string,类型断言就会失败,所以ok为false,r就会被赋值为*os.File的类型零值nil。

非空接口.(具体类型)


rw.(os.File)是要判断rw的动态类型是否为os.File,前面介绍过,程序中用到的itab结构体都会缓存起来,可以通过接口类型和动态类型组合起来的key,查找到对应的itab指针,所以这里的类型断言只需要一次比较就能完成。只要看它是否指向这个itab结构体就好。


如果rw这样赋值,它的动态值就是f,动态类型就是*os.File,所以tab指向这个itab结构体,类型断言成功,ok为true,r被赋值为rw的动态值


然而如果rw动态类型不是*os.File,此时tab就指向它,而不是<io.ReadWriter,os.File>,所以类型断言就会失败,ok为false,而r会被置为os.File的类型零值nil

空接口.(非空接口)



e.(io.ReadWriter)是要判断e的动态类型,是否实现了io.ReadWriter接口,我们已经介绍过类型关联的方法列表该去哪里找了。如果e像这样赋值,它的动态之就是f,动态类型就是os.File,虽然os.File类型数据后面可以找到类型关联的方法元数据数组,也不必每次都去检查这里是否有对应接口要求的所有方法



不是有itab缓存嘛,可以先去itab缓存中查找一下,如果没有io.ReadWrite和os.File对应的itab结构体,那么就去检查os.File的方法列表。值得强调的是,就算能从缓存中查找到对应的itab指针,也要进一步判断itab.fun[0]是否等于0,这是因为断言失败的类型组合,其对应的itab结构体也会被缓存起来,只是会把itab.fun[0]置为0,用以标识这里的动态类型并没有实现对应的接口,这样以后在遇到同样类型断言时,就不用再去检查方法列表了,可以直接断言失败


上例中类型断言是成功的,所以ok为true,rw就是一个io.ReadWriter接口类型的变量。其动态值与e相同,而tab就指向这一个itab结构体


如果e被赋值为一个字符串,它的动态类型就是string,并没有实现要求的Read和Write方法,所以对应的itab中fun[0]=0,并且会被添加到itab缓存中,这里断言失败,ok为false,rw为io.ReadWriter的类型零值


非空接口.(非空接口)



w.(io.ReadWriter)是要判断w存储的动态类型是否实现了io.ReadWriter接口,w是io.Writer类型,接口要求一个Write方法,io.ReadWriter接口要求实现Read和Write两个方法




如果w像这样赋值,其动态值就是f,tab指向这样一个itab结构体。要确定os.File是否实现了io.ReadWriter接口,同样会先去itab缓存里查找这个组合对应的itab指针,若存在,且itab.fun[0]!=0,则断言成功,若不存在,再次检查os.File的方法列表,并缓存itab信息,这里断言成功,ok为true,rw为io.ReadWriter类型的变量,动态值与w相同。

如果我们自定义一个eggo类型,并且*eggo类型只实现了io.Writer接口,并没有实现io.ReadWriter接口


若把一个eggo类型的变量赋值给w,此时w的动态类型为eggo,并没有实现指定接口,fun[0]=0,这个itab被缓存起来,类型断言失败,ok为false,rw的tab和data均为nil



所以类型断言的关键,是明确接口的动态类型,以及对应的类型实现了哪些方法,而明确这些的关键,还是“类型元数据”,以及空接口与非空接口的数据结构。

目录
相关文章
|
4月前
|
Java Go
Golang底层原理剖析之垃圾回收GC(二)
Golang底层原理剖析之垃圾回收GC(二)
46 0
|
4月前
|
存储 SQL 安全
Golang底层原理剖析之上下文Context
Golang底层原理剖析之上下文Context
57 0
|
4月前
|
编译器 Go
Golang底层原理剖析之method
Golang底层原理剖析之method
22 2
|
4月前
|
存储 Go
Golang底层原理剖析之map
Golang底层原理剖析之map
31 1
|
4月前
|
存储 编译器 Go
Golang底层原理剖析之闭包
Golang底层原理剖析之闭包
48 0
|
4月前
|
编译器 Go
Golang底层原理剖析之函数调用栈-传参和返回值
Golang底层原理剖析之函数调用栈-传参和返回值
19 0
|
5天前
|
安全 Go
Golang深入浅出之-接口(Interfaces)详解:抽象、实现与空接口
【4月更文挑战第22天】Go语言接口提供抽象能力,允许类型在不暴露实现细节的情况下遵循行为约定。接口定义了一组方法签名,类型实现这些方法即实现接口,无需显式声明。接口实现是隐式的,通过确保类型具有接口所需方法来实现。空接口`interface{}`接受所有类型,常用于处理任意类型值。然而,滥用空接口可能丧失类型安全性。理解接口、隐式实现和空接口的使用能帮助编写更健壮的代码。正确使用避免方法,如确保方法签名匹配、检查接口实现和谨慎处理空接口,是关键。
14 1
|
4月前
|
Java 编译器 Go
Golang底层原理剖析之内存逃逸
Golang底层原理剖析之内存逃逸
24 0
|
4月前
|
存储 算法 Java
Golang底层原理剖析之多路select、channel数据结构和阻塞与非阻塞
Golang底层原理剖析之多路select、channel数据结构和阻塞与非阻塞
24 0
|
4月前
|
存储 编译器 Go
Golang底层原理剖析之互斥锁sync.Mutex
Golang底层原理剖析之互斥锁sync.Mutex
30 0