Go语言基础之接口
我们学习了结构体(以及其他非结构类型)实现方法。接口是一组方法签名的集合,然后我们可以定义一个结构体实现该接口所有方法。因此,接口就是定义了对象的行为。
例如,结构体Dog可以walk和bark, 如果一个接口声明了walk和bark的方法签名,而Dog实现了walk和bark方法,那么Dog就实现了该接口。
接口的主要工作是仅提供由方法名称,输入参数和返回类型组成的方法签名集合。由类型(例如struct结构体)来声明方法并实现它们。
如果您曾经是面向对象的程序员,您肯定会经常使用implements关键字来实现接口。但是在go中,你没有明确提到一个类型是否实现了一个接口。如果一个类型实现了在接口中定义的签名方法,则称该类型实现该接口。就像说它像鸭子一样走路,像鸭子一样游泳,像鸭子一样嘎嘎叫,那就是鸭子。
Go是一门静态语言,有着严格的静态语言的类型检查。同时GO又引入了动态语言的便利,通过“鸭子类型”的接口来实现动态多态非常的方便。
goroutine
和channel
(后面会讲到)支撑起了GO的高并发模型,而接口类型则是Go的整个类型系统的基石。通过接口,可以实现运行时多态,类型转换,类型断言,方法的动态分派等等功能。
接口
接口类型
在Go语言中接口(interface)是一种类型,一种抽象的类型,用来定义行为(方法)。这句话有两个重点,类型和定义行为。
首先解释定义行为:
接口即一组方法定义的集合,定义了对象的一组行为,就是定义了一些函数,由具体的类型实例实现具体的方法。
换句话说,一个接口就是定义(规范或约束),接口并不会实现这些方法,具体的实现由类实现,实现接口的类必须严格按照接口的声明来实现接口提供的所有功能。接口的作用应该是将定义与实现分离,降低耦合度。
在多人合作开发同一个项目时,接口表示调用者和设计者的一种约定,事先定义好相互调用的接口可以大大提高开发的效率。有了接口,就可以在不影响现有接口声明的情况下,修改接口的内部实现,从而使兼容性问题最小化。
为了保护你的Go语言职业生涯,请牢记接口(interface)是一种类型。
为什么要使用接口
type cat struct {}func (c cat) speak() { fmt.Println("喵喵喵")}type dog struct {}func (d dog) speak() { fmt.Println("汪汪汪")}func main() { c := Cat{} fmt.Println("猫:", c.speak()) d := Dog{} fmt.Println("狗:", d.speak())}
上面的代码中定义了猫和狗,然后它们都会叫,你会发现main函数中明显有重复的代码,如果我们后续再加上猪、青蛙等动物的话,我们的代码还会一直重复下去。那我们能不能把它们当成“能叫的动物”来处理呢?
像类似的例子在我们编程过程中会经常遇到:
比如一个网上商城可能使用支付宝、微信、银联等方式去在线支付,我们能不能把它们当成“支付方式”来处理呢?
比如三角形,四边形,圆形都能计算周长和面积,我们能不能把它们当成“图形”来处理呢?
比如销售、行政、程序员都能计算月薪,我们能不能把他们当成“员工”来处理呢?
Go语言中为了解决类似上面的问题,就设计了接口这个概念。接口区别于我们之前所有的具体类型,接口是一种抽象的类型。当你看到一个接口类型的值时,你不知道它是什么,唯一知道的是通过它的方法能做什么。
接口的定义
Go语言提倡面向接口编程。
每个接口由数个方法组成,接口的定义格式如下:
type 接口名称 interface{ 方法名1( 参数1, 参数2, ...) (返回值1, 返回值2, ...) 方法名2( 参数2, 参数2, ...) (返回值1, 返回值2, ...) …}
其中:
接口名称:使用
type
将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加er
,如有写操作的接口叫writer
,有字符串功能的接口叫stringer
等。接口名最好要能突出该接口的类型含义。方法名:当方法名首字母是大写且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以省略。
举个例子:
type writer interface{ write([]byte) error}
当你看到这个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的Write方法来做一些事情。
实现接口的条件
实现接口的类并不需要显式声明,只需要实现接口所有的函数就表示实现了该接口。
我们来定义一个speaker
接口:
// Speaker 接口type speaker interface { speak()}
定义dog
和cat
两个结构体:
type dog struct {}type cat struct {}
因为speaker
接口里只有一个speak
方法,所以我们只需要给dog
和cat
分别实现speak
方法就可以实现speaker
接口了。
// dog实现了Speaker接口func (d dog) speak() { fmt.Println("汪汪汪")}// cat实现了Speaker接口func (c cat) speak() { fmt.Println("喵喵喵")}
接口的实现就是这么简单,只要实现了接口中的所有方法,就实现了这个接口。
接口类型变量
那实现了接口有什么用呢?
接口类型变量能够存储所有实现了该接口的实例。例如上面的示例中,speaker
类型的变量能够存储dog
和cat
类型的变量。
func main() { var x speaker // 声明一个speaker类型的变量x a := cat{} // 实例化一个cat b := dog{} // 实例化一个dog x = a // 可以把cat实例直接赋值给x x.speak() // 喵喵喵 x = b // 可以把dog实例直接赋值给x x.speak() // 汪汪汪}
通过这个例子,我们可以思考一下,这个x是什么类型?
我们打印一下:
func main() { var x speaker // 声明一个speaker类型的变量x a := cat{ name: "蓝猫", age: 1, } b := dog{ name: "旺财", age: 3, } x = a // 可以把cat实例直接赋值给x x.speak() // 喵喵喵 fmt.Printf("%T\n", x) x = b // 可以把dog实例直接赋值给x x.speak() // 汪汪汪 fmt.Printf("%T\n", x)}
运行一下:
喵喵喵main.cat汪汪汪 main.dog
咦?x的类型怎么是cat和dog?不应该是speaker类型吗?
其实我们想想,为什么我们的cat和dog为什么可以复制给x?
接口类型的变量我们都是分成两部分的:
一部分是类型,一部分是值。在接口的类型中,存的是我们实例的类型,接口的值存的就是实例的值。
所以我们的接口可以存任何类型,接口的类型和值是动态的。后面我们讲到空接口的时候,会具体讲到。
值接收者和指针接收者实现接口的区别
使用值接收者实现接口和使用指针接收者实现接口有什么区别呢?接下来我们通过一个例子看一下其中的区别。
我们有一个speaker
接口和一个dog
结构体。
type mover interface { move()}type dog struct {}
值接收者实现接口
func (d dog) move() { fmt.Println("狗会动")}
此时实现接口的是dog
类型:
func main() { var x mover var wangcai = dog{} // 旺财是dog类型 x = wangcai // x可以接收dog类型 var fugui = &dog{} // 富贵是*dog类型 x = fugui // x可以接收*dog类型 x.move()}
从上面的代码中我们可以发现,使用值接收者实现接口之后,不管是dog结构体还是结构体指针*dog
类型的变量都可以赋值给该接口变量。因为Go语言中有对指针类型变量求值的语法糖,dog指针fugui
内部会自动求值*fugui
。
指针接收者实现接口
同样的代码我们再来测试一下使用指针接收者有什么区别:
func (d *dog) move() { fmt.Println("狗会动")}func main() { var x Mover var wangcai = dog{} // 旺财是dog类型 x = wangcai // x不可以接收dog类型 var fugui = &dog{} // 富贵是*dog类型 x = fugui // x可以接收*dog类型}
此时实现mover
接口的是*dog
类型,所以不能给x传入dog类型的wangcai,此时x只能存储*dog
类型的值。
类型与接口的关系
一个类型实现多个接口
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。例如,狗可以叫,也可以动。我们就分别定义speaker接口和mover接口,如下:mover接口。
// speaker 接口type speaker interface { speakpeak()}// mover 接口type mover interface { move()}
dog既可以实现speaker接口,也可以实现mover接口。
type dog struct { name string}// 实现Speaker接口func (d dog) speak() { fmt.Printf("%s会叫汪汪汪\n", d.name)}// 实现Mover接口func (d dog) move() { fmt.Printf("%s会动\n", d.name)}func main() { var x speaker var y mover var a = dog{name: "旺财"} x = a y = a x.speak() y.move()}
多个类型实现同一接口
Go语言中不同的类型还可以实现同一接口 首先我们定义一个mover接口,它要求必须由一个move方法。
// mover 接口type mover interface { move()}
例如狗可以动,汽车也可以动,可以使用如下代码实现这个关系:
type dog struct { name string}type car struct { brand string}// dog类型实现mover接口func (d dog) move() { fmt.Printf("%s会跑\n", d.name)}// car类型实现mover接口func (c car) move() { fmt.Printf("%s速度70迈\n", c.brand)}
这个时候我们在代码中就可以把狗和汽车当成一个会动的物体来处理了,不再需要关注它们具体是什么,只需要调用它们的move方法就可以了。
func main() { var x mover var a = dog{name: "旺财"} var b = car{brand: "保时捷"} x = a x.move() x = b x.move()}
上面的代码执行结果如下:
旺财会跑保时捷速度70迈
并且一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。
// washingMachine 洗衣机type washingMachine interface { wash() dry()}// 甩干器type dryer struct{}// 实现washingMachine接口的dry()方法func (d dryer) dry() { fmt.Println("甩一甩")}// 海尔洗衣机type haier struct { dryer //嵌入甩干器}// 实现washingMachine接口的wash()方法func (h haier) wash() { fmt.Println("洗刷刷")}
接口嵌套
接口与接口间可以通过嵌套创造出新的接口。
// speaker 接口type speaker interface { speak()}// mover 接口type mover interface { move()}// 接口嵌套type animal interface { speaker mover}
嵌套得到的接口的使用与普通接口一样,这里我们让cat实现animal接口:
type cat struct { name string}func (c cat) speak() { fmt.Println("喵喵喵")}func (c cat) move() { fmt.Println("猫会动")}func main() { var x animal x = cat{name: "花花"} x.move() x.speak()}
空接口
空接口的定义
空接口是指没有定义任何接口方法的接口。没有定义任何接口方法,意味着Go中的任意对象都可以实现空接口(因为没方法需要实现),任意对象都可以保存到空接口实例变量中。
空接口没有必要起名字,通常这样定义:
interface{}
空接口类型的变量可以存储任意类型的变量。
func main() { // 定义一个空接口x var x interface{} s := "Hello 蜘蛛侠" x = s fmt.Printf("type:%T value:%v\n", x, x) i := 100 x = i fmt.Printf("type:%T value:%v\n", x, x) b := true x = b fmt.Printf("type:%T value:%v\n", x, x)}
空接口的应用
空接口作为函数的参数
使用空接口实现可以接收任意类型的函数参数。
// 空接口作为函数参数func show(a interface{}) { fmt.Printf("type:%T value:%v\n", a, a)}
空接口数据结构
可以定义一个空接口类型的array、slice、map、struct等,这样它们就可以用来存放任意类型的对象,因为任意类型都实现了空接口。
例如,创建一个空接口的slice:
package mainimport "fmt"func main() { any := make([]interface{}, 5) any[0] = 11 any[1] = "hello world" any[2] = []int{11, 22, 33, 44} for _, value := range any { fmt.Println(value) }}
输出结果:
11hello world[11 22 33 44]
显然,通过空接口类型,Go也能像其它动态语言一样,在数据结构中存储任意类型的数据。
再比如,某个struct中,如果有一个字段想存储任意类型的数据,就可以将这个字段的类型设置为空接口:
type my_struct struct { anything interface{} anythings []interface{}}
拷贝数据结构到空接口数据结构
前面解释了任意类型的对象都能赋值给空接口实例。
var any interface{}any = "hello world"any = 11
空接口是一种接口,它是一种指针类型的数据类型,虽然不严谨,但它确实保存了两个指针,一个是对象的类型,一个是对象的值。所以上面的赋值过程是让空接口any保存各个数据对象的类型和对象的值。
换一种角度考虑,空接口有自己的内存布局方式:两个指针,占用两个机器字长。
Golang给的一个经典的示例:将某个slice中的数据拷贝到空接口slice中将报错。
package mainimport "fmt"func main() { testSlice := []int{11,22,33,44} // 成功拷贝 var newSlice []int newSlice = testSlice fmt.Println(newSlice) // 拷贝失败 var any []interface{} any = testSlice fmt.Println(any)}
这是因为每个空接口的内存布局都占用两个机器字长的内容。对于长度为N的空接口slice来说,它的每个元素都是以2机器字长为单元的连续空间,共占用N*2个机器字长的空间。
而普通的slice,例如上面的testSlice,它的每个元素是int类型的,int类型的内存布局和空接口不一样。
这些对象的内存布局在编译期间就已经确定好了,所以没法直接将不同内存布局的数据结构进行拷贝。
要想完成期待的拷贝,可以使用for-range的方式,将testSlice中的每个元素赋值给空接口slice的空接口元素:也就是一个个的空接口实例。
var any []interface{}for _,value := range testSlice{ any = append(any,value)}
这样,空接口Slice中的每个空接口实例都指向更底层的各个数据对象。而不是像前面错误的拷贝方式:每个空接口元素想要当作这些数据对象。
不仅空接口的Slice如此,其它包含空接口的数据结构,也都类似。
接口的底层实现方式
接口的特征实际是是底层结构的反映,而且接口的底层实现也很简单。
上面提到Go根据接口类型是否含有方法集将接口分为了两类:
非空接口,含有一组方法集
空接口,不含方法集
二者在底层数据结构的实现上略有不同。
非空接口
type iface struct{ // 两个指针,16byte tab *itab // 指向一个内部表 data unsafe.Pointer // 指向所持有的数据}
上面就是iface的数据结构,只包含两个指针,大小为16byte,可以说是非常简单了。
itab指针指向一个itab结构体,该itab结构体记录了该接口值的一系列信息,包括接口静态类型信息,持有数据的动态类型信息,方法集等,用以进行 接口的类型转换,编译器类型检查,辅助反射等等;即:非空接口的itab既包含接口类型相关的信息,又包括所持数据的类型相关的信息
data是一个指向实际数据的指针
具体来看一下itab,这个结构体还是很重要的,是GO接口实现的基础。
type itab struct { // 32 bytes inter *interfacetype // 类型的静态类型信息,比如io.Reader _type *_type // 是一个结构体,记录所持有数据的类型相关的一系列信息 hash uint32 // _ [4]byte fun [1]uintptr // 存储接口的方法集}
从iface或itab都可以看出,接口interface包含有两种类型:
一种是接口自身的类型,称为接口的静态类型,比如io.Reader等,用于确定接口类型,直接存储在itab结构体中
一种是接口所持有数据的类型,称为接口的动态类型,用于在反射或者接口转换时确认所持有数据的实际类型,存储在运行时runtime/_type结构体中。
hash是一个uint32类型的值,实际上可以看作是类型的校验码,当将接口转换成具体的类型的时候,会通过比较二者的hash值确定是否相等,只有hash相等才能进行转换。
注:Go中每种类型都有自己的类型信息,存储在运行时runtime/_type结构体中,类型的hash值即是_type的字段之一。实际上,itab中的hash 只是其所持有数据的类型的_type结构体中hash的一个拷贝。
fun最终指向的是接口的方法集,即存储了接口所有方法的函数的指针。通过比较接口的方法集和类型的方法集,可以用来判断该类型是否实现了该接口。把fun指向的方法集看作是一个虚函数表,也是很贴切的。
最后再简单看一下运行时runtime/_type结构体。该结构体包含了Go类型的所有类型信息,如类型大小/类别/哈希等等。只需要有这个概念就好。
type _type struct { size uintptr // 类型大小 ptrdata uintptr hash uint32 // 前面阐述过,相当于类型的校验码 tflag tflag align uint8 // 对齐方式 fieldAlign uint8 kind uint8 // 类别 equal func(unsafe.Pointer, unsafe.Pointer) bool gcdata *byte str nameOff ptrToThis typeOff}
空接口
理解了非空接口的底层数据结构,再来看空接口的底层数据结构就更简单了。
空接口类型也是接口类型的一种,只不过它没有方法集,同时空接口也是Go实现多态的基础,因此将空接口进行单独定义,一来简化底层数据结构,二来更好的支持Go的运行时多态。
type eface struct{ // 两个指针,16byte _type *_type // 指向一个内部表 data unsafe.Pointer // 指向所持有的数据}
空接口是单独的唯一的一种接口类型,因此自然不需要itab中的接口类型字段了
空接口也没有任何的方法,因此自然也不存在itab中的方法集了
以上,空接口使用itab字段就有点多余了。因此,空接口中直接保存所持有数据的运行时类型信息_type,而不必使用itab。
类型断言
空接口可以存储任意类型的值,那我们如何获取其存储的具体数据呢?
接口值
前面提到一个接口的值(简称接口值)是由一个具体类型和具体类型的值两部分组成的。这两部分分别称为接口的动态类型和动态值。
我们来看一个具体的例子:
var w io.Writerw = os.Stdoutw = new(bytes.Buffer)w = nil
请看下图分解:
想要判断空接口中的值这个时候就可以使用类型断言,其语法格式:
x.(T)
其中:
x:表示类型为
interface{}
的变量T:表示断言
x
可能是的类型。
该语法返回两个参数,第一个参数是x
转化为T
类型后的变量,第二个值是一个布尔值,若为true
则表示断言成功,为false
则表示断言失败。
举个例子:
func main() { var x interface{} x = "Hello 蜘蛛侠" v, ok := x.(string) if ok { fmt.Println(v) } else { fmt.Println("类型断言失败") }}
上面的示例中如果要断言多次就需要写多个if
判断,这个时候我们可以使用switch
语句来实现:
func justifyType(x interface{}) { switch v := x.(type) { case string: fmt.Printf("x is a string,value is %v\n", v) case int: fmt.Printf("x is a int is %v\n", v) case bool: fmt.Printf("x is a bool is %v\n", v) default: fmt.Println("unsupport type!") }}
因为空接口可以存储任意类型值的特点,所以空接口在Go语言中的使用十分广泛。
关于接口需要注意的是,只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要定义接口。不要为了接口而写接口,那样只会增加不必要的抽象,导致不必要的运行时损耗。