web-dev-qa-db-fra.com

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

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 CS, il a déclaré que la classe serait à la fois "horrible pour la mémoire" et "mauvaise pratique". Je n'ai pas trouvé le premier particulièrement vrai et le second subjectif.

Mon raisonnement pour cette utilisation est que j'avais une idée d'un objet qui devait être défini comme quelque chose qui pouvait avoir une profondeur arbitraire, où le comportement d'une instance pouvait être défini soit par une implémentation personnalisée soit par le comportement de plusieurs objets similaires interagissant . Cela permettrait l'abstraction d'objets dont l'implémentation physique serait composée de nombreux sous-composants interagissant.¹

D'un autre côté, je vois comment 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 réalisable.

Y a-t-il une raison valable pour laquelle je ne devrais pas l'utiliser dans mon code, compte tenu de mon utilisation?


¹ Si j'ai besoin d'expliquer cela davantage, je serais heureux de le faire; J'essaie simplement de garder cette question concise.

48
Addison Crump

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

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

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

Ce qui est logique; node ( a une liste d'enfants ou de nœuds descendants.

106
Robert Harvey

L'argument "horrible pour la mémoire" est tout à fait faux, mais c'est une objectivement "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 tout. Chaque méthode qu'il 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 quelques heuristiques pour une bonne conception orientée objet. Ici, le [~ # ~] i [~ # ~] Principe de ségrégation d'interface (FAI) et le [~ # ~] l [ ~ # ~] iskov Substitution Pricinple (LSP) a quelque chose à dire.

Le FAI nous dit de garder nos interfaces aussi petites que possible. Mais en héritant de ArrayList, vous obtenez de très nombreuses méthodes. Est-ce significatif pour 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? Que signifie pour sort() un nœud? Les utilisateurs de votre classe sont-ils vraiment censés obtenir une subList()? Comme vous ne pouvez pas masquer les méthodes dont vous ne voulez pas, la seule solution consiste à avoir ArrayList en tant que variable membre et à transmettre toutes les méthodes que vous souhaitez 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 ArrayList comme paramètre et que nous passons notre Node à la place, rien n'est censé changer.

La compatibilité des sous-classes commence par des choses simples comme les signatures de type. Lorsque vous remplacez une méthode, vous ne pouvez pas rendre les types de paramètres plus stricts car cela pourrait exclure des utilisations qui étaient légales avec la classe parente. 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 parentes. Dans leur réponse , Lynn a trouvé un tel cas où l'interface List (dont vous avez hérité via ArrayList) garantit comment la fonction equals() et Les méthodes hashCode() sont censées fonctionner. Pour hashCode(), on vous donne même un algorithme particulier qui doit être implémenté exactement. Supposons que vous ayez é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 value ne puisse pas contribuer à hashCode() et ne puisse pas influencer equals(). L'interface List - que vous promettez d'honorer en l'héritant - requiert que new Node(0).equals(new Node(123)) soit vraie.


Étant donné que l'héritage des classes rend trop facile de rompre accidentellement une promesse faite par une classe parent, et parce qu'il expose généralement plus de méthodes que vous ne le souhaitiez, il est généralement suggéré que vous préférez la composition à l'héritage . Si vous devez hériter de quelque chose, il est suggéré d'hériter uniquement des interfaces. Si vous souhaitez réutiliser le comportement d'une classe particulière, vous pouvez le conserver en tant qu'objet séparé dans une variable d'instance, de sorte que toutes ses promesses et exigences ne vous sont 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. Dois-je définir les classes Car et Motorcycle qui héritent d'une classe Vehicle? La conception orientée objet ne consiste pas à refléter le monde réel exactement dans notre code. Nous ne pouvons pas facilement encoder les riches taxonomies du monde réel dans notre code source.

Un tel exemple est le problème de modélisation employé-patron. Nous avons plusieurs Person, chacun avec un nom et une adresse. Un Employee est un Person et a un Boss. Un Boss est aussi un Person. Dois-je donc créer une classe Person héritée par Boss et Employee? Maintenant, j'ai un problème: le patron est aussi un employé et a un autre supérieur. Il semble donc que Boss devrait étendre Employee. Mais le CompanyOwner est un Boss mais n'est pas un Employee? Tout type de graphe d'héritage se décomposera en quelque sorte ici.

La POO ne concerne pas les hiérarchies, l'héritage et la réutilisation des classes existantes, il s'agit de comportement généralisant. OOP concerne "J'ai un tas d'objets et je veux qu'ils fassent une chose particulière - et je me fiche de savoir comment." C'est ce que interfaces sont pour. Si vous implémentez l'interface Iterable pour votre Node parce que vous voulez la rendre itérable, c'est très bien. Si vous implémentez l'interface Collection parce que vous souhaitez ajouter/supprimer des nœuds enfants, etc., ça va. Mais hériter d'une autre classe parce qu'elle vous donne tout ce qui ne l'est pas, ou du moins pas sauf si vous y avez réfléchi comme indiqué ci-dessus.

57
amon

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. Prolonger un contenant de vous-même le rend encore plus étrange.

17
DeadMG

En plus de ce qui a été dit, il existe une raison quelque peu spécifique à Java d'éviter ce type de structures.

Le contrat de la méthode equals pour les listes requiert 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)

Plus précisément, cela signifie qu'avoir une classe conçue comme une liste d'elle-même peut rendre les comparaisons d'égalité coûteuses (et les calculs de hachage également si les listes sont mutables), et si la classe a des champs d'instance, eh bien ceux-ci doit être ignoré dans la comparaison d'égalité.

14
Lynn

Concernant la mémoire:

Je dirais que c'est une question de perfectionnisme. Le constructeur par défaut de ArrayList ressemble à 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.

Envisagez maintenant de construire une liste à liaison unique avec votre code. Vous venez de gonfler avec succès votre consommation de mémoire par le facteur 10x (pour être précis, même légèrement plus élevé). Pour les arbres, cela peut facilement devenir très mauvais sans aucune exigence particulière sur la structure de l'arbre. L'utilisation d'une LinkedList ou d'une autre classe devrait résoudre ce problème.

Pour être honnête, dans la plupart des cas, ce n'est rien d'autre que du perfectionnisme, même si nous ignorons la quantité de mémoire disponible. Un LinkedList ralentirait un peu le code comme alternative, c'est donc un compromis entre les performances et la consommation de mémoire dans les deux cas. Pourtant, mon opinion personnelle à ce sujet serait de ne pas gaspiller autant de mémoire d'une manière qui peut ê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 à liaison unique et avec la meilleure utilisation de la mémoire (dans les implémentations réelles, le facteur peut changer un peu, mais il est toujours suffisamment grand pour résumer un peu de mémoire gaspillée).

Concernant les "mauvaises pratiques":

Absolument. Ce n'est pas la manière standard d'implémenter un graphe pour une raison simple: un graphe-nœud a nœuds enfants, pas c'est comme 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 variables ou dans des structures exprimantes comme celle-ci. Point suivant: garder les interfaces minimales: vous avez mis toutes les méthodes de ArrayList à la disposition de l'utilisateur de la classe via l'héritage. Il n'y a aucun moyen de modifier cela sans casser la structure entière du code. Stockez plutôt le List 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.

3
Paul