更多优质内容
请关注公众号

Go入门系列(十一) 接口——接口的定义、作用、具体类型和具体值(上)-张柏沛IT博客

正文内容

Go入门系列(十一) 接口——接口的定义、作用、具体类型和具体值(上)

栏目:Go语言 系列:Go入门系列 发布时间:2021-01-08 10:04 浏览量:2021

Go中的接口是一种数据类型,不过它的作用和其他语言中的接口一样,用作于一种协议以暴露和隐藏一个变量或一种类型的方法。

我们回想一下php中的接口,php中的接口中会定义一系列的方法,这些方法没有方法体,且php接口不能实例化,只能被其他类实现,并且这个类必须实现该接口的所有方法。

 

Go中,我们一般会用type声明一个底层类型为接口类型的新类型,并且在这个接口中声明需要实现的空方法。然后我们需要定义一个类来实现这个接口,实现的方式就是这个类要定义和实现和空方法同名的方法(实现一个接口其实只需要实现这个接口的所有方法)。

 

例如:

type Animal interface {
	Move()   // 能移动的就是动物
}

type Person struct {
	Name string
	Age int
}

type Dog struct {
	Age int
}

func (person Person) Move() {
	fmt.Println("person move by two feet")
}

func (dog Dog) Move() {
	fmt.Println("person move by four feet")
}

 

这里我以interface接口类型为底层类型声明了一个Animal接口,并且定义了PerosnDog这两个类。然后为PersonDog都定义了Move方法,只要PersonDog定义了Animal规定的Move方法,那么PersonDog就算是实现了Animal接口,那么PerosnDog类型就是Animal接口类型。

 

接下来我们可以简单的做个试验看看Person类型和Animal类型是否是相同类型:

var a Animal	// 声明一个动物变量
zbp := Person{Name:"zbp", Age:24}	// 定义一个人类变量,这个人就是zbp本人啦
a = zbp			// 将Person类型的zbp变量赋值给Animal类型的a变量。发现没有报错,我们知道go中不允许不同类型的变量之间相互赋值的,因此说明Person和Animal类型是同一类型

fmt.Println(zbp)		// {zbp 24}
fmt.Println(a)			// {zbp 24}

PersonDog都是Animal类型,但是Animal类型不是Person类型或Dog类型,PersonDog之间也不是相同类型哦!

 

需要提醒大家的是,学习接口的时候,我们需要刻意分清楚实际类型和接口类型(这样有利于我们去理解接口及其使用),像Person和Dog它们就是实际类型,而Animal是一个接口类型,后者就向是一个空壳(因为它里面的方法都没有实现),前者就像是灵魂,把Person这种实际类型的变量zbp赋值给Animal这种接口类型的变量a就像是往空壳里面注入灵魂的过程。

把一个实际类型的变量赋给一个接口类型的变量我们也可以将其视为是一种隐式的类型转换(从Person类型隐式的转为Animal类型)

 

下面再举一个经典的例子来说明接口,我们常见的fmt.Printffmt.Sprintf方法其实就使用了接口。这两个函数都使用了另一个函数fmt.Fprintf。我们可以看看go的源码如何实现的

package fmt

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error){
    // ....略
}

func Printf(format string, args ...interface{}) (int, error) {
    return Fprintf(os.Stdout, format, args...)
}

func Sprintf(format string, args ...interface{}) string {
    var buf bytes.Buffer
    Fprintf(&buf, format, args...)
    return buf.String()
}

Fprintf的第一参要求是一个io.Writer类型,这个类型是一个接口类型,Go源码对io.Writer类型的定义如下:

package io

// Writer is the interface that wraps the basic Write method.
type Writer interface {
    Write(p []byte) (n int, err error)
}

Io.Writer接口声明了一个Write方法。

 

我们再回过头来看 PrintfSprintf,前者将一个 *os.File类型的 os.Stdout常量传给了Fprintf 的第一参,后者将一个bytes.Buffer类型的指针&buf传给了Fprintf的第一参。说明os.Stdout&buf的类型(*os.File*bytes.Buffer)都算是 io.Writer 类型,原因是*os.File*bytes.Buffer类型都实现了io.Writer接口的Write方法。

