Golang 中的 Interface(接口),全面解析
Go语言中的interface没有强制要求实现方法,但是interface是go中非常强大的工具之一。任一类型都可以实现interface中的方法,interface中的值可以代表是各种类型的值,这就是Go中实现多态的基础
什么是接口
interface就是字面意思——接口,C++中可以用虚基类表示;Java中就是interface。interface则是Golang更接近面向对象编程范式的另一个难点
interface是方法签名的一个集合,这些方法可以被任一类型通过方法实现。因此接口就是对象行为的申明(不是定义,仅仅表示方法签名,也可以称作函数原型)。
💡注意:我多次强调 任一类型 ,Golang中所有类型都可以实现自己的方法,为了便于理解,本文还是使用 struct(结构体)来做示例
举个例子🌰,狗会走路也会汪汪叫,如果在一个接口中申明了Walk方法签名代表走路,Bark方法签名表示狗叫,然后我们有个结构体对象Dog,我们就说Dog实现了这个接口。
所以,接口的任务就是提供方法签名,方法签名由方法名,输入参数,返回值**三部分组成,任一类型可以实现这些方法,比如struct结构体。
如果你是一个OOP(面向对象)类型的程序员,你可能用过implement关键字来实现一个接口,但是在Golang中,你不需要明确的使用关键字来表示你实现了一个接口,如果你使用的类型实现了一个接口中的所有方法签名,那么Golang就默认你实现了这个接口。
💡注意:一个类型定义了一个或多个方法,方法的名字,参数,返回值和接口中的方法签名完全一致,并且接口中的所有方法签名在这个类型中都存在,那么我们称为实现了这个接口
举例子🌰,如果我们说一个东西,走起来像鸭子,游泳像鸭子,并且叫起来像鸭子,那么在Golang中我们就认为它就是一只鸭子🦆!!
申明一个接口
类似struct的申明方法,我们需要使用interface关键字来定义类型别名来方便使用接口。
在上面的例子中,我们定义了一个Shape接口,其中包含了两个方法签名:Area, Perimeter,无形参,返回float64值。任何类型,如果实现了和这两个签名形式一样(同样的方法名,同样的传参,同样的返回值)的方法,我们就称这个类型实现了Shape这个接口
由于接口 是一种类似结构体 的类型,我们可以创建一个Shape类型的变量s。
上面的例子虽然简单,但是包含了很多信息,让我来理理接口 的概念。接口 包含两个类型(听上去有点奇奇怪怪)和一个值。
类型呢,一种是静态类型,另一种就是动态类型。静态类型就是指接口本身的类型,比如上图中的Shape 就是静态类型。
值只有动态值(没有静态值)。一个接口类型的变量只能表示实现了这个接口的动态类型的值。这个动态类型的变量就是这个接口的动态值。(ok,不知道有没有被我绕晕,晕了没事,这里做好笔记,继续往下看,回头再来理解这里的概念就明白了。^.^)
从上面的例子中,我们可以发现,接口类型变量值和类型都是nil 。这是因为,我们这里声明的是Shape 变量,它还没有指定动态类型,更没有指定任何动态值。
当我们用fmt.Println函数的时候,它接受的就是接口类型的参数,第一个Println的参数就是指向了这个接口的动态值,第二个Println的参数指向的就是这个接口的动态类型。
💡 事实上, 接口 s 是有个静态的类型的:Shape
接口的实现
接着上例子🌰,我们先定义一个包含Area和*Perimeter*方法签名的Shape接口。然后我们创建一个实现了shape接口的结构体Rect(实现了以上两个方法)
在上面的程序中,我们定义了一个Shape接口和Rect结构体。然后Rect实现了Area和*Perimeter*方法,这就实现了Shape接口的所有方法签名,所以我们就说Rect实现了Shape接口(这是Golang默认的,自动实现)。但是我们并没有明确的显式指明Rect实现了Shape接口,(如果是java语言则需要使用implement指明实现了某个接口,比如 public class Rect implement Shape)。
当一个类型实现了某个接口,这个类型的变量也可以用它所实现的接口类型表示(或者说用接口类型的变量去存放)。我们可以声明一个Shape接口类型的便令s ,然后用s 去接Rect类型的对象。(上图24,25行代码)
💡 其实上面我们已经使用了多态的特性
因为Rect实现了Shape接口,所以第25行代码是完全有效的。我们可以看到,接口变量 s 的动态类型就是Rect,动态值就是Rect结构体对象 {5,4}
💡 我们用动态这个词,是因为我们也可以给接口变量 s 赋值另一个实现了 Shape接口的结构体类型, 所以 s 实际指向的对象类型不是固定的,是动态的
有时候 接口 的动态类型也叫做具体类型,因为我们获取接口类型的时候,它返回的是隐藏的动态值的类型,它的静态类型一直是隐式状态。
我们可以用 s 调用Area方法,因为Shape接口定义了Area方法并且 s 的具体类型Rect也实现了该方法。所以 s 调用的方法是动态对象的方法。
我们也可以比较变量 s 和 *r* 的值,因为此时他们动态的类型都是Rect类型,动态值都是 {5,4} 。
让我们改变 s 的动态类型和动态值:
我们定义了一个新的结构体Circle,它也实现了Shape接口,所以我们可以给变量 s 赋一个Circle类型的值。我想你现在应该明白了为啥接口的值和类型都是动态的。
猜猜下面程序会发生什么?
上面程序中,我们删除了Perimeter方法,这个程序就编译不过并且抛出一个错误。
从上面的错误中我们可以很容易的理解实现接口的要求:我们需要实现接口中申明的所有方法签名。这也解释了我之前说的要实现接口中的所有方法,看到这里应该有了清晰的理解。
空接口
当一个接口没有申明任何方法签名,它就是空接口,用*interface{}*表示。因为空接口没有方法签名,所以所有的类型都是隐式实现了空接口。
现在你知道标准库fmt中的*Println*函数是如何接收不同类型的参数没?就是使用了空接口,让我们看看Println的函数签名
如你所见,Println 是一个接收接口类型的可变参数函数。下面来更深入的了解一下.
我们创建一个explain函数,有一个空接口类型的输入参数,无返回值。用它来解释动态类型和空接口。
上面的程序中,我们创建了一个自定义字符串类型MyString和一个结构体Rect。因为explain函数接收的 空接口 类型的参数,所以我们可以传入一个 MyString,*Rect*,或者其他类型的变量。因为所有类型实现了空接口interface{},所以这样使用是合法的。又一次完美体现了多态的特性。explain的形参 i 静态类型是接口类型,但是它的动态类型是我们传入参数的类型。
多接口
一个类型可以实现多个接口,也可以理解为多继承。直接例子🌰🌰🌰🌰
上面的程序中,我们创建了一个有Area方法*Shape*接口和一个用Volume方法的Object接口。结构体Cube同时实现了这两个方法,所以也就同时实现了这两个接口。所以我可以把Cube类型的值赋值给Shape 和*Object*接口类型的变量。
我们指定变量 s 和 *o* 的动态值都是 c,我们可以用 s 调用 Area方法,用 o 调用Volume方法,因为 s 申明了Area方法签名,*o* 申明了Volume方法签名,所以这样用是合法的。但是如果我们用 *s* 调用Volume方法,用 *o* 调用Area方法,会发生什么呢?
然后呢,就出现了一下的错误:
这个程序是编译不过的,因为 s 的静态类型是Shape,o 的静态类型是Object。但是Shape没有定义Volume方法,Object没有定义Area方法,所以就会产生以上错误。
为了让上面程序正常运行,我们需要设法获取这些接口的动态值——Cube类型对象(实现了这些接口的类型)。这时候我们就要用到类型断言,下面有请类型断言。
类型断言
我们创建一个变量 i ,*i* 是接口类型,这时候我们需要把 i 转换成它所代表的的静态类型,我们就可以使用语法:
i.(Type), Type代表的目标类型,这个类型实现了 i 的静态类型接口。 Go会检查 i 的动态类型是不是Type类型,如果是,就返回对应的静态值(静态对象)。
例子🌰来了:
上面的程序中,Shape类型的变量 s 的动态值是 Cube 类型的。我们可以使用 s.(Cube) 语法来获取这个 s 的动态值,并且赋值给 s。这样我们就可以用 c 调用 Area和*Volume*方法了,因为 c 是 Cube 类型的, Cube 同时实现了这两个方法。
注意,如果使用类型断言语法:i.(Type), 但是接口变量 i 得动态值不是 Type 类型的,也就是说,Type 没有实现 i 的接口,那么Go编译器会抛出一个编译错误
但是,即使 Type 实现了 i 的接口,如果 i 没有赋予Type类型的静态值,就是说 i = nil ,这时候执行这个类型断言的话,Go就会在程序运行过程中抛出一个运行错误
但是呢,我们还是有方法能避免运行错误的,需要用另一种 类型断言 语法:
在上面的语法中,我们可以用bool类型的变量 ok 来检查 i 是否指向动态类型 Type 的值。如果不是,那么 ok 就是 false, 并且 vlaue 就等于 nil。这样通过 ok 来判断,程序在运行过程中也不会报错了。
因为接口变量 s 的动态值是 Cube 类型的,而 Cube 又实现了 Object 接口,所以第一个类型断言是成功的。value1 的静态类型就是 Object,value1 也是指向 s 的动态值 {3}(通过 Printf 的打印结果可以看出来)。
但是,因为接口变量 s 的动态类型 Cube 没有实现 Skin 类型,所以 ok2 的值是 false ,*value2* 的值是 nil (接口类型的零值)。如果我们使用简化的类型断言语法 “ value2 := s.(Skin) ” ,那么在程序运行的时候就会抛出以下的错误:
⚠️ 注意:我们使用类型断言获取到该接口的动态值后,我们就能通过它获取这个动态值的属性和方法。但是你不能直接通过接口类型对象直接去获取它指向的动态值的属性信息。
>
简而言之,获取任何接口类型没有定义的属性或者方法,都会引起运行时错误。所以在必要的时候,记得使用类型断言进行转换和判断。
类型断言 不仅仅只是用来判断某个接口是否指向了某个具体值。我们也用来从一个接口类型到另一个接口类型的转换(请看上面的例子:s.(Skin) ;或者看这个例子)。
Switch
现在,让我们看看空接口对的作用,上面的例子是之间空接口那一节中用的例子,explain 函数有一形参是空接口类型的,我们可以传任何参数进去。
但是如果有一个string类型的参数,我们把这个参数传入 explain 函数中,并且把这个字符串的转成所有字母大写冰打印。这时候我们该怎么做呢。
我们可以用 string 包的 ToUpper 这个函数,但是这个函数只接受字符串类型的参数,在 explain 中使用 ToUpper 函数之前,我们需要确认传入的接口变量的动态类型是否是字符串类型。
这时候我们就可以用到 Switch 关键字了。Switch 语法很类似之前用的 类型断言 的语法:i.(Type) ,*i* 是一个接口,type 是一个内置关键字。这里 i.(type) 得到的是这个接口的动态类型,而不是类型断言一节中得到的是动态值。
💡 注意 i.(type) 这个语法只用在 switch 语句中
让我们来看个例子:
在上面的例子中,我们用 Switch 修改了 explain 函数,当我们使用 explain 函数的时候,i 接收的参数既包含了传入参数的动态类型和动态值。
在 Switch 中使用 i.(type),我们可以获取 i 的动态类型。然后在 Switch 中使用:case 关键字 + 类型,这种方式来判断是否符合 i 的动态类型。
在 case string 块中,我们使用 strings.ToUpper 函数来把字符串转换成大写模式。但是 strings.ToUpper 函数只接受字符串类型参数,所以我们需要用 类型断言 来获取影藏在 i 中的动态值。
## 接口的嵌套
在Go语言中,一个接口不能实现或者扩展另一个接口,只能通过组合的方式,把多个接口组合成一个新的接口。下面重写Shape—Cube程序来感受一下:
上面的程序中,因为Cube实现了 Area 和 *Volume* 方法,所以Cube同时实现了Shape 和*Object*接口。又因为Material接口是由Shape和*Object*组合而成,所以Cube也实现了Material接口。
就像匿名嵌套结构体,这是可行的,所有内嵌接口中的方法签名也都属于父接口,父接口可以随意访问。
指针接收者和值接收者
本文直到这里,所有方法都是用的值接收者的方式。如果使用指针接收者,这些程序还是否可行?让我们来检验一下:
[
](https://imgchr.com/i/GSTVSJ)
在上面的程序中,Area 方法属于 *Rect类型,因此 Area 的接收者会去获取是 Rect 类型的指针(即使使用Rect类型的值去调用,底层也会转换成 *Rect 类型去调用)。但是,上诉程序将会编译不通过,go编译器会报编译错误。
这是什么鬼!Rect 明明已经实现了 Shape 接口的所有方法签名,这是怎么回事呢,Rect表示不服!凭什么提示 Rect does not implement Shape。
在仔细品品上面的错误,后面还有一句 Area method has pointer receiver 。所以 Area 的接收者是指针类型将会发生什么情况呢。
一个结构类型的方法(比如上面的Rect结构体的Area方法),它的接收者无论是这个结构体的指针类型,还是值类型,我们都可以用 r.Area() 来调用,这是不会报错的,完全合法!
但是,在实现接口的情况下,可能会有一点点的不同。上面程序中,接口 s 的动态类型是Rect,*Rect*类型没有实现 *Area 方法,而是 Rect 类型实现了 Area 方法。
💡 在Golang中 Rect 类型 和 *Rect 类型是两种不同的类型,在使用Rect类型变量调用方法的时候,Golang底层会去自动转换成指定的接收者类型去调用该方法。但是,在接口的实现中,Rect实现的方法,不代表 *Rect 就实现了该方法。就像上面的程序,*Rect实现了 *Area* 方法,但是 Rect 没有实现 Area 方法,Golang底层不会默认 Rect 也实现了 Area 方法。
所以让上面的程序可以顺利编译通过,我们可以给 s 接口变量分配一个 *Rect 类型值(取 r 的地址),而不是直接把 Rect 类型值(r)直接赋值给 s 。 这样接口变量 s 的动态类型就是 *Rect 类型了,*Rect 类型实现了 Area 方法。
用上面的方法重写程序:
仅仅改变了第25行代码,编译完美通过并成功执行。
但是,有个问题,虽然 *Rect 没有实现 Perimeter,但是*s.Perimeter()* 调用没有报错,这也是我不理解的一个地方,有哪位大侠知道的话,请指教一二。
我还是希望Go语言在处理接口实现的时候能统一一下这方面的细节,既然已经做到语法格式如此的严格,那么就请严格到底吧!
接口的比较
两个接口可以通过比较运算符 == ,*!=*来进行比较。如果是两个空接口,那么这两个总是相等的。所以 == 操作符返回 true。
如果两个不是空接口,那么只有当他们的动态类型和动态值都相等的情况下,他们才相等(==操作符返回true)。
上面的情况都是基于动态类型都能比较的情况下进行的,如果动态类型不能比较呢(比如:slice,map,array, function和结构体等),那么,执行比较操作的时候会抛出运行时异常。
接口的用处
到此为止,接口的特性和使用方法基本都讲完了,总结一下接口的用处吧:
作为函数和方法的参数使用,这时候你可能会用到 类型断言 或者 switch语法。
多态的使用,在程序设计过程中,可能需要抽象出某些对象共同拥有的方法,这时候多种类型需要实现同一接口,然后通过接口变量指向具体对象来操作这些方法。
版权声明: 本文为 InfoQ 作者【Eriol】的原创文章。
原文链接:【http://xie.infoq.cn/article/71d2e8c9ed41d036ad7f3ee94】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论