为什么要求所有接口的实现都要扩展基类?

27

我刚刚在GitHub上查看了Java Hamcrest代码,注意到他们采用了一种看起来不直观且笨拙的策略,但这使我想知道是否我遗漏了什么。

我注意到在HamCrest API中有一个接口Matcher和一个抽象类BaseMatcher。 Matcher接口声明了这个方法,并有这个javadoc:

    /**
     * This method simply acts a friendly reminder not to implement Matcher directly and
     * instead extend BaseMatcher. It's easy to ignore JavaDoc, but a bit harder to ignore
     * compile errors .
     *
     * @see Matcher for reasons why.
     * @see BaseMatcher
     * @deprecated to make
     */
    @Deprecated
    void _dont_implement_Matcher___instead_extend_BaseMatcher_();

然后在BaseMatcher中,这个方法的实现如下:

    /**
     * @see Matcher#_dont_implement_Matcher___instead_extend_BaseMatcher_()
     */
    @Override
    @Deprecated
    public final void _dont_implement_Matcher___instead_extend_BaseMatcher_() {
        // See Matcher interface for an explanation of this method.
    }
诚然,这样做既有效又可爱(也非常尴尬)。但是如果意图是让每个实现Matcher的类也扩展BaseMatcher,为什么还要使用接口呢?为什么不一开始就将Matcher作为抽象类,并让所有其他匹配器都扩展它呢?采用Hamcrest的方式有什么优势吗?或者这是一个糟糕实践的绝佳例子吗?
编辑:一些好答案,但为了寻求更多细节,我提供了赏金。我认为向后/二进制兼容性问题是最好的答案。然而,我希望看到关于兼容性问题的更多细节,最好包括一些代码示例(最好是Java)。此外,“向后”兼容性和“二进制”兼容性之间是否存在细微差别?
进一步编辑:2014年1月7日——pigroxalot在下面提供了一个答案,链接到HamCrest作者在Reddit上的这个评论。我鼓励每个人都阅读它,如果你觉得它有帮助,请为pigroxalot的答案点赞。
甚至进一步编辑:2017年12月12日——pigroxalot的答案不知怎么被删除了。太遗憾了......那个简单的链接非常有帮助。

1
我猜如果他们将来更改 BaseMatcher,他们不会破坏依赖于 Matcher 类型对象使用的代码。我也想知道它是否有助于依赖注入(只是一个猜测,因为我不是专业人士)。 - Hovercraft Full Of Eels
10
坦白地说,我认为这里是一个糟糕实践的例子。如果你想知道他们为什么这么做,你需要直接问他们。 - T.J. Crowder
5
这似乎是一种保持与之前实现的向后二进制兼容性的方法,直接实现Matcher,但通过破坏源代码兼容性来确保没有人再这样做。 - JB Nizet
3
不。类会被正确加载,只有在调用缺失的方法时才会导致运行时错误(如果我没记错的话是NoSuchMethodError)。这就是旧版JDBC驱动程序仍能在最新的JRE上正常工作的原因,尽管Connection、Statement、ResultSet等已经添加了大量方法。如果不调用未实现的方法,则没有问题。 - JB Nizet
2
@zhong.j.yu 默认情况下,它们无法抛出SQLException,因为它们是添加到接口的方法,并且在接口中的方法(直到Java 8)不能有任何默认实现。 - JB Nizet
显示剩余12条评论
7个回答

10

git log 中有这条记录,来自2006年12月(大约在最初的检入9个月后):

添加了抽象的 BaseMatcher 类,所有匹配器都应该扩展它。这将允许未来 API 的兼容性,因为 Matcher 接口会发展。

我没有尝试去研究细节。但随着系统的发展,保持兼容性和连续性是一个困难的问题。这确实意味着有时你会得到一种设计,如果你从头开始设计整个系统,你永远不会创建出这种设计。


1
总结抽象类提供的额外功能将使这个好答案更加出色。 - David Harkness
@DavidHarkness同意了。因此,设置了悬赏。 - James Dunn
将此答案标记为已接受,因为在我看来,这是第一个正确的答案。我希望它能更详细地阐述。 - James Dunn
@TJamesBoone 我认为你需要向Hamcrest的开发人员询问更多细节。我能找到git log条目,我认为这会有所帮助,但我真的不知道内部情况。 - ajb

5
但如果每个实现Matcher接口的类都要扩展BaseMatcher,那么为什么还要使用接口呢?
这并不是完全的意图。从OOP的角度来看,抽象基类和接口提供了完全不同的“契约”。
接口是一种通信协定。一个类实现接口,表示它遵循某些通信标准,并将根据特定参数和特定调用返回特定类型的结果。
抽象基类是一种实现协定。一个类继承抽象基类,以提供基类所需但留给实现者提供的功能。
在这种情况下,两者重叠,但这只是方便之问 - 接口是你需要实现的,而抽象类是为了让实现接口变得更容易 - 没有任何要求必须使用该基类才能提供接口,它只是为了让这样做更加轻松。你完全可以扩展基类以达到自己的目的,而不关心接口契约,或者实现一个实现相同接口的自定义类。
这种做法在老式的COM / OLE代码中非常普遍,在其他促进进程间通信(IPC)的框架中也是如此,其中分离实现和接口变得至关重要 - 这正是此处所做的。

1
“这并不完全是意图”...我不同意。我同意你的大部分答案,但我认为Hamcrest已经非常清楚地表明他们不希望任何人在不扩展抽象类的情况下实现接口(即使从技术上讲仍然可能)。否则,为什么要编写一个笨拙且功能无用的方法来混淆他们的API呢?这超出了“方便”的范畴。如果只是这样,他们可以在接口的javadoc中注明抽象类。 - James Dunn

3
我认为发生的情况是最初创建了一个Matcher API接口。
然后在以各种方式实现接口时,发现了一个通用的代码库,然后将其重构为BaseMatcher类。

所以我的猜测是保留Matcher接口,因为它是初始API的一部分,而描述性方法则作为提醒添加。

在搜索了代码后,我发现可以轻松地放弃该接口,因为它仅由BaseMatcher和2个测试单元实现,这些测试单元很容易更改为使用BaseMatcher。

所以回答你的问题 - 在这种情况下,这样做没有任何优势,除了不破坏其他人对Matcher的实现。

至于是否是不良实践? 我认为它是明确而有效的 - 所以我不认为是不良实践,只是有点奇怪 :)

尽管这个答案没有涉及到我所寻找的所有细节(请参见我问题底部的编辑),但我觉得它最接近值得获得悬赏。因此,我将悬赏授予这个答案。 - James Dunn

3
Hamcrest提供匹配功能,仅限匹配。这是一个小众市场,但他们似乎做得很好。该Matcher接口的实现散布在一些单元测试库中,例如Mockito的ArgumentMatcher,以及大量的匿名复制粘贴实现。
他们希望能够通过新方法扩展Matcher,而不会破坏所有现有的实现类。升级将非常困难。想象一下突然间所有的单元测试类都显示出愤怒的红色编译错误。这种愤怒和恼怒会迅速摧毁Hamcrest的小众市场。请参见http://code.google.com/p/hamcrest/issues/detail?id=83,了解其中的一点。此外,Hamcrest的重大变更将把使用Hamcrest的各个版本库分为变更前和变更后,并使它们相互排斥。这也是一个非常困难的情况。因此,为保持一定的自由度,他们需要将Matcher作为一个抽象基类。
但是他们也在嘲笑业务中,并且接口比基类更容易模拟。当Mockito团队对Mockito进行单元测试时,他们应该能够模拟匹配器。因此,他们还需要抽象基类具有Matcher接口。
我认为他们已经认真考虑了各种选择,并发现这是最不糟糕的选择。

2

有一个关于这个话题的有趣讨论,链接在这里。引用nat_pryce的话:

嗨,我写了Hamcrest的原始版本,尽管Joe Walnes在基类中添加了这个奇怪的方法。
这是因为Java语言的一种特殊性。正如下面的评论者所说,将Matcher定义为基类将使扩展库变得更容易,而不会破坏客户端。向接口添加方法会阻止客户端代码中任何实现类的编译,但可以向抽象基类添加新的具体方法而不会破坏子类。
然而,Java还有一些仅适用于接口的功能,特别是java.lang.reflect.Proxy。
因此,我们定义了Matcher接口,以便人们可以编写Matcher的动态实现。我们为人们提供了基类,以便他们可以在自己的代码中进行扩展,从而使他们的代码不会因为我们向接口添加更多方法而被破坏。
此后,我们向Matcher接口添加了describeMismatch方法,并继承了默认实现,而不会出现破坏。我们还提供了其他基类,使实现describeMismatch变得更加容易,而不重复逻辑。
因此,这是一个例子,说明为什么在设计时不能盲目地遵循某些通用的“最佳实践”。您必须理解您正在使用的工具,并在该上下文中进行工程权衡。
编辑:将接口与基类分离还有助于应对脆弱的基类问题:
如果向被抽象基类实现的接口添加方法,则在更改以实现新方法的子类时,您可能会在基类或子类中重复逻辑。如果更改基类以删除重复逻辑,但这样做会更改提供给子类的API,则会破坏所有子类——如果接口和实现都在同一个代码库中,则不是大问题,但如果您是库作者,则是坏消息。
如果将接口与抽象基类分开(即区分类型的用户和实现者),则当您向接口添加方法时,可以向基类添加默认实现,而不会破坏现有的子类,并引入一个新的基类,为新的子类提供更好的部分实现。当有人来改变现有的子类以更好地实现方法时,如果有意义,他们可以选择使用新的基类来减少重复的逻辑。
如果接口和基类是相同类型(如本线程中的一些人所建议的),并且您想以这种方式引入多个基类,则会陷入困境。您无法引入新的超类型作为接口,因为那会破坏客户端代码。您无法将部分实现下移到新的抽象基类中,因为那会破坏现有的子类。
这同样适用于特质,Java风格的接口和类或C++多重继承。

谢谢!那篇文章帮助我更清楚地理解了它,比之前更加明确。这正是我所寻找的! - James Dunn

1
Java8 现在允许在接口中添加新方法,如果它们包含默认实现。
interface Match<T>

    default void newMethod(){ impl... }

这是一个极好的工具,为界面设计和演变带来很多自由。
但是,如果你真的想要添加一个没有默认实现的抽象方法怎么办?
我认为你应该直接添加这个方法。它会破坏一些现有代码;必须修复它们。这并不是什么大问题。它可能比其他在保留二进制兼容性的代价下搞砸整个设计的解决方案更好。

1
有人能向我解释一下,在接口中添加代码的这种“特性”是如何改进的吗?在我看来,这似乎是一个可怕的黑客行为,它通过在接口中引入可执行代码来违反了面向对象编程的本质,而概念上应该是“契约”,不包含任何代码。 - Federico Berasategui
2
@HighCore 它允许接口声明一个可选方法到合同中,并在实现或选择省略它时一致地定义其行为。一个很好的例子是 Iterator.remove,其文档规定不支持元素删除的必须抛出特定异常。使用此功能允许接口在程序上强制执行此规则。 - David Harkness
1
@DavidHarkness,这听起来像是抽象类的作用。它并没有解释为什么Java采取了这种可怕的方式,通过在接口中引入可执行代码来更糟糕地破坏了面向对象编程。C#以一种美妙的方式解决了这个问题,引入了扩展方法,而Java似乎正在做出巨大的努力,诱导开发者编写甚至更糟糕的代码。 - Federico Berasategui
1
HighCore这提供了C++和Python中我所错过的多继承的一个好处。我真的很喜欢每种语言以不同的方式解决类似的问题。我想说,各有所好。 - David Harkness
2
默认方法不能访问对象的任何内部状态,因此它们不会破坏封装性。它们本质上与静态方法相同,只是它们可以被覆盖重写。我没有看出这有什么问题。 - MikeFHay
2
“圣经”明确规定,只有异端才会在接口中实现方法。 - Idan Arye

1

但是,如果每个实现Matcher接口的类也都需要扩展BaseMatcher,那么为什么要使用接口呢?为什么不一开始就将Matcher作为抽象类,并使所有其他匹配器扩展它呢?

通过分离接口和实现(抽象类仍然是实现),您遵守了依赖倒置原则。不要与依赖注入混淆,两者没有任何关系。您可能会注意到,在Hamcrest中,接口保留在hamcrest-api包中,而抽象类则在hamcrest-core中。这提供了低耦合性,因为实现只依赖于接口而不依赖于其他实现。有关此主题的好书是:面向接口的设计:使用模式

按照Hamcrest的方式做有什么优点吗?还是这是一个糟糕实践的很好例子?

这个例子中的解决方案看起来很丑陋。我认为注释足够了。制作这样的存根方法是多余的。我不会采用这种方法。


我同意并理解依赖倒置原则。让我困惑的是,尽管技术上遵循了依赖倒置原则和低耦合,但 Hamcrest 坚持不喜欢它,强制你实现一个无用的方法。我仍然认为二进制兼容性是最有可能的解释,但因提到依赖倒置原则而+1。 - James Dunn
我认为这是一个不好的实践例子。说实话,我不理解你关于二进制兼容性的想法。 - Mikhail

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接