11 接口

[TOC]

接口

Go中接口是一个编程规约,也是一组方法声明的集合。

  • 接口是引用类型,意味着接口作为形参将是指针拷贝。

  • Go中接口是非侵入式设计,实现接口不需要在语法上显式声明实现哪个接口(java中implements关键字指定实现哪些接口为显式实现),只要它实现了接口中所有方法就是实现了这个接口,实现了多个接口的所有方法那就是实现了多个接口。。

  • 任何自定义类型实现了接口所有的方法才算实现了该接口,缺一个都不算实现。

  • 接口定义方法,但不需要实现。接口体现了程序设计的多态和高内聚低耦合的思想。

  • 接口不能包含任何变量(java中接口中可以定义常量,Go是没有这样的功能)。

  • Go中接口本身不能实例化,但它可以指向实现了该接口的实例,所以Go的多态的特性主要通过接口实现。

  • 接口之间可以继承,实现时要实现包括继承的接口的全部方法。

接口的优点

  • 解耦:复杂系统进行垂直和水平的分割是常用的设计手段,在层与层之间使用接口进行抽象和解耦是一种好的编程策略。Go的非侵入式的接口使层与层之间的代码更加干净,具体类型和实现的接口之间不需要显示声明,增加了接口使用的自由度。

  • 实现泛型:由于Go语言还不支持泛型,使用空接口作为函数或方法参数能够用在需要泛型的场景中。

接口的使用形式

  • Go 中接口类型是“第一公民”,可以用在任何使用变量的地方,使用灵活,方便解耦,主要使用在:作为结构内嵌字段;作为函数或方法的形参;作为函数或方法的返回值;作为其它接口定义的嵌入字段;。

接口定义

interface 关键字声明接口,type关键字定义接口类型

  • 声明方法格式【方法名(形参列表) (返回值列表)】

接口的组合和初始化

单纯地声明一个接口变量没有任何意义,接口只有被初始化为具体的类型时才有意义。

  • 接口作为抽象层起到抽象和适配的作用。

  • 接口绑定具体实例的过程就是接口初始化。

  • 没有初始化的接口默认值为nil

一个接口包含越多的方法其抽象性就越低,表达的行为就越具体。

  • 编程语言塑造了我们的思维习惯。Go语言的设计者认为,对于传统拥有类型继承的面向对象语言,必须尽早设计其层次结构,一旦开始编写程序,早期决策就很难改变。这种方式导致了早期的过度设计,因为开发者试图预测程序所有可能的行为,增加了不必要的类型和抽象层。

  • Go语言在设计之初就鼓励开发者使用组合而不是继承的方式来编写程序,通常使用一种方法的接口来定义琐碎的行为,这些行为充当组件之间清晰、可理解的边界。Go语言中的接口可以使程序自然、优雅、安全地增长,接口的更改仅影响实现接口的直接类型。

  • 定义IP接口

  • 定义IA接口组合IP接口

  • 定义IB接口组合IP接口

  • 自定义类型str实现IP、IA、IB接口

实例赋值给接口(接口初始化)

  • 实例赋值给接口编译器会进行静态类型检查,接口初始化后相当于调用接口绑定的具体类型的方法。

  • str实例赋值给IP接口的变量是没有问题的,但是因为IP接口并没有testA()和testB()方法,所以在编译时进行静态类型检查就无法通过。

接口的动态类型和静态类型

动态类型

  • 实现接口的类型的具体实例为接口的动态类型。

  • 接口可以被不同类型实现,所以接口的动态类型是随着不同的实现接口的类型的实例而发生变化的。

  • 定义Animal接口 声明sleep()、work(int) int两个方法

  • Cat和Doge结构体都实现了Animal接口

  • do(Animal)函数参数为Animal类型

  • Cat和Doge实例都可以作为do(Animal)函数的形参,do函数在调用sleep()方法时,又都动态切换回调用的实例,因此成功读取到Name字段。

静态类型

  • 接口被定义时,其类型就已经被确定,这个类型就是接口的静态类型。

  • 静态类型的本质特征就是接口的方法签名集合。两个接口的方法签名集合相同(顺序可以不同)则这两个接口在语义上完全等价,它们之间不需要强制类型转换就可以相互赋值,原因是Go编译器检验接口是否能赋值是比较二者的方法集。

  • 定义Pet接口,声明work(int) int、sleep()两个方法,尽管顺序和Animal接口不同,但是它和Animal接口是等价的

  • Animal和Pet接口是可以相互赋值的(两个接口的方法签名集合相同(顺序可以不同)则这两个接口在语义上完全等价)