func (f *File) Write(b []byte) (n int, err error) {
	if err := f.checkValid("write"); err != nil {
		return 0, err
	}
	n, e := f.write(b)
	if n < 0 {
		n = 0
	}
	if n != len(b) {
		err = io.ErrShortWrite
	}

	epipecheck(f, e)

	if e != nil {
		err = f.wrapErr("write", e)
	}

	return n, err
}

func (b *Buffer) Write(p []byte) (n int, err error) {
	b.lastRead = opInvalid
	m, ok := b.tryGrowByReslice(len(p))
	if !ok {
		m = b.grow(len(p))
	}
	return copy(b.buf[m:], p), nil
}

我们会发现,在go中让一个类型实现一个接口不会像php那样需要显式的用专门的语法进行实现操作(PHP中用”class 类名 implement 接口名的语法显式的实现一个接口)。只要go中的类型定义了接口中指定的方法(不过参数和返回值列表要与接口中的方法一致才行),那么这个类型就自动实现了这个接口。

 

接下来我们就自己写一个ByteCouter类型来实现io.Writer接口,很简单,我们只需为ByteCouter类型定义一个Write方法即可。

type ByteCouter int

func (bc *ByteCouter) Write(p []byte) (n int, err error) {
	*bc += ByteCouter(len(p))
	return len(p), nil
}

func main(){
    str:="Hello World!"
    var byteCouter ByteCouter
    fmt.Fprintf(&byteCouter, "%s", str)
    fmt.Println(byteCouter)
}

ByteCouter类型用于计算一个字节切片的长度。由于&byteCouter对应的类型(*ByteCouter)实现了Write方法,因此它也算是io.Writer方法。 Fprintf内部会调用&byteCouterWrite方法从而改变byteCouter的内容。

 

小结:接口是一种协议;接口定义了一系列空方法让其他类型实现;定义了接口指定的方法的类型我们称这个类型实现了这个接口,这个实际类型就和这个接口类型是同一类型。

 

io.Writer类型是用得最广泛的接口之一,因为它提供了所有实际类型的写入bytes的抽象,包括文件类型,内存缓冲区,网络链接,HTTP客户端,压缩工具,哈希等等。io包中定义了很多其它有用的接口类型。Reader可以代表任意可以读取bytes的类型,Closer可以是任意可以关闭的值,例如一个文件或是网络链接。如下

package io
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}

这些是单方法接口。

如果我们希望在一个新的接口中复用一个原有的接口(的方法),那么也可以用类似于类型嵌套的方式来进行接口嵌套。

 

比如:

type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

ReadWriter这个接口嵌套了ReaderWriter这两个接口,因此ReadWriter接口也拥有和指定了ReadWrite方法。

 

当然我们也可以不用接口嵌套,而是直接这样

type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

不过就是麻烦些。

 

或者甚至使用一种混合的风格

type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Writer
}

 

接下来我们说一下接口实现的条件:

A.一个类必须实现这个接口的所有方法才算实现了这个接口。也就是说*ByteCouter算是实现了io.Writer接口,但是不算实现 io.ReadWriter接口,因此 *ByteCouterio.Writer是同一类型,和io.ReadWriter不是。

B.实现方法的时候的接收器才是和这个接口是同一类型。比如说,上面 ByteCouter类型,其Write方法的接收器是一个 *ByteCouter 指针,因此我们只能说 *ByteCouter (指针)类型实现了io.Writer接口,但是 ByteCouter类型本身没有实现io.Writer接口(所以ByteCouterio.Writer不是同一类型)。 所以往Fprintf()传入 byteCouter会报错,传入*byteCouter才不会。

C.当一个类型除了实现了一个接口的所有方法之外还有其他方法的话,当把这个类型声明为接口类型的话,这个类型的其他方法会被隐藏而无法使用。

 

举个例子:

Os.Stdout常量是一个 *os.File类型,这个类型定义了WriteClose方法。

os.Stdout.Write([]byte("hello")) // 在屏幕上打印hello
os.Stdout.Close()	// 关闭资源

 

但是如果我们将其赋值给一个 io.Writer 接口类型的变量,我们就把 os.Stdout能用的方法限定为了io.Writer 类型下的方法Write,此时我们就无法调用Close方法以及*os.File的其他方法了:

var w io.Writer
w = os.Stdout
w.Write([]byte("hello")) // 在屏幕上打印hello
w.Close()		// 报错

 

我们甚至可以将一个接口赋值给另一个接口,但前提是等式右边的接口把等式左边的接口的方法全部实现,例如:

