接口
Go语言之父Rob Pike曾说过一句名言:那些试图避免白痴行为的语言最终自己变成了白痴语言(Languages that try to disallow idiocy become themselves idiotic)。
一般静态编程语言都有着严格的类型系统,这使得编译器可以深入检查程序员有没有作出什么出格的举动。
但是,过于严格的类型系统却会使得编程太过繁琐,让程序员把大好的青春都浪费在了和编译器的斗争中。
Go语言试图让程序员能在安全和灵活的编程之间取得一个平衡。它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。
Go的接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。
很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。
所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。
Go语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。
这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不用去破坏这些类型原有的定义;
当我们使用的类型来自于不受我们控制的包时这种设计尤其灵活有用。
Go语言的接口类型是延迟绑定,可以实现类似虚函数的多态功能。
接口本身是调用方和实现方均需要遵守的一种协议,大家按照统一的方法命名参数类型和数量俩协调逻辑处理的过程。
Go 语言中使用组合实现对象特性的描述。
对象的内部使用结构体内嵌组合对象应该具有的特性,对外通过接口暴露能使用的特性
Go 语言的接口设计是非侵入式的,接口编写者无须知道接口被哪些类型实现。
而接口实现者只需知道实现的是什么样子的接口,但无须指明实现哪一个接口。
编译器知道最终编译时使用哪个类型实现哪个接口,或者接口应该由谁来实现。
非侵入式设计是 Go 语言设计师经过多年的大项目经验总结出来的设计之道。
只有让接口和实现者真正解耦,编译速度才能真正提高,项目之间的耦合度也会降低不少
声明接口
接口是双方约定的一种合作协议。
接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。
接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构
接口声明的格式
每个接口类型由数个方法组成。接口的形式代码如下:
1 2 3 4 5 |
|
- 接口类型名: 使用 type 将接口定义为自定义的类型名。Go 语言的接口在命名时,一般会在单词后面添加 er,如有写操作的接口交 Writer,有字符串功能的接口叫 Stringer,有关闭功能的接口叫 Closer 等
- 方法名: 当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问
- 参数列表、返回值列表: 参数列表和返回值列表中的参数变量名可以被忽略,例如
1 2 3 |
|
开发中常见的接口及写法
Go 语言提供的很多包中都有接口,例如 io 包中提供的 Writer 接口:
1 2 3 |
|
这个接口可以调用 Write() 方法写入一个字节数组([]byte
),返回值告知写入字节数(n int)和可能发生的错误(err error)
类似的,还有将一个对象以字符串形式展现的接口,只要实现了这个接口的类型,在调用 String() 方法时,都可以获得对象对应的字符串。
在 fmt 包中定义如下:
1 2 3 |
|
Stringer 接口在 Go 语言中的使用频率非常高,功能类似于 Java 或者 C#
语言里的 ToString 的操作
Go 语言的每个接口中的方法数量不会很多。
Go 语言希望通过一个接口精准描述它自己的功能,而通过多个接口的嵌入和组合的方式将简单的接口扩展为复杂的接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
输出结果:
1 2 3 4 5 6 |
|
实现接口的条件
接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。
接口的实现需要遵循两条规则才能让接口可用
接口被实现的条件一
接口的方法与实现接口的类型方法格式一致
在类型中添加与接口签名一致的方法就可以实现该方法。
签名包括方法中的名称、参数列表、返回参数列表。
也就是说,只要实现接口类型中的方法的名称、参数列表、返回参数列表中的任意一项与接口要实现的方法不一致,那么接口的这个方法就不会被实现。
为了抽象数据写入的过程,定义 DataWriter 接口来描述数据写入需要实现的方法,接口中的 WriterData() 方法表示将数据写入,写入方无须关心写入到哪里。
实现接口的类型实现 WriteData 方法时,会具体编写将数据写入到什么结构中。
这里使用 file 结构体实现 DataWriter 接口的 WriteData 方法,方法内部只是打印一个日志,表示有数据写入
数据写入器的抽象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
代码说明如下:
定义 DataWriter 接口。这个接口只有一个方法,即 WriteData(),输入一个 interface{}
类型的 data,返回一个 error 结构表示可能发生的错误。
file 的 WriteData() 方法使用指针接收器。输入一个 interface{}
类型的 data,返回 error
实例化 file 赋值给 f,f 的类型为 *file
声明 DataWriter 类型的 writer 接口变量
将 *file
类型的 f 赋值给 DataWriter 接口的 writer,虽然两个变量类型不一致。
但是 writer 是一个接口,且 f 已经完全实现了 DataWriter() 的所有方法,因此赋值是成功的
DataWriter 接口类型的 writer 使用 WriteData() 方法写入一个字符串
当类型无法实现接口时,编译器会报错,下面列出常见的几种接口无法实现的错误
函数名不一致导致的报错
理解类型与接口的关系
类型和接口之间有一对多和多对一的关系
一个类型可以实现实现多个接口
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。
网络上的两个程序通过一个双向的通信连接实现数据的交换,连接的一端称为 Socket。
Socket 能够同时读取和写入数据,这个特性与文件类似。
因此,开发中把文件和 Socket 都具备的读写特性抽象为独立的读写器概念。
Socket 和文件一样,在使用完毕后,也需要对资源进行释放。
把 Socket 能够写入数据和需要关闭的特性使用接口来描述,请参考下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Socket 结构的 Write() 方法实现了 io.Writer 接口
1 2 3 |
|
同时,Socket 结构也实现了 io.Closer 接口:
1 2 3 |
|
使用 Socket 实现的 Writer 接口的代码,无须了解 Writer 接口的实现者是否具备 Closer 接口的特性。
同样,使用 Closer 接口的代码也并不知道 Socket 已经实现了 Writer 接口
在代码中使用 Socket 结构实现的 Writer 接口和 Closer 接口代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
usingWriter() 和 usingCloser() 完全独立,互相不知道对方的存在,也不知道自己使用的接口是 Socket 实现的
多个类型可以实现相同的接口
一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。
也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的。
Service 接口定义了两个方法: 一个是开启服务的方法(Start()),一个是输出日志的方法(Log())。
使用 GameService 结构体来实现 Service,GameService 自己的结构只能实现 Start() 方法,而 Service 接口中的 Log() 方法已经被一个能输出日志的日志器(Logger)实现了,无须再进行 GameService 封装,或者重新实现一遍。
所以,选择将 Logger 嵌入到 GameService 能最大程度地避免代码冗余
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
代码说明如下:
定义服务接口,一个服务需要实现 Start() 方法和日志方法.
定义能输出日志的日志器结构.
为 Logger 添加 Log() 方法,同时实现 Service 的 Log() 方法
定义 GameService 结构。
在 GameService 中嵌入 Logger 日志器,以实现日志功能。
GameService 的 Start() 方法实现了 Service 的 Start() 方法
此时,实例化 GameService,并将实例赋给 Service
1 2 3 |
|
s 就可以使用 Start() 方法和 Log() 方法,其中,Start() 由 GameService 实现,Log() 方法由 Logger 实现
示例:便于扩展输出方式的日志系统
日志可以用于查看和分析应用程序的运行状态。
日志一般可以支持输出多种形式,如命令行、文件、网络等
本例中定义一个日志写入器接口(LogWriter),要求写入设备必须遵守这个接口协议才能被日志器(Logger)注册。
日志器有一个写入器的注册方法(Logger 的 RegisterWriter() 方法)
日志器还有一个 Log() 方法,进行日志的输出,这个函数会将日志写入到所有已经注册的日志写入器(LogWriter)中,
日志对外的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
文件写入器
文件写入器(fileWriter)是众多日志写入器(LogWriter)中的一种。
文件写入器的功能是根据一个文件名创建日志文件(fileWriter 的 SetFile)方法。在有日志写入时,将日志写入文件中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
示例:使用接口进行数据的排序
Go 语言中在排序时,需要使用者通过 sort.Interface 接口提供数据的一些特性和操作方法。
接口定义代码如下:
1 2 3 4 5 6 7 8 9 10 |
|
使用 sort.Interface 接口进行排序
对一系列字符串进行排序时,使用字符串切片([] string
)承载多个字符串。
使用 type 关键字,将字符串切片([] string
)定义为自定义类型 MyStringList。
为了让给你 sort 包能识别 MyStringList,能够对 MyStringList 进行排序,就必须让 MyStringList 实现 sort.Interface 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
输出结果:
1 2 3 4 5 |
|
常见类型的便捷排序
通过实现 sort.Interface 接口的排序过程具有很强的可定制性,可以根据被排序对象比较复杂的特性进行定制。
例如,需要多种排序逻辑的需求就适合使用 sort.Interface 接口进行排序。
但大部分情况下,只需要对字符串、整型等进行快速排序。
Go 语言中提供了一些固定模式的封装以方便开发者迅速对内容进行排序
sort 包中有一个 StringSlice 类型,定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
sort 包中的 StringSlice 的代码与 MyStringList 的实现代码几乎一样。
因此,只需要使用 sort 包的 StringSlice 就可以更简单快速地进行字符串排序
1 2 3 4 5 6 7 8 9 |
|
编程中经常用到的 int32、int64、float32、bool 类型并没有由 sort 包实现,使用时依然需要开发者自己编写
对结构体数据进行排序
除了基本类型的排序,也可以对结构体进行排序。
结构体比基本类型更为复杂,排序时不能像数值和字符串一样拥有一些固定的单一原则。
结构体的多个字段在排序中可能会存在多种排序的规则,例如,结构体中的名字按字母升序排列,数值从小到大排序。
一般在多种规则同时存在时,需要确定规则的优先度,如先按名字排序,再按年龄排序
完整实现 sort.Interface 进行结构体排序
将一批英雄名单使用结构体定义,英雄名单的结构体中定义了英雄的名字和分类。
排序时要求按照英雄的分类进行排序,相同分类的情况下按名字进行排序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
|
输出结果:
1 2 3 4 5 6 |
|
使用 sort.Slice 进行切片元素排序
从 Go 1.8 开始,Go 语言在 sort 包中提供了 sort.Slice() 函数进行更为简便的排序方法。
sort.Slice() 函数只要求传入需要排序的数据,以及一个排序时对元素的回调函数,类型为 func(i, j int) bool,sort.Slice() 函数的定义如下:
1 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
|
接口的嵌套组合
在 Go 语言中,不仅结构体与结构体之间可以嵌套,接口与接口间也可以通过嵌套创造出新的接口
接口与接口嵌套组合而成了新接口,只要接口的所有方法被实现,则这个接口中的所有嵌套接口的方法均可以被调用
空接口类型
空接口是任何类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。
从实现的角度看,任何值都满足这个接口的需求。
因此空接口类型可以保存任何值,也可以从空接口中取出原值。
示例
接口在Go语言中无处不在,在“Hello world”的例子中,fmt.Printf函数的设计就是完全基于接口的,它的真正功能由fmt.Fprintf函数完成。
用于表示错误的error类型更是内置的接口类型。在C语言中,printf只能将几种有限的基础数据类型打印到文件对象中。
但是Go语言灵活接口特性,fmt.Fprintf却可以向任何自定义的输出流对象打印,可以打印到文件或标准输出、也可以打印到网络、甚至可以打印到一个压缩文件;
同时,打印的数据也不仅仅局限于语言内置的基础类型,任意隐式满足fmt.Stringer接口的对象都可以打印,不满足fmt.Stringer接口的依然可以通过反射的技术打印。
fmt.Fprintf函数的签名如下:
1 |
|
其中 io.Writer
用于输出的接口,error
是内置的错误接口,它们的定义如下:
1 2 3 4 5 6 7 |
|
我们可以通过定制自己的输出对象,将每个字符转为大写字符后输出:
1 2 3 4 5 6 7 8 9 10 11 |
|
当然,我们也可以定义自己的打印格式来实现将每个字符转为大写字符后输出的效果。
对于每个要打印的对象,如果满足了fmt.Stringer接口,则默认使用对象的String方法返回的结果打印:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Go语言中,对于基础类型(非接口类型)不支持隐式的转换,我们无法将一个int类型的值直接赋值给int64类型的变量,也无法将int类型的值赋值给底层是int类型的新定义命名类型的变量。
Go语言对基础类型的类型一致性要求可谓是非常的严格,但是Go语言对于接口类型的转换则非常的灵活。
对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子:
1 2 3 4 5 6 |
|
有时候对象和接口之间太灵活了,导致我们需要人为地限制这种无意之间的适配。常见的做法是定义一个含特殊方法来区分接口。
比如runtime包中的Error接口就定义了一个特有的RuntimeError方法,用于避免其它类型无意中适配了该接口:
1 2 3 4 5 6 7 8 9 |
|
在protobuf中,Message接口也采用了类似的方法,也定义了一个特有的ProtoMessage,用于避免其它类型无意中适配了该接口:
1 2 3 4 5 |
|
不过这种做法只是君子协定,如果有人刻意伪造一个proto.Message接口也是很容易的。
再严格一点的做法是给接口定义一个私有方法。
只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只能在包内部实现这个私有方法才能满足这个接口。
测试包中的testing.TB接口就是采用类似的技术:
1 2 3 4 5 6 7 8 9 10 |
|
不过这种通过私有方法禁止外部对象实现接口的做法也是有代价的:
首先是这个接口只能包内部使用,外部包正常情况下是无法直接创建满足该接口对象的;
其次,这种防护措施也不是绝对的,恶意的用户依然可以绕过这种保护机制。
在前面我们提到,通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。
其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。
我们可以通过嵌入匿名的testing.TB接口来伪造私有的private方法,因为接口方法是延迟绑定,编译时private方法是否真的存在并不重要。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
我们在自己的TB结构体类型中重新实现了Fatal方法,然后通过将对象隐式转换为 testing.TB
接口类型(因为内嵌了匿名的 testing.TB
对象,因此是满足 testing.TB
接口的),然后通过 testing.TB
接口来调用我们自己的Fatal方法。
这种通过嵌入匿名接口或嵌入匿名指针对象来实现继承的做法其实是一种纯虚继承,我们继承的只是接口指定的规范,真正的实现在运行的时候才被注入。
比如,我们可以模拟实现一个gRPC的插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
构造的grpcPlugin类型对象必须满足generate.Plugin接口(在 "github.com/golang/protobuf/protoc-gen-go/generator"
包中):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
generate.Plugin
接口对应的 grpcPlugin
类型的 GenerateImports
方法中使用的 p.P(...)
函数却是通过 Init
函数注入的 generator.Generator
对象实现。
这里的 generator.Generator
对应一个具体类型,但是如果 generator.Generator
是接口类型的话我们甚至可以传入直接的实现。
Go语言通过几种简单特性的组合,就轻易就实现了鸭子面向对象和虚拟继承等高级特性,真的是不可思议。