将一个类定义为自身列表的子类有什么缺点?


48

在我最近的项目中,我定义了一个带有以下标头的类:

public class Node extends ArrayList<Node> {
    ...
}

但是,在与我的CS教授讨论后,他说这堂课既“令人难以忘怀”又是“不良习惯”。我还没有发现第一个是特别真实的,第二个是主观的。

我这样做的理由是,我有一个对象的想法,该对象需要定义为可以具有任意深度的对象,在这种情况下,实例的行为可以通过自定义实现或几个类似对象相互作用的行为来定义。这将允许抽象对象,这些对象的物理实现将由许多相互作用的子组件组成。¹

另一方面,我知道这可能是不好的做法。将某物定义为自身列表的想法不是简单的,也不是物理上可以实现的。

考虑到我的用法,有什么正当的理由为什么我不应该在代码中使用它?


¹如果需要进一步解释,我会很高兴;我只是想使这个问题简明扼要。



1
@gnat与将类严格定义为自身列表有关,而不是包含包含列表的列表。我认为这是类似事物的变体,但并不完全相同。这个问题涉及罗伯特·哈维Robert Harvey)的回答
Addison Crump

16
从本身被参数化的内容继承并不罕见,如C ++中常用的CRTP习惯用法所示。对于容器,关键问题是证明为什么必须使用继承而不是组合。
Christophe

1
我喜欢这个主意。毕竟,树中的每个节点都是(子)树。通常,节点的树状结构从类型视图中隐藏(传统节点不是树,而是单个对象),而是在数据视图中表达(单个节点允许访问分支)。这是另一种方式。我们不会看到分支数据,它在类型中。具有讽刺意味的是,C ++中的实际对象布局可能非常相似(继承与包含数据的实现非常相似),这使我认为我们正在处理两种表达同一事物的方式。
彼得-恢复莫妮卡

1
也许我有点想念,但与实现相比,您真的需要扩展 吗?ArrayList<Node> List<Node>
马提亚斯

Answers:


107

坦白说,我认为这里不需要继承。这没有道理;Node ArrayListNode

如果这只是递归数据结构,则只需编写如下内容:

public class Node {
    public String item;
    public List<Node> children;
}

确实意义; 节点具有子节点或后代节点列表。


34
不一样 名单列表清单广告无限是毫无意义的,除非你存储的东西,除了列表中的一个新的列表。这Item 就是需要的,并且Item独立于列表运行。
罗伯特·哈维

6
哦,我明白了。我曾向Node ArrayListNodeNode 包含0到许多 Node对象。它们的功能是一样的,但本质上,而不是喜欢的对象列表,它具有像对象的列表。编写该类的原始设计是相信任何一个Node都可以表示为一组sub Node,这两种实现都可以做到这一点,但是这种方式肯定会减少混乱。+1
Addison Crump

11
我想说的是,当您用C或Lisp或其他任何形式编写单链接列表时,节点和列表之间的身份往往会“自然地”出现:在某些设计中,根节点是唯一的,因此头节点“就是”列表曾经用来表示列表的东西。因此,即使我同意在Java中感觉到EvilBadWrong,我也无法完全消除在某些情况下节点“是”(而不仅仅是“具有”)集合的感觉。
Steve Jessop

12
@ nl-x一个语法较短并不意味着它会更好。另外,顺便说一句,访问像这样的树中的项目非常罕见。通常,结构在编译时是未知的,因此您倾向于不使用常量值遍历此类树。深度也不是固定的,因此您甚至无法使用该语法遍历所有值。当您使代码适应于使用传统的树遍历问题时,您最终Children只编写了一次或两次,并且出于代码清晰的目的,通常最好在这种情况下写出来。
Servy '16


57

“可怕的记忆”论点是完全错误的,但这是客观上的 “坏习惯”。从类继承时,您不仅继承了您感兴趣的字段和方法,还继承了一切。它声明的每个方法,即使对您没有用。最重要的是,您还继承了该类提供的所有合同和担保。

首字母缩写词SOLID为良好的面向对象设计提供了一些启发。在这里,覆盖整个院落隔离原则(ISP)和大号 iskov换人Pricinple(LSP)有话要说。