接口运算

基于接口编程是Go语言编程的基本思想。

  • 类型断言和类型查询是在运行时确定接口是哪个具体的实例。

类型断言 (Type Assertion)

类型断言可用于判断接口变量是否是某个具体的实例,也可用于判断具体实例是否实现了某个接口。

  • 类型断言语法 【 r := i.(TypeName) 】,这种不常用,因为如果断言失败会出现panic,断言成功r为具体类型的实例。

  • 类型断言语法2 【 r,ok := i.(TypeName) 】,断言成功ok为true,失败ok则为false

类型断言判断接口变量是否是某个具体的实例

  • doWork(Animal)断言成功,传入的确实是Cat实例,但是doWork2(Animal)断言失败报panic,因为传入的是Cat实例

  • doWork3(Animal)断言失败但是不报panic,ok为false表示断言失败,断言失败实例的结果为nil

类型断言判断实例是否实现接口

  • 由于实例本身不能作为左值进行类型断言,因此先将实例赋值给一个空接口再进行实例是否实现指定接口的类型断言

一个标准的类型断言判断

  • if r,ok := i.(Animal); ok {}

类型查询 (Type Switches)

Go中类型查询,v := i.(type)

  • 通过判断v来进行类型查询,可以通过switch也可以if else

使用 switch 控制语句配合类型断言使用

  • 语法 switch v := i.(type) {case 类型:}

  • i必须是接口,.(type)是固定的,v是类型断言的返回值

接口的比较性

两个接口之间可以通过 == 或 != 进行比较,接口的比较规则:

  • 动态值为nil的接口变量总是相等的

  • 如果只有1个接口为nil,那么比较结果总是false

  • 如果两个接口不为nil且接口变量具有相同的动态类型和动态类型值,那么两个接口是相同的。

  • 如果接口存储的动态类型值是不可比较的,那么在运行时会报错。

空接口

定义一个接口,当中没有定义任何方法,即为空接口,例:interface{}

  • 默认所有类型都实现了空接口,当一个函数形参类型是空接口,则意味着该函数可接受任何类型参数。

  • 空接口有点类似于java中的Objec。

空接口和泛型

  • Go语言没有泛型,如果一个函数需要接受任意类型参数,则参数类型可以使用空接口类型,这是弥补没有泛型的一种手段。

空接口和反射

  • 空接口是反射实现的基础,反射库就是将相关具体的类型转换并赋值给空接口后再进行处理。

接口的陷阱

陷阱1:实现接口的方法绑定实例的指针和绑定实例的不同之处

自定义类型实现接口的方法绑定实例的指针则实例赋值给接口时只能是将实例的指针赋值给接口变量。 如果方法绑定的是实例本身则赋值给接口变量时可以直接赋值实例也可以赋值实例的指针。

  • 案例【实现接口的方法绑定实例的指针和绑定实例的不同之处】

  • var c C = integer 是无法通过编译的,但如果是func (integer Integer) TestC() 则是可以成功的。Go语言在编译时阻止这种写法原因是这种写法会让人产生困惑,如果绑定的是值那么其必定已经对原始值进行了复制,在堆区产生了副本。而如果允许这种调用方式,那么即使修改了接口中的值也不会修改原始值,非常容易产生误解。

  • Integer8 实现 TestC() 方法绑定的是实例本身,因此实例赋值和实例的指针赋值给接口变量都是符合编译器要求的

陷阱2:将类型切片转换为接口切片

类型切片转换为接口切片

  • 如下无法通过编译 【Cannot use '[]int{1,2,3}' (type []int) as the type []interface{} 】

  • Go语言不允许这种做法,批量转换为接口是低效率的操作,每个元素都要转换为接口,并且数据需要内存逃逸。

陷阱3:接口与nil之间的关系,接口比较陷阱

接口与nil之间的关系

  • test1() 结果 nil 与 er 比较结果为true,因为test1()返回值没有任何动态类型和动态类型值,当接口为nil时,代表接口中的动态类型和动态类型值都为nil。

  • test2() 结果 nil 与 er比较结果为false,因为接口error具有动态类型*os.PathError,因此test2() == nil 为false。

接口内部实现

接口变量必须初始化才有意义,没有初始化的接口变量的默认值是nil,没有任何意义。具体类型实例传递给接口称为接口的实例化。在接口的实例化的过程中,编译器通过特定的数据结构描述这个过程,而空接口的底层数据结构和非空接口底层数据结构是不同的。

非空接口底层数据结构

