有意思的接口规则:自动实现
Golang也支持接口,但是它的接口规则很有意思:
一个类型不需要显示声明它要实现的接口,只要实现了接口中规定的所有方法,那么就自动实现了相应的接口了。[1]
所以正常情况下,如果有如下一个struct
type A struct {
}
func (*A) func Foo() {}
func (*A) func Bar(i int) int {
return i
}
那么它就自动同时实现了下面三个接口
type IFoo interface {
Foo()
}
type IBar interface {
Bar(i int) int
}
type IFooBar interface {
IFoo
IBar
}
所以我们可以进行如下的赋值:
var a *A = &A{}
// 下面三个赋值都是正确的
var foo IFoo = a
var bar IBar = a
var foobar IFooBar = a
这种自动的接口实现带来了一个很大的好处:功能的使用方可以自由的给功能实现定义自己喜欢的接口,并且通过自己定义的接口使用该功能。这在某些时候是很有用的。
自动实现带来的便利
比如某个很实用的功能(比如日志打印功能)有第三方一个实现(假设是SomeLogger
),我们希望在自己的系统中使用它。但是出于代码整洁的考虑,我们不希望代码直接依赖第三方实现,而希望是依赖一个接口:这样当需要更换实现的时候就不用满世界改代码。
这个问题Golang的处理很简单:我们只需要自己定义一个接口(比如Logger
),里面罗列上SomeLogger
中提供的并且我们需要使用的方法即可。这时我们就可以依赖我们自己的Logger
接口来使用SomeLogger
提供的功能。
相比之下,Java是需要显式声明实现的接口的,比如图2
中类SomeLogger
实现了ILogger
接口,即使ILogger
接口与业务自己定义的Logger
接口的方法列表完全相同,SomeLogger
类与Logger
接口也没有“实现”的关系。对于前面提到的那种业务不希望依赖实现,而希望依赖与接口Logger
的情况,我们一般只能借助于适配器模式[2]来实现,如图2
的bridge
部分所示。
两个结构对比,我们显然能看到java里面则多了很多代码。自己定义适配器,有时候是非常复杂的,而且还容易引发各种依赖的问题。Golang的这种自动实现方式则很简洁而且灵活很多。像这种为一套实现定义接口,而业务依赖于这一套接口,这其实是一种很实用的做法,他能有效的给组件之间解耦[3],我们会经常使用,满世界的适配器还是非常恐怖的。
自动实现的问题
虽然自动实现理论上能规避java里面的满世界适配器的局面,但是实际上这很难发生,有些时候还是免不了适配器。这是因为适配器适配的双方一般只是在同一个领域而已,他们在开发过程中不会考虑是否要跟对方使用相同的接口。所以这种情况下,不一定能找到一个接口是两者刚好都能实现。这种时候,相比起来大家蹩脚的实现同一个接口,不如各自自由发展,然后定义适配器来得简单。
其实现在Golang的实现机制并不完善。接口自动实现有一个很大的不足,他不能适用里氏替换原则,这让接口实现很多时候无法摆脱实现类型对接口的强依赖,这对于实现来说是很不友好的。这也会使得对于一些比较复杂的场景,使用方很难定义一个好用的接口。比如看下面这样一个KV存储的客户端的例子:
某种KV存储有一个第三方实现的SDK,提供了客户端类型SomeClient
,SomeClient
提供了一个GetConn()
的方法返回该SDK中定义的连接类型SomeConn
的实例,SomeConn
实例用于真正请求发送。由于业务需要依赖接口,不希望依赖实现,所以我们定义了一套接口KvClient
和KvConn
,希望KvClient
能作为SomeClient
的接口抽象,KvConn
能作为SomeConn
的抽象,自然KvClient
的GetConn()
方法返回的是KvConn
类型的实例。
但是此时我们就会惊奇的发现,SomeClient
并不能自动实现KvClient
接口了。原因是这两个类型的GetConn()
方法的返回值类型不一致,所以不认为SomeClient
实现了KvClient
中的GetConn()
方法。但是其实我们是希望这种时候能判定为"实现"关系的,因为实现类中返回的SomeConn
类型的实例就是KvConn
类型的实例。
接口实现关系,Java的处理规则值得赞赏!Java是使用里氏替换原则[4]来处理方法的覆盖关系的(Override
)。比如对于上面的KvClient
的例子,Java则认为SomeClient
的GetConn()
方法能覆盖KvClient
接口中的GetConn()
方法。因为按照KvClient.GetConn(): KvConn
的语义调用SomeClient
的GetConn()
方法返回的SomeConn
实例都是KvConn
类型的实例;所以代码任何使用KvClient
实例的地方替换成SomeClient
实例都不会出问题。
如果Golang的接口实现规则未来还会变更,希望他能借鉴这种规则。
其实Java的方法覆盖关系也没有完全参照里氏替换原则[4],他对参数里面的类型就没有按照里氏替换的方式处理。
比如接口方法是
void foo(Foo)
,而实现类的方法是void foo(Object)
,可以肯定能传给接口方法的Foo
类型的参数都是可以传给实现类的方法的(接受Object
类型)。但是Java没有把这当成合法的覆盖,这主要是因为与重载(Overloading)
的规则冲突了。但是Golang本身就没有方法重载的设计[5],所以它是有条件对里氏替换原则方式作完整支持的。
最后,在现在没有使用里氏替换
的规则的情况下,SomeClient
与KvClient
的问题应该如何解决?答案是使用适配器
[2]来实现,这是一个古老而又实用的解决方案!
最后,自动实现还有一个致命性的缺点:容易造成接口混乱。golang中,接口一般会倾向于定义的比较简单,从而可以简单灵活的使用各个功能实现代码的各个侧面。但是接口越简单,其他无关的类型就越容易实现他。这可能会造成我们看到满世界都是实现类型,分不清谁是真的是谁是假的,而不得不承受辨别真假美猴王的纠结。这种情况,会给代码的理解和使用带来很多干扰信息。
网友评论