Perché si estende è male

La extendsparola chiave è il male; forse non a livello di Charles Manson, ma abbastanza grave da dover essere evitato ogni volta che è possibile. Il libro Gang of Four Design Patterns discute a lungo la sostituzione dell'ereditarietà dell'implementazione ( extends) con l'ereditarietà dell'interfaccia ( implements).

I bravi progettisti scrivono la maggior parte del loro codice in termini di interfacce, non di classi di base concrete. Questo articolo descrive perché i progettisti hanno abitudini così strane e introduce anche alcune nozioni di base sulla programmazione basata sull'interfaccia.

Interfacce contro classi

Una volta ho partecipato a una riunione del gruppo di utenti Java in cui James Gosling (l'inventore di Java) era l'oratore principale. Durante la memorabile sessione di domande e risposte, qualcuno gli ha chiesto: "Se potessi rifare Java, cosa cambieresti?" "Tralascerei le lezioni", ha risposto. Dopo che le risate si sono placate, ha spiegato che il vero problema non erano le classi in sé, ma piuttosto l'eredità dell'implementazione (la extendsrelazione). L'ereditarietà dell'interfaccia (la implementsrelazione) è preferibile. Quando possibile, dovresti evitare l'ereditarietà dell'implementazione.

Perdere flessibilità

Perché dovresti evitare l'ereditarietà dell'implementazione? Il primo problema è che l'uso esplicito di nomi di classi concreti ti blocca in implementazioni specifiche, rendendo inutilmente difficili le modifiche a valle.

Al centro delle metodologie di sviluppo Agile contemporanee c'è il concetto di progettazione e sviluppo paralleli. Si inizia a programmare prima di specificare completamente il programma. Questa tecnica è in contrasto con la saggezza tradizionale, ovvero che un progetto dovrebbe essere completo prima dell'inizio della programmazione, ma molti progetti di successo hanno dimostrato che è possibile sviluppare codice di alta qualità più rapidamente (ed economicamente) in questo modo rispetto al tradizionale approccio pipeline. Al centro dello sviluppo parallelo, tuttavia, c'è la nozione di flessibilità. È necessario scrivere il codice in modo tale da poter incorporare i requisiti appena scoperti nel codice esistente nel modo più indolore possibile.

Piuttosto che implementare le funzionalità di cui potresti aver bisogno, implementa solo le funzionalità di cui hai sicuramente bisogno, ma in un modo che si adatta al cambiamento. Se non si dispone di questa flessibilità, lo sviluppo parallelo semplicemente non è possibile.

La programmazione per le interfacce è al centro della struttura flessibile. Per capire perché, diamo un'occhiata a cosa succede quando non li usi. Considera il codice seguente:

f () {LinkedList list = new LinkedList (); // ... g (elenco); } g (elenco LinkedList) {list.add (...); g2 (elenco)}

Supponiamo ora che sia emerso un nuovo requisito per la ricerca rapida, quindi LinkedListnon funziona. Devi sostituirlo con un file HashSet. Nel codice esistente, tale modifica non è localizzata poiché è necessario modificare non solo f()ma anche g()(che richiede un LinkedListargomento) e qualsiasi cosa g()passa l'elenco a.

Riscrivere il codice in questo modo:

f () {Collection list = new LinkedList (); // ... g (elenco); } g (Collection list) {list.add (...); g2 (elenco)}

rende possibile cambiare l'elenco collegato in una tabella hash semplicemente sostituendo il new LinkedList()con un new HashSet(). Questo è tutto. Non sono necessarie altre modifiche.

Come altro esempio, confronta questo codice:

f () {Collection c = new HashSet (); // ... g (c); } g (Collezione c) {for (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

a questa:

f2 () {Collection c = new HashSet (); // ... g2 (c.iterator ()); } g2 (Iteratore i) {while (i.hasNext ();) do_something_with (i.next ()); }

Il g2()metodo può ora attraversare i Collectionderivati ​​così come le liste di chiavi e valori che puoi ottenere da un file Map. In effetti, puoi scrivere iteratori che generano dati invece di attraversare una raccolta. È possibile scrivere iteratori che forniscono informazioni al programma da uno scaffold di test o da un file. C'è un'enorme flessibilità qui.

Accoppiamento

Un problema più cruciale con l'ereditarietà dell'implementazione è l' accoppiamento: l'affidamento indesiderato di una parte di un programma su un'altra parte. Le variabili globali forniscono il classico esempio del motivo per cui un accoppiamento forte causa problemi. Se si modifica il tipo della variabile globale, ad esempio, tutte le funzioni che utilizzano la variabile (cioè, sono accoppiate alla variabile) potrebbero essere interessate, quindi tutto questo codice deve essere esaminato, modificato e ritestato. Inoltre, tutte le funzioni che utilizzano la variabile sono accoppiate tra loro tramite la variabile. Cioè, una funzione potrebbe influenzare in modo errato il comportamento di un'altra funzione se il valore di una variabile viene modificato in un momento difficile. Questo problema è particolarmente orribile nei programmi multithread.

In qualità di designer, dovresti sforzarti di ridurre al minimo le relazioni di accoppiamento. Non è possibile eliminare del tutto l'accoppiamento perché una chiamata al metodo da un oggetto di una classe a un oggetto di un'altra è una forma di accoppiamento libero. Non puoi avere un programma senza qualche accoppiamento. Tuttavia, è possibile minimizzare notevolmente l'accoppiamento seguendo pedissequamente i precetti OO (orientati agli oggetti) (il più importante è che l'implementazione di un oggetto dovrebbe essere completamente nascosta agli oggetti che lo usano). Ad esempio, le variabili di istanza di un oggetto (campi membro che non sono costanti), dovrebbero sempre essere private. Periodo. Nessuna eccezione. Mai. Voglio dire che. (Occasionalmente puoi usare protectedmetodi in modo efficace, maprotected le variabili di istanza sono un abominio.) Non dovresti mai usare le funzioni get / set per lo stesso motivo: sono solo modi eccessivamente complicati per rendere pubblico un campo (anche se le funzioni di accesso che restituiscono oggetti in piena regola piuttosto che un valore di tipo base sono ragionevole in situazioni in cui la classe dell'oggetto restituito è un'astrazione chiave nella progettazione).

Non sono pedante qui. Ho trovato una correlazione diretta nel mio lavoro tra la rigidità del mio approccio OO, il rapido sviluppo del codice e la facile manutenzione del codice. Ogni volta che violo un principio OO centrale come l'occultamento dell'implementazione, finisco per riscrivere quel codice (di solito perché è impossibile eseguire il debug del codice). Non ho tempo per riscrivere i programmi, quindi seguo le regole. La mia preoccupazione è del tutto pratica: non mi interessa la purezza per amore della purezza.

Il fragile problema della classe base

Applichiamo ora il concetto di accoppiamento all'ereditarietà. In un sistema di ereditarietà dell'implementazione che utilizza extends, le classi derivate sono strettamente accoppiate alle classi di base e questa stretta connessione è indesiderabile. I progettisti hanno applicato il moniker "il fragile problema della classe base" per descrivere questo comportamento. Le classi di base sono considerate fragili perché è possibile modificare una classe di base in modo apparentemente sicuro, ma questo nuovo comportamento, se ereditato dalle classi derivate, potrebbe causare il malfunzionamento delle classi derivate. Non è possibile stabilire se una modifica alla classe base è sicura semplicemente esaminando i metodi della classe base in isolamento; devi guardare (e testare) anche tutte le classi derivate. Inoltre, è necessario controllare tutto il codice che utilizza sia la classe base cheanche oggetti di classe derivata, dal momento che questo codice potrebbe anche essere rotto dal nuovo comportamento. Una semplice modifica a una classe di base chiave può rendere inutilizzabile un intero programma.

Esaminiamo insieme i fragili problemi di accoppiamento di classe base e classe base. La classe seguente estende la ArrayListclasse di Java per farla comportare come uno stack:

la classe Stack estende ArrayList {private int stack_pointer = 0; public void push (Object article) {add (stack_pointer ++, article); } public Object pop () {return remove (--stack_pointer); } public void push_many (Object [] articoli) {for (int i = 0; i <articoli.length; ++ i) push (articoli [i]); }}

Anche una classe così semplice come questa ha dei problemi. Consideriamo che cosa accade quando un utente fa leva eredità e utilizza il ArrayList's clear()metodo per pop tutto dallo stack:

Stack a_stack = new Stack (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Il codice viene compilato correttamente, ma poiché la classe base non sa nulla del puntatore dello stack, l' Stackoggetto è ora in uno stato indefinito. La chiamata successiva a push()mette il nuovo elemento all'indice 2 (il stack_pointervalore corrente di), quindi lo stack ha effettivamente tre elementi su di esso: i due inferiori sono spazzatura. (La Stackclasse Java ha esattamente questo problema; non usarla.)

Una soluzione al problema indesiderato dell'ereditarietà del metodo consiste Stacknell'override di tutti i ArrayListmetodi che possono modificare lo stato dell'array, in modo che gli override manipolino correttamente il puntatore dello stack o generino un'eccezione. (Il removeRange()metodo è un buon candidato per lanciare un'eccezione.)

Questo approccio presenta due svantaggi. Primo, se si sovrascrive tutto, la classe base dovrebbe essere davvero un'interfaccia, non una classe. Non ha senso l'ereditarietà dell'implementazione se non si utilizza nessuno dei metodi ereditati. Secondo, e ancora più importante, non vuoi che uno stack supporti tutti i ArrayListmetodi. Questo removeRange()metodo fastidioso non è utile, per esempio. L'unico modo ragionevole per implementare un metodo inutile è fare in modo che generi un'eccezione, poiché non dovrebbe mai essere chiamato. Questo approccio sposta efficacemente quello che sarebbe un errore in fase di compilazione nel runtime. Non bene. Se il metodo semplicemente non viene dichiarato, il compilatore genera un errore di metodo non trovato. Se il metodo è presente ma genera un'eccezione, non scoprirai la chiamata finché il programma non viene effettivamente eseguito.

Una soluzione migliore al problema della classe base consiste nell'incapsulare la struttura dei dati invece di utilizzare l'ereditarietà. Ecco una versione nuova e migliorata di Stack:

classe Stack {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); public void push (Object article) {the_data.add (stack_pointer ++, article); } public Object pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] articoli) {for (int i = 0; i <o.length; ++ i) push (articoli [i]); }}

Fin qui tutto bene, ma considera la fragile questione della classe base. Supponiamo che tu voglia creare una variante Stackche tenga traccia della dimensione massima dello stack in un determinato periodo di tempo. Una possibile implementazione potrebbe essere simile a questa:

class Monitorable_stack estende Stack {private int high_water_mark = 0; private int current_size; public void push (Oggetto articolo) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (articolo); } public Object pop () {--current_size; return super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

Questa nuova classe funziona bene, almeno per un po '. Sfortunatamente, il codice sfrutta il fatto che push_many()fa il suo lavoro chiamando push(). All'inizio, questo dettaglio non sembra una cattiva scelta. Semplifica il codice e ottieni la versione della classe derivata di push(), anche quando Monitorable_stacksi accede a tramite un Stackriferimento, quindi si high_water_markaggiorna correttamente.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Nota che non hai questo problema se usi l'ereditarietà dell'interfaccia, poiché non ci sono funzionalità ereditate che ti rovinano. Se Stackè un'interfaccia, implementata da a Simple_stacke a Monitorable_stack, il codice è molto più robusto.