接口
接口是什么?
接口是属于面向对象编程的范畴,接口描述了对象(类)的行为。接口仅仅是一个描述,描述对象(或类)应该实现哪些行为。但是具体如何实现接口并不关注,而是由具体对象(或类)决定。
Golang中的接口其实就是一个方法签名的集合。如果某个类型实现了某个接口中的所有方法,那么我们说该类型实现了这个接口。这其实跟面向对象编程中接口的定义与实现非常类似。接口描述一个类型应该实现的方法集合,但是并不决定具体如何实现,具体的实现还是由具体的类型决定的。
举个例子,WashingMachine(洗衣机)可以定义为包含Cleaning()和Drying()方法签名的接口。而所有实现了这两个方法的类型都算是实现了这个接口。
声明一个接口并实现该接口
下面我们声明一个接口,并创建一个类型来实现该接口。
package main
import "fmt"
type VowelsFinder interface {
FindVowels() []rune
}
type MyString string
func (ms MyString) FindVowels() []rune {
var vowels []rune
for _, rune := range ms {
if rune == 'a' || rune == 'e' || rune == 'i' || rune == 'o' || rune == 'u' {
vowels = append(vowels, rune)
}
}
return vowels
}
func main() {
name := MyString("Sam Anderson")
var v VowelsFinder
v = name // 由于MyString实现了该VowelsFinder接口,因此可赋值
fmt.Printf("Vowels are %c", v.FindVowels())
}声明接口的语法如下:
这里我们声明了一个接口VowelsFinder,该接口包含一个方法签名:FindVowels() []rune。然后创建了一个类似MyString。
我们给该类型绑定了一个方法FindVowels() []rune,这时我们便说MyString实现了接口VowelsFinder,这跟Java确实不一样,在Java中需要显示的使用关键字implements来声明某个类实现了某个接口。Golang中不需要这种显示声明,只要某个类型实现了某个接口中的所有方法,那么该类型就隐式的实现了该接口。
前面程序中我们将MyString类型的变量name赋值给了VowelsFinder类型的变量v。由于MyString实现了接口VowelsFinder,因此这个赋值是合法的。后面的v.FindVowels()调用了MyString绑定的方法来打印出了找到的所有元音字母,该程序输出:
这样我们便声明了一个接口,并实现了该接口。
接口的实际应用
前面的例子展示了如何声明一个接口并实现,但是并不实用。我们完全可以调用name.FindVowels(),而不是调用v.FindVowels()来完成相同的功能,也就是说完全没必要声明一个接口也能实现相同的功能。
现在我们来看一个比较实用的例子,根据公司里每个员工的薪资来计算整个公司的总开销。场景其实很简单,比如公司共有两个员工,那么这两个员工的薪资总和即为公司的总开销,为了简单我们假设薪资的单位统一为USD。
这里我们声明了一个接口SalaryCalculator,该接口含一个方法签名CalculateSalary() int。接着声明了两种员工类型,Permanent和Contract,分别表示正式工和合同工。正式工的薪资包含basicpay和pf两部分,合同工薪资仅包含basicpay。Permanent和Contract均实现了SalaryCalculator接口。
函数totalExpense接收的入参是SalaryCalculator类型的切片,我们可以将实现了该接口的变量存入该切片,比如Permanent类型和Contract类型。然是在totalExpense函数看来,它只关心入参是SalaryCalculator类型的切片。由于所有实现了SalaryCalculator接口的类型都绑定了CalculateSalary方法,因此无论切片中存放的是哪个具体的类型,都可以调用该类型绑定的CalculateSalary方法。
这种程序设计的最大好处是可扩展性,假设该公司加入了一种新的员工类型Freelancer,即自由职业者,那么只要该类型也实现了接口SalaryCalculator即可,这时我们就可以将该类型的变量添加到切片employees中,而函数totalExpense完全不需要升级。
前面的程序输出结果如下:
接口的内部表示
Golang中我们可以把接口看作是由type和value组成的一个元组(type, value)。type表示接口内部具体的数据类型,value表示接口内部具体的数据值。
这里的元组两个字可能不太好理解,这里多说几句,我个人觉得元组这个中文翻译比较晦涩难懂,但是也没有更好的词替代...。大家可以这么理解元组:由多个元组成的一个组合。那么这里的元是什么意思呢?其实很简单,它类似于我们初高中学习的一元二次方程组中的元,比如(x, y)表示一个二维坐标系中的点,这里的x和y就分别是一元。因此元组这个词一般用来表示类似于(x, y)这种多元组合。
从接口的声明语法type InterfaceName interface {}也可以看出来,Golang中的接口本质上也是一种数据类型,既然是一种数据类型,我们就可以声明该类型的变量,比如var v VowelsFinder,然后我们会给该变量赋值,比如前面的:
因此接口类型变量v内部有一个具体数据类型MyString和具体值"Sam Anderson"。
我们实现一个例子来理解下:
这里的接口Tester含有一个方法签名Test()。我们声明了一个该接口类型的变量var t Tester,由于MyFloat类型实现了该接口,因此我们将MyFloat类型的变量f赋值给变量t,这时t内部的具体类型即为MyFloat,具体值即为89.7。describe函数打印出了具体类型和具体值。该程序输出结果如下:
空接口
如果一个接口没有一个方法签名,那么我们称这个接口为空接口。空接口可以简短的表示为interface {}。由于空接口不含方法签名,因此相当于所有类型都实现了空接口。
这里函数describe(i interface{})接收的入参类型是一个空接口,因此我们可以传入任何类型的参数。 我们分别传入了string、int以及struct,该程序执行结果如下:
类型断言
类型断言指的是断言某个接口类型变量底层具体的数据类型。语法如下:
其中T为断言(猜测)的底层具体数据类型,i.(T)的返回值是底层T类型的具体数据值。我们通过一个例子来理解下:
这里接口变量s的具体类型为int。我们通过i.(int)来获取接口变量i内部的具体值。该程序输出为:56。
前面断言成功是因为s确实是int类型,但是假设s不是int类型会怎么样呢?我们尝试如下:
这里接口变量s内部的具体数据类型是string,我们尝试断言为int类型,或尝试获取其内部值,执行会有运行时错误:
可以改用如下方式解决:
如果接口变量i的内部具体数据类型确实是T,则v即为内部的具体值,ok为true;反之,则v 被赋值为T类型的零值,ok为false。并且程序不会报错。
该程序执行结果如下:
类型 switch
类型switch用来判断某个接口内部具体的数据类型是否是多个数据类型中的某一个,并执行匹配上的语句。语法类似于普通的switch语句,但是普通switch语句比较的是一个值是否匹配,而类型switch语句比较的是一个类型是否匹配。 类型匹配的具体语法我们通过一个具体例子来看下:
这里switch i.(type)即为一个类型switch语句,该语句内每一个case语句用来判断i的具体类型是否是某个类型。该程序执行结果如下:
前面程序中的89.98具体类型为float64,因此未匹配到前两种情况,最后的默认case得以执行。
我们不仅能判断某个接口变量是否匹配某个具体类型,比如前面的int、string,还能判断某个接口变量是否匹配某个接口类型。 举例如下:
这里string类型并未实现接口Describer,因此执行默认case。而结构体类型Person实现了接口Describer,因此case Describer得以匹配成功,接着执行v.Describe()。该程序执行结果如下:
实现接口:指针接收器 vs 值接收器
对于接口中声明的某个方法签名,我们既可以基于值接收器又可基于指针接收器实现。但是具体用哪种来实现,还是得分场景,这点在前面方法一节已经提到过:
那么何时使用值接收器,何时又使用指针接收器呢?其实很简单,这本质上还是指针传递和值传递的问题。如果你不想通过指针来改变方法外部定义的变量,那么就使用值传递,否则就使用指针传递。但是用值传递会将变量整体拷贝一份,对于比较大的结构体变量开销比较大,这种情况下考虑到性能可以使用指针接收器。
下面我们举个例子:
这里我们声明了两个类型UserMutable和UserImmutable。从名字也能看出来,UserMutable表示一种可变类型,我们可通过指针来改变该结构体的属性。
而UserImmutable表示不可变类型,在其上调用绑定的方法时,由于是值接收器,因此相当于将值拷贝到了一个新的变量上,在函数内部的任何操作都是对这个新拷贝变量的操作,并不会影响原结构体变量。
前面的章节我们已经了解到,Golang提供了非常方便的语法糖来调用结构体绑定的方法,可以通过变量本身或者变量的指针来调用,Golang根据方法接收器具体类型是指针类型还是值类型来帮我们把代码转换一下。但是这点在接口这里不是完全适用。对于指针接收器定义的方法,我们仅能通过指针来访问,无法通过变量来访问。
举例来说就是前面程序中的:
由于Golang中的接口无法寻址内部具体的变量,因此我们不能寄托于Golang帮我们自动解析出接口内部具体变量地址。具体为什么接口无法寻址内部具体的变量,可以参考这里和这个问答。
前面的程序输出如下:
需要说明一下,这里将原文中的两节接口相关内容合成了一节。刚刚使用的例子来自这里,跟原文中的例子并非一个,我个人觉得更加合适就直接替换了。
实现多个接口
Golang中一个类型可以实现多个接口,我们举个例子:
该程序声明了两个接口类型,SalaryCalculator和LeaveCalculator。由于Employee结构体类型同时实现了这两个接口中所有方法,因此我们说Employee同时实现了这两个接口。因此该结构体类型的变量e可以分别赋值给这两种接口类型的变量,两次赋值都是合法的。我们可以这样理解,将结构体变量e赋值给不同的接口变量相当于将该结构体变量分别看作不同的接口类型变量。这就好比我们既可以将一个智能手机看作是通讯工具(因为它能用来打电话),也可以看作是mp4(因为它能用来播放视频),还可以看作是游戏机(因为它能用来玩游戏),这时我们说智能手机这个类型同时实现了通讯工具、mp4以及游戏机的接口。
接口嵌套
Golang不支持继承,但是支持接口嵌套,即将多个接口嵌套在另外一个接口内,或者说将多个接口组合在一块生成一个新接口。我们通过一个例子来理解下。
这里接口EmployeeOperations是由两个子接口SalaryCalculator和LeaveCalculator内嵌组合生成的。那么如果一个类型同时实现了SalaryCalculator和LeaveCalculator接口,我们就说它实现了EmployeeOperations接口,建议暂停几秒钟,好好理解下这句话。该程序中Employee同时实现了SalaryCalculator和LeaveCalculator接口,因此也就是实现了EmployeeOperations接口。因此我们可以将Employee类型的变量e赋值给声明好的EmployeeOperations接口类型变量empOp。该程序输出如下:
接口类型变量的零值
Golang中所有数据类型的变量都有一个默认的零值,即如果近声明了某个类型的变量,而不进行初始化,那么Golang自动帮我们初始化为对应类型的零值,接口类型也不例外。 接口变量的零值为nil,其内部具体的类型和值也是nil。
这里的d1仅仅进行了声明,并没有手动初始化,因此被自动初始化为零值nil,该程序输出如下:
如果我们尝试在调用nil接口变量的某个方法,那么程序会报运行时错误,因为nil的内部具体类型和变量也是nil。
该程序执行报如下运行时错误:
Last updated
Was this helpful?