GO语言基础篇(十九)- go语言接口类型详解

719 阅读10分钟

这是我参与8月更文挑战的第 19 天,活动详情查看: 8月更文挑战

接口

接口类型是对其他类型行为的概括与抽象。通过使用接口,我们可以写出更加灵活和通用的函数,这些函数不用绑定在一个特定的类型实现上

很多面向对象的语言都有接口这个概念,Go语言接口的独特之处在于它是隐式实现。 也就是说,对于一个具体的类型,无须声明它实现了哪些接口,只要提供接口所必需的方法即可。这种设计让你无须改变已有类型的实现,就可以为这些类型创建新的接口,对于那些不能修改包的类型,这一点特别有用

可以想一下传统的面向对象中,假设现在有一个接口File,它里边提供了可读和可写的方法。这个File会告诉使用它的人(它也不知道使用它的人是谁),它提供了可读和可写,其他人可以通过这来用它

但是在Go语言中,接口是由使用者定义。比如说现在有一个玩具鸭子,如果使用者是一个小孩,那它就是一个鸭子;但是如果使用者是一个吃货,那它就不认为它是个鸭子

可能比较抽象,下边有代码示例

接口即约定

之前介绍的类型都是具体类型。具体类型指定了它所含数据的精确布局,还暴露了基于这个精确布局的内部操作。比如对于数值有算术操作,对于slice类型我们有索引、append、 range等操作。具体类型还会通过其方法来提供额外的能力。总之,如果你知道了一个具体类型的数据,那么你就精确地知道了它是什么以及它能干什么

Go语言中还有另外一种类型称为接口类型接口是一种抽象类型,它并没有暴露所含数据的布局或者内部结构,当然也没有那些数据的基本操作,它所提供的仅仅是一些方法而已。如果你拿到一个接口类型的值,你无从知道它是什么,你能知道的仅仅是它能做什么, 或者更精确地讲,仅仅是它提供了哪些方法

接口类型

一个接口类型定义了一套方法,如果一个具体类型要实现该接口,那么必须实现接口类型定义中的所有方法

上边这种说法,跟传统面向对象里的接口不容易区分。比如PHP中的接口,你要去实现某个接口,需要显式的使用一个关键字implements来跟上你要实现的接口。但是在Go语言中,你并不需要显式的说明你要实现哪个接口,只要你实现了某个接口中的所有方法,那你就实现了这个接口

接口的定义方式如下:

package io

type Reader interface{ //关键字interface
    Read(p []byte) (n int, err error)  //接口中包含的方法,只需要说明方法名、参数列表、返回值列表即可
}

type Closer interface{
    Close() error
}

注意:在接口中定义方法时,不需要加关键字func

下边通过接口实现中的示例来帮助理解接口

接口实现

下边直接通过代码示例来看接口的实现

package main

type Retriver interface { //定义一个Retriver接口,它里边有一个Get方法
    Get(url string) string
}

func download(r Retriver) string {//download是一个使用者,它调用接口中的Get方法
    return r.Get("<http://www.baidu.com>")
}

上边定义了一个叫Retriver的接口,然后使用者download函数中,使用了接口中的Get方法。下边在一个新的文件中定义一个结构体类型,并且为该结构体实现了一个Get方法

package mock

import "fmt"

type Mock struct {
    Contains string
}

func (r Mock) Get(url string) string { //跟接口中定义的Get函数结构一致(参数&返回值)
    return r.Contains
}

我们可以发现,在上边整个文件中,并没有出现接口Retriver这个词,但是这个结构体类型,却实现了Retriver接口,所以,类型只要实现了接口中的方法,就说这个类型实现了这个接口

有没有发现,这个时候,接口的名字随便改,一点也不影响使用者。此时就可以在main包中来使用

func main() {
    retriever := mock.Mock{"www.baidu.com"}
    fmt.Println(download(retriever))// "www.baidu.com"
}

可以发现download需要的参数是一个Retriver接口类型,但是我们传实参的时候,是一个结构体类型,它能够知道该结构体实现了Retriver接口

下边再在一个新的文件中定义一个结构体,也实现Get方法,在Get方法中,真实获取到页面数据

package real

import (
    "net/http"
    "net/http/httputil"
    "time"
)

type Real struct {
    UserAgent string
    TimeOut time.Duration
}

func (r Real) Get(url string) string {
    resp, err := http.Get(url)
    if err != nil {
        panic(err)
    }

    result, err := httputil.DumpResponse(resp, true)

    resp.Body.Close()

    if err != nil {
        panic(err)
    }

    return string(result)
}

然后在main中使用

var r Retriver
r = real.Real{
    UserAgent: "mozilla/5.0",
    TimeOut: time.Minute,
}
fmt.Println(download(r))// 会打印出百度首页的源代码

总结:在上边的例子中,Mock和Real是两个不同的结构体,但他们都隐式的实现了Retriver接口,所以他们都可以作为参数传递给download函数,它会根据不同的实现者,来调用不同的Get方法。所以,接口的实现是隐式的,它只需要实现接口里边的方法,就实现了这个接口。如果理解了,上边的示例,相信你对接口会有一个大致的认识

接口的值类型

从概念上来说,一个接口类型的值(简称接口值)其实由两个部分组成:一个具体的类型和该类型的值。二者称为接口的动态类型动态值

对于像Go这样的静态类型语言,类型仅仅是一个编译时的概念,所以类型不是一个值。在通常的概念模型中,用类型描述符来提供每个类型的具体信息,比如它的名字和方法。对于一个接口值,类型部分就用对应的类型描述符聊表述

下边的四个语句中,变量w有三个不同的值(最初和最后是同一个值)

var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil

第一个语句

var w io.Writer

在Go语言中,变量总是初始化为一个特定的值,接口也不例外。接口的零值就是把它的动态类型和值都设置为nil

image.png

一个接口的值是否是nil,取决于它的动态类型,所以现在这是一个nil接口值。可以用w == nil或者w ≠ nil来检测一个接口值是否是nil。调用一个nil接口的任何方法都会导致宕机

w.Write([]byte("hello"))//宕机:对空指针取引用值

第二个语句

第二个语句把*os.File类型赋值给了w

w = os.Stdout

这次的赋值是把一个具体的类型隐式转换为一个接口类型,它与对应的显式转换io.Writer(os.Stdout)等价。不管这种类型的转换是隐式的还是显式的,它都可以转换操作数的类型和值。接口值的动态类型会设置为指针类型*os.File的类型描述符,它的动态值会设置为os.Stdout的副本,即一个指向代表进程的标准输出的os.File类型的指针。如图:

image.png

调用该接口值的Write方法,会实际调用(*os.File).Write方法

w.Write([]byte("hello")) // "hello"

一般来说,在编译时我们无法知道一个接口值的动态类型会是什么,所以通过接口来做调用,必然需要使用动态分发。编译器必须生成一段代码来从类型描述符拿到名为Write的方法地址,再间接调用该方法地址。调用的接收者就是接口值的动态值,即os.Stdout,所以实际效果与直接调用等价

os.Stdout.Write([]byte("hello")) // "hello"

第三个语句

第三个语句把一个*bytes.Buffer类型的赋值给了接口值

w = new(bytes.Buffer)

动态类型现在是*bytes.Buffer,动态值现在则是一个指向新分配缓冲区的指针,如图:

image.png

调用Write方法的机制也跟第二个语句一致

w.Write([]byte("hello")) //把"hello"写入bytes.Buffer

这次,类型描述符是*bytes.Buffer,所以调用的是(*bytes.Buffer).Write方法,方法的接收者是缓冲区的地址。调用该方法会追加"hello"到缓冲区

还是接口实现中的示例,现在来看一下那个接口类型打印出来到底是什么

var r Retriver

r = mock.Retriver{"www.baidu.com"}
fmt.Printf("%T\t%v\n", r, r) //打印出类型和值
r = real.Real{} //没有给初始值,默认是对应类型的零值
fmt.Printf("%T\t%v\n", r, r)

输出结果:
mock.Mock   {www.baidu.com}
real.Real   { 0s} //注意,前边有个空格,其实就是UserAgent的默认值,空

在其它的语言中,这个r一般是一个指针,指向真实的值的地址。但是上边的r并不是一个值那么简单,从打印的结果中可以看出来,它里边有一个类型,还有一个值。看到值,一般就想到指针,所以我们其实还可以将接收者换成指针类型,如下

func (r *Mock) Get(url string) string {
    return r.Contains
}

接收者改成指针类型之后,需要在调用的时候,通过地址来调用(这个其实就是方法的使用,在前边的文章中已经分享过,可以点击这里

var r Retriver

r = &mock.Retriver{"www.baidu.com"}
fmt.Printf("%T\t%v\n", r, r) //打印出类型和值

输出结果:
*mock.Mock   &{www.baidu.com}//类型成了一个指针,值成了一个地址

既然接口中有类型,那如何知道这个类型?就涉及到类型断言

类型断言

类型断言是一个作用在接口上的操作,写出来类似于x.(T)。其中x是一个接口类型的表达式,而T是一个类型(称为断言类型)。类型断言会检查作为操作数的动态类型是否满足指定的断言类型

这里有两种可能

  1. 如果断言类型是一个具体类型。那么类型断言会检查x的动态类型是否就是T。如果检查成功,类型断言的结果就是x的动态值。换句话说,类型断言就是用来从它的操作数中把具体类型的值提取出来的操作。如果检查失败,操作就会崩溃
var w io.Writer
w = os.Stdout
f := w.(*os.File) // 成功:f == os.Stdout
c := w.(*bytes.Buffer) // 崩溃:接口持有的是*os.File,不是*bytes.Buffer

var r Retriver
r = mock.Mock{"www.baidu.com"}
fmt.Println(r.(mock.Mock)) // 输出:{"www.baidu.com"}
  1. 如果断言类型T是一个接口类型,那么类型断言检查x的动态类型是否满足了T。如果检查成功,动态值并没有提取出来,结果仍然是一个接口值,接口值的类型和值部分也没有变更,只是结果的类型为接口类型T。换句话说,类型断言是一个接口值表达式,从一个接口类型变为拥有另外一套方法的接口类型,但是保留了接口值中的动态类型和动态值部分

在下边的代码中,w和rw都持有os.Stdout,于是所有对应的动态类型都是*os.File,但w作为io.Writer仅暴露了文件的Write方法,而rw还暴露了他的Read方法

var w io.Writer
w = os.Stdout
rw := w.(io.ReadWriter)//成功:*os.File有Read和Write方法

w = new(ByteCounter)
rw = w.(io.ReadWriter) //崩溃:*ByteCounter没有Read方法

我们经常无法确定一个接口的动态类型,这时就需要检测它是否是某一个特定类型。如果类型断言出现在需要两个结果的赋值表达式中,那么断言不会在失败的时候崩溃(panic),而是会多返回一个布尔型的返回值来指示断言是否成功

var w io.Writer = os.Stdout
f, ok := w.(*os.File) //成功,ok == true
b, ok := w.(*bytes.Buffer)//失败,ok == false

另一种方式是在switch中使用的,还是以接口实现部分中的例子为例

package main

func main() {
    var r Retriver

    r = mock.Mock{"www.baidu.com"}
    inspect(r)		

    r = real.Real{
        UserAgent: "Mozilla/5.0",
        TimeOut: time.Minute
    }
    inspect(r)
}

func inspect(r Retriver) {
    switch v := r.(type) {
    case mock.Mock:
        fmt.Println("Contents: ", v.Contents)
    case real.Real:
        fmt.Println("UserAgent: ", v.UserAgent)
    }
}

输出:
Contents:  www.baidu.com
UserAgent:  mozilla/5.0