Quale sarebbe lo svantaggio di definire una classe come sottoclasse di un elenco di se stessa?


48

In un mio recente progetto, ho definito una classe con la seguente intestazione:

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

Tuttavia, dopo aver discusso con il mio professore di CS, ha dichiarato che la classe sarebbe stata "orribile per la memoria" e "cattiva pratica". Non ho trovato il primo particolarmente vero e il secondo soggettivo.

Il mio ragionamento per questo utilizzo è che avevo un'idea per un oggetto che doveva essere definito come qualcosa che potesse avere una profondità arbitraria, in cui il comportamento di un'istanza poteva essere definito da un'implementazione personalizzata o dal comportamento di diversi oggetti simili che interagivano . Ciò consentirebbe l'astrazione di oggetti la cui implementazione fisica sarebbe composta da molti sottocomponenti che interagiscono.¹

D'altra parte, vedo come questa potrebbe essere una cattiva pratica. L'idea di definire qualcosa come un elenco di se stessa non è semplice o fisicamente implementabile.

C'è qualche motivo valido per cui non dovrei usarlo nel mio codice, considerando il mio uso per esso?


¹ Se dovessi spiegarlo ulteriormente, sarei felice di farlo; Sto solo cercando di mantenere concisa questa domanda.


1
Possibile duplicato di motivi
moscerino

1
@gnat Questo ha più a che fare con la definizione rigorosa di una classe come un elenco di se stessa, piuttosto che contenere elenchi che contengono elenchi. Penso che sia una variante di una cosa simile, ma non del tutto uguale. Questa domanda si riferisce a qualcosa sulla falsariga della risposta di Robert Harvey .
Addison Crump,

16
Ereditare da qualcosa che è parametrizzato da solo non è insolito, come dimostra il linguaggio CRTP comunemente usato in C ++. Nel caso del contenitore, la domanda chiave è tuttavia giustificare il motivo per cui è necessario utilizzare l'ereditarietà anziché la composizione.
Christophe,

1
Mi piace l'idea. Dopotutto, ogni nodo in un albero è un (sotto) albero. Di solito la struttura ad albero di un nodo è nascosta da una vista di tipo (il nodo classico non è un albero ma un singolo oggetto) e invece espresso nella vista di dati (il singolo nodo consente l'accesso ai rami). Qui è il contrario; non vedremo i dati del ramo, è nel tipo. Ironia della sorte, il layout reale dell'oggetto in C ++ sarebbe probabilmente molto simile (l'ereditarietà è implementata in modo molto simile al contenimento dei dati), il che mi fa pensare che abbiamo a che fare con due modi di esprimere la stessa cosa.
Peter - Ripristina Monica il

1
Forse mi manca un po ', ma hai davvero bisogno di estenderlo ArrayList<Node> , rispetto all'attrezzo List<Node> ?
Matthias,

Risposte:


107

Francamente, non vedo la necessità di eredità qui. Non ha senso; Node è un ArrayList di Node?

Se questa è solo una struttura di dati ricorsiva, dovresti semplicemente scrivere qualcosa del tipo:

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

Il che ha senso; Il nodo ha un elenco di nodi figlio o discendente.


34
Non è la stessa cosa Un elenco di un elenco di un elenco all'infinito non ha senso se non si memorizza qualcosa oltre a un nuovo elenco nell'elenco. Questo è ciò che Item serve e Itemfunziona indipendentemente dalle liste.
Robert Harvey,

6
Oh, vedo ora. Mi ero avvicinato Node è un ArrayList of Nodeas Node contiene 0 a molti Node oggetti. La loro funzione è la stessa, essenzialmente, ma invece di essere un elenco di oggetti simili, ha un elenco di oggetti simili. Il progetto originale per la classe scritta era la convinzione che qualcuno Nodepotesse essere espresso come un insieme di sottotitoli Node, cosa che entrambe le implementazioni fanno, ma questa è sicuramente meno confusa. +1
Addison Crump

11
Direi che l'identità tra un nodo e un elenco tende a sorgere "naturalmente" quando scrivi elenchi collegati singolarmente in C o Lisp o altro: in alcuni disegni il nodo principale "è" l'elenco, nel senso di essere l'unico cosa mai usata per rappresentare un elenco. Quindi non riesco a dissipare completamente la sensazione che in alcuni contesti forse un nodo "sia" (non solo "ha") una raccolta di nodi, anche se concordo sul fatto che si sente EvilBadWrong in Java.
Steve Jessop,

12
@ nl-x Il fatto che l'unica sintassi sia più breve non significa che sia meglio. Inoltre, per inciso, è abbastanza raro accedere a elementi in un albero del genere. Di solito la struttura non è nota al momento della compilazione, quindi si tende a non attraversare tali alberi con valori costanti. Inoltre, la profondità non è fissa, quindi non puoi nemmeno iterare su tutti i valori usando quella sintassi. Quando si adatta il codice per utilizzare un tradizionale problema di attraversamento dell'albero, si finisce per scrivere Childrensolo una o due volte, ed è generalmente preferibile scriverlo in questi casi per motivi di chiarezza del codice.
Servito il


57

L'argomento "orribile per la memoria" è completamente sbagliato, ma è oggettivamente una "cattiva pratica". Quando erediti da una classe, non erediti solo i campi e i metodi che ti interessano. Invece, erediti tutto . Ogni metodo che dichiara, anche se non è utile per te. E, soprattutto, erediti anche tutti i suoi contratti e le garanzie che la classe fornisce.

L'acronimo SOLID fornisce alcune euristiche per un buon design orientato agli oggetti. Qui, I nterface Segregation Principle (ISP) e L iskov Substitution Pricinple (LSP) hanno qualcosa da dire.

L'ISP ci dice di mantenere le nostre interfacce il più piccolo possibile. Ma ereditando da ArrayList, ottieni molti, molti metodi. E 'significativo get(), remove(), set()(sostituzione), o add()(inserto) un nodo figlio ad un particolare indice? È sensato ensureCapacity()dall'elenco sottostante? Cosa significa sort()un nodo? Gli utenti della tua classe dovrebbero davvero ottenere un subList()? Dato che non puoi nascondere i metodi che non desideri, l'unica soluzione è avere ArrayList come variabile membro e inoltrare tutti i metodi che desideri effettivamente:

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

Se vuoi davvero tutti i metodi che vedi nella documentazione, possiamo passare a LSP. L'LSP ci dice che dobbiamo essere in grado di usare la sottoclasse ovunque ci si aspetti la classe genitore. Se una funzione accetta un ArrayListparametro as e passiamo Nodeinvece il nostro , nulla dovrebbe cambiare.

La compatibilità delle sottoclassi inizia con cose semplici come le firme dei tipi. Quando si esegue l'override di un metodo, non è possibile rendere più rigorosi i tipi di parametro poiché ciò potrebbe escludere gli usi legali con la classe genitore. Ma questo è qualcosa che il compilatore controlla per noi in Java.

Ma l'SPL funziona molto più a fondo: dobbiamo mantenere la compatibilità con tutto ciò che è promesso dalla documentazione di tutte le classi e interfacce parent. Nella loro risposta , Lynn ha trovato uno di questi casi in cui l' Listinterfaccia (che hai ereditato tramite ArrayList) garantisce il funzionamento dei metodi equals()e hashCode(). Perché hashCode()ti viene persino dato un algoritmo particolare che deve essere implementato esattamente. Supponiamo che tu abbia scritto questo 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);
  }

  ...

}

Ciò richiede che il valuenon possa contribuire al hashCode()e non possa influenzare equals(). L' Listinterfaccia - che prometti di onorare ereditando da essa - richiede new Node(0).equals(new Node(123))di essere vera.


Poiché ereditare dalle classi rende troppo facile infrangere accidentalmente una promessa fatta da una classe genitore, e poiché di solito espone più metodi di quelli previsti, in genere si suggerisce di preferire la composizione rispetto all'eredità . Se è necessario ereditare qualcosa, si consiglia di ereditare solo le interfacce. Se si desidera riutilizzare il comportamento di una determinata classe, è possibile mantenerlo come oggetto separato in una variabile di istanza, in questo modo tutte le sue promesse e requisiti non sono obbligati.

A volte, il nostro linguaggio naturale suggerisce una relazione ereditaria: un'auto è un veicolo. Una moto è un veicolo. Devo definire le classi Care Motorcyclequelle ereditate da una Vehicleclasse? Il design orientato agli oggetti non riguarda il mirroring del mondo reale esattamente nel nostro codice. Non possiamo facilmente codificare le ricche tassonomie del mondo reale nel nostro codice sorgente.

Uno di questi esempi è il problema di modellazione dei dipendenti-boss. Abbiamo più Persons, ognuna con un nome e un indirizzo. An Employeeè a Persone ha a Boss. A Bossè anche a Person. Quindi dovrei creare una Personclasse ereditata da Bosse Employee? Ora ho un problema: il capo è anche un dipendente e ha un altro superiore. Quindi sembra che Bossdovrebbe estendersi Employee. Ma il CompanyOwnerè un Bossma non è un Employee? Qualsiasi tipo di grafico dell'eredità si romperà in qualche modo qui.

OOP non riguarda gerarchie, ereditarietà e riutilizzo di classi esistenti, si tratta di generalizzare il comportamento . OOP riguarda "Ho un sacco di oggetti e voglio che facciano una cosa particolare - e non mi interessa come". Ecco a cosa servono le interfacce . Se implementi l' Iterableinterfaccia per il tuo Nodeperché vuoi renderla iterabile, va benissimo. Se si implementa l' Collectioninterfaccia perché si desidera aggiungere / rimuovere nodi figlio ecc., Va bene. Ma ereditare da un'altra classe perché capita di darti tutto ciò che non lo è, o almeno non a meno che tu non abbia riflettuto attentamente come indicato sopra.


10
Ho stampato i tuoi ultimi 4 paragrafi, li ho intitolati About OOP and Inheritance e li ho appesi al muro al lavoro. Alcuni dei miei colleghi devono vedere che, come so, apprezzerebbero il modo in cui lo metti :) Grazie.
YSC,

17

L'estensione di un contenitore da solo è generalmente considerata una cattiva pratica. Ci sono davvero pochissime ragioni per estendere un container invece di averne solo uno. L'estensione di un contenitore di te stesso lo rende ancora più strano.


14

In aggiunta a quanto è stato detto, esiste un motivo in qualche modo specifico per Java per evitare questo tipo di strutture.

Il contratto del equalsmetodo per gli elenchi richiede che un elenco sia considerato uguale a un altro oggetto

se e solo se l'oggetto specificato è anche un elenco, entrambi gli elenchi hanno le stesse dimensioni e tutte le coppie corrispondenti di elementi nei due elenchi sono uguali .

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

In particolare, ciò significa che avere una classe progettata come un elenco di se stesso può rendere costosi i confronti di uguaglianza (e anche i calcoli dell'hash se gli elenchi sono mutabili), e se la classe ha alcuni campi di istanza, questi devono essere ignorati nel confronto di uguaglianza .


1
Questo è un ottimo motivo per rimuovere questo dal mio codice a parte la cattiva pratica. : P
Addison Crump,

3

Per quanto riguarda la memoria:

Direi che è una questione di perfezionismo. Il costruttore predefinito di ArrayListassomiglia a questo:

public ArrayList(int initialCapacity) {
     super();

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

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Fonte . Questo costruttore viene utilizzato anche in Oracle-JDK.

Ora considera la costruzione di un elenco collegato singolarmente con il tuo codice. Hai appena gonfiato con successo il consumo di memoria del fattore 10x (per essere precisi anche leggermente più alto). Per gli alberi questo può facilmente peggiorare anche senza requisiti speciali sulla struttura dell'albero. L'uso di una LinkedListo un'altra classe in alternativa dovrebbe risolvere questo problema.

Ad essere onesti, nella maggior parte dei casi questo non è altro che mero perfezionismo, anche se ignoriamo la quantità di memoria disponibile. A LinkedListpotrebbe rallentare un po 'il codice in alternativa, quindi è un compromesso tra prestazioni e consumo di memoria in entrambi i modi. Tuttavia la mia opinione personale su questo sarebbe quella di non sprecare così tanta memoria in modo da poter essere facilmente aggirata come questa.

EDIT: chiarimenti in merito ai commenti (@amon per la precisione). Questa sezione della risposta non tratta la questione dell'eredità. Il confronto dell'utilizzo della memoria viene effettuato rispetto a un elenco collegato singolarmente e con il miglior utilizzo della memoria (nelle implementazioni effettive il fattore potrebbe cambiare un po ', ma è ancora sufficientemente grande da riassumere un bel po' di memoria sprecata).

Per quanto riguarda "Cattive pratiche":

Decisamente. Questo non è il modo standard di implementare un grafico per un semplice motivo: un nodo grafico ha nodi figlio, non è come un elenco di nodi figlio. Esprimere con precisione ciò che intendi nel codice è una grande abilità. Sia che si tratti di nomi di variabili o che esprima strutture come questa. Punto successivo: mantenere le interfacce minime: hai reso tutti i metodi ArrayListdisponibili per l'utente della classe tramite ereditarietà. Non c'è modo di alterarlo senza rompere l'intera struttura del codice. Conservare invece la Listvariabile interna e rendere disponibili i metodi richiesti tramite un metodo adattatore. In questo modo puoi facilmente aggiungere e rimuovere funzionalità dalla classe senza rovinare tutto.


1
10 volte più alto rispetto a cosa ? Se ho un ArrayList predefinito come campo di istanza (invece di ereditare da esso), sto effettivamente usando un po 'più di memoria poiché ho bisogno di memorizzare un puntatore ad ArrayList aggiuntivo nel mio oggetto. Anche se stiamo confrontando le mele con le arance e confrontiamo l'eredità da ArrayList con il non utilizzo di ArrayList, si noti che in Java abbiamo sempre a che fare con puntatori ad oggetti, mai oggetti per valore come farebbe C ++. Supponendo che new Object[0]usi un totale di 4 parole, new Object[10]userebbe solo 14 parole di memoria.
Amon,

@amon Non l'ho dichiarato esplicitamente, per questo è un peccato. Anche se il contesto dovrebbe essere sufficiente per vedere che sto confrontando con un LinkedList. E si chiama riferimento, non puntatore btw. E il punto con la memoria non era se la classe fosse usata tramite ereditarietà o meno, ma semplicemente il fatto che (quasi) i messaggi inutilizzati ArrayListrinunciano a un bel po 'di memoria.
Paul

-2

Questo sembra assomigliare alla semantica del modello composito . Viene utilizzato per modellare strutture di dati ricorsive.


13
Non voglio sottovalutare questo, ma è per questo che odio davvero il libro GoF. È un albero sanguinante! Perché abbiamo dovuto inventare il termine "modello composito" per descrivere un albero?
David Arno,

3
@DavidArno Credo che sia l'intero aspetto del nodo polimorfico che lo definisce come "modello composito", l'uso di una struttura di dati ad albero ne fa solo parte.
JAB

2
@RobertHarvey, non sono d'accordo (con entrambi i tuoi commenti alla tua risposta e qui). public class Node extends ArrayList<Node> { public string Item; }è la versione ereditaria della tua risposta. Il tuo è più semplice da ragionare, ma entrambi ottengono qualcosa: definiscono alberi non binari.
David Arno,

2
@DavidArno: non ho mai detto che non avrebbe funzionato, ho solo detto che probabilmente non è l'ideale.
Robert Harvey,

1
@DavidArno non si tratta di sentimenti e gusti qui, ma di fatti. Un albero è fatto di nodi e ogni nodo ha figli potenziali. Un dato nodo è un nodo foglia solo in un dato momento nel tempo. In un composito, un nodo foglia è foglia per costruzione e la sua natura non cambierà.
Christophe,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.