var w io.Writer		// 声明一个有Write方法的接口变量
w = os.Stdout	// 将1个指向标准输出的指针赋值给w

var res io.ReadWriteCloser	// 声明一个有Read Write Close方法的接口变量
res = os.Stdout		// 同上

//res = w		// 报错,因为w只实现了1个Write方法,res需要其实现3个方法
//w = res     // 正确,因为w只需实现1个Write方法,res实现了3个,不过这么一来当res赋给w之后,w就只有Write方法能用了。

 

D. 空接口 interface{} 可以被任何类型的方法实现,但是由于空接口没有指定任何方法,所以实现了空接口的类型不能调用它自己原有的所有方法。

var nilInterface interface{}
nilInterface = os.Stdout
nilInterface.Write([]byte("hello")) // 报错,此时os.Stdout对应的所有方法都被空接口给隐藏屏蔽了
nilInterface.Close()

那是不是就意味着空接口没有任何意义和用处呢?不是的,因为空接口类型对实现它的类型没有要求,所以我们可以将任意一个值赋给空接口类型的变量。

var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)

这个用处被常用在函数的某个形参接收任意类型实参时使用,例如fmt.Println方法:

func Println(a ...interface{}) (n int, err error) {
		return Fprintln(os.Stdout, a...)
}

 

而且由于一个空接口把原类型的所有方法给隐藏了,因此我们不能直接对它持有的值做操作,这个变量就变成了只读,我们也可以用这种方式保护一个变量使其不会在函数中被随意修改。

例如:

var i interface{}
i = 10
i += 1		// 报错,加法运算这个方法已被隐藏
fmt.Println(i)

 

接口值

当我们声明一个接口变量的时候

var w io.Writer

这个接口变量w就是一个接口值,接口值包含两部分内容:接口的具体类型(字符串、数字、一个自定义的结构体等等) + 这个类型的具体值

我们可以用类型描述符来描述一个接口的具体类型,所谓的类型描述符就是类似于 int,string,ByteCouter,*bytes.Buffer,*os.File等等。

 

下面我们通过一个例子看看将一个类型赋值给一个接口变量时发生了什么:

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

 

第一句话

var w io.Writer

go中,一个变量完成声明后总是会被赋上一个零值初值,接口类型也不例外。一个接口的零值就是他的具体类型和具体值都为nil,此时这个接口值w本身就是一个io.Writer类型的nil。如下所示:

fmt.Printf("%#v", w)           // <nil>

 

只有当接口值w的具体类型和值都为nil的时候,这个接口值才是nil如果具体类型不为nil,但是值为nil,那么w也不会是nil

比如:

var y *bytes.Buffer
w = y
fmt.Printf("%#v", w)		// (*bytes.Buffer)(nil)
fmt.Println(w==nil)	// false

可以看到w的打印结果也是按照 (具体类型)(具体值)的格式给出的。这里的w就是个类型为字节缓冲区指针,值为nil的类型值,但是w整体不是nil。

当一个接口变量wnil的时候,这个w就是一个空接口。可以通过 w!=nil 判断它是否为空接口。用空接口调用方法会产生panic异常。

 

两个不同类型的接口之间是可以进行比较的,但前提是其中一个接口的方法要涵盖另一个接口的所有方法,这样就会视为两种接口是同一种类型,否则就不是同一类型。例如:

var rw io.ReadWriter
var wc io.WriteCloser
fmt.Println(rw==wc)		// 报错mismatched types io.ReadWriter and io.WriteCloser(两个接口类型不匹配的意思)
var w io.Writer
var c io.ReadWriteCloser
fmt.Println(w==c)			// 不报错,而且为true,因为w和c都是零值接口,都为nil

 

 

第二句话

w = os.Stdout

 

这句话其实相当于做了一个隐式转换,将os.Stdout转换为io.Writer类型。相当于:

io.Writer(os.Stdout)

此时会将os.Stdout的具体类型和值记录到w中:具体类型就是 *os.File 这个类型描述符。具体值就是一个指向标准输出的指针。

当调用 w.Write([]byte(“hello”))的时候,本质上调用的是(*os.File).Write

 

同样的,如果我们将一个*os.File类型的变量传入一个函数,且这个函数的接收参数是一个io.Writer类型,那么也会发生这种隐式转换,如:

