美文网首页
Golang的接口

Golang的接口

作者: 千寻客 | 来源:发表于2019-07-10 00:56 被阅读0次

    有意思的接口规则:自动实现

    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提供的功能。

    图1:给第三方实现定义接口

    相比之下,Java是需要显式声明实现的接口的,比如图2中类SomeLogger实现了ILogger接口,即使ILogger接口与业务自己定义的Logger接口的方法列表完全相同,SomeLogger类与Logger接口也没有“实现”的关系。对于前面提到的那种业务不希望依赖实现,而希望依赖与接口Logger的情况,我们一般只能借助于适配器模式[2]来实现,如图2bridge部分所示。

    图2:桥接模式

    两个结构对比,我们显然能看到java里面则多了很多代码。自己定义适配器,有时候是非常复杂的,而且还容易引发各种依赖的问题。Golang的这种自动实现方式则很简洁而且灵活很多。像这种为一套实现定义接口,而业务依赖于这一套接口,这其实是一种很实用的做法,他能有效的给组件之间解耦[3],我们会经常使用,满世界的适配器还是非常恐怖的。

    自动实现的问题

    虽然自动实现理论上能规避java里面的满世界适配器的局面,但是实际上这很难发生,有些时候还是免不了适配器。这是因为适配器适配的双方一般只是在同一个领域而已,他们在开发过程中不会考虑是否要跟对方使用相同的接口。所以这种情况下,不一定能找到一个接口是两者刚好都能实现。这种时候,相比起来大家蹩脚的实现同一个接口,不如各自自由发展,然后定义适配器来得简单。

    其实现在Golang的实现机制并不完善。接口自动实现有一个很大的不足,他不能适用里氏替换原则,这让接口实现很多时候无法摆脱实现类型对接口的强依赖,这对于实现来说是很不友好的。这也会使得对于一些比较复杂的场景,使用方很难定义一个好用的接口。比如看下面这样一个KV存储的客户端的例子:

    某种KV存储有一个第三方实现的SDK,提供了客户端类型SomeClientSomeClient提供了一个GetConn()的方法返回该SDK中定义的连接类型SomeConn的实例,SomeConn实例用于真正请求发送。由于业务需要依赖接口,不希望依赖实现,所以我们定义了一套接口KvClientKvConn,希望KvClient能作为SomeClient的接口抽象,KvConn能作为SomeConn的抽象,自然KvClientGetConn()方法返回的是KvConn类型的实例。

    图3:自动实现失效的情况

    但是此时我们就会惊奇的发现,SomeClient并不能自动实现KvClient接口了。原因是这两个类型的GetConn()方法的返回值类型不一致,所以不认为SomeClient实现了KvClient中的GetConn()方法。但是其实我们是希望这种时候能判定为"实现"关系的,因为实现类中返回的SomeConn类型的实例就是KvConn类型的实例。

    接口实现关系,Java的处理规则值得赞赏!Java是使用里氏替换原则[4]来处理方法的覆盖关系的(Override)。比如对于上面的KvClient的例子,Java则认为SomeClientGetConn()方法能覆盖KvClient接口中的GetConn()方法。因为按照KvClient.GetConn(): KvConn的语义调用SomeClientGetConn()方法返回的SomeConn实例都是KvConn类型的实例;所以代码任何使用KvClient实例的地方替换成SomeClient实例都不会出问题。

    如果Golang的接口实现规则未来还会变更,希望他能借鉴这种规则。

    其实Java的方法覆盖关系也没有完全参照里氏替换原则[4],他对参数里面的类型就没有按照里氏替换的方式处理。

    比如接口方法是void foo(Foo),而实现类的方法是void foo(Object),可以肯定能传给接口方法的Foo类型的参数都是可以传给实现类的方法的(接受Object类型)。但是Java没有把这当成合法的覆盖,这主要是因为与重载(Overloading)的规则冲突了。

    但是Golang本身就没有方法重载的设计[5],所以它是有条件对里氏替换原则方式作完整支持的。

    最后,在现在没有使用里氏替换的规则的情况下,SomeClientKvClient的问题应该如何解决?答案是使用适配器[2]来实现,这是一个古老而又实用的解决方案!

    图4:Golang使用适配器

    最后,自动实现还有一个致命性的缺点:容易造成接口混乱。golang中,接口一般会倾向于定义的比较简单,从而可以简单灵活的使用各个功能实现代码的各个侧面。但是接口越简单,其他无关的类型就越容易实现他。这可能会造成我们看到满世界都是实现类型,分不清谁是真的是谁是假的,而不得不承受辨别真假美猴王的纠结。这种情况,会给代码的理解和使用带来很多干扰信息。


    1. https://gobyexample.com/interfaceshttps://tour.golang.org/methods/10

    2. 适配器模式

    3. 依赖倒置原则

    4. 里氏替换原则

    5. Golang/ISSUE-21659

    相关文章

      网友评论

          本文标题:Golang的接口

          本文链接:https://www.haomeiwen.com/subject/jxsqkctx.html