聊一聊 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 的隐式接口更加方便系统的扩展和重构。
  • 结构体和指针都可以实现接口,结构体实现了接口的方法,会隐式地让其指针也实现该方法;但反过来则不是。
  • 空接口可以承载任何类型的数据。
posted @ 2019-09-11 11:18  古明地盆  阅读(3724)  评论(1编辑  收藏  举报