func main(){
    w := new(bytes.Buffer)
    demo(w)   // 传入的时候,相当于会隐式的赋值y = w和做类型转换,将 *bytes.buffer转为io.Writer类型
}

func demo(y io.Writer){
    // do something
}

 

第三句话

w = new(bytes.Buffer)

w的类型会变为*bytes.Buffer,此时系统会划分一块缓冲区并将缓冲区的内存指针返回给w,w的具体值就是指向新分配的缓冲区的指针。

 

 

最后一句:

w = nil

 

这个重置将它所有的部分都设为nil值,把变量w恢复到和它之前定义时相同的状态。

 

在上面的例子中我们知道接口值是可比较的。那么什么时候两个接口值相等呢?仅当它们都是nil值,或者它们的动态类型相同并且动态值相等。

因为接口值是可比较的,所以它们可以用在map的键或者作为switch语句的操作数。

 

如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片和map),将它们进行比较就会失败并且panic

var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // panic: comparing uncomparable type []int

 

我们可以通过 Printf(“%T”, w)  的方式查看接口的具体类型。

 

下面我们在看看一个比较容易出错混淆的例子

var w io.Writer
var y *bytes.Buffer		// y是一个nil指针,但是此时底层并没有分配缓冲区,没有缓冲区也就没有指向缓冲区的指针故而为nil指针
fmt.Printf("%T %v", y, y)	// *bytes.Buffer <nil>
w = y
w.Write([]byte("Hello"))	// 报错 使用空指针操作缓冲区(此时根本没有分配缓冲区)

 

此时w是:

正确的用法应该是这样:

var w io.Writer
y := new(bytes.Buffer)		// new的时候底层会分配一个缓冲区,并生成一个指针指向这个缓冲区,然后返回这个指针给y,此时这个指针不为空
fmt.Printf("%#v", y)	// &bytes.Buffer{buf:[]uint8(nil)
w = y
w.Write([]byte("Hello"))    // 不会报错
fmt.Println(y)

所以这两个例子告诉了我们 var 声明一个引用类型的变量和使用new创建一个引用类型的变量的区别。前者是没有初始化的,其值会为nil;后者则操作系统会创建相应资源,并为变量赋初始值,不会为nil。当然啦,new()返回的一定是一个指针。

 

我们看看作者给出的一个关于包含nil指针的接口和nil接口的例子,作者通过这个例子想告诉我们二者是不相同的,并且借此让我们避免一些使用接口时可能会犯的错误。

 

例子:

这个例子的情景是这样的,平时我们在本地开发的时候可能需要输出打印信息以进行调试,在项目上线之后则不需要打印这样的调试信息,因此我们需要一个开关进行控制。

func main() {
    var buf io.Writer
    debug := false
    if debug {		// 如果开启调试模式
        buf = new(bytes.Buffer)	// 就申请一块缓冲区用来存储调试过程中的日志
    }

// do something

debugInfo(buf)		// 将日志写入缓冲区,传入的是个指针
fmt.Println(buf)
}

func debugInfo(out io.Writer) {
	if out != nil {
		out.Write([]byte("Done!"))
	}
}

 

上面这个程序是一个正确示范,下面是错误示范:

func main() {
    var buf *bytes.Buffer
    debug := false
    if debug {
        buf = new(bytes.Buffer)
    }

    // do something
debugInfo(buf)
fmt.Println(buf)
}

func debugInfo(out io.Writer) {
	if out != nil {
		out.Write([]byte("Done!"))
	}
}

这个错误示范错在,那么当debugfalse的时候,bufnil指针)传入到debugInfoout时候,out这个接口变量会将具体类型设为*byte.buffer类型,因此out不为 nil能运行到if的代码去执行Write方法,但是out的具体值为nil(因为bufnil),因此执行if代码块中的out.Write从而引发一个“用nil指针调用Write方法的错误”。

也就是说,out不为nil,但是out的具体指为nil从而引发了这个错误

但是如果一开始声明buf的时候是用的 io.Writer 声明的话就不会发生这种事情,因为传入的buf是具体类型和具体值都为nil的一个io.Writer类型变量buf,此时buf==nil

 

接下来,作者介绍了关于Go中几个接口的实际运用来加深我们对接口的印象,我们在下一节介绍。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > Go入门系列(十一) 接口——接口的定义、作用、具体类型和具体值(上)

热门推荐
推荐新闻