非空接口底层数据结构是iface,源码在src/runtime/runtime2.go。

  • 非空接口初始化的过程就是初始化一个iface类型的结构

  • iface结构中*itab字段用来存放接口自身类型和绑定的实例类型及实例相关的函数指针

  • 数据指针data:指向接口绑定的实例的副本,接口的初始化也是一种值拷贝。data指向具体的实例数据,如果传递给接口的是值类型则data指向实例的副本,如果传递给接口的是指针类型,则data指向指针的副本。总之,无论接口的转换还是函数调用,Go遵循的都是值传递。

itab 数据结构,itab是接口内部实现的核心和基础。itab这个数据结构是非空接口实现动态调用的基础,itab的信息被编译器和链接器保存了下来,存放在可执行文件的只读存储段(.rodata)中。itab存放在静态分配的存储空间中,不受GC的限制其内存不会被回收。

  • 源码 /src/runtime/runtime2.go

  • itab有5个字段:

  • inner 是指向接口类型元信息的指针,接口自身的静态类型

  • _type 是指向接口存放的具体类型元信息的指针,iface里的data指针指向的是该类型的值。一个是类型信息,另一个是类型的值。_type就是接口存放的具体实例的类型(动态类型)

  • hash 是存放具体类型的Hash值,_type里面也有hash,这里冗余存放主要是为了接口断言或类型查询时快速访问

  • fun 是一个函数指针数组,这里虽然是[1]uintptr但是它的大小是可变的,编译器负责填充,运行时使用底层指针进行访问,不受struct类型越界检查的约束,数组中存放的是指向具体类型的方法的指针

_type数据结构

  • Go语言是强类型语言,编译器在编译时会做严格的类型校验,所以Go会为每种类型维护一个类型的元信息,这个元信息在运行和反射时都会用到,Go语言的类型元信息的通用结构是_type(源码在src/runtime/type.go),其它类型都是以_type为内嵌字段封装而成的结构体。

  • 包含所有类型的共同元信息,编译器和运行时可以根据该原信息解析具体类型,类型名存放位置、类型的Hash值等基本信息。

_type中的nameOff和typeOff最终是由链接器负责确定和填充的,它们都是一个偏移量(offset),类型的名称和类型元信息实际上存放在连接后可执行文件的某个段(section)里,这两个值是相对于段内的偏移量,运行时提供了两个转换查找函数

Go语言类型元信息最初由编译器负责构建,并以表的形式存放在编译后的对象文件中,再由链接器在链接时进行段合并、符号重定向(填充某些值)。这些类型信息在接口的动态调用和反射中被运行时调用。

  • 接口类型元信息数据结构

空接口底层数据结构

空接口内部没有任何方法,所以空接口内部不需要维护和动态内存分配相关的数据结构itab,空接口只关心存放的具体类型是什么,具体的类型的值是什么。

  • 从eface结构体可以看出,空接口不是真的为空,其保留了具体实例的类型和值拷贝,即便存放的具体类型是空的,空接口也不是空的。

接口调用的代价

接口动态调用过程是有代价的,会比直接调用函数耗时更多,因为它多出两个步骤。

  • 一个是接口实例化的过程即iface结构建立的过程,一旦实例化后这个接口和具体类型的itab数据结构是可以复用的。

  • 另一个是接口的方法调用,它是一个函数指针的间接调用,并且接口调用是一种动态的计算后的跳转调用,对计算机CPU的执行很不友好,会导致CPU缓存失效和分支预测失败,这也有一部分的性能损失。

接口调用与函数直接调用比较

接口调用和直接调用耗时差距是有的,但是差距并不明显,增加调用次数就可以测试出差距。

接口动态调用过程的效率评价

接口提高了开发效率,但是运行效率上接口调用没有直接调用函数速度快。

  • 接口作为Go语言在设计时鼓励并推荐的习惯用法,在Go源代码中也经常看到接口的应用,这一事实已经足够让人相信接口动态调用的效率损失并不高,实际上在大部分情况下,接口的调用成本都可以忽略不计。

接口最佳实践

sort包下Sort方法 sort.Sort(data Interface) 形参是接口,任何实现这个接口的自定义类型都可以作为参数,实现接口以完成排序。

接口和继承比较

接口和继承比较,接口是继承的一种补充,接口和继承解决的问题是不同的

  • 继承的价值主要在于解决代码的复用性以提高它的可维护性

  • 接口的价值主要用于设计好规范(方法),让其它自定义类型去实现这些方法。

  • 接口比继承更加灵活,一定程度上实现代码解耦。

Last updated