Quale sarebbe lo svantaggio di definire una classe come sottoclasse di una lista 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 affermato che la classe sarebbe stata "orribile per memoria" e "cattiva pratica". Non ho trovato il primo ad essere particolarmente vero, e il secondo ad essere soggettivo.

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

D'altra parte, vedo come questa potrebbe essere una cattiva pratica. L'idea di definire qualcosa come una lista 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 questo?

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

    
posta Addison Crump 02.11.2016 - 21:27
fonte

6 risposte

108

Francamente, non vedo il bisogno di ereditare qui. Non ha senso; Node è un ArrayList di Node ?

Se questa è solo una struttura dati ricorsiva, dovresti semplicemente scrivere qualcosa come:

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

Quale ha ha senso; nodo ha un elenco di nodi figli o discendenti.

    
risposta data 02.11.2016 - 21:39
fonte
57

L'argomento "orribile per la memoria" è completamente sbagliato, ma è una cattiva pratica obiettivamente ". Quando si eredita da una classe, non si ereditano solo i campi e i metodi a cui si è interessati. Invece, si eredita tutto . Ogni metodo che dichiara, anche se non è utile per te. E, soprattutto, ereditate anche tutti i suoi contratti e garantisce che la classe fornisce.

L'acronimo SOLID fornisce alcune euristiche per una buona progettazione orientata agli oggetti. Qui, il I principio di segregazione di nterface (ISP) e L iskov Sostituzione Pricampo (LSP) hanno qualcosa da dire.

L'ISP ci dice di mantenere le nostre interfacce il più piccole possibile. Ma ereditando da ArrayList , ottieni molti, molti metodi. È significativo per get() , remove() , set() (sostituisci) o add() (inserire) un nodo figlio in un particolare indice? È sensato a ensureCapacity() dell'elenco sottostante? Che cosa significa sort() un nodo? Gli utenti della tua classe dovrebbero davvero ottenere un subList() ? Dal momento che non puoi nascondere metodi che non vuoi, l'unica soluzione è avere ArrayList come variabile membro e inoltrare tutti i metodi che vuoi realmente:

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 all'LSP. L'LSP ci dice che dobbiamo essere in grado di usare la sottoclasse ovunque sia prevista la classe genitore. Se una funzione prende un ArrayList come parametro e invece passiamo il Node , non si suppone che nulla cambi.

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

Ma l'LSP funziona molto più a fondo: dobbiamo mantenere la compatibilità con tutto ciò che è promesso dalla documentazione di tutte le classi e interfacce genitore. In la loro risposta , Lynn ha trovato uno di questi casi in cui l'interfaccia List (che hai ereditato tramite ArrayList ) garantisce come dovrebbero funzionare i metodi equals() e hashCode() . Per hashCode() ti viene anche assegnato un particolare algoritmo 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 value non possa contribuire a hashCode() e non può influenzare equals() . L'interfaccia List , che prometti di onorare ereditando da essa, richiede che new Node(0).equals(new Node(123)) sia true.

Poiché ereditare dalle classi rende troppo facile rompere accidentalmente una promessa fatta da una classe genitore, e poiché di solito espone più metodi di quelli che intendi, in genere è consigliabile che preferisca la composizione sull'ereditarietà . Se devi ereditare qualcosa, si consiglia di ereditare solo le interfacce. Se vuoi riutilizzare il comportamento di una particolare classe, puoi mantenerla come un oggetto separato in una variabile di istanza, in questo modo tutte le sue promesse e i tuoi requisiti non ti sono obbligati.

A volte, il nostro linguaggio naturale suggerisce una relazione di ereditarietà: un'auto è un veicolo. Una motocicletta è un veicolo. Devo definire le classi Car e Motorcycle che ereditano da una classe Vehicle ? 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.

Un esempio è il problema della modellazione dipendente-capo. Abbiamo più Person s, ognuno con un nome e un indirizzo. Un Employee è un Person e ha un Boss . Anche un Boss è un Person . Quindi dovrei creare una classe Person ereditata da Boss e Employee ? Ora ho un problema: il capo è anche un impiegato e ha un altro superiore. Quindi sembra che Boss debba estendere Employee . Ma il CompanyOwner è un Boss ma non è un Employee ? Qualsiasi tipo di grafico ereditario verrà in qualche modo suddiviso qui.

OOP non riguarda le gerarchie, l'ereditarietà e il riutilizzo di classi esistenti, riguarda il . OOP parla di "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'interfaccia Iterable per Node perché desideri renderla iterabile, va benissimo. Se implementi l'interfaccia Collection perché vuoi aggiungere / rimuovere nodi figli ecc., Allora va bene. Ma ereditando da un'altra classe perché capita di darti tutto ciò che non è, o almeno non a meno che tu non gli abbia dato un'attenta riflessione come descritto sopra.

    
risposta data 03.11.2016 - 15:51
fonte
17

L'estensione di un contenitore in sé è generalmente considerata una cattiva pratica. C'è davvero molto poco motivo per estendere un contenitore invece di averne uno solo. Estendere un contenitore di te stesso lo rende ancora più strano.

    
risposta data 02.11.2016 - 21:43
fonte
14

Aggiungendo ciò che è stato detto, esiste un motivo un po 'specifico per Java per evitare questo tipo di strutture.

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

if and only if the specified object is also a list, both lists have the same size, and all corresponding pairs of elements in the two lists are equal.

Fonte: link

Specificamente, questo significa che avere una classe progettata come una lista di se stessa può rendere costosi i confronti di uguaglianza (e anche i calcoli di hash se le liste sono mutevoli), e se la classe ha alcuni campi di istanza, beh questi devono essere ignorato nel confronto di uguaglianza.

    
risposta data 03.11.2016 - 12:26
fonte
3

Riguardo alla memoria:

Direi che è una questione di perfezionismo. Il costruttore predefinito di ArrayList assomiglia 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);
}

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

Considera ora 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 marginalmente più alti). Per gli alberi questo può facilmente diventare anche piuttosto brutto senza particolari requisiti sulla struttura dell'albero. Utilizzare un LinkedList o un'altra classe in alternativa dovrebbe risolvere questo problema.

Per essere onesti, nella maggior parte dei casi questo non è altro che mero perfezionismo, anche se ignoriamo la quantità di memoria disponibile. Un LinkedList rallenterebbe il codice un po 'come alternativa, quindi è un compromesso tra prestazioni e consumo di memoria in entrambi i casi. Tuttavia la mia opinione personale su questo sarebbe non sprecare quella memoria in modo che possa essere facilmente aggirato come questo.

EDIT: chiarimento riguardo ai commenti (@amon per la precisione). Questa sezione della risposta non riguarda la questione dell'ereditarietà. Il confronto tra l'utilizzo della memoria è fatto su una lista collegata singolarmente e con il miglior utilizzo della memoria (nelle effettive implementazioni il fattore potrebbe cambiare un po ', ma è ancora sufficientemente grande da riassumere in un bel po' di memoria sprecata).

Riguardo a "Cattiva pratica":

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

    
risposta data 04.11.2016 - 00:00
fonte
-2

Sembra assomigliare alla semantica del pattern composito . È utilizzato per modellare strutture dati ricorsive.

    
risposta data 02.11.2016 - 22:07
fonte

Leggi altre domande sui tag