接口在 Go 语言中有着至关重要的地位,如果说 goroutine 和 channel 是支撑起 Go 语言并发模型的基石,那么接口就是 Go 语言整个类型系统的基石。Go 语言的接口不单单只是接口,下面就让我们一步步来探索 Go 语言的接口特性。
传统侵入式接口实现
和类的实现相似,Go 语言的接口和其他语言中提供的接口概念完全不同。以 Java、PHP 为例,接口主要作为不同类之间的契约(Contract)存在,对契约的实现是强制的,体现在具体的细节上就是如果一个类实现了某个接口,就必须实现该接口声明的所有方法,这个叫「履行契约」:
// 声明一个'iTemplate'接口 interface iTemplate { public function setVariable($name, $var); public function getHtml($template); } // 实现接口 // 下面的写法是正确的 class Template implements iTemplate { private $vars = array(); public function setVariable($name, $var) { $this->vars[$name] = $var; } public function getHtml($template) { foreach($this->vars as $name => $value) { $template = str_replace('{' . $name . '}', $value, $template); } return $template; } }
这个时候,如果有另外有一个接口 iTemplate2
声明了与 iTemplate
完全一样的接口方法,甚至名字也叫 iTemplate
,只不过位于不同的命名空间下,编译器也会认为上面的类 Template
只实现了 iTemplate
而没有实现 iTemplate2
接口。
这在我们之前的认知中是理所当然的,无论是类与类之间的继承,还是类与接口之间的实现,在 Java、PHP 这种单继承语言中,存在着严格的层级关系,一个类只能直接继承自一个父类,一个类也只能实现指定的接口,如果没有显式声明继承自某个父类或者实现某个接口,那么这个类就与该父类或者该接口没有任何关系。
我们把这种接口称为侵入式接口,所谓「侵入式」指的是实现类必须明确声明自己实现了某个接口。这种实现方式虽然足够明确和简单明了,但也存在一些问题,尤其是在设计标准库的时候,因为标准库必然涉及到接口设计,接口的需求方是业务实现类,只有具体编写业务实现类的时候才知道需要定义哪些方法,而在此之前,标准库的接口就已经设计好了,我们要么按照约定好的接口进行实现,如果没有合适的接口需要自己去设计,这里的问题就是接口的设计和业务的实现是分离的,接口的设计者并不能总是预判到业务方要实现哪些功能,这就造成了设计与实现的脱节。
接口的过分设计会导致某些声明的方法实现类完全不需要,如果设计的太简单又会导致无法满足业务的需求,这确实是一个问题,而且脱离了用户使用场景讨论这些并没有意义,以 PHP 自带的 SessionHandlerInterface 接口为例,该接口声明的接口方法如下:
SessionHandlerInterface { /* 方法 */ abstract public close ( void ) : bool abstract public destroy ( string $session_id ) : bool abstract public gc ( int $maxlifetime ) : int abstract public open ( string $save_path , string $session_name ) : bool abstract public read ( string $session_id ) : string abstract public write ( string $session_id , string $session_data ) : bool }
用户自定义的 Session 管理器需要实现该接口,也就是要实现该接口声明的所有方法,但是实际在做业务开发的时候,某些方法其实并不需要实现,比如如果我们基于 Redis 或 Memcached 作为 Session 存储器的话,它们自身就包含了过期回收机制,所以 gc
方法根本不需要实现,又比如 close
方法对于大部分驱动来说,也是没有什么意义的。
正是因为这种不合理的设计,所以在编写 PHP 类库中的每个接口时都需要纠结以下两个问题(Java 也类似):
- 一个接口需要声明哪些接口方法?
- 如果多个类实现了相同的接口方法,应该如何设计接口?比如上面这个
SessionHandlerInterface
,有没有必要拆分成多个更细分的接口,以适应不同实现类的需要?
接下我们来看看 Go 语言的接口是如何避免这些问题的。
Go 语言的接口实现
在 Go 语言中,类对接口的实现和子类对父类的继承一样,并没有提供类似 implement
这种关键字显式声明该类实现了哪个接口,一个类只要实现了某个接口要求的所有方法,我们就说这个类实现了该接口。
例如,我们定义了一个 File
类,并实现了 Read()
、Write()
、Seek()
、Close()
四个方法:
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
假设我们有如下接口(Go 语言通过关键字 interface
来声明接口,以示和结构体类型的区别,花括号内包含的是待实现的方法集合):
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
类实现了这些接口,因为 File
类实现了上述所有接口声明的方法。当一个类的成员方法集合包含了某个接口声明的所有方法,换句话说,如果一个接口的方法集合是某个类成员方法集合的子集,我们就认为该类实现了这个接口。
与 Java、PHP 相对,我们把 Go 语言的这种接口称作非侵入式接口,因为类与接口的实现关系不是通过显式声明,而是系统根据两者的方法集合进行判断。这样做有两个好处:
- 其一,Go 语言的标准库不需要绘制类库的继承/实现树图,在 Go 语言中,类的继承树并无意义,你只需要知道这个类实现了哪些方法,每个方法是干什么的就足够了。
- 其二,定义接口的时候,只需要关心自己应该提供哪些方法即可,不用再纠结接口需要拆得多细才合理,也不需要为了实现某个接口而引入接口所在的包,接口由使用方按需定义,不用事先设计,也不用考虑之前是否有其他模块定义过类似接口。
这样一来,就完美地避免了传统面向对象编程中的接口设计问题。
通过组合实现接口继承
我们知道在 Java、PHP 等传统面向对象编程语言中,支持通过 extends
关键字实现接口之间的继承关系:
interface A { public function foo(); } interface B extends A { public function bar(); }
在上述代码中,我们定义了两个 PHP 接口:A
和 B
,其中接口 B
继承自 A
,这样一来,如果某个类实现了接口 B
,则必须实现这两个接口中声明的方法,否则会报错。
Go 语言也支持类似的「接口继承」特性,但是由于不支持 extends
关键字,所以其实现和类的继承一样,是通过组合来完成的。以上面这个 PHP 示例为例,在 Go 语言中,我们可以这样通过接口组合来实现接口继承,就像类的组合一样:
type A interface { Foo() } type B interface { A Bar() }
然后我们定义一个类 T
实现接口 B
:
type T struct {} func (t T) Foo() { fmt.Println("call Foo function from interface A.") } func (t T) Bar() { fmt.Println("call Bar function from interface B.") }
不过,在 Go 语言中,又与传统的接口继承有些不同,因为接口实现不是强制的,是根据类实现的方法来动态判定的,比如我们上面的 T
类可以只实现 Foo
方法,也可以只实现 Bar
方法,也可以都不实现。如果只实现了 Foo
方法,则 T
实现了接口 A
;如果只实现了 Bar
方法,则既没有实现接口 A
也没有实现接口 B
,只有两个方法都实现了系统才会判定实现了接口 B
。
可以认为接口组合是匿名类型组合(没有显式为组合类型设置对应的属性名称)的一个特定场景,只不过接口只包含方法,而不包含任何属性。Go 语言底层很多包就是基于接口组合实现的,比如 io 里面的 Reader
、Writer
、ReadWriter
这些接口:
// Reader is the interface that wraps the basic Read method. type Reader interface { Read(p []byte) (n int, err error) } // Writer is the interface that wraps the basic Write method. type Writer interface { Write(p []byte) (n int, err error) } // ReadWriter is the interface that groups the basic Read and Write methods. type ReadWriter interface { Reader Writer }
学习打卡
学习打卡~
再看一遍
学习打卡
设计得真巧妙啊