Quel serait l'inconvénient de définir une classe en tant que sous-classe d'une liste d'elle-même?

48

Dans un de mes projets récents, j'ai défini une classe avec l'en-tête suivant:

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

Cependant, après avoir discuté avec mon professeur d’informatique, il a déclaré que le cours serait à la fois "horrible pour la mémoire" et "une mauvaise pratique". Je n'ai pas trouvé le premier particulièrement véridique et le second subjectif.

Mon raisonnement pour cet usage est que j'ai eu l'idée d'un objet qui devait être défini comme quelque chose pouvant avoir une profondeur arbitraire, où le comportement d'une instance pourrait être défini soit par une implémentation personnalisée, soit par le comportement de plusieurs objets similaires en interaction. . Cela permettrait d'abstraction d'objets dont l'implémentation physique serait composée de nombreuses sous-composantes en interaction¹.

D'autre part, je vois à quel point cela pourrait être une mauvaise pratique. L’idée de définir quelque chose comme une liste d’elle-même n’est ni simple ni physiquement applicable.

Existe-t-il une raison valable pour ne pas utiliser cela dans mon code, compte tenu de mon utilisation?


¹ Si j’ai besoin d’expliquer plus avant, j’en serais ravi; J'essaie simplement de garder cette question concise.

Addison Crump
la source
1
Duplication possible de modèles
gnat
1
@gnat Cela a plus à voir avec la définition stricte d'une classe comme une liste d'elle-même, plutôt que de contenir des listes contenant des listes. Je pense que c'est une variante d'une chose similaire, mais pas tout à fait la même chose. Cette question renvoie à quelque chose du genre de la réponse de Robert Harvey .
Addison Crump
16
Hériter de quelque chose qui est paramétré par lui-même n'est pas inhabituel, comme le montre l' idiome CRTP couramment utilisé en C ++. Dans le cas du conteneur, la question clé est toutefois de justifier pourquoi vous devez utiliser l'héritage au lieu de la composition.
Christophe
1
J'aime l'idée. Après tout, chaque nœud d’un arbre est un (sous) arbre. Généralement, l’arborescence d’un nœud est dissimulée dans une vue de type (le nœud classique n’est pas un arbre mais un seul objet) et est plutôt exprimée dans la vue des données (le nœud unique autorise l’accès aux branches). Ici, c'est l'inverse. nous ne verrons pas les données de branche, c'est dans le type. Ironiquement, la disposition réelle des objets en C ++ serait probablement très similaire (l'héritage est implémenté de la même manière que contenir des données), ce qui me fait penser que nous avons affaire à deux manières d'exprimer la même chose.
Peter - Réintégrez Monica
1
Peut-être me manque un peu, mais avez-vous vraiment besoin d' étendre ArrayList<Node> , par rapport à mettre en œuvreList<Node> ?
Matthias

Réponses:

107

Franchement, je ne vois pas la nécessité d'un héritage ici. Ça n'a pas de sens; Node est un ArrayList de Node?

S'il ne s'agit que d'une structure de données récursive, vous écrirez simplement quelque chose comme:

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

Ce qui a du sens noeud a une liste d'enfants ou de noeuds descendants.

Robert Harvey
la source
34
Ce n'est pas la même chose. Une liste d'une liste ad-infinitum n'a de sens que si vous enregistrez quelque chose en plus d'une nouvelle liste dans la liste. C’est pour cela Item et Itemfonctionne indépendamment des listes.
Robert Harvey
6
Oh, je vois maintenant. Je l' avais approché Node est un ArrayList des Nodecomme Node contient 0 à de nombreux Node objets. Leur fonction est essentiellement la même, mais au lieu d’ être une liste d’objets similaires, elle contient une liste d’objets similaires. La conception initiale de la classe écrite était la conviction que tout Nodepeut être exprimé sous la forme d'un ensemble de sous- Nodetitres, ce que les deux implémentations font, mais celui-ci est certainement moins déroutant. +1
Addison Crump
11
Je dirais que l'identité entre un nœud et une liste a tendance à survenir "naturellement" lorsque vous écrivez des listes à liaison unique en C ou Lisp ou autre: dans certains modèles, le nœud principal "est" la liste, dans le sens où il est le seul. chose jamais utilisée pour représenter une liste. Je ne peux donc pas dissiper complètement le sentiment que, dans certains contextes, un nœud est peut-être "(et pas seulement" a ") une collection de nœuds, même si je suis d'accord pour dire que EvilBadWrong a le même sens en Java.
Steve Jessop
12
@ nl-x Le fait qu'une syntaxe soit plus courte ne signifie pas qu'elle est meilleure. De plus, accessoirement, il est assez rare d’accéder aux éléments d’un arbre comme celui-ci. Généralement, la structure n'est pas connue au moment de la compilation, vous n'avez donc pas tendance à parcourir de tels arbres avec des valeurs constantes. La profondeur n'est pas non plus fixe, vous ne pouvez donc même pas parcourir toutes les valeurs utilisant cette syntaxe. Lorsque vous adaptez le code pour utiliser un problème classique de traversée d'arbre, vous finissez par écrire Childrenune ou deux fois, et il est généralement préférable de l'écrire dans de tels cas pour des raisons de clarté du code.
Servy
8
+1 pour favoriser la composition par rapport à l'héritage .
Menelaos Kotsollaris
57

L'argument «horrible pour la mémoire» est totalement faux, mais c'est objectivement une «mauvaise pratique». Lorsque vous héritez d'une classe, vous n'héritez pas seulement des champs et des méthodes qui vous intéressent. Au lieu de cela, vous héritez de tout . Chaque méthode qu’elle déclare, même si elle ne vous est pas utile. Et surtout, vous héritez également de tous ses contrats et garanties que la classe fournit.

L'acronyme SOLID fournit des heuristiques pour une bonne conception orientée objet. Ici, le I nterface Principe Ségrégation (ISP de) et le L Iskov Remplacement Pricinple (LSP) ont quelque chose à dire.

Le FAI nous dit de garder nos interfaces aussi petites que possible. Mais en héritant de ArrayList, vous obtenez beaucoup, beaucoup de méthodes. Est - il un sens get(), remove(), set()(remplacer), ou add()(insérer) un nœud enfant à un index particulier? Est-il judicieux de ensureCapacity()de la liste sous-jacente? Qu'est-ce que cela signifie pour sort()un nœud? Les utilisateurs de votre classe sont-ils vraiment supposés en avoir subList()? Étant donné que vous ne pouvez pas masquer les méthodes que vous ne voulez pas, la seule solution est d'avoir ArrayList en tant que variable membre et de transférer toutes les méthodes que vous voulez réellement:

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

Si vous voulez vraiment toutes les méthodes que vous voyez dans la documentation, nous pouvons passer au LSP. Le LSP nous dit que nous devons pouvoir utiliser la sous-classe partout où la classe parente est attendue. Si une fonction prend un ArrayListcomme paramètre et que nous passons le nôtre Node, rien n’est supposé changer.

La compatibilité des sous-classes commence par des éléments simples tels que les signatures de type. Lorsque vous substituez une méthode, vous ne pouvez pas rendre les types de paramètre plus stricts, car cela pourrait exclure les utilisations légales de la classe parent. Mais c’est quelque chose que le compilateur vérifie pour nous en Java.

Mais le LSP est beaucoup plus profond: nous devons maintenir la compatibilité avec tout ce qui est promis par la documentation de toutes les classes et interfaces parent. Dans leur réponse , Lynn a trouvé un cas de ce type où l' Listinterface (dont vous avez hérité via ArrayList) garantit le fonctionnement des méthodes equals()et hashCode(). Car hashCode()on vous donne même un algorithme particulier qui doit être implémenté exactement. Supposons que vous avez écrit ceci 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);
  }

  ...

}

Cela nécessite que le valuene puisse ni contribuer hashCode()ni influencer equals(). L' Listinterface - que vous promettez d'honorer en en héritant - nécessite new Node(0).equals(new Node(123))d'être vraie.


En raison de l'héritage de classes, il est trop facile de rompre accidentellement une promesse faite par une classe parente, et parce qu'elle expose généralement plus de méthodes que prévu, il est généralement suggéré de préférer la composition à l'héritage . Si vous devez hériter de quelque chose, il est suggéré de n'hériter que des interfaces. Si vous souhaitez réutiliser le comportement d'une classe particulière, vous pouvez la conserver en tant qu'objet séparé dans une variable d'instance, afin que toutes ses promesses et exigences ne vous soient pas imposées.

Parfois, notre langage naturel suggère une relation d'héritage: une voiture est un véhicule. Une moto est un véhicule. Devrais-je définir des classes Caret Motorcyclequi héritent d'une Vehicleclasse? La conception orientée objet ne consiste pas à refléter exactement le monde réel dans notre code. Nous ne pouvons pas facilement encoder les riches taxonomies du monde réel dans notre code source.

Un exemple de ce type est le problème de modélisation employé-patron. Nous avons plusieurs Persons, chacun avec un nom et une adresse. Un Employeeest un Personet a un Boss. A Bossest aussi un Person. Donc, devrais-je créer une Personclasse qui est héritée par Bosset Employee? Maintenant, j'ai un problème: le patron est aussi un employé et a un autre supérieur. Donc, il semble que Bossdevrait s'étendre Employee. Mais CompanyOwnerc'est un Bossmais n'est pas un Employee? Tout type de graphique d'héritage se décompose d'une manière ou d'une autre ici.

La POO ne concerne pas les hiérarchies, l'héritage et la réutilisation de classes existantes, il s'agit de généraliser le comportement . OOP signifie «J'ai plusieurs objets et je veux qu'ils fassent quelque chose - et je me fiche de savoir comment.» C'est à cela que servent les interfaces . Si vous implémentez l' Iterableinterface pour vous Nodeparce que vous voulez la rendre itérable, c'est très bien. Si vous implémentez l' Collectioninterface parce que vous souhaitez ajouter / supprimer des nœuds enfants, etc., c'est très bien. Mais hériter d'une autre classe parce qu'il arrive à vous donner tout ce qui ne l'est pas, ou du moins pas à moins d'y avoir bien réfléchi.

Amon
la source
10
J'ai imprimé vos 4 derniers paragraphes, intitulés À propos de la programmation orientée objet et de l'héritage, et les ai épinglés au mur au travail. Certains de mes collègues ont besoin de le savoir car je sais qu'ils apprécieraient votre façon de le dire :) Merci.
YSC
17

L'extension d'un conteneur en soi est généralement considérée comme une mauvaise pratique. Il y a vraiment très peu de raisons d'étendre un conteneur au lieu d'en avoir un. L'extension d'un conteneur de vous-même le rend encore plus étrange.

DeadMG
la source
14

En plus de ce qui a été dit, il existe une raison un peu spécifique à Java pour éviter ce type de structure.

Le contrat de la equalsméthode pour les listes nécessite qu'une liste soit considérée comme égale à un autre objet

si et seulement si l'objet spécifié est également une liste, les deux listes ont la même taille et toutes les paires d'éléments correspondantes dans les deux listes sont égales .

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

Cela signifie notamment que le fait de concevoir une classe comme une liste en soi peut rendre les comparaisons d'égalité coûteuses (ainsi que les calculs de hachage si les listes sont modifiables), et si la classe contient des champs d'instance, ceux-ci doivent être ignorés dans la comparaison d'égalité. .

Lynn
la source
1
C’est une excellente raison de supprimer cela de mon code, mis à part les mauvaises pratiques. : P
Addison Crump
3

En ce qui concerne la mémoire:

Je dirais que c'est une question de perfectionnisme. Le constructeur par défaut de ArrayListressemble à ceci:

public ArrayList(int initialCapacity) {
     super();

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

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Source . Ce constructeur est également utilisé dans Oracle-JDK.

Maintenant, envisagez de construire une liste à lien unique avec votre code. Vous venez de gonfler votre consommation de mémoire du facteur 10x (pour être précis, même légèrement supérieur). Pour les arbres, cela peut facilement devenir assez mauvais sans aucune exigence particulière sur la structure de l'arbre. Utiliser LinkedListalternativement une classe ou une autre devrait résoudre ce problème.

Pour être honnête, dans la plupart des cas, il ne s'agit que de perfectionnisme, même si nous ignorons la quantité de mémoire disponible. LinkedListComme alternative, A ralentirait un peu le code, ce serait donc un compromis entre les performances et la consommation de mémoire. Pourtant, mon opinion personnelle à ce sujet serait de ne pas gaspiller autant de mémoire d’une manière qui puisse être aussi facilement contournée que celle-ci.

EDIT: clarification concernant les commentaires (@amon pour être précis). Cette section de la réponse ne traite pas de la question de l'héritage. La comparaison de l'utilisation de la mémoire est effectuée par rapport à une liste liée de manière simple et avec une utilisation optimale de la mémoire (dans les implémentations réelles, le facteur peut changer un peu, mais il reste suffisamment important pour résumer un peu de mémoire gaspillée).

En ce qui concerne les "mauvaises pratiques":

Absolument. Ce n’est pas la manière standard d’implémenter un graphique pour une raison simple: un graphe-nœud a des nœuds enfants, pas une liste de nœuds enfants. Exprimer précisément ce que vous voulez dire dans le code est une compétence majeure. Que ce soit dans des noms de variable ou des structures d'expression telles que celle-ci. Point suivant: minimiser les interfaces: vous avez mis toutes les méthodes de à la ArrayListdisposition de l'utilisateur de la classe via l'héritage. Il n'y a aucun moyen de modifier cela sans casser toute la structure du code. Stockez plutôt la Listvariable en tant que variable interne et rendez les méthodes requises disponibles via une méthode adaptateur. De cette façon, vous pouvez facilement ajouter et supprimer des fonctionnalités de la classe sans tout gâcher.

Paul
la source
1
10x plus élevé par rapport à quoi ? Si j'ai une ArrayList construite par défaut comme champ d'instance (au lieu d'en hériter), j'utilise en fait un peu plus de mémoire, car je dois stocker un pointeur supplémentaire sur ArrayList dans mon objet. Même si nous comparons des pommes avec des oranges et comparons l'héritage d'ArrayList à ne pas utiliser d'ArrayList, notez qu'en Java, nous traitons toujours les pointeurs vers les objets, jamais les objets par valeur comme le ferait C ++. En supposant qu’un new Object[0]utilise 4 mots au total, un new Object[10]n’utiliserait que 14 mots de mémoire.
amon
@amon je ne l'ai pas dit explicitement, sry pour ça. Bien que le contexte devrait être suffisant pour voir que je compare à une LinkedList. Et cela s'appelle référence, pas pointeur d'ailleurs. Et le problème de la mémoire ne portait pas sur le fait que la classe soit utilisée ou non par héritage, mais simplement sur le fait que des s (presque) inutilisés ArrayListperdent un peu de mémoire.
Paul
-2

Cela semble ressembler à la sémantique du motif composite . Il est utilisé pour modéliser des infrastructures de données récursives.

oopexpert
la source
13
Je ne baisserai pas les yeux, mais c’est la raison pour laquelle je déteste vraiment le livre de GoF. C'est un arbre saignant! Pourquoi avons-nous besoin d'inventer le terme "motif composite" pour décrire un arbre?
David Arno
3
@DavidArno Je crois que c'est tout l'aspect du nœud polymorphe qui le définit comme un "motif composite", l'utilisation d'une structure de données arborescente n'en est qu'une partie.
JAB
2
@ RobertHarvey, je ne suis pas d'accord (avec vos deux commentaires à votre réponse et ici). public class Node extends ArrayList<Node> { public string Item; }est la version d'héritage de votre réponse. Il est plus simple de raisonner, mais vous réussissez tous les deux: ils définissent des arbres non binaires.
David Arno
2
@ DavidArno: Je n'ai jamais dit que cela ne fonctionnerait pas, je viens de dire que ce n'est probablement pas idéal.
Robert Harvey
1
@DavidArno, il ne s'agit pas de sentiments ni de goûts, mais de faits. Un arbre est composé de nœuds et chaque nœud a des enfants potentiels. Un nœud donné est un nœud feuille uniquement à un moment donné. Dans un composite, un nœud feuille est feuille par construction et sa nature ne changera pas.
Christophe