接口本身是调用方和实现方均需要遵守的一种协议,大家按照统一的方法命名参数类型和数量来协调逻辑处理的过程。
7.1 声明接口
接口是双方约定的一种合作协议。接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。
7.1.1 接口的声明格式
每个接口类型由数个方法组成。接口的形式代码如下:
type 接口类型名 interface{
方法名1( 参数列表1 ) 返回值列表1
方法名2( 参数列表2 ) 返回值列表2
…
}
接口类型名:使用type将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加er,如有写操作的接口叫Writer,有字符串功能的接口叫Stringer,有关闭功能的接口叫Closer等。
方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以被忽略,例如:
type writer interface{
Write([]byte) error
}
7.2 实现接口的条件
接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用。
7.2.1 接口被实现的条件一:接口的方法与实现接口的类型方法格式一致
在类型中添加与接口签名一致的方法就可以实现该方法。签名包括方法中的名称、参数列表、返回参数列表。也就是说,只要实现接口类型中的方法的名称、参数列表、返回参数列表中的任意一项与接口要实现的方法不一致,那么接口的这个方法就不会被实现。
当类型无法实现接口时,编译器会报错,下面列出常见的几种接口无法实现的错误。
- 函数名不一致导致的报错
- 实现接口的方法签名不一致导致的报错
7.2.2 接口被实现的接口二:接口中所有方法均被实现
当一个接口中有多个方法时,只有这些方法都被实现了,接口才能被正确编译并使用。
7.3 理解类型与接口的关系
类型和接口之间有一对多和多对一的关系,下面将列举出这些常见的概念,以方便读者理解接口与类型在复杂环境下的实现关系。
7.3.1 一个类型可以实现多个接口
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。
网络上的两个程序通过一个双向的通信连接实现数据的交换,连接的一端称为一个Socket。Socket能够同时读取和写入数据,这个特性与文件类似。因此,开发中把文件和Socket都具备的读写特性抽象为独立的读写器概念。
Socket和文件一样,在使用完毕后,也需要对资源进行释放。
把Socket能够写入数据和需要关闭的特性使用接口来描述,请参考下面的代码:
package main
import "io"
type socket struct {
}
func (s *socket) Write(p []byte) (n int, err error) {
return 0, nil
}
func (s *socket) Close() error {
return nil
}
func usingWrite(writer io.Writer) {
writer.Write(nil)
}
func usingCloser(closer io.Closer) {
closer.Close()
}
func main() {
s := new(socket)
usingWrite(s)
usingCloser(s)
}
usingWriter()和usingCloser()完全独立,互相不知道对方的存在,也不知道自己使用的接口是Socket实现的。
7.3.2 多个类型可以实现相同的接口
一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的
package main
import "fmt"
type service interface {
Start()
Log(string)
}
type Logger struct {
}
func (l *Logger) Log(str string) {
fmt.Println(str)
}
type GameService struct {
Logger
}
func (g *GameService) Start() {
fmt.Println("开始游戏")
}
func main() {
var s service = new(GameService)
s.Start()
s.Log("进入游戏")
}
Service接口定义了两个方法:一个是开启服务的方法(Start()),一个是输出日志的方法(Log())。使用GameService结构体来实现Service,GameService自己的结构只能实现Start()方法,而Service接口中的Log()方法已经被一个能输出日志的日志器(Logger)实现了,无须再进行GameService封装,或者重新实现一遍。所以,选择将Logger嵌入到GameService能最大程度地避免代码冗余,简化代码结构。
7.4 在接口和类型之间转换
Go语言中使用接口断言(type assertions)将接口转换成另外一个接口,也可以将接口转换为另外的类型。接口的转换在开发中非常常见,使用也非常频繁。
7.4.1
类型断言的格式
类型断言的基本格式如下:
t := i.(T)
i代表接口变量。
T代表转换的目标类型。
t代表转换后的变量。
如果i没有完全实现T接口的方法,这个语句将会触发宕机。触发宕机不是很友好,因此上面的语句还有一种写法:
t,ok := i.(T)
这种写法下,如果发生接口未实现时,将会把ok置为false,t置为T类型的0值。正常实现时,ok为true。这里ok可以被认为是:i接口是否实现T类型的结果。
7.4.2 将接口转换为其他接口
实现某个接口的类型同时实现了另外一个接口,此时可以在两个接口间转换。
对保存实例的interface{}变量进行断言操作,如果断言对象是断言指定的类型,则返回转换为断言对象类型的接口;如果不是指定的断言类型时,断言的第二个参数将返回false。
var obj interface = new(bird)
f, isFlyer := obj.(Flyer)
7.4.3 将接口转换为其他类型
可以实现将接口转换为普通的指针类型。例如将Walker接口转换为*pig类型:
01 p1 := new(pig)
02
03 var a Walker = p1
04 p2 := a.(*pig)
05
06 fmt.Printf("p1=%p p2=%p", p1, p2)
·第3行,由于pig实现了Walker接口,因此可以被隐式转换为Walker接口类型保存于a中。
·第4行,由于a中保存的本来就是pig本体,因此可以转换为pig类型。
·第6行,对比发现,p1和p2指针是相同的。
如果尝试将上面这段代码中的Walker类型的a转换为*bird类型,将会发出运行时错误,请参考下面的代码:
01 p1 := new(pig)
02
03 var a Walker = p1
04 p2 := a.(*bird)
运行时报错:
panic: interface conversion: main.Walker is *main.pig, not *main.bird
报错意思是:接口转换时,main.Walker接口的内部保存的是main.pig,而不是main.bird。
因此,接口在转换为其他类型时,接口内保存的实例对应的类型指针,必须是要转换的对应的类型指针。
总结:接口和其他类型的转换可以在Go语言中自由进行,前提是已经完全实现。
接口断言类似于流程控制中的if。但大量类型断言出现时,应使用更为高效的类型分支switch特性。
7.5 空接口类型(interface{})——能保存所有值的类型
空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。
空接口的内部实现保存了对象的类型和指针。使用空接口保存一个数据的过程会比直接用数据对应类型的变量保存稍慢。因此在开发中,应在需要的地方使用空接口,而不是在所有地方使用空接口。
7.5.1 将值保存到空接口
空接口的赋值如下:
01 var any interface{}
02
03 any = 1
04 fmt.Println(any)
05
06 any = "hello"
07 fmt.Println(any)
08
09 any = false
10 fmt.Println(any)
7.5.2 从空接口获取值
保存到空接口的值,如果直接取出指定类型的值时,会发生编译错误,代码如下:
01 // 声明a变量,类型int,初始值为1
02 var a int = 1
03
04 // 声明i变量,类型为interface{},初始值为a,此时i的值变为1
05 var i interface{} = a
06
07 // 声明b变量,尝试赋值i
08 var b int = i
有如下报错:
cannot use i (type interface {}) as type int in assignment: need type
assertion
编译器告诉我们,不能将i变量视为int类型赋值给b。
在代码第15行中,将a的值赋值给i时,虽然i在赋值完成后的内部值为int,但i还是一个interface{}类型的变量。类似于无论集装箱装的是茶叶还是烟草,集装箱依然是金属做的,不会因为所装物的类型改变而改变。
为了让第8行的操作能够完成,编译器提示我们得使用type assertion,意思就是类型断言。
使用类型断言修改第8行代码如下:
var b int = i.(int)
修改后,代码可以编译通过,并且b可以获得i变量保存的a变量的值:1。
7.5.3 空接口的值比较
“空接口在保存不同的值后,可以和其他变量值一样使用“==”进行比较操作。空接口的比较有以下几种特性。
1.类型不同的空接口间的比较结果不相同
保存有类型不同的值的空接口进行比较时,Go语言会优先比较值的类型。因此类型不同,比较结果也是不相同的,代码如下:
01 // a保存整型
02 var a interface{} = 100
03
04 // b保存字符串
05 var b interface{} = "hi"
06
07 // 两个空接口不相等
08 fmt.Println(a == b)
代码输出如下:
false
2.不能比较空接口中的动态值
当接口中保存有动态类型的值时,运行时将触发错误,代码如下:
01. // c保存包含10的整型切片
02 var c interface{} = []int{10}
03
04 // d保存包含20的整型切片
05 var d interface{} = []int{20}
06
07 // 这里会发生崩溃
08 fmt.Println(c == d)
代码运行到第8行时发生崩溃:
panic: runtime error: comparing uncomparable type []int
7.6 类型分支——批量判断空接口中变量的类型
Go语言的switch不仅可以像其他语言一样实现数值、字符串的判断,还有一种特殊的用途——判断一个接口内保存或实现的类型。
7.6.1 类型断言的书写格式
switch实现类型分支时的写法格式如下:
switch 接口变量.(type) {
case 类型1:
// 变量是类型1时的处理
case 类型2:
// 变量是类型2时的处理
…
default:
// 变量不是所有case中列举的类型时的处理
}
接口变量:表示需要判断的接口类型的变量。
类型1、类型2……:表示接口变量可能具有的类型列表,满足时,会指定case对应
应用:
1.使用类型分支判断接口类型
2.使用类型分支判断基本类型
7.7 interface底层的实现
interface实际就是一个结构体,包含两个成员。其中一个成员是指向具体数据的指针,另一个成员包含了类型信息。在内部空接口和自主定义的带方法的接口是分开表示的,Eface是空接口的底层数据结构,Iface是带方法的接口的底层数据结构。
7.7.1Eface和Iface
Eface是空接口的底层数据结构,Iface是带方法的接口的底层数据结构
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab
data unsafe.Pointer
}
1.先来看Eface
Eface是interface{}底层使用的数据结构。包含一个类型结构体_type的指针和unsafe.Pointer指针(类似C语言中的void*类型的指针,它可以包含任意类型变量的地址)data域同样是指向原始数据的。interface{}的_type类型信息是用来实现反射的关键。
类型结构体_type的定义如下:
type _type struct {
size uintptr // 类型占用内存大小
ptrdata uintptr // 包含所有指针的内存前缀大小
hash uint32 // 类型 hash
tflag tflag // 标记位,主要用于反射
align uint8 // 对齐字节信息
fieldAlign uint8 // 当前结构字段的对齐字节数
kind uint8 // 基础类型枚举值
equal func(unsafe.Pointer, unsafe.Pointer) bool // 比较两个形参对应对象的类型是否相等
gcdata *byte // GC 类型的数据
str nameOff // 类型名称字符串在二进制文件段中的偏移量
ptrToThis typeOff // 类型元信息指针在二进制文件段中的偏移量
UncommonType *x;
}
2.再来看Iface
其中Iface中的itab类型的定义如下:
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
}
Itab中不仅存储了Type信息,而且还多了一个方法表fun[]。一个Iface中的具体类型中实现的方法会被拷贝到Itab的fun数组中。
7.7.2 具体类型向接口类型赋值
将具体类型数据赋值给interface{}这样的抽象类型,中间会涉及到类型转换操作。从接口类型转换为具体类型(也就是反射),也涉及到了类型转换。
1. 具体类型转化为空接口
如果是转换成空接口,这个过程比较简单,就是返回一个Eface,将Eface中的data指针指向原型数据,type指针会指向数据的Type结构体。
import (
"fmt"
"strconv"
)
type Binary uint64
func main() {
b := Binary(200)
any := (interface{})(b)
fmt.Println(any)
}
复制后的结构图:2.具体类型转化为有方法的接口
将某个类型数据转换为带方法的接口时,会复杂一些。中间涉及了一道检测,该类型必须要实现了接口中声明的所有方法才可以进行转换。这个检测是在编译过程中做的,那么这个检测是如何实现的呢?在runtime下找到了iface.c文件,应该是早期版本是在运行时检测留下的,其中有一个itab函数就是判断某个类型是否实现了某个接口,如果是则返回一个Itab结构体。
类型转换时的检测就是比较具体类型的方法表和接口类型的方法表,看具体类型是实现了接口类型所声明的所有的方法。还记得Type结构体中是有个UncommonType字段的,里面有张方法表,类型所实现的方法都在里面。而在Itab中有个inter字段,这个字段中也有一张方法表,就是这个接口所要求的方法。这两处方法表都是排序过的,只需要一遍顺序扫描进行比较,应该可以知道Type中否实现了接口中声明的所有方法。最后还会将Type方法表中的函数指针,拷贝到Itab的fun字段中。
package main
import (
"fmt"
"strconv"
)
type Binary uint64
func (i Binary) String() string {
return strconv.FormatUint(i.Get(), 10)
}
func (i Binary) Get() uint64 {
return uint64(i)
}
func main() {
b := Binary(200)
any := fmt.Stringer(b)
fmt.Println(any)
}
资料:
https://juejin.cn/post/6844903688633319431
https://golang.design/go-questions/interface/iface-eface/
深入解析Go