ISP告诉我们保持接口尽可能小。但是,通过从继承ArrayList,您可以获得许多方法。它是有意义的get()remove()set()(替换),或add()(插入)子节点在一个特定的指数?ensureCapacity()对基础列表明智吗?对sort()节点意味着什么?您班上的用户真的应该获得subList()吗?由于您无法隐藏不需要的方法,因此唯一的解决方案是将ArrayList作为成员变量,并转发您实际想要的所有方法:

private final ArrayList<Node> children = new ArrayList();
public void add(Node child) { children.add(child); }
public Iterator<Node> iterator() { return children.iterator(); }

如果您真的想要您在文档中看到的所有方法,我们可以继续使用LSP。LSP告诉我们,必须在期望父类的任何地方使用子类。如果一个函数接受一个ArrayListas参数,而我们却传递了它Node,那么什么都不会改变。

子类的兼容性始于诸如类型签名之类的简单事物。覆盖方法时,不能使参数类型更严格,因为这可能会排除对父类合法的使用。但这就是编译器使用Java检查我们的内容。

但是LSP的运行更加深入:我们必须与所有父类和接口的文档所承诺的所有内容保持兼容性。在他们的答案中,Lynn发现了一种这样的情况,其中List接口(您已通过继承ArrayList)保证了equals()and hashCode()方法应该如何工作。因为hashCode()甚至为您提供了必须精确实现的特定算法。假设您已经写过Node

public class Node extends ArrayList<Node> {
  public final int value;

  public Node(int value, Node... children) {
    this.value = Value;
    for (Node child : children)
      add(child);
  }

  ...

}

这就要求value不能对做出贡献hashCode(),也不能影响equals()List您必须通过继承来兑现您所承诺要尊重的接口new Node(0).equals(new Node(123))


因为从类继承很容易意外地打破了父类做出的承诺,并且由于它通常公开比预期更多的方法,所以通常建议您首选组合而不是继承。如果必须继承某些内容,建议仅继承接口。如果要重用特定类的行为,则可以将其作为一个单独的对象保留在实例变量中,这样就不必强加它的所有诺言和要求。

有时,我们的自然语言暗示了一种继承关系:汽车就是车辆。摩托车是车辆。我应该定义类CarMotorcycleVehicle类继承吗?面向对象的设计并不是要完全在我们的代码中反映现实世界。我们无法轻松地在源代码中对现实世界的丰富分类法进行编码。

一个这样的例子就是员工-老板的建模问题。我们有多个Person,每个都有一个名称和地址。An Employee是一个,Person并且有个Boss。一个Boss也是Person。因此,我应该创建一个PersonBoss和继承的类Employee吗?现在我有一个问题:老板既是雇员又有另一个上司。因此似乎Boss应该扩展Employee。但是,CompanyOwnerBoss但不是Employee?任何种类的继承图都会在某种程度上分解。

OOP不是关于层次结构,继承和对现有类的重用,而是关于行为的概括。OOP的主题是“我有很多对象,希望它们做特定的事情-我不在乎如何做。”这就是接口的作用。如果因为要使其可迭代而Iterable为您实现该接口,Node那将很好。如果Collection由于要添加/删除子节点等来实现接口,那么就可以了。但是从另一个类继承,是因为它碰巧会给您带来所有不该拥有的东西,或者至少不会给您带来所有的一切,除非您如上所述进行了仔细的考虑。


10
我已经打印了您的最后4个段落,将它们命名为About OOP and Inheritance,并将它们固定在墙上。我的一些同事需要了解一下,据我所知,他们会喜欢您所说的:)谢谢。
YSC

17

通常将容器本身扩展是一种不好的做法。实际上,没有什么理由扩展容器而不是仅仅扩展一个容器。扩展自己的容器只会使其变得更加奇怪。


14

除了上述内容外,还有某种特定于Java的原因可以避免这种结构。

equals列表方法的协定要求列表被视为等于另一个对象

当且仅当指定对象也是一个列表时,两个列表的大小相同,并且两个列表中所有对应的元素对均相等

