我要投搞

标签云

收藏小站

爱尚经典语录、名言、句子、散文、日志、唯美图片

当前位置:彩66彩票 > 多态编程语言 >

深度解密Go语言之关于 interface 的 10 个问题

归档日期:05-29       文本归类:多态编程语言      文章编辑:爱尚语录

  的方方面面,有例子,有源码分析,有汇编分析,前前后后写了 20 多天。洋洋洒洒,长篇大论,依然有些东西没有涉及到,比如文章里没有写到反射,当然,后面会单独写一篇关于反射的文章,这是后话。

  还是希望看你在看完文章后能有所收获,有任何问题或意见建议,欢迎在文章后面留言。

  翻译过来就是:如果某个东西长得像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那它就可以被看成是一只鸭子。

  Duck Typing,鸭子类型,是动态编程语言的一种对象推断策略,它更关注对象能如何被使用,而不是对象的类型本身。Go 语言作为一门静态语言,它通过通过接口的方式完美支持鸭子类型。

  当调用此函数的时候,可以传入任意类型,只要它实现了 say_hello()函数就可以。如果没有实现,运行过程中会出现错误。

  而在静态语言如 Java, C++ 中,必须要显示地声明实现了某个接口,之后,才能用在任何需要这个接口的地方。如果你在程序中调用hello_world 函数,却传入了一个根本就没有实现 say_hello()的类型,那在编译阶段就不会通过。这也是静态语言比动态语言更安全的原因。

  动态语言和静态语言的差别在此就有所体现。静态语言在编译期间就能发现类型不匹配的错误,不像动态语言,必须要运行到那一行代码才会报错。插一句,这也是我不喜欢用python的一个原因。当然,静态语言要求程序员在编码阶段就要按照规定来编写程序,为每个变量规定数据类型,这在某种程度上,加大了工作量,也加长了代码量。动态语言则没有这些要求,可以让人更专注在业务上,代码也更短,写起来更快,这一点,写 python 的同学比较清楚。

  Go 语言作为一门现代静态语言,是有后发优势的。它引入了动态语言的便利,同时又会进行静态语言的类型检查,写起来是非常 Happy 的。Go 采用了折中的做法:不要求类型显示地声明实现了某个接口,只要实现了相关的方法即可,编译器就能检测到。

  在 main 函数中,调用调用 sayHello() 函数时,传入了 golang, php对象,它们并没有显式地声明实现了 IGreeting 类型,只是实现了接口所规定的 sayHello() 函数。实际上,编译器在调用 sayHello() 函数时,会隐式地将golang, php对象转换成 IGreeting 类型,这也是静态语言的类型检查功能。

  变量绑定的类型是不确定的,在运行期间才能确定 函数和方法可以接收任何类型的参数,且调用时不检查参数类型 不需要实现接口

  总结一下,鸭子类型是一种动态语言的风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由它当前方法和属性的集合决定。Go 作为一种静态语言,通过接口实现了鸭子类型,实际上是 Go 的编译器在其中作了隐匿的转换工作。

  方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。

  在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。

  也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。

  调用了 growUp 函数后,不管调用者是值类型还是指针类型,它的 Age值都改变了。

  实际上,当类型和方法的接收者类型不同时,其实是编译器在背后做了一些工作,用一个表格来呈现:

  实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针

  前面说过,不管接收者类型是值类型还是指针类型,都可以通过值类型或指针类型调用,这里面实际上通过语法糖起作用的。

  先说结论:实现了接收者是值类型的方法,相当于自动实现了接收者是指针类型的方法;而实现了接收者是指针类型的方法,不会自动生成对应接收者是值类型的方法。

  接着定义了一个结构体 Gopher,它实现了两个方法,一个值接收者,一个指针接收者。

  看出这两处代码的差别了吗?第一次是将&Gopher 赋给了 coder;第二次则是将Gopher 赋给了 coder。

  第二次报错是说,Gopher 没有实现 coder。很明显了吧,因为Gopher 类型并没有实现 debug方法;表面上看,*Gopher 类型也没有实现 code 方法,但是因为 Gopher 类型实现了 code 方法,所以让 *Gopher 类型自动拥有了 code方法。

  当然,上面的说法有一个简单的解释:接收者是指针类型的方法,很可能在方法中会对接收者的属性进行更改操作,从而影响接收者;而对于接收者是值类型的方法,在方法中不会对接收者本身产生影响。

  所以,当实现了一个接收者是值类型的方法,就可以自动生成一个接收者是对应指针类型的方法,因为两者都不会影响接收者。但是,当实现了一个接收者是指针类型的方法,如果此时自动生成一个接收者是值类型的方法,原本期望对接收者的改变(通过指针实现),现在无法实现,因为值类型会产生一个拷贝,不会真正影响调用者。

  如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

  如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。

  •避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。

  是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。

  如果类型具备“原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的header,而 header本身就是为复制设计的。

  如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体。

  iface 和 eface 都是 Go 中描述接口的底层结构体,区别在于 iface 描述的接口包含方法,而 eface则是不包含任何方法的空接口:interface{}。

  iface 内部维护两个指针,tab 指向一个 itab实体, 它表示接口的类型以及赋给这个接口的实体类型。data则指向接口具体的值,一般而言是一个指向堆内存的指针。

  再来仔细看一下 itab结构体:_type字段描述了实体的类型,包括内存对齐方式,大小等;inter字段则描述了接口的类型。fun字段放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时会更新此表,或者直接拿缓存的 itab。

  这里只会列出实体类型和接口相关的方法,实体类型的其他方法并不会出现在这里。如果你学过 C++ 的话,这里可以类比虚函数的概念。

  另外,你可能会觉得奇怪,为什么 fun数组的大小为 1,要是接口定义了多个方法可怎么办?实际上,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储。从汇编角度来看,通过增加地址就能获取到这些函数指针,没什么影响。顺便提一句,这些方法是按照函数名称的字典序进行排列的。

  可以看到,它包装了 _type 类型,_type实际上是描述 Go 语言中各种数据类型的结构体。我们注意到,这里还包含一个mhdr 字段,表示接口所定义的函数列表, pkgpath记录定义了接口的包名。

  相比 iface,eface就比较简单了。只维护了一个_type字段,表示空接口所承载的具体的实体类型。data描述了具体的值。

  上面两个函数的参数和 iface 及 eface结构体的字段是可以联系起来的:两个函数都是将参数组装一下,形成最终的接口。

  Go 语言各种数据类型都是在 _type字段的基础上,增加一些额外的字段来进行管理的:

  从源码里可以看到:iface包含两个字段:tab是接口表指针,指向类型信息;data是数据指针,则指向具体的数据。它们分别被称为动态类型和动态值。而接口值包括动态类型和动态值。

  接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为nil 的情况下,这个接口值就才会被认为 接口值 == nil。

  一开始,c 的 动态类型和动态值都为 nil,g 也为 nil,当把 g 赋值给 c 后,c 的动态类型变成了 *main.Gopher,仅管 c 的动态值仍为 nil,但是当 c 和 nil 作比较的时候,结果就是 false了。

  这里先定义了一个 MyError 结构体,实现了 Error 函数,也就实现了 error接口。Process 函数返回了一个 error接口,这块隐含了类型转换。所以,虽然它的值是nil,其实它的类型是 *MyError,最后和 nil 比较的时候,结果为 false。

  代码里直接定义了一个 iface 结构体,用两个指针来描述 itab 和 data,之后将 a, b, c 在内存中的内容强制解释成我们自定义的 iface。最后就可以打印出动态类型和动态值的地址。

  a 的动态类型和动态值的地址均为 0,也就是 nil;b 的动态类型和 c 的动态类型一致,都是*int;最后,c 的动态值为 5。

  这时候会有点懵,不知道作者想要干什么,实际上这就是此问题的答案。编译器会由此检查*myWriter 类型是否实现了 io.Writer接口。

  实际上,上述赋值语句会发生隐式地类型转换,在转换的过程中,编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数。

  总结一下,可通过在代码中添加类似如下的代码,用来检测类型是否实现了接口:

  为了研究清楚接口是如何构造的,接下来我会拿起汇编的武器,还原背后的真相。

  我们从第 10 行开始看,如果不理解前面几行汇编代码的话,可以回去看看公众号前面两篇文章,这里我就省略了。

  把每个字段的大小相加,itab结构体的大小就是 40 字节。上面那一串数字实际上是itab 序列化后的内容,注意到大部分数字是 0,从 24 字节开始的 4 个字节 da 9f 20 d4 实际上是 itab 的 hash值,这在判断两个类型是否相同的时候会用到。

  下面两行是链接指令,简单说就是将所有源文件综合起来,给每个符号赋予一个全局的位置值。这里的意思也比较明确:前8个字节最终存储的是type..Person 的地址,对应 itab 里的 inter字段,表示接口类型;8-16 字节最终存储的是type..Student 的地址,对应 itab 里 _type字段,表示具体类型。

  第二个参数就比较简单了,它就是数字 18 的地址,这也是初始化 Student结构体的时候会用到。

  这块代码比较简单,把 tab 赋给了 iface 的 tab字段;data 部分则是在堆上申请了一块内存,然后将 elem 指向的 18拷贝过去。这样iface就组装好了。

  检测 i.tab 是否是 nil,如果不是的线 个字节,也就是把 itab 的 _type 字段赋给了 CX,这也是接口的实体类型,最终要作为 fmt.Println 函数的参数

  后面,就是调用 fmt.Println函数及之前的参数准备工作了,不再赘述。

  了一个山寨版的 iface 和 itab,说它山寨是因为 itab 里的一些关键数据结构都不具体展开了,比如 _type,对比一下正宗的定义就可以发现,但是山寨版依然能工作,因为 _type就是一个指针而已嘛。

  在 main 函数里,先构造出一个接口对象 qcrao,然后强制类型转换,最后读取出 hash值,非常妙!你也可以自己动手试一下。

  值得一提的是,构造接口 qcrao 的时候,即使我把 age 写成其他值,得到的 hash 值依然不变的,这应该是可以预料的,hash值只和他的字段、方法相关。

  我们知道,Go 语言中不允许隐式类型转换,也就是说 =两边,不允许出现类型不相同的变量。

  类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。

  上面的代码里,我定义了一个 int 型和 float64型的变量,尝试在它们之前相互转换,结果是成功的:int 型和 float64是相互兼容的。

  前面说过,因为空接口 interface{}没有定义任何函数,因此 Go 中所有类型都实现了空接口。当一个函数的形参是interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。

  直接 panic 了,这是因为 i 是 *Student 类型,并非 Student类型,断言失败。这里直接发生了panic,线上代码可能并不适合这样做,可以采用“安全断言”的语法:

  断言其实还有另一种形式,就是用在利用 switch语句判断接口的类型。每一个case会被顺序地考虑。当命中一个case 时,就会执行 case 中的语句,因此 case 语句的顺序是很重要的,因为很有可能会有多个 case匹配的情况。

  main函数里有三行不同的声明,每次运行一行,注释另外两行,得到三组运行结果:

  i 是一个 *Student类型,匹配上第三个 case,从打印的三个地址来看,这三处的变量实际上都是不一样的。在main 函数里有一个局部变量 i;调用函数时,实际上是复制了一份参数,因此函数里又有一个变量v,它是 i的拷贝;断言之后,又生成了一份新的拷贝。所以最终打印的三个变量的地址都不一样。

  这里想说明的其实是 i 在这里动态类型是 (*Student), 数据为 nil,它的类型并不是 nil,它与 nil 作比较的时候,得到的结果也是 false。

  【引申1】 fmt.Println 函数的参数是 interface。对于内置类型,函数内部会用穷举法,得出它的真实类型,然后转换为字符串打印。而对于自定义类型,首先确定该类型是否实现了String() 方法,如果实现了,则直接打印输出 String()方法的结果;否则,会通过反射来遍历对象的成员进行打印。

  注意看两个函数的接受者类型不同,现在 Student 结构体只有一个接受者类型为 指针类型 的 String()函数,打印结果:

  类型 T 只有接受者是 T的方法;而类型*T 拥有接受者是 T 和 *T的方法。语法上T 能直接调 *T 的方法仅仅是 Go的语法糖。

  所以, Student 结构体定义了接受者类型是值类型的 String() 方法时,通过

  如果 Student 结构体定义了接受者类型是指针类型的 String() 方法时,只有通过

  通过前面提到的 iface 的源码可以看到,实际上它包含接口的类型 interfacetype 和 实体类型的类型 _type,这两者都是 iface 的字段 itab的成员。也就是说生成一个itab同时需要接口的类型和实体的类型。

  当判定一种类型是否满足某个接口时,Go 使用类型的方法集和接口所需要的方法集进行匹配,如果类型的方法集完全包含接口的方法集,则可认为该类型实现了该接口。

  例如某类型有 m 个方法,某接口有 n 个方法,则很容易知道这种判定的时间复杂度为 O(mn),Go 会对方法集的函数按照函数名的字典序进行排序,所以实际的时间复杂度为 O(m+n)。

  这里我们来探索将一个接口转换给另外一个接口背后的原理,当然,能转换的原因必然是类型兼容。

  简单解释下上述代码:定义了两个interface: coder 和 runner。定义了一个实体类型Gopher,类型 Gopher 实现了两个方法,分别是 run() 和 code()。main 函数里定义了一个接口变量c,绑定了一个 Gopher 对象,之后将 c 赋值给另外一个接口变量 r。赋值成功的原因是c 中包含 run()方法。这样,两个接口变量完成了转换。

  代码比较简单,函数参数 inter 表示接口类型,i 表示绑定了实体类型的接口,r 则表示接口转换了之后的新的 iface。通过前面的分析,我们又知道,iface 是由 tab 和 data两个字段组成。所以,实际上convI2I 函数真正要做的事,找到新 interface 的 tab 和 data,就大功告成了。

  简单总结一下:getitab 函数会根据interfacetype 和 _type去全局的 itab 哈希表中查找,如果能找到,则直接返回;否则,会根据给定的interfacetype 和 _type 新生成一个 itab,并插入到 itab 哈希表,这样下一次就可以直接拿到 itab。

  这里查找了两次,并且第二次上锁了,这是因为如果第一次没找到,在第二次仍然没有找到相应的 itab的情况下,需要新生成一个,并且写入哈希表,因此需要加锁。这样,其他协程在查找相同的itab 并且也没有找到时,第二次查找时,会被挂住,之后,就会查到第一个协程写入哈希表的 itab。

  additab 会检查 itab 持有的 interfacetype 和 _type 是否符合,就是看 _type 是否完全实现了 interfacetype 的方法,也就是看两者的方法列表重叠的部分就是 interfacetype所持有的方法列表。注意到其中有一个双层循环,乍一看,循环次数是ni * nt,但由于两者的函数列表都按照函数名称进行了排序,因此最终只执行了 ni + nt次,代码里通过一个小技巧来实现:第二层循环并没有从 0 开始计数,而是从上一次遍历到的位置开始。

  更一般的,当把实体类型赋值给接口的时候,会调用 conv 系列函数,例如空接口调用 convT2E 系列、非空接口调用 convT2I系列。这些函数比较相似:

  1.具体类型转空接口时,_type 字段直接复制源类型的 _type;调用 mallocgc 获得一块新内存,把值复制进去,data 再指向这块新内存。

  2.具体类型转非空接口时,入参 tab 是编译器在编译阶段预先生成好的,新接口 tab 字段直接指向入参 tab 指向的 itab;调用 mallocgc 获得一块新内存,把值复制进去,data 再指向这块新内存。

  3.而对于接口转接口,itab 调用 getitab 函数获取。只用生成一次,之后直接从 hash 表中获取。

  Go语言并没有设计诸如虚函数、纯虚函数、继承、多重继承等概念,但它通过接口却非常优雅地支持了面向对象的特性。

  main 函数里先生成 Student 和 Programmer 的对象,再将它们分别传入到函数 whatJob 和 growUp。函数中,直接调用接口函数,实际执行的时候是看最终传入的实体类型是什么,调用的是实体类型实现的函数。于是,不同对象针对同一消息就有多种表现,多态就实现了。

  更深入一点来说的话,在函数 whatJob() 或者 growUp() 内部,接口 person 绑定了实体类型 *Student 或者 Programmer。根据前面分析的iface 源码,这里会直接调用 fun里保存的函数,类似于:s.tab-fun[0],而因为 fun数组里保存的是实体类型实现的函数,所以当函数传入不同的实体类型时,调用的实际上是不同的函数实现,从而实现多态。

  C++ 的接口是使用抽象类来实现的,如果类中至少有一个函数被声明为纯虚函数,则这个类就是抽象类。纯虚函数是通过在声明中使用 = 0 来指定的。例如:

  设计抽象类的目的,是为了给其他类提供一个可以继承的适当的基类。抽象类不能被用于实例化对象,它只能作为接口使用。

  C++ 定义接口的方式称为“侵入式”,而 Go 采用的是 “非侵入式”,不需要显式声明,只需要实现接口定义的函数,编译器自动会识别。

  C++ 和 Go 在定义接口方式上的不同,也导致了底层实现上的不同。C++ 通过虚函数表来实现基类调用派生类的函数;而 Go 通过itab 中的 fun字段来实现接口变量调用实体类型的函数。C++ 中的虚函数表是在编译期生成的;而 Go 的itab 中的 fun字段是在运行期间动态生成的。原因在于,Go 中实体类型可能会无意中实现 N 多接口,很多接口并不是本来需要的,所以不能为类型实现的所有接口都生成一个itab, 这也是“非侵入式”带来的影响;这在 C++ 中是不存在的,因为派生需要显示声明它继承自哪个基类。

本文链接:http://chapmanswifts.com/duotaibianchengyuyan/379.html