11.12 接口与动态类型
11.12.1 Go 的动态类型
在经典的面向对象语言(像 C++,Java 和 C#)中数据和方法被封装为类的概念:类包含它们两者,并且不能剥离。
Go 没有类:数据(结构体或更一般的类型)和方法是一种松耦合的正交关系。
Go 中的接口跟 Java/C# 类似:都是必须提供一个指定方法集的实现。但是更加灵活通用:任何提供了接口方法实现代码的类型都隐式地实现了该接口,而不用显式地声明。
和其它语言相比,Go 是唯一结合了接口值,静态类型检查(是否该类型实现了某个接口),运行时动态转换的语言,并且不需要显式地声明类型是否满足某个接口。该特性允许我们在不改变已有的代码的情况下定义和使用新接口。
接收一个(或多个)接口类型作为参数的函数,其实参可以是任何实现了该接口的类型的变量。 实现了某个接口的类型可以被传给任何以此接口为参数的函数。
类似于 Python 和 Ruby 这类动态语言中的动态类型 (duck typing);这意味着对象可以根据提供的方法被处理(例如,作为参数传递给函数),而忽略它们的实际类型:它们能做什么比它们是什么更重要。
这在程序 duck_dance.go 中得以阐明,函数 DuckDance()
接受一个 IDuck
接口类型变量。仅当 DuckDance()
被实现了 IDuck
接口的类型调用时程序才能编译通过。
示例 11.16 duck_dance.go:
package main
import "fmt"
type IDuck interface {
Quack()
Walk()
}
func DuckDance(duck IDuck) {
for i := 1; i <= 3; i++ {
duck.Quack()
duck.Walk()
}
}
type Bird struct {
// ...
}
func (b *Bird) Quack() {
fmt.Println("I am quacking!")
}
func (b *Bird) Walk() {
fmt.Println("I am walking!")
}
func main() {
b := new(Bird)
DuckDance(b)
}
输出:
I am quacking!
I am walking!
I am quacking!
I am walking!
I am quacking!
I am walking!
如果 Bird
没有实现 Walk()
(把它注释掉),会得到一个编译错误:
cannot use b (type *Bird) as type IDuck in function argument:
*Bird does not implement IDuck (missing Walk method)
如果对 cat
调用函数 DuckDance()
,Go 会提示编译错误,但是 Python 和 Ruby 会以运行时错误结束。
11.12.2 动态方法调用
像 Python,Ruby 这类语言,动态类型是延迟绑定的(在运行时进行):方法只是用参数和变量简单地调用,然后在运行时才解析(它们很可能有像 responds_to
这样的方法来检查对象是否可以响应某个方法,但是这也意味着更大的编码量和更多的测试工作)
Go 的实现与此相反,通常需要编译器静态检查的支持:当变量被赋值给一个接口类型的变量时,编译器会检查其是否实现了该接口的所有函数。如果方法调用作用于像 interface{}
这样的“泛型”上,你可以通过类型断言(参见 11.3 节)来检查变量是否实现了相应接口。
例如,你用不同的类型表示 XML 输出流中的不同实体。然后我们为 XML 定义一个如下的“写”接口(甚至可以把它定义为私有接口):
type xmlWriter interface {
WriteXML(w io.Writer) error
}
现在我们可以实现适用于该流类型的任何变量的 StreamXML()
函数,并用类型断言检查传入的变量是否实现了该接口;如果没有,我们就调用内建的 encodeToXML()
来完成相应工作:
// Exported XML streaming function.
func StreamXML(v interface{}, w io.Writer) error {
if xw, ok := v.(xmlWriter); ok {
// It’s an xmlWriter, use method of asserted type.
return xw.WriteXML(w)
}
// No implementation, so we have to use our own function (with perhaps reflection):
return encodeToXML(v, w)
}
// Internal XML encoding function.
func encodeToXML(v interface{}, w io.Writer) error {
// ...
}
Go 在这里用了和 gob
相同的机制:定义了两个接口 GobEncoder
和 GobDecoder
。这样就允许类型自己实现从流编解码的具体方式;如果没有实现就使用标准的反射方式。
因此 Go 提供了动态语言的优点,却没有其他动态语言在运行时可能发生错误的缺点。
对于动态语言非常重要的单元测试来说,这样即可以减少单元测试的部分需求,又可以发挥相当大的作用。
Go 的接口提高了代码的分离度,改善了代码的复用性,使得代码开发过程中的设计模式更容易实现。用 Go 接口还能实现“依赖注入模式”。
11.12.3 接口的提取
提取接口是非常有用的设计模式,可以减少需要的类型和方法数量,而且不需要像传统的基于类的面向对象语言那样维护整个的类层次结构。
Go 接口可以让开发者找出自己写的程序中的类型。假设有一些拥有共同行为的对象,并且开发者想要抽象出这些行为,这时就可以创建一个接口来使用。
我们来扩展 11.1 节的示例 11.2 interfaces_poly.go,假设我们需要一个新的接口 TopologicalGenus
,用来给 shape
排序(这里简单地实现为返回 int
)。我们需要做的是给想要满足接口的类型实现 Rank()
方法:
示例 11.17 multi_interfaces_poly.go:
//multi_interfaces_poly.go
package main
import "fmt"
type Shaper interface {
Area() float32
}
type TopologicalGenus interface {
Rank() int
}
type Square struct {
side float32
}
func (sq *Square) Area() float32 {
return sq.side * sq.side
}
func (sq *Square) Rank() int {
return 1
}
type Rectangle struct {
length, width float32
}
func (r Rectangle) Area() float32 {
return r.length * r.width
}
func (r Rectangle) Rank() int {
return 2
}
func main() {
r := Rectangle{5, 3} // Area() of Rectangle needs a value
q := &Square{5} // Area() of Square needs a pointer
shapes := []Shaper{r, q}
fmt.Println("Looping through shapes for area ...")
for n, _ := range shapes {
fmt.Println("Shape details: ", shapes[n])
fmt.Println("Area of this shape is: ", shapes[n].Area())
}
topgen := []TopologicalGenus{r, q}
fmt.Println("Looping through topgen for rank ...")
for n, _ := range topgen {
fmt.Println("Shape details: ", topgen[n])
fmt.Println("Topological Genus of this shape is: ", topgen[n].Rank())
}
}
输出:
Looping through shapes for area ...
Shape details: {5 3}
Area of this shape is: 15
Shape details: &{5}
Area of this shape is: 25
Looping through topgen for rank ...
Shape details: {5 3}
Topological Genus of this shape is: 2
Shape details: &{5}
Topological Genus of this shape is: 1
所以你不用提前设计出所有的接口;整个设计可以持续演进,而不用废弃之前的决定。类型要实现某个接口,它本身不用改变,你只需要在这个类型上实现新的方法。
11.12.4 显式地指明类型实现了某个接口
如果你希望满足某个接口的类型显式地声明它们实现了这个接口,你可以向接口的方法集中添加一个具有描述性名字的方法。例如:
type Fooer interface {
Foo()
ImplementsFooer()
}
类型 Bar 必须实现 ImplementsFooer
方法来满足 Fooer
接口,以清楚地记录这个事实。
type Bar struct{}
func (b Bar) ImplementsFooer() {}
func (b Bar) Foo() {}
大部分代码并不使用这样的约束,因为它限制了接口的实用性。
但是有些时候,这样的约束在大量相似的接口中被用来解决歧义。
11.12.5 空接口和函数重载
在 6.1 节中, 我们看到函数重载是不被允许的。在 Go 语言中函数重载可以用可变参数 ...T
作为函数最后一个参数来实现(参见 6.3 节)。如果我们把 T
换为空接口,那么可以知道任何类型的变量都是满足 T
(空接口)类型的,这样就允许我们传递任何数量任何类型的参数给函数,即重载的实际含义。
函数 fmt.Printf
就是这样做的:
fmt.Printf(format string, a ...interface{}) (n int, errno error)
这个函数通过枚举 slice 类型的实参动态确定所有参数的类型,并查看每个类型是否实现了 String()
方法,如果是就用于产生输出信息。我们可以回到 11.10 节查看这些细节。
11.12.6 接口的继承
当一个类型包含(内嵌)另一个类型(实现了一个或多个接口)的指针时,这个类型就可以使用(另一个类型)所有的接口方法。
例如:
type Task struct {
Command string
*log.Logger
}
这个类型的工厂方法像这样:
func NewTask(command string, logger *log.Logger) *Task {
return &Task{command, logger}
}
当 log.Logger
实现了 Log()
方法后,Task
的实例 task
就可以调用该方法:
task.Log()
类型可以通过继承多个接口来提供像多重继承一样的特性:
type ReaderWriter struct {
*io.Reader
*io.Writer
}
上面概述的原理被应用于整个 Go 包,多态用得越多,代码就相对越少(参见 12.8 节)。这被认为是 Go 编程中的重要的最佳实践。
有用的接口可以在开发的过程中被归纳出来。添加新接口非常容易,因为已有的类型不用变动(仅仅需要实现新接口的方法)。已有的函数可以扩展为使用接口类型的约束性参数:通常只有函数签名需要改变。对比基于类的 OO 类型的语言在这种情况下则需要适应整个类层次结构的变化。
练习 11.11:map_function_interface.go:
在练习 7.13 中我们定义了一个 map()
函数来使用 int
切片 (map_function.go)。
通过空接口和类型断言,现在我们可以写一个可以应用于许多类型的泛型的 map()
函数,为 int
和 string
构建一个把 int
值加倍和将字符串值与其自身连接(译者注:即 "abc"
变成 "abcabc"
)的 map()
函数 mapFunc()
。
提示:为了可读性可以定义一个 interface{}
的别名,比如:type obj interface{}
。
练习 11.12:map_function_interface_var.go:
稍微改变练习 11.11,允许 mapFunc()
接收不定数量的 items
。
练习 11.13:main_stack.go—stack/stack_general.go:
在练习 10.16 和 10.17 中我们开发了一些栈结构类型。但是它们被限制为某种固定的内建类型。现在用一个元素类型是 interface{}
(空接口)的切片开发一个通用的栈类型。
实现下面的栈方法:
Len() int
IsEmpty() bool
Push(x interface{})
Pop() (interface{}, error)
Pop()
改变栈并返回最顶部的元素;Top()
只返回最顶部元素。
在主程序中构建一个充满不同类型元素的栈,然后弹出并打印所有元素的值。
链接
- 目录
- 上一节:Printf 和反射
- 下一节:总结:Go 中的面向对象