面向对象 – 接口

  Go 语言的接口并不是其他语言(C++、Java、C#等)中所提供的接口概念。

  在 Go 语言出现之前,接口主要作为不同组件之间的契约存在。对契约的实现是强制的,你必须声明你的确实现了该接口。为了实现一个接口,你需要从该接口继承:

interface IFoo {
    void Bar();
}

class Foo implements IFoo { // Java文法
    // ...
}

class Foo : public IFoo { // C++文法
    // ...
}

IFoo* foo = new Foo;

  即使另外有一个接口 IFoo2 实现了与 IFoo 完全一样的接口方法甚至名字也叫 IFoo 只不过位于不同的名字空间下,编译器也会认为上面的类 Foo 只实现了 IFoo 而没有实现 IFoo2 接口。

  这类接口我们称为侵入式接口侵入式的主要表现在于实现类需要明确声明自己实现了某个接口。这种强制性的接口继承是面向对象编程思想发展过程中一个遭受相当多置疑的特性。我们接下来讨论一下为什么这是个问题,以及为何 Go 语言的接口设计是一个更合适的选择。设想我们现在要实现一个简单搜索引擎(SE),它需要依赖两个模块,一个是哈希表(HT),一个是HTML分析器(HtmlParser)。

  搜索引擎的实现者认为,SE 对 HT 的依赖是确定性的,所以不需要在 SE 和 HT 之间定义接口,而是直接通过 import(或者 include)的方式使用 HT;而模块 SE 对 HtmlParser 的依赖是不确定的,未来可能需要有 WordParser、PdfParser 等模块来替代 HtmlParser,以达到不同的业务要求。为此,他定义了 SE 和 HtmlParser 之间的接口,在模块 SE 中通过接口调用方式间接引用模块 HtmlParser。

  应当注意到,接口的需求方是 SE,只有 SE 才知道接口应该定义成什么样子,但是接口的实现方是 HtmlParser。基于模块设计的单向依赖原则,模块 HtmlParser 实现自身的业务时,不应该关心某个具体使用方的要求。HtmlParser 在实现的时候,甚至还不知道未来有一天 SE 会用上它。期望模块 HtmlParser 能够知道需求方需要的所有接口,并提前声明实现这些接口是不合理的。同样的道理发生在 SE 自己身上。SE 并不能够预计未来会有哪些需求方会用到自己,并且实现它们所要求的接口。

  这个问题在设计标准库时变得更加突出,比如我们实现了 File 类(这里我们用 Go 语言的文法来描述要实现的方法,请忽略文法上的细节),它有下面这些方法:

Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
Seek(off int64, whence int) (pos int64, err error)
Close() error

  那么,到底是应该定义一个 IFile 接口,还是应该定义一系列的 IReader、IWriter、ISeeker、ICloser 接口,然后让 File 从它们继承好呢?脱离了实际的用户场景,讨论这两个设计哪个更好并无意义。问题在于,实现 File 类的时候,我怎么知道外部会如何用它呢?

  正是因为这种不合理的设计,实现 Java、C# 类库中的每个类时都需要纠结以下两个问题。

  • 问题1:我提供哪些接口好呢?
  • 问题2:如果两个类实现了相同的接口,应该把接口放到哪个包好呢?

  接下来我们通过介绍 Go 语言中的接口概念来解释 Go 语言如何避免这几个困扰了无数开发人员的传统难题。

非侵入式接口

  在 Go 语言中,一个类只需要实现了接口要求的所有函数,我们就说这个类实现了该接口。例如:

type File struct {
    // ...
}

func (f *File) Read(buf []byte) (n int, err error)
func (f *File) Write(buf []byte) (n int, err error)
func (f *File) Seek(off int64, whence int) (pos int64, err error)
func (f *File) Close() error

  这里我们定义了一个 File 类,并实现有 Read()、Write()、Seek()、Close() 等方法。设想我们有如下接口:

type IFile interface {
    Read(buf []byte) (n int, err error)
    Write(buf []byte) (n int, err error)
    Seek(off int64, whence int) (pos int64, err error)
    Close() error
}

type IReader interface {
    Read(buf []byte) (n int, err error)
}

type IWriter interface {
    Write(buf []byte) (n int, err error)
}

type ICloser interface {
    Close() error
}

  尽管 File 类并没有从这些接口继承,甚至可以不知道这些接口的存在,但是 File 类实现了这些接口,可以进行赋值:

var file1 IFile = new(File)
var file2 IReader = new(File)
var file3 IWriter = new(File)
var file4 ICloser = new(File)

  Go 语言的非侵入式接口,看似只是做了很小的文法调整,实则影响深远。其一,Go 语言的标准库,再也不需要绘制类库的继承树图。你一定见过不少 C++、Java、C# 类库的继承树图。这里给个 Java 继承树图:

这里写图片描述

  在 Go 中,类的继承树并无意义,你只需要知道这个类实现了哪些方法,每个方法是啥含义就足够了。

  其二,实现类的时候,只需要关心自己应该提供哪些方法,不用再纠结接口需要拆得多细才合理。接口由使用方按需定义,而不用事前规划。

  其三,不用为了实现一个接口而导入一个包,因为多引用一个外部的包,就意味着更多的耦合。接口由使用方按自身需求来定义,使用方无需关心是否有其他模块定义过类似的接口。

接口赋值

实例赋值给接口

  接口赋值在 Go 语言中分为如下两种情况:

  • 将对象实例赋值给接口;
  • 将一个接口赋值给另一个接口;

  先讨论将某种类型的对象实例赋值给接口,这要求该对象实例实现了接口要求的所有方法,例如之前作过一个 Integer 类型,如下:

type Integer int

func (a Integer) Less(b Integer) bool {
    return a < b
}

func (a *Integer) Add(b Integer) {
    *a += b
}

  相应地,我们定义接口 LessAdder,如下:

type LessAdder interface {
    Less(b Integer) bool
    Add(b Integer)
}

  现在有个问题:假设我们定义一个 Integer 类型的对象实例,怎么将其赋值给 LessAdder 接口呢?应该用下面的语句 (1),还是语句 (2) 呢?

var a Integer = 1
var b LessAdder = &a ... (1)
var b LessAdder = a ... (2)

  答案是应该用语句 (1)。原因在于,Go 语言可以根据下面的函数:

func (a Integer) Less(b Integer) bool

  自动生成一个新的 Less() 方法:

func (a *Integer) Less(b Integer) bool {
    return (*a).Less(b)
}

  这样,类型 *Integer 就既存在 Less() 方法,也存在 Add() 方法,满足 LessAdder 接口。而从另一方面来说,根据

func (a *Integer) Add(b Integer)

  这个函数无法自动生成以下这个成员方法:

func (a Integer) Add(b Integer) {
    (&a).Add(b)
}

  因为 (&a).Add() 改变的只是函数参数 a,对外部实际要操作的对象并无影响,这不符合用户的预期。所以,Go 语言不会自动为其生成该函数。因此,类型 Integer 只存在 Less() 方法,缺少 Add() 方法,不满足 LessAdder 接口,故此上面的语句 (2) 不能赋值。

  为了进一步证明以上的推理,我们不妨再定义一个 Lesser 接口,如下:

type Lesser interface {
    Less(b Integer) bool
}

  然后定义一个 Integer 类型的对象实例,将其赋值给 Lesser 接口:

var a Integer = 1
var b1 Lesser = &a ... (1)
var b2 Lesser = a ... (2)

  正如我们所料的那样,语句 (1) 和语句 (2) 均可以编译通过。

接口赋值给接口

  我们再来讨论另一种情形:将一个接口赋值给另一个接口。在 Go 语言中,只要两个接口拥有相同的方法列表(次序不同不要紧),那么它们就是等同的,可以相互赋值。

  下面我们来看一个示例,这是第一个接口:

package one

type ReadWriter interface {
    Read(buf []byte) (n int, err error)
    Write(buf []byte) (n int, err error)
}

  第二个接口位于另一个包中:

package two

type IStream interface {
    Write(buf []byte) (n int, err error)
    Read(buf []byte) (n int, err error)
}

  这里我们定义了两个接口,一个叫 one.ReadWriter,一个叫 two.Istream,两者都定义了 Read()、Write() 方法,只是定义次序相反。one.ReadWriter 先定义了 Read() 再定义了 Write(),而 two.IStream 反之。

  在 Go 语言中,这两个接口实际上并无区别,因为:

  • 任何实现了 one.ReadWriter 接口的类,均实现了 two.IStream;
  • 任何 one.ReadWriter 接口对象可赋值给 two.IStream,反之亦然;
  • 在任何地方使用 one.ReadWriter 接口与使用 two.IStream 并无差异。

  以下这些代码可编译通过:

var file1 two.IStream = new(File)
var file2 one.ReadWriter = file1
var file3 two.IStream = file2

  接口赋值并不要求两个接口必须等价。如果接口 A 的方法列表是接口 B 的方法列表的子集,那么接口 B 可以赋值给接口 A。例如,假设我们有 Writer 接口:

type Writer interface {
    Write(buf []byte) (n int, err error)
}

  就可以将上面的 one.ReadWriter 和 two.IStream 接口的实例赋值给 Writer 接口:

var file1 two.IStream = new(File)
var file4 Writer = file1

  但是反过来并不成立:

var file1 Writer = new(File)
var file5 two.IStream = file1 // 编译不能通过

  这段代码无法编译通过,原因是显然的:file1 并没有 Read() 方法。

接口查询

  有办法让上面的 Writer 接口转换为 two.IStream 接口么?有。那就是即将讨论的接口查询语法,代码如下:

var file1 Writer = ...

if file5, ok := file1.(two.IStream); ok {
    ...
}

  这个 if 语句检查 file1 接口指向的对象实例是否实现了 two.IStream 接口,如果实现了,则执行特定的代码。

  接口查询是否成功,要在运行期才能够确定。它不像接口赋值,编译器只需要通过静态类型检查即可判断赋值是否可行。

  在 Windows 下做过开发的人,通常都接触过 COM,知道 COM 也有一个接口查询(QueryInterface)。是的,Go 语言的接口查询和 COM 的接口查询非常类似,都可以通过对象(组件)的某个接口来查询对象实现的其他接口。不过,Go 语言的接口查询优雅得多。在 Go 语言中,对象是否满足某个接口,通过某个接口查询其他接口,这一切都是完全自动完成的。

  让语言内置接口查询,这是一件非常了不起的事情。在 COM 中实现接口查询的过程非常繁复,但接口查询是 COM 体系的根本。COM 书对接口查询的介绍,往往从类似下面这样一段问话开始,它在 Go 语言中同样适用:

>你会飞吗? // IFly
>不会。
>你会游泳吗? // ISwim
>会。
>你会叫吗? // IShout
>会。
> ...

  随着问题的深入,你从开始对对象(组件)一无所知(在 Go 语言中是 interface{},在 COM 中是 IUnknown),到逐步深入了解。

  但是你最终能够完全了解对象么?COM 说:不能,你只能无限逼近,但永远不能完全了解一个组件。Go 语言说:你能。

  在 Go 语言中,你可以询问接口它指向的对象是否是某个类型,比如:

var file1 Writer = ...

if file6, ok := file1.(*File); ok {
    ...
}

  这个 if 语句判断 file1 接口指向的对象实例是否是 *File 类型,如果是则执行特定代码。查询接口所指向的对象是否为某个类型的这种用法可以认为只是接口查询的一个特例。接口是对一组类型的公共特性的抽象,所以查询接口与查询具体类型的区别好比是下面这两句问话的区别:

>你是医生吗?
>是。
>你是某某某?
>是。

  第一句问话查的是一个群体,是查询接口;而第二句问话已经到了具体的个体,是查询具体类型。

  在 C++、Java、C# 等语言中,也有类似的动态查询能力,比如查询一个对象的类型是否继承自某个类型(基类查询),或者是否实现了某个接口(接口派生查询),但是它们的动态查询与 Go 的动态查询很不一样。

>你是医生吗?

  对于上面这个问题,基类查询看起来像是在这么问:“你老爸是医生吗?”接口派生查询则看起来像是这么问:“你有医师执照吗?”在Go语言中,则是先确定满足什么样的条件才是医生,比如技能要求有哪些,然后才是按条件一一拷问,只要满足了条件你就是医生,而不关心你是否有医师执照。

类型查询

  在 Go 语言中,还可以更加直截了当地询问接口指向的对象实例的类型,例如:

var v1 interface{} = ...

switch v := v1.(type) {
    case int: // 现在 v 的类型是int
    case string: // 现在 v 的类型是string
    ...
}

  就像现实生活中物种多得数不清一样,语言中的类型也多得数不清,所以类型查询并不经常使用。它更多是个补充,需要配合接口查询使用,例如:

type Stringer interface {
    String() string
}

func Println(args ...interface{}) {
    for _, arg := range args {
        switch v := v1.(type) {
        case int: // 现在v的类型是int
        case string: // 现在v的类型是string
        default:
            if v, ok := arg.(Stringer); ok { // 现在v的类型是Stringer
            val := v.String()
                // ...
            } else {
                // ...
            }
        }
    }
}

  当然,Go 语言标准库的 Println() 比这个例子要复杂很多,我们这里只摘取其中的关键部分进行分析。对于内置类型,Println() 采用穷举法,将每个类型转换为字符串进行打印。对于更一般的情况,首先确定该类型是否实现了 String() 方法,如果实现了,则用 String() 方法将其转换为字符串进行打印。否则,Println() 利用反射功能来遍历对象的所有成员变量进行打印。

  是的,利用反射也可以进行类型查询,详情可参阅 reflect.TypeOf() 方法的相关文档。

接口组合

  像之前的类型组合一样,Go 语言同样支持接口组合。已经说过 Go 语言包中 io.Reader 接口和 io.Writer 接口,接下来再说说同样来自于 io 包的另一个接口 io.ReadWriter:

// ReadWriter 接口将基本的 Read 和 Write 方法组合起来
type ReadWriter interface {
    Reader
    Writer
}

  这个接口组合了 Reader 和 Writer 两个接口,它完全等同于如下写法:

type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

  因为这两种写法的表意完全相同:ReadWriter 接口既能做 Reader 接口的所有事情,又能做 Writer 接口的所有事情。在 Go 语言包中,还有众多类似的组合接口,比如 ReadWriteCloser、ReadWriteSeeker、ReadSeeker 和 WriteCloser 等。

  可以认为接口组合是类型匿名组合的一个特定场景,只不过接口只包含方法,而不包含任何成员变量。

Any 类型

  由于 Go 语言中任何对象实例都满足空接口 interface{},所以 interface{} 看起来像是可以指向任何对象的 Any 类型,如下:

var v1 interface{} = 1 // 将int类型赋值给interface{}
var v2 interface{} = "abc" // 将string类型赋值给interface{}
var v3 interface{} = &v2 // 将*interface{}类型赋值给interface{}
var v4 interface{} = struct{ X int }{1}
var v5 interface{} = &struct{ X int }{1}

  当函数可以接受任意的对象实例时,我们会将其声明为 interface{},最典型的例子是标准库 fmt 中 PrintXXX 系列的函数,例如:

func Printf(fmt string, args ...interface{})
func Println(args ...interface{})
...

  总体来说,interface{} 类似于 COM 中的 IUnknown、Java 中的 Object,我们刚开始对其一无所知,但可以通过接口查询和类型查询逐步了解它。

  参考《Go语言编程》

发表评论

电子邮件地址不会被公开。 必填项已用*标注