引言
最近在Slack上有很多关于Golang中接口的问题。大多数时候,答案都偏技术性和细节性,某种程度上甚至过于关注于实现细节。实现的细节对于调试很重要,但是对设计没什么帮助。当使用接口类型来设计代码时,行为应当是主要的关注点。这篇文章希望提供一种不同的方式来思考接口以及如何用它们来设计代码。
面向数据的设计
在使用Go编写代码时,面向数据的设计胜过面向对象的设计。如果你不理解正在处理的数据,那么实际上就不理解要解决的问题。实际上业务中解决的很多个问题都是数据转换问题。有一些输入,然后产生一些输出。这就是程序所做的。我们编写的每个函数本质上都是一些较小的数据转换,组合起来之后它可以解决较大的转换。
由于要解决的问题是数据转换问题,所以编写的算法也是基于具体数据的。这些数据存储在内存中、通过网络发送或写入文件。当数据在变化时,问题也在变化。当问题发生变化时,那么编写的算法就需要更改。
当数据发生变化时更改算法,这是保持程序可读性和性能的最佳方法。但很多人认为要创建多层抽象,并通过概括事物来处理变化。实际上很多时候其成本大于收益。你需要一种方法使算法对于需要执行的每个数据转换都保持小而精确。当数据发生变化时,需要更改这些算法,避免导致大部分代码库中的级联变更。而这就是接口发挥作用的地方。当你关注接口时,更重要的是关注行为。
具体的数据
因为所有内容都是关于具体数据的,所以从下面这个简单的例子开始。
清单1
05 type file struct {
06 name string
07 }
清单1第05行中使用关键字struct定义了具体数据类型。声明类型后,可以创建该类型的值。
清单2
13 func main() {
14 var f file
由于清单2第14行中的变量声明,现在有一个file类型的值存储在内存中,并且可以通过变量f引用。接下来再次使用关键字struct定义另一个具体数据类型。
清单3
09 type pipe struct {
10 name string
11 }
清单3第9行中声明的pipe类型也表示一个具体的数据块。同样,使用这个类型声明,可以在程序中创建值。
清单4
01 package main
02
03 import "fmt"
04
05 type file struct {
06 name string
07 }
08
09 type pipe struct {
10 name string
11 }
12
13 func main() {
14 var f file
15 var p pipe
16
17 fmt.Println(f, p)
18 }
现在来看一下完整的程序,这个程序定义了两段不同的具体数据,并为每种类型创建了值。第14行创建了一个file类型的值,第15行创建了一个pipe类型的值,最后在第17行使用fmt包打印两个值。
接口类型不包含任何值
你可能一直使用关键字struct来定义所需要的具体数据。如同上面的例子,其实还可以使用另一个关键字来定义类型,就是interface关键字。
清单5
05 type reader interface {
06 read(b []byte) (int, error)
07 }
清单5中的第5行声明了一个接口类型。接口类型与结构类型(即struct关键字声明的类型)相反。接口类型只能声明行为的方法集。也就是说接口类型没有任何具体的值。
清单6
var r reader
有趣的是,可以声明接口类型的变量,如清单6所示。如果没有关于接口类型的任何具体内容,这意味着变量r是没有值的。接口类型定义并创建没有值的值!
关于接口的一些重要概念:
-
变量r仅仅是概念上的。
-
变量r未指向任何具体值。
-
变量r没有包含类似type reader这样的值。
清单7
37 func retrieve(r reader) error {
38 data := make([]byte, 100)
39
40 len, err := r.read(data)
41 if err != nil {
42 return err
43 }
44
45 fmt.Println(string(data[:len]))
46 return nil
47 }
清单7中定义的函数retrieve也称为多态函数。在继续之前,先解释一下多态性。编程语言Basic的发明者Tom Kurtz给出了定义:“多态意味着特定的程序,其行为取决于它所操作的数据。”
多态性是由具体数据驱动的,根据不同的数据能够改变代码行为。如前所述,我们正在解决的问题植根于具体的数据,而面向数据的设计是关于具体数据的。如果你不理解正在处理的数据,那么绝大可能性不理解要解决的问题。
回到清单7中的代码。当在第37行查看retrieve的函数声明时,函数似乎意味着,传递给我一个reader类型的值。但应该知道这是不可能的,因为不存在type reader这样的值。类型reader的值不存在,因为reader实际上是接口类型。而接口类型的值是没有实际值的。
那么这个函数声明是什么意思呢?
其含义是该函数将接受任何实现了reader接口类型契约的具体数据(任何值或指针)。它实现了由reader接口定义的完整的行为方法集。
这就是如何在Go中实现多态性。retrieve函数不绑定到单个具体数据,而是绑定到任何显示读取行为的具体数据。
提供数据的行为
下一个问题是,数据如何显示行为?这就是方法的用武之地。方法是提供数据行为的机制。一旦一段数据具有行为,就可以实现多态性。
清单8
05 type reader interface {
06 read(b []byte) (int, error)
07 }
08
09 type file struct {
10 name string
11 }
12
13 func (file) read(b []byte) (int, error) {
14 s := "<rss><channel><title>Going Go</title></channel></rss>"
15 copy(b, s)
16 return len(s), nil
17 }
18
19 type pipe struct {
20 name string
21 }
22
23 func (pipe) read(b []byte) (int, error) {
24 s := `{name: "bill", title: "developer"}`
25 copy(b, s)
26 return len(s), nil
27 }
注意:你可能已经注意到第13行和第23行方法的接收方声明时没有变量名。当方法不需要从接收方值访问任何内容时,这是一种常见的实践。
在清单8中,第13行声明了用于file类型的方法,第23行声明了用于pipe类型的方法。现在,每种类型都定义了一个名为read的方法,该行为与reader接口定义的方法集相匹配。由于这些方法声明,现在可以认为:
“具体类型file和pipe现在使用值接收器(value receiver)实现reader接口。”
一旦使用值接收器声明这些方法,这些具体类型的值和指针就可以传递到retrieve函数中。
清单9
01 package main
02
03 import "fmt"
04
05 type reader interface {
06 read(b []byte) (int, error)
07 }
08
09 type file struct {
10 name string
11 }
12
13 func (file) read(b []byte) (int, error) {
14 s := "<rss><channel><title>Going Go</title></channel></rss>"
15 copy(b, s)
16 return len(s), nil
17 }
18
19 type pipe struct {
20 name string
21 }
22
23 func (pipe) read(b []byte) (int, error) {
24 s := `{name: "bill", title: "developer"}`
25 copy(b, s)
26 return len(s), nil
27 }
28
29 func main() {
30 f := file{"data.json"}
31 p := pipe{"cfg_service"}
32
33 retrieve(f)
34 retrieve(p)
35 }
36
37 func retrieve(r reader) error {
38 data := make([]byte, 100)
39
40 len, err := r.read(data)
41 if err != nil {
42 return err
43 }
44
45 fmt.Println(string(data[:len]))
46 return nil
47 }
清单9在Go中提供了一个完整的多态示例。retrieve函数可以接受实现reader接口的任何值或指针。这正是在第33行和第34行上所展示的如何进行方法调用。现在你能够更精确地进行解耦,因为你准确地知道数据在传递到函数时所应当具备的行为或方法。这不是泛化或隐藏的。
接口值赋值
清单10
05 type Reader interface {
06 Read()
07 }
08
09 type Writer interface {
10 Write()
11 }
12
13 type ReadWriter interface {
14 Reader
15 Writer
16 }
通过声明这些接口,可以实现一个具备所有三个接口方法集的具体类型。
清单11
18 type system struct{
19 Host string
20 }
21
22 func (*system) Read() { /* ... */ }
23 func (*system) Write() { /* ... */ }
现在可以看到system类型间接实现了Reader和Writer接口。
清单12
25 func main() {
26 var rw ReadWriter = &system{"127.0.0.1"}
27 var r Reader = rw
28 fmt.Println(rw, r)
29 }
// OUTPUT
&{127.0.0.1} &{127.0.0.1}
在清单12的第26行中,声明了一个名为rw的变量,其接口类型为ReadWriter,并分配了具体的数据:一个指向系统值的指针。然后在第27行,声明接口类型Reader的一个名为r的变量。然后将接口类型ReadWriter的rw变量分配给接口类型Reader的新声明变量r。
变量rw与r的类型不同。在Go中不会在两个不同的类型之间进行隐式类型转换。但这里稍有不同,这些变量不是基于具体的类型,而是基于接口类型,因此可以隐式转换。由于接口声明的变量rw和r本质上是无值的。因此,它唯一能分配的东西是接口值中指向的具体数据。由于接口的类型声明,编译器可以验证某个接口指向的具体数据其类型是否也满足另一个接口。
在处理接口值时,仍然只处理其中指向的具体数据。当你将接口值传递给fmt包中的函数用于打印或显示时,其所显示的是具体的数据。这才是唯一真实的东西。
结论
希望这篇文章提供了一种不同的方式来思考接口以及如何用它们来设计代码。一旦不再关注实现细节,而是关注具体的数据,事情就会变得更容易理解。面向数据的设计是编写更好算法的一个重要方面,但解耦来自于对方法或行为的关注。