这是我参与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
一个接口的值是否是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类型的指针。如图:
调用该接口值的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,动态值现在则是一个指向新分配缓冲区的指针,如图:
调用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是一个类型(称为断言类型)。类型断言会检查作为操作数的动态类型是否满足指定的断言类型
这里有两种可能
- 如果断言类型是一个具体类型。那么类型断言会检查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"}
- 如果断言类型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