来源:https : //docs.oracle.com/javase/7/docs/api/java/util/List.html#equals(java.lang.Object)

具体来说,这意味着将一个类设计为自身的列表可能会使相等比较变得昂贵(如果列表可变,哈希比较也将变得昂贵),并且如果该类具有某些实例字段,那么在相等比较中必须忽略这些实例字段。。


1
除了错误的做法,这是从我的代码中删除此代码的极好理由。:P
Addison Crump

3

关于内存:

我会说这是一个完美主义的问题。的默认构造函数ArrayList如下所示:

public ArrayList(int initialCapacity) {
     super();

     if (initialCapacity < 0)
         throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

来源。在Oracle-JDK中也使用此构造函数。

现在考虑用您的代码构造一个单链接列表。您已经成功地使内存消耗膨胀了10倍(准确地说甚至更高)。对于树木,这也很容易变得很糟糕,而对树木的结构没有任何特殊要求。LinkedList或者使用a 或其他类可以解决此问题。

老实说,在大多数情况下,这只是完美主义,即使我们忽略了可用内存量。LinkedList作为替代方案,A 会稍微减慢代码的速度,因此这是在性能和​​内存消耗之间的一种折衷方案。我个人对此的看法仍然是,不要浪费太多内存,而这种内存可以像这种内存一样容易地绕开。

编辑:有关评论的澄清(准确地说是@amon)。答案的这一部分涉及继承问题。将内存使用情况与单个链接的List进行比较,并以最佳的内存使用情况进行比较(在实际的实现中,该因素可能会有所变化,但是它仍然足够大,可以总计相当多的内存浪费)。

关于“不良做法”:

绝对是 这不是实现图形的原因很简单的标准方式:图形节点子节点,而不是的子节点的名单。准确表达您在代码中的含义是一项主要技能。可以是变量名,也可以是这样的结构。下一步:使接口保持最少:您已经ArrayList通过继承使类用户可以使用所有方法。在不破坏代码整体结构的情况下,无法进行更改。而是将Listas 存储为内部变量,并通过适配器方法使所需的方法可用。这样,您可以轻松地从类中添加和删除功能,而不会弄乱所有内容。


1
10倍以上的比什么?如果我有一个默认构造的ArrayList作为实例字段(而不是从其继承),则实际上我在使用更多的内存,因为我需要在对象中存储一个额外的ArrayList指针。即使我们将苹果与橘子进行比较,并比较从ArrayList继承到不使用任何ArrayList,也请注意,在Java中,我们始终处理指向对象的指针,从不按值处理对象,就像C ++那样。假设a new Object[0]总共使用4个单词,a new Object[10]将仅使用14个单词的内存。
阿蒙

@amon我没有明确指出,对此表示抱歉。尽管上下文应该足以看到我正在与LinkedList进行比较。它称为引用,而不是指针btw。内存的意义不在于该类是否通过继承使用,而仅仅是(几乎)未使用的ArrayLists占用了大量内存的事实。
Paul

-2

这似乎看起来像复合模式的语义。它用于对递归数据结构进行建模。


13
我不会对此表示反对,但这就是为什么我真的讨厌GoF书。这是一棵流血的树!为什么我们需要发明术语“复合模式”来描述一棵树?
David Arno

3
@DavidArno我相信是整个多态节点方面将其定义为“复合模式”,树数据结构的使用只是其中的一部分。
JAB 2016年

2
@RobertHarvey,我不同意(对您的回答和此处的评论都如此)。public class Node extends ArrayList<Node> { public string Item; }是您答案的继承版本。您的推理起来更简单,但是两者都可以实现某些目标:它们定义了非二叉树。
David Arno

2
@DavidArno:我从未说过它不会起作用,我只是说这可能不理想。
罗伯特·哈维

1
@DavidArno不是这里的感觉和品味,而是事实。一棵树由节点组成,每个节点都有潜在的子节点。给定节点仅在给定时间是叶节点。在复合材料中,叶节点是通过构造构造而成的叶,其性质不会改变。
Christophe
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.