与定义一个方法可以调用相比,如何定义一个方法可以被覆盖更强的承诺?


36

来自:http : //www.artima.com/lejava/articles/designprinciples4.html

埃里希·伽玛(Erich Gamma):即使十年之后,我仍然认为这是真的。继承是改变行为的一种很酷的方法。但是我们知道它很脆弱,因为子类可以轻松地对调用其重写的方法的上下文进行假设。基类和子类之间存在紧密的耦合,因为隐式上下文将在其中调用我插入的子类代码。合成具有更好的属性。通过将一些较小的东西插入较大的对象中,可以减少耦合,而较大的对象仅将较小的对象回调。从API的角度来看,定义可以覆盖的方法比定义可以调用的方法要强。

我不明白他的意思。有人可以解释一下吗?

Answers:


63

承诺可以减少您将来的选择。发布方法意味着用户将调用它,因此您必须在不破坏兼容性的情况下删除此方法。如果您保留它private,他们将无法(直接)调用它,并且有一天您可以毫无问题地将其重构。因此,与不发布方法相比,发布方法是更强有力的承诺。发布一个可覆盖的方法是一个更加坚定的承诺。您的用户可以调用它,并且他们可以创建新类,其中该方法无法实现您想像的!

例如,如果发布清理方法,则只要用户记得最后一次调用此方法,就可以确保资源被正确地重新分配。但是,如果该方法是可重写的,则有人可以在子类中重写它,而不是调用super。结果,第三位用户可能会使用该类并导致资源泄漏,即使他们cleanup()在结尾处尽职尽责地调用了它!这意味着您不能再保证代码的语义,这是非常不好的事情。

从本质上讲,您不再可以依赖用户可重写方法中运行的任何代码,因为某些中间人可能会将其覆盖。这意味着您必须完全在private方法中实现清理例程,而无需用户的帮助。因此,通常最好只发布final元素,除非它们明确地被API用户覆盖。


11
这可能是我所读过的关于继承的最佳论据。在我遇到的所有反对原因中,我之前从未遇到过这两个参数(通过覆盖来耦合和破坏功能),但是这两个都是反对继承的非常有力的论据。
David Arno

5
@DavidArno我不认为这是反对继承的论据。我认为这是反对“使所有默认情况下都可以覆盖”的论点。继承本身并不是危险的,不加思索地使用它是危险的。
svick

15
虽然这听起来不错,但我无法真正理解“用户可以添加自己的错误代码”是一个争论。启用继承使用户可以添加缺少的功能而不会失去可更新性,这是可以预防和修复错误的措施。如果用户在API之上的代码被破坏,则不是API缺陷。
Sebb 2015年

4
您可以轻松地将该参数转换为:第一个编码器提供一个清理参数,但会犯错误并且不会清理所有内容。第二个编码器重写了cleanup方法并做得很好,并且#3编码器使用了该类,并且即使#1编码器弄乱了它也没有任何资源泄漏。
Pieter B

6
@多瓦尔确实。这就是为什么在几乎所有OOP入门书籍和课程中,继承都是第一课是很可笑的原因。
凯文·克鲁姆维德

30

如果发布一个正常函数,则会给出一个单边合同:
如果调用该函数会做什么?

如果发布回调,则还会给出一个单边合同:
何时以及如何调用它?

而且,如果您发布一个可重写的函数,那么它们会同时出现,因此您要提供一个双向合同:
何时调用它,如果调用它必须做什么?

即使您的用户没有滥用您的API(通过破坏他们的合同部分,这可能会花费巨大的检测成本),您也可以轻松地看到后者需要更多的文档,并且您记录的所有内容都是一项承诺,这限制了您的进一步选择。

违背这种双面合同上的一个例子是来自移动showhidesetVisible(boolean)java.awt.Component中


+1。我不确定为什么其他答案会被接受。它提出了一些有趣的观点,但这绝对不是这个问题的正确答案,因为这绝对不是引文所指的意思。
ruakh 2015年

这是正确的答案,但我不理解该示例。用setVisible(boolean)替换显示和隐藏似乎破坏了也不使用继承的代码。我想念什么吗?
eigensheep

3
@eigensheep:showhide仍然存在,他们只是@Deprecated。因此,更改不会破坏仅调用它们的任何代码。但是,如果您覆盖了它们,则迁移到新的“ setVisible”的客户端将不会调用您的覆盖。(我从未使用过Swing,所以我不知道覆盖它们是多么普遍;但是由于它是很久以前发生的,因此我可以想象Deduplicator记得它的原因是它痛苦地咬了他/她。)
ruakh

12

Kilian Foth的答案非常好。我只想添加关于为什么这是一个问题的规范示例。想象一个整数Point类:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

现在让我们将其子类化为3D点。

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

超级简单!让我们使用我们的观点:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

您可能想知道为什么我要发布这样一个简单的示例。这是要抓住的地方:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

当我们将2D点与等效的3D点进行比较时,我们得到了true,但是当我们逆转比较时,我们得到了false(因为p2a失败instanceof Point3D)。

结论

  1. 通常可以以与超类期望它的工作方式不再兼容的方式在子类中实现方法。

  2. 通常,不可能以与其父类兼容的方式在明显不同的子类上实现equals()。

当您编写要允许人们继承的类时,对于每个方法的行为写一个契约是一个非常好的主意。更好的是,人们可以对覆盖方法的实现进行一系列的单元测试,以证明他们没有违反合同。几乎没有人这样做,因为它工作太多。但是,如果您在乎,那就是要做的事情。

精心阐述了合同的一个很好的例子比较.equals()出于上述原因,只需忽略它所说的内容即可。下面是的比较如何能做到的事情为例.equals()不了

笔记

  1. Josh Bloch的“ Effective Java”项目8是此示例的来源,但Bloch使用ColorPoint而不是第三轴添加颜色,并使用double代替int。Odersky / Spoon / Venners基本上复制了Bloch的Java示例,并将其示例在线提供。

  2. 有几个人反对此示例,因为如果您让父类知道有关子类的信息,则可以解决此问题。如果子类的数量足够少,并且父级知道所有子类,则为真。但是最初的问题是关于制作其他人将为其编写子类的API。在这种情况下,通常无法将父实现更新为与子类兼容。

奖金

比较器也很有趣,因为它可以解决正确实现equals()的问题。更好的是,它遵循一种用于解决此类继承问题的模式:策略设计模式。Haskell和Scala让人兴奋的Typeclass也是Strategy模式。继承不是坏事,也不是错误的,只是棘手的事情。进一步阅读,请查阅Philip Wadler的论文:如何减少即席多态性


1
不过,SortedMap和SortedSet实际上并未更改equalsMap和Set对其定义的定义。相等完全忽略了排序,例如,两个具有相同元素但排序顺序不同的SortedSet仍然比较相等。
user2357112支持Monica 2015年

1
@ user2357112您说得对,我已经删除了该示例。与Map兼容的SortedMap.equals()是一个单独的问题,我将继续抱怨。SortedMap通常为O(log2 n),HashMap(Map的规范实现)为O(1)。因此,只有在真正关心订购时才使用SortedMap 。因此,我认为顺序非常重要,足以成为SortedMap实现中equals()测试的关键组成部分。他们不应该与Map共享equals()实现(它们通过Java中的AbstractMap共享)。
GlenPeterson

3
“继承不是坏事,也不是错误的,只是棘手的事情。” 我理解您的意思,但是棘手的事情通常会导致错误,错误和问题。如果您可以以更可靠的方式完成相同的事情(或几乎所有相同的事情),那么棘手的方法不好了。
jpmc26 2015年

7
格伦,这是一个可怕的例子。您只是以不应该使用的方式使用了继承,也就不足为奇了,这些类无法按您预期的方式工作。您通过提供错误的抽象(二维点)破坏了Liskov的替换原理,但是仅仅因为继承在您的错误示例中是不好的,并不意味着它通常就不好。尽管这个答案看似合理,但只会使没有意识到它违反最基本的继承规则的人们感到困惑。
安迪2015年

3
Liskov的“替代原理”的ELI5说:如果类B是类的子级,A并且应该实例化B类的B对象,则应该能够将类对象强制转换为它的父级,并使用强制转换变量的API,而不会丢失任何实现细节。这个孩子。您通过提供第三个属性违反了规则。当基类不知道存在这样的属性时,如何zPoint3D变量转换为之后计划访问坐标Point2D?如果通过将子类转换为基类而破坏了公共API,则抽象是错误的。
安迪2015年

4

继承削弱封装

当发布允许继承的接口时,将大大增加接口的大小。每个可重写方法都可以替换,因此应将其视为提供给构造函数的回调。您的类提供的实现仅仅是回调的默认值。因此,应提供某种合同以表明对该方法的期望。很少发生这种情况,这是为什么将面向对象的代码称为易碎的主要原因。

下面是来自Java集合框架的真实(简化)示例,由Peter Norvig(http://norvig.com/java-iaq.html)提供。

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

那么,如果我们将其子类化怎么办?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

我们有一个错误:有时我们添加“ dog”,哈希表会获得“ dogss”的条目。原因是有人提供了实现Hashtable类的人员所期望的put的实现。

继承破坏可扩展性

如果允许您的类被子类化,那么您承诺不向您的类添加任何方法。否则,可以完成此操作而不会破坏任何内容。

当您向接口添加新方法时,从您的类继承的任何人都将需要实现这些方法。


3

如果要调用某个方法,则只需确保该方法正确运行即可。而已。做完了

如果要重写某个方法,则还必须仔细考虑该方法的范围:如果范围太大,子类通常将需要包含父方法的复制粘贴代码;如果它太小,则需要重写许多方法才能具有所需的新功能-这会增加复杂性和不必要的行数。

因此,父方法的创建者需要对将来如何重写该类及其方法进行假设。

但是,作者在引用的文本中讨论的是另一个问题:

但是我们知道它很脆弱,因为子类可以轻松地对调用其重写的方法的上下文进行假设。

考虑a通常从method调用的method b,但在某些罕见和非显而易见的情况下,可以从method 调用c。如果重写方法的作者忽略了该c方法及其对的期望a,则很明显事情会出错。

因此,更重要的是,a要明确,明确地定义并且有据可查,“做一件事情并做得很好” —比仅设计为被调用的方法要重要得多。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.