抖音技术能力大揭密!钜惠大礼、深度体验,尽在火山引擎增长沙龙,就等你来! 立即报名>> 了解详情
写点什么

深度解密 Go 语言:关于 interface 的 10 个问题(上)

2019 年 9 月 17 日

深度解密 Go 语言:关于 interface 的 10 个问题(上)

本文篇幅较长,故分为上下两篇,此篇为上篇。本文基本上涵盖了 interface 的方方面面,有例子,有源码分析,有汇编分析,前前后后写了 20 多天。不足之处欢迎留言批评指正,同时有些东西没有涉及到(关于「反射」,作者后期将单独分享文章。)


目录

  • 1、Go 语言与鸭子类型的关系

  • 2、值接者和指针接收者的区别

  • 方法

  • 值接收者和指针接收者

  • 两者分别在何时使用

  • 3、face eface 的区别是什么

  • 4、接口的动态类型和动态值

  • 5、编译器自动检测类型是否实现接口

  • 6、接口的构造过程是怎样的

  • 7、类型转换和断言的区别

  • 类型转换

  • 断言

  • 8、接口转换的原理

  • 9、如何用 interface 实现多态

  • 10、接口与 C++接口有何异同


1. Go 语言与鸭子类型的关系

先直接来看维基百科里的定义:


If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
复制代码


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


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


例如,在动态语言 python 中,定义一个这样的函数:


def hello_world(coder):    coder.say_hello()
复制代码


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


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


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


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


来看个例子:


先定义一个接口,和使用此接口作为参数的函数:


type IGreeting interface {    sayHello()}
func sayHello(i IGreeting) { i.sayHello()}
复制代码


再来定义两个结构体:


type Go struct {}func (g Go) sayHello() {    fmt.Println("Hi, I am GO!")}
type PHP struct {}func (p PHP) sayHello() { fmt.Println("Hi, I am PHP!")}
复制代码


最后,在 main 函数里调用 sayHello() 函数:


func main() {    golang := Go{}    php := PHP{}
sayHello(golang) sayHello(php)}
复制代码


程序输出:


Hi, I am GO!Hi, I am PHP!
复制代码


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


顺带再提一下动态语言的特点:


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


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


2、值接收者和指针接收者的区别

方法

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


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


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


来看个例子:


package main
import "fmt"
type Person struct { age int}
func (p Person) howOld() int {return p.age}
func (p *Person) growUp() { p.age += 1}
func main() {// qcrao 是值类型 qcrao := Person{age: 18}
// 值类型 调用接收者也是值类型的方法 fmt.Println(qcrao.howOld())
// 值类型 调用接收者是指针类型的方法 qcrao.growUp() fmt.Println(qcrao.howOld())
// ----------------------
// stefno 是指针类型 stefno := &Person{age: 100}
// 指针类型 调用接收者是值类型的方法 fmt.Println(stefno.howOld())
// 指针类型 调用接收者也是指针类型的方法 stefno.growUp() fmt.Println(stefno.howOld())}
复制代码


上例子的输出结果是:


1819100101
复制代码


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


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


-值接收者指针接收者
值类型调用者方法会使用调用者的一个副本,类似于“传值”使用值的引用来调用方法,上例中,qcrao.growUp()实际上是 (&qcrao).growUp()
指针类型调用者指针被解引用为值,上例中,stefno.howOld() 实际上是 (*stefno).howOld()实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针


值接收者和指针接收者

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


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


来看一个例子,就会完全明白:


package main
import "fmt"
type coder interface { code() debug()}
type Gopher struct { language string}
func (p Gopher) code() { fmt.Printf("I am coding %s language\n", p.language)}
func (p *Gopher) debug() { fmt.Printf("I am debuging %s language\n", p.language)}
func main() { var c coder = &Gopher{"Go"} c.code() c.debug()}
复制代码


上述代码里定义了一个接口 coder,接口定义了两个函数:


code()debug()
复制代码


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


最后,我们在 main 函数里通过接口类型的变量调用了定义的两个函数。


运行一下,结果:


I am coding Go languageI am debuging Go language
复制代码


但是如果我们把 main 函数的第一条语句换一下:


func main() {    var c coder = Gopher{"Go"}    c.code()    c.debug()}
复制代码


运行一下,报错:


./main.go:24:6: cannot use Programmer literal (type Programmer) as type coder in assignment:    Programmer does not implement coder (debug method has pointer receiver)
复制代码


看出这两处代码的差别了吗?第一次是将 &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)就不应该被复制,应该只有一份实体。


3、iface 和 eface 的区别是什么

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


从源码层面看一下:


type iface struct {    tab  *itab    data unsafe.Pointer}
type itab struct { inter *interfacetype _type *_type link *itab hash uint32 // copy of _type.hash. Used for type switches. bad bool // type does not implement interface inhash bool // has this itab been added to hash? unused [2]byte fun [1]uintptr // variable sized}
复制代码


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


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


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


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


再看一下 interfacetype 类型,它描述的是接口的类型:


type interfacetype struct {    typ     _type    pkgpath name    mhdr    []imethod}
复制代码


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


这里通过一张图来看下 iface 结构体的全貌:



接着来看一下 eface 的源码:


type eface struct {    _type *_type    data  unsafe.Pointer}
复制代码


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



我们来看个例子:


package mainimport "fmt"func main() {    x := 200    var any interface{} = x    fmt.Println(any)    g := Gopher{"Go"}    var c coder = g    fmt.Println(c)}type coder interface {    code()    debug()}type Gopher struct {    language string}func (p Gopher) code() {    fmt.Printf("I am coding %s language\n", p.language)}func (p Gopher) debug() {    fmt.Printf("I am debuging %s language\n", p.language)}
复制代码


执行命令,打印出汇编语言:


go tool compile -S ./src/main.go
复制代码


可以看到,main 函数里调用了两个函数:


func convT2E64(t *_type, elem unsafe.Pointer) (e eface)func convT2I(tab *itab, elem unsafe.Pointer) (i iface)
复制代码


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


作为补充,我们最后再来看下 _type 结构体:


type _type struct {    // 类型大小    size       uintptr    ptrdata    uintptr    // 类型的 hash 值    hash       uint32    // 类型的 flag,和反射相关    tflag      tflag    // 内存对齐相关    align      uint8    fieldalign uint8    // 类型的编号,有bool, slice, struct 等等等等    kind       uint8    alg        *typeAlg    // gc 相关    gcdata    *byte    str       nameOff    ptrToThis typeOff}
复制代码


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


type arraytype struct {    typ   _type    elem  *_type    slice *_type    len   uintptr}
type chantype struct { typ _type elem *_type dir uintptr}
type slicetype struct { typ _type elem *_type}
type structtype struct { typ _type pkgPath name fields []structfield}
复制代码


这些数据类型的结构体定义,是反射实现的基础。


4、接口的动态类型和动态值

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


【引申 1】接口类型和 nil 作比较


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


来看个例子:


package mainimport "fmt"type Coder interface {    code()}type Gopher struct {    name string}func (g Gopher) code() {    fmt.Printf("%s is coding\n", g.name)}func main() {    var c Coder    fmt.Println(c == nil)    fmt.Printf("c: %T, %v\n", c, c)    var g *Gopher    fmt.Println(g == nil)    c = g    fmt.Println(c == nil)    fmt.Printf("c: %T, %v\n", c, c)}
复制代码


输出:


truec: <nil>, <nil>truefalsec: *main.Gopher, <nil>
复制代码


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


【引申 2】 来看一个例子,看一下它的输出:


package mainimport "fmt"type MyError struct {}func (i MyError) Error() string {    return "MyError"}func main() {    err := Process()    fmt.Println(err)    fmt.Println(err == nil)}func Process() error {    var err *MyError = nil    return err}
复制代码


函数运行结果:


<nil>false
复制代码


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


【引申 3】如何打印出接口的动态类型和值?


直接看代码:


package main
import ( "unsafe" "fmt")
type iface struct { itab, data uintptr}
func main() { var a interface{} = nil
var b interface{} = (*int)(nil)
x := 5 var c interface{} = (*int)(&x)
ia := *(*iface)(unsafe.Pointer(&a)) ib := *(*iface)(unsafe.Pointer(&b)) ic := *(*iface)(unsafe.Pointer(&c))
fmt.Println(ia, ib, ic)
fmt.Println(*(*int)(unsafe.Pointer(ic.data)))}
复制代码


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


运行结果如下:


{0 0} {17426912 0} {17426912 842350714568}5
复制代码


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


5. 编译器自动检测类型是否实现接口

经常看到一些开源库里会有一些类似下面这种奇怪的用法:


var _ io.Writer = (*myWriter)(nil)
复制代码


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


来看一个例子:


package main
import "io"
type myWriter struct {
}
/*func (w myWriter) Write(p []byte) (n int, err error) { return}*/
func main() { // 检查 *myWriter 类型是否实现了 io.Writer 接口 var _ io.Writer = (*myWriter)(nil)
// 检查 myWriter 类型是否实现了 io.Writer 接口 var _ io.Writer = myWriter{}}
复制代码


注释掉为 myWriter 定义的 Write 函数后,运行程序:


src/main.go:14:6: cannot use (*myWriter)(nil) (type *myWriter) as type io.Writer in assignment:    *myWriter does not implement io.Writer (missing Write method)src/main.go:15:6: cannot use myWriter literal (type myWriter) as type io.Writer in assignment:    myWriter does not implement io.Writer (missing Write method)
复制代码


报错信息:*myWriter/myWriter 未实现 io.Writer 接口,也就是未实现 Write 方法。


解除注释后,运行程序不报错。


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


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


var _ io.Writer = (*myWriter)(nil)var _ io.Writer = myWriter{}
复制代码


2019 年 9 月 17 日 15:51637

评论

发布
暂无评论
发现更多内容

Spark源代码::Spark多线程::NettyRpcEnv.ask解读

dclar

spark 多线程 Spark调优 源代码

架构实战营模块二作业

王晓宇

模块-6 拆分电商系统为微服务

小遵

金九银十马上要来了,我熬夜整理10 万字节详细面试笔记(带完整目录) 良心分享

云流

Java 编程 程序员 架构 面试

八位阿里大牛耗时三年总结出Java面试复盘手册!带你实现逆风翻盘

白亦杨

Java 编程 程序员

智能运维系列之三:什么是智能运维

micklongen

JDBC数据库链接的那些事

卢卡多多

JDBC 7月日更

【Flutter 专题】84 图解自定义 ACEWave 波浪 Widget (二)

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 7月日更

🐧【Linux技术专题系列】「必备基础知识」一起探索(su、sudo等相关身份提权/身份切换机制)

李浩宇/Alex

Linux linux 文件权限控制 7月日更 Linux身份提权

模块-1 微信业务架构图 和 备选方案

小遵

要性能还是要模型?

escray

极客时间 学习笔记 7月日更 如何落地业务建模

Python 正则表达式急速入门

喵叔

7月日更

vivo S系列的爆款密码:自拍,自然,自出机杼

脑极体

北鲲云计算,用技术成就生物科学领域发展

北鲲云

模块2作业

Tina

Liunx之chown命令

入门小站

Linux

华为18级工程师耗时三年才总结出这份Java亿级高并发核心编程手册

Java 白

Java 编程 程序员

Discourse 调整使用不同的表情符号

HoneyMoose

项目管理实践篇(一):技术人如何做好风险把控

后台技术汇

项目管理

OLAP 扫盲

Galaxy数据平台

OLAP 高基数

golang解析---进程,线程,协程

en

golang 线程 进程 协程 goroutine

微服务架构服务容错设计分析

慕枫技术笔记

微服务 后端 架构·

Spark SQL和DataFrames:内置数据源简介(四)

数据与智能

spark sparksq

架构实战营第二周作业——微信朋友圈高性能复杂度

发酵的死神

架构实战营

2021年网络安全市场全景图,看看都有哪些企业吧!

郑州埃文科技

分布式事务最经典的七种解决方案

叶东富

golang 数据库 分布式事务 微服务 TCC

一文搞懂二分查找面试

泽睿

算法 二分查找

有趣的SVG、Favicon

devpoint

favicon SVG 7月日更

夏季小伙伴们注意安全

IT蜗壳-Tango

7月日更

架构实战营 模块二 作业

一雄

作业 架构实战营 模块二

深入了解Spring之MessageSource

邱学喆

MessageFormat MessageSource MessageSourceAware

Study Go: From Zero to Hero

Study Go: From Zero to Hero

深度解密 Go 语言:关于 interface 的 10 个问题(上)-InfoQ