聊一聊 Go 语言的接口
楔子
当你使用 Go 一段时间之后,肯定会发现一个问题:那就是 Go 对类型的检查太严格了。当然这是一件好事,可以避免我们犯错误,但有些时候我们需要一个变量能够接收不同类型的值。比如在定义函数参数的时候,我们希望参数可以接收多种类型的值,那么这个时候该怎么做呢?
为了解决这一问题,Go 为我们提供了 interface{},也就是接口。
鸭子类型
先来看看鸭子类型的定义:如果某个东西长得像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那它就可以被看成是一只鸭子。
Duck Typing,鸭子类型,是动态编程语言的一种对象推断策略,它更关注对象能如何被使用,而不是对象的类型本身。Go 语言作为一门静态语言,它通过接口 interface{} 的方式完美支持鸭子类型。
笔者本人是主 Python 的,在 Python 中我们可以定义一个这样的函数:
def say_hi(obj):
return obj.hi()
在调用该函数的时候,你可以传入任意类型,那么 Python 解释器是如何做的呢?
- 首先 Python 中的变量都是一个泛型指针 PyObject *。
- 当执行 obj.hi 的时候,解释器会调用 PyObject_GetAttr(obj, "hi") 获取返回 "hi" 对应的 value,结果也是一个PyObject *。如果没有找到的话,那么会抛出 AttributeError。
- 找到之后再通过 PyObject_CallObject 进行调用。
所以我们看到给 obj 参数传递什么根本无关紧要,只要传递的变量可以调用 hi 即可,而且这一步是在运行时才发生的。换言之,如果报属性错误,那么一定是发生在运行时。
但对于静态语言来说,比如 Java,必须要显式地声明实现了某个接口之后,才能用在任何需要这个接口的地方。比如还是调用 obj.hi(),对于 Java 来说,在传入 obj 之前,必须显式地声明 obj 已经实现了 hi,否则在编译阶段就不会通过。这也是静态语言比动态语言更安全的原因。
动态语言和静态语言的差别在此就有所体现:静态语言在编译期间就能发现类型不匹配的错误,不像动态语言,必须要运行到某一行代码才会报错。当然,静态语言要求程序员在编码阶段就要按照规定来编写程序,为每个变量规定数据类型,这在某种程度上加大了工作量,也增长了代码量。动态语言则没有这些要求,可以让人更专注在业务上,代码也更短,写起来更快。所以有优势就有劣势,鱼和熊掌往往是不可兼得的。
Go 语言作为一门现代静态语言,是有后发优势的。它引入了动态语言的便利,同时又会进行静态语言的类型检查,写起来是非常 Happy 的。Go 采用了折中的做法:不要求类型显式地声明实现了某个接口,只要实现了相关的方法,编译器就能检测到。
package main
import "fmt"
// 定义一个接口 People, 和接口中的函数:
type People interface {
say(word string)
}
// 定义两个结构体
type Girl struct {name string}
type Boy struct {name string}
// 我们并没有将 Girl 显示地声明为接口 People 类型
// 只要实现了接口内部的方法,那么编译器就可以隐式地将其转成 People 类型
func (g Girl) say(word string) {
fmt.Printf("%s say %s\n", g.name, word)
}
func (b Boy) say(word string) {
fmt.Printf("%s say %s\n", b.name, word)
}
// 声明一个函数,参数 p 是 People 类型
func commonSay(p People, word string) {
p.say(word)
}
func main() {
g := Girl{"mashiro"}
b := Boy{"sorata"}
commonSay(g, "hello") // mashiro say hello
commonSay(b, "hello") // sorata say hello
}
看一下 commonSay 函数,它的第一个参数类型是 People,而 People 是一个接口类型。只要某个类型实现了接口 People 的所有方法,那么两者之间就可以相互转化。比如这里的 Girl 和 Boy 都实现了 say 方法,那么其实例就可以转成 People 类型。
因此有些时候,我们并不关心参数的类型是什么,而是更关心行为。比如这里的 commonSay,我们其实不在乎第一个参数的类型,我们只是希望它能够调用 say 方法即可。那么便可将第一个参数声明为接口类型,并规定实现该接口所需要实现的方法,因此它相比 Python 和 Java 会更友好一些。
Python:不关心是否实现了相应的方法,而是直接调用,如果找不到就报错;
Java:定义一个接口,规定了实现该接口所需要实现的方法,并且某个类型在实现接口的时候,还必须显式地指明自己实现的是哪一个接口;
Go:和 Java 类似,但不需要指明自己实现的是哪一个接口,只要该类型实现了指定接口里面的方法,那么编译器就认为该类型实现了指定接口;
所以 Go 里面如果想实现某个接口,不需要显式地声明,只需要实现对应接口中的方法即可。这样既没有 Java 那么啰嗦,又能够对类型进行检测。
顺带再提一下动态语言的特点:
变量指向的对象的类型是不确定的, 在运行期间才能确定;
函数和方法可以接收任何类型的参数, 且调用时不检查参数类型、不需要实现接口;
总结一下,鸭子类型是一种动态语言的风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类决定,而是由它当前方法和属性的集合决定。Go 作为一种静态语言,通过接口实现了鸭子类型,实际上是 Go 的编译器在其中作了隐匿的转换工作。
值接收者和指针接收者的区别
方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。
举个栗子:
package main
import "fmt"
type girl struct {
age int
}
// 值接收者
func (g girl) ageIncr1() {
g.age++
}
// 指针接收者
func (g *girl) ageDecr1() {
g.age--
}
func main() {
g1 := girl{16}
// 值类型 调用 值接收者的方法
g1.ageIncr1()
fmt.Println(g1.age) // 16
g2 := girl{16}
// 值类型 调用 指针接收者的方法
g2.ageDecr1()
fmt.Println(g2.age) // 15
g3 := &girl{16}
// 指针类型 调用 值接收者的方法
g3.ageIncr1()
fmt.Println(g3.age) // 16
g4 := &girl{16}
// 指针类型 调用 指针接收者的方法
g4.ageDecr1()
fmt.Println(g4.age) // 15
}
我们暂时先不看结果,总之目前可以得出:不管接收者是什么类型,该类型的值和指针都可以调用。
实际上,当 调用者的类型 和 方法的接收者类型 不同时,其实是编译器在背后做了一些工作:
所以正如前面所说,不管接收者类型是值类型还是指针类型,都可以通过 值调用者 或 指针调用者 进行调用,这里面实际上通过语法糖起作用的。
如果是值接收者, 指针类型调用, 那么会通过 *指针 的方式调用, 并将值拷贝一份;
如果是指针接收者, 值类型调用, 那么会通过 &值 的方式调用, 并将指针拷贝一份;
因此在调用之后 age 是否改变,取决于接收者到底是值类型还是指针类型,与调用者无关,因为 Go 编译器会进行转化。
但是问题来了,如果值接收者实现了一个方法,那么相同类型的指针接收者可不可以实现相同的方法呢?可以自己测试一下,答案是不行的。因为不管是值还是指针,它们都是同一类型的值和指针。
而在实现接口的时候,它们也是有区别的,举个栗子:
package main
import "fmt"
type People interface {
a()
b()
}
type Girl struct {}
// 如果想实现某个接口, 那么只需要实现该接口中的方法即可
func (g Girl) a() {
fmt.Println("girl -> a")
}
func (g *Girl) b() {
fmt.Println("girl -> b")
}
// 但是我们看到方法 a 是值接收者实现的, 方法 b 是指针接收者实现的
func main() {
g := Girl{}
var p People
// 此时将 g 赋值给 p 是报错的, 因为没有实现 People 中的所有方法
// 但指针可以
p = &g
p.a() //girl -> a
p.b() //girl -> b
}
所以区别如下:
实现了接收者是值类型的方法, 相当于自动实现了接口中接收者是指针类型的方法;
而实现了接收者是指针类型的方法, 不会自动生成接口中对应接收者是值类型的方法;
因此 Girl 实现了方法 a,会自动让 *Girl 也实现了方法 a;但是*Girl 实现了方法 b,不代表 Girl 也实现了方法 b。
如果此时将 g 强行赋值给 p,那么会出现如下编译错误。
报错信息给的很详细,不能将 Girl 类型的变量 g 作为 people 类型进行赋值,因为它没有实现 People 这个接口。括号里面提示:方法 b 的接收者是指针,并不会让值接收也拥有方法 b。因此 Girl 只实现了接口中的一个方法,它没有实现该接口的全部方法,因此不能赋值。
至于为什么会有这么一个设计,原因很简单:
接收者是指针类型的方法,很可能在方法中会对接收者的属性进行更改操作,从而影响调用者;而对于接收者是值类型的方法,在方法中不会对调用者本身产生影响,因为不是同一个对象。
所以当实现了接收者是值类型的方法时,会自动生成接口中接收者是对应指针类型的方法,因为两者都不会影响调用者。但是当实现了接收者是指针类型的方法,如果此时自动生成接口中接收者是值类型的方法,原本期望对调用者进行改变(通过指针实现),现在却无法实现,因为值类型会产生一个拷贝,不会真正影响调用者。
因此,只要记住一点就可以了:如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法,针对于接口而言。
那么问题又来了,在实现方法的时候到底应该采用值接收者还是指针接收者呢?
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身,会影响调用者。
而使用指针作为方法的接收者的理由:
方法能够修改接收者指向的值;
避免在每次调用方法时复制该值, 在值的类型为大型结构体时, 这样做会更加高效;
但是判断使用值接收者还是指针接收者,不是由该方法是否修改了调用者来决定,而是应该基于该类型的本质。
- 如果类型具备 "原始的本质",也就是说它的成员都是由 Go 语言里内置的原始类型组成,如字符串,整型值等,那就使用值接收者。
- 像内置的复合类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header,这些类型也是使用值接收者。这样的话,调用方法时也是直接 copy 一份,但介绍切片的时候说过,这些 header 本身不存储数据,所以直接传值即可,无需使用指针。
- 如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 Go 源码里的文件结构体(struct File)就不应该被复制,而是应该只有一份实体。
类型转换与断言
Go 语言中不允许隐式类型转换,也就是说 =
两边,不允许出现类型不相同的变量。而 类型转换、类型断言本质上都是把一个类型转换成另外一个类型,不同之处在于,类型断言是对接口变量进行的操作。
首先是类型转换,类型转换前后的两个类型要相互兼容才行,语法为:<变量> := <目标类型> ( <表达式> )
package main
import "fmt"
func main() {
var i int = 9
var j float64
// 这个时候直接把 i 赋值给 j 是非法的
// 因为 = 两边不允许出现类型不同的变量,我们需要进行类型转换
j = float64(i)
fmt.Println(j) // 9
// 但是注意: int(3.14) 这种则不行, 因为会涉及截断, 前后值发生了改变
// 将一个 float64 类型转化为 int, 除非这个 float64 的小数点后面是 0
// 那如果遇见小数点后面不是 0 的浮点数该咋办呢?
// 可以使用 math.Floor(), 会返回一个小数点后面是 0 的浮点数, 此时再转成 int 即可
}
类型转换比较简单,再来看看类型断言。断言针对的是接口来说的,因为多个类型都可以实现同一个接口,那么如何判断一个接口变量对应哪一种类型呢?
package main
import "fmt"
type People interface {
say(word string)
}
type Girl struct {name string}
type Boy struct {name string}
func (g Girl) say(word string) {
fmt.Printf("%s say %s\n", g.name, word)
}
func (b Boy) say(word string) {
fmt.Printf("%s say %s\n", b.name, word)
}
func main() {
var p1 People = Girl{"mashiro"}
var p2 People = Boy{"sorata"}
// 此时的 p1 和 p2 都是 People 类型
// 那么问题来了,如何才能得到它们的原始类型呢
var g = p1.(Girl)
var b, ok = p2.(Boy)
fmt.Println(g) // {mashiro}
fmt.Println(b, ok) // {sorata} true
}
将一个变量赋值给一个接口之后,这个变量的原始类型其实是被保存起来了的,如果想转成原始类型,那么需要进行断言。而断言有两种方式,一种是非安全类型断言,另一种是安全类型断言。
非安全类型断言: <目标类型变量> := <接口变量>.( <目标类型> )
安全类型断言: <目标类型变量>, <布尔值变量> := <接口变量>.( <目标类型> )
如果采用非安全类型断言,那么当目标类型指定错误,会抛出 panic。比如代码中变量 p1 的原始类型是 Girl,而如果写成 p1.(Boy),那么就会断言失败:main.People is main.Girl, not main.Boy。所以更建议使用安全类型断言,如果断言失败,那么返回目标类型的零值,和一个 false,但程序不会 panic。
空接口
一个类型如果实现了接口里面的方法,那么就实现了该接口,但要是接口里面没有定义方法呢?这种接口叫做空接口,显然任何类型都实现了空接口。
package main
import "fmt"
func main() {
// i 是一个空接口, 任何类型都实现了空接口
// 这里给 i 赋一个整数 123
var i interface{} = 123
// 注意: i 是一个空接口, 我们要如何得知变量 i 背后的原始类型呢?
// 可以使用断言的方式, 我们认定它是整型, 那么就可以这么做
var num = i.(int)
fmt.Println(num) // 123
fmt.Println(num == 123) // true
// 但如果断言是一个字符串的话, 显然是会报错的
// 这个时候可以使用安全断言, 也就是采用两个变量来接收
s, flag := i.(string)
fmt.Printf("%q %t\n", s, flag) // "" false
// 如果不能成功转换, 那么会得到 零值 和 false
// 成功转换会得到 对应的值 和 true
// 如果一个接口变量可以对应多种类型, 那么还可以使用 switch 语句
switch i.(type) {
case int:
// 当匹配成功时, i 会被转成指定的类型
fmt.Println("int", i) // int 123
case float64:
fmt.Println("float64", i)
default:
fmt.Println("Unknown type")
}
}
所以当一个函数既可以接收整数、字符串、浮点数的时候,就可以使用 interface{}, 然后进行断言。
目前我们便使用 interface 实现了多态,Go 语言并没有设计诸如虚函数、纯虚函数、继承、多重继承等概念,但它通过接口却非常优雅地支持了面向对象的特性。多态是一种运行时的行为,它有以下几个特点:
一种类型具有多种类型的能力;
允许不同的对象对同一消息做出灵活的反应;
以一种通用的方式对待使用的对象;
非动态语言必须通过继承和接口的方式来实现
接口类型的值在底层是怎么表示的
如果某个结构体实现了某个接口的所有方法,那么该结构体实例便可以赋值给接口变量,举个例子:
package main
import (
"fmt"
)
type Car interface {
Drive()
}
type Truck struct {
Name string
}
func (t Truck) Drive() {
fmt.Printf("拖拉机%s在狂飙", t.Name)
}
func main() {
var c Car = Truck{"古尔丹"}
fmt.Println(c)
}
Car 是一个接口类型,内部定义了一些方法集,任何实现了这些方法的结构体实例都可以赋值给该类型的接口变量。所以接口相当于就是一个抽象,当你不关心对象的类型、而是行为时,那么便可以使用接口。比如函数的某个参数,我们不关心它到底是什么类型,只要它能调用指定的一系列方法即可,那么便可以声明为接口类型,至于接口变量的值具体对应哪一种类型,则需要通过断言来判断。
所以接口变量,本质上还是由结构体实例赋值得到的,以上都是已经说过的内容。但是问题来了,接口到底是怎么实现的呢?我们看一下它的底层定义。
// runtime/runtime2.go
type iface struct {
tab *itab
data unsafe.Pointer
}
接口在底层也是一个结构体,其中里面的 data 指向的便是具体的结构体实例,对于当前来说就是 Truck 实例。而第一个字段 tab 指向的也是结构体:
type itab struct {
// 接口类型本身
inter *interfacetype
// 接口存储的值的类型,做类型断言的时候会进行比较
_type *_type
hash uint32
_ [4]byte
// 实现的方法,这里显示长度为 1,但具体多长取决于实现了多少个方法
fun [1]uintptr
}
还是比较简单的,但是注意:iface 不包括空接口,空接口的话专门实现了一个结构体叫 eface。
type eface struct {
_type *_type
data unsafe.Pointer
}
因为空接口没有实现方法啥的,所以第一个字段就是一个 *_type,表示存储的值的具体类型。
package main
import (
"fmt"
"unsafe"
)
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag uint8
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str int32
ptrToThis int32
}
type eface struct {
_type *_type
data unsafe.Pointer
}
func main() {
var a interface{} = 123
// 转成 eface 指针
pointerEface := (*eface)(unsafe.Pointer(&a))
// 拿到里面的 data 属性,指向了具体的值
// 由于是个整型,所以转成 *int,再解引用即可拿到具体的值
fmt.Println(*(*int)(pointerEface.data)) // 123
}
所以当给一个接口变量赋值时,编译器会转成 iface 或 eface 之后赋值。
小结
- Go 的隐式接口更加方便系统的扩展和重构。
- 结构体和指针都可以实现接口,结构体实现了接口的方法,会隐式地让其指针也实现该方法;但反过来则不是。
- 空接口可以承载任何类型的数据。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