In che modo definire che un metodo può essere ignorato è un impegno più forte rispetto alla definizione che un metodo può essere chiamato?


36

Da: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Penso ancora che sia vero anche dopo dieci anni. L'ereditarietà è un modo interessante per cambiare comportamento. Ma sappiamo che è fragile, perché la sottoclasse può facilmente fare ipotesi sul contesto in cui viene chiamato un metodo che ignora. Esiste un accoppiamento stretto tra la classe base e la sottoclasse, a causa del contesto implicito in cui verrà chiamato il codice della sottoclasse che inserisco. La composizione ha una proprietà migliore. L'accoppiamento si riduce semplicemente avendo alcune cose più piccole da inserire in qualcosa di più grande, e l'oggetto più grande richiama semplicemente l'oggetto più piccolo. Da un punto di vista API la definizione di un metodo che può essere sovrascritto è un impegno più forte rispetto alla definizione di un metodo che può essere chiamato.

Non capisco cosa voglia dire. Qualcuno potrebbe spiegarlo, per favore?

Risposte:


63

Un impegno è qualcosa che riduce le tue opzioni future. La pubblicazione di un metodo implica che gli utenti lo chiameranno, pertanto non è possibile rimuovere questo metodo senza interrompere la compatibilità. Se lo avessi tenuto private, non avrebbero potuto (direttamente) chiamarlo e potresti un giorno rifattarlo via senza problemi. Pertanto, pubblicare un metodo è un impegno più forte che non pubblicarlo. La pubblicazione di un metodo scavalcabile è un impegno ancora più forte. I tuoi utenti possono chiamarlo e possono creare nuove classi in cui il metodo non fa quello che pensi che faccia!

Ad esempio, se pubblichi un metodo di pulizia, puoi assicurarti che le risorse siano adeguatamente allocate fintanto che gli utenti si ricordano di chiamare questo metodo come ultima cosa che fanno. Ma se il metodo è sostituibile, qualcuno potrebbe ignorarlo in una sottoclasse e non chiamare super. Di conseguenza, un terzo utente potrebbe usare quella classe e causare una perdita di risorse anche se cleanup()alla fine hanno chiamato doverosamente ! Ciò significa che non puoi più garantire la semantica del tuo codice, il che è una cosa molto negativa.

In sostanza, non è più possibile fare affidamento su alcun codice in esecuzione con metodi sostituibili dall'utente, poiché alcuni intermediari potrebbero ignorarlo. Ciò significa che devi implementare la tua routine di pulizia interamente con privatemetodi, senza l'aiuto dell'utente. Pertanto, di solito è una buona idea pubblicare solo finalelementi a meno che non siano esplicitamente destinati alla sostituzione da parte degli utenti API.


11
Questo è probabilmente il miglior argomento contro l'eredità che io abbia mai letto. Di tutte le ragioni contro cui l'ho incontrata, non ho mai incontrato questi due argomenti prima (accoppiamento e interruzione della funzionalità attraverso l'override), ma entrambi sono argomenti molto potenti contro l'ereditarietà.
David Arno,

5
@DavidArno Non credo sia un argomento contro l'eredità. Penso che sia un argomento contro "rendere tutto sovrascrivibile di default". L'ereditarietà non è pericolosa di per sé, usarla senza pensarlo lo è.
svick,

15
Anche se questo sembra un buon punto, non riesco davvero a capire come "un utente possa aggiungere il proprio codice di errore" è un argomento. L'abilitazione dell'ereditarietà consente agli utenti di aggiungere funzionalità mancanti senza perdere la aggiornabilità, una misura che può prevenire e correggere i bug. Se un codice utente in cima alla tua API è rotto, non è un difetto dell'API.
Sebb,

4
Potresti facilmente trasformare questo argomento in: il primo programmatore fa un argomento di pulizia, ma fa errori e non ripulisce tutto. Il secondo programmatore sostituisce il metodo di pulizia e fa un buon lavoro, e il programmatore n. 3 usa la classe e non ha perdite di risorse anche se il programmatore n. 1 ne ha fatto un casino.
Pieter B,

6
@Doval In effetti. Ecco perché è una parodia che l'ereditarietà sia la lezione numero uno in quasi tutti i libri e le lezioni introduttive di OOP.
Kevin Krumwiede il

30

Se pubblichi una funzione normale, dai un contratto unilaterale:
cosa fa la funzione se viene chiamata?

Se pubblichi un callback, dai anche un contratto unilaterale:
quando e come verrà chiamato?

E se pubblichi una funzione scavalcabile, entrambe sono contemporaneamente, quindi dai un contratto bilaterale:
quando verrà chiamato e cosa deve fare se chiamato?

Anche se i tuoi utenti non stanno abusando della tua API (rompendo la loro parte del contratto, che potrebbe essere proibitivamente costosa da rilevare), puoi facilmente vedere che quest'ultimo ha bisogno di molta più documentazione e tutto ciò che documenti è un impegno, che limita le tue ulteriori scelte.

Un esempio di rinnegare tale contratto bilaterale è il passaggio da showe hideverso setVisible(boolean)in java.awt.Component .


+1. Non sono sicuro del motivo per cui l'altra risposta è stata accettata; fa alcuni punti interessanti, ma sicuramente non è la risposta giusta a questa domanda, in quanto non è sicuramente ciò che si intende per passaggio citato.
Ruakh,

Questa è la risposta corretta, ma non capisco l'esempio. La sostituzione di show and hide con setVisible (booleano) sembra violare il codice che non utilizza anche l'ereditarietà. Mi sto perdendo qualcosa?
eigensheep,

3
@eigensheep: showed hideesistono ancora, sono solo @Deprecated. Quindi la modifica non rompe alcun codice che li invoca semplicemente. Ma se le hai sovrascritte, le tue sostituzioni non verranno chiamate dai client che migrano al nuovo 'setVisible'. (Non ho mai usato Swing, quindi non so quanto sia comune scavalcarli; ma dato che è successo molto tempo fa, immagino che il motivo per cui Deduplicator ricorda che è che l'ha morso dolorosamente.)
ruakh

12

La risposta di Kilian Foth è eccellente. Vorrei solo aggiungere l'esempio canonico * del perché questo è un problema. Immagina una classe Point intera:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

Ora suddividiamo la sottoclasse in un punto 3D.

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

Super semplice! Usiamo i nostri punti:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

Probabilmente ti starai chiedendo perché sto pubblicando un esempio così semplice. Ecco il trucco:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

Quando confrontiamo il punto 2D con il punto 3D equivalente, diventiamo veri, ma quando invertiamo il confronto, diventiamo falsi (perché p2a fallisce instanceof Point3D).

Conclusione

  1. Di solito è possibile implementare un metodo in una sottoclasse in modo tale che non sia più compatibile con il modo in cui la superclasse si aspetta che funzioni.

  2. È generalmente impossibile implementare equals () su una sottoclasse significativamente diversa in un modo compatibile con la sua classe genitore.

Quando scrivi una classe che intendi consentire alle persone di sottoclassare, è davvero una buona idea scrivere un contratto su come ciascun metodo dovrebbe comportarsi. Ancora meglio sarebbe un insieme di unit test che le persone potrebbero eseguire contro l'implementazione di metodi scavalcati per dimostrare che non violano il contratto. Quasi nessuno lo fa perché è troppo lavoro. Ma se ti interessa, questa è la cosa da fare.

Un ottimo esempio di contratto ben definito è Comparator . Basta ignorare ciò che dice .equals()per i motivi sopra descritti. Ecco un esempio di come Comparator può fare le cose .equals()no .

Gli appunti

  1. La voce 8 di "Effective Java" di Josh Bloch è stata la fonte di questo esempio, ma Bloch utilizza un ColorPoint che aggiunge un colore anziché un terzo asse e utilizza doppi invece di ints. L'esempio Java di Bloch è sostanzialmente duplicato da Odersky / Spoon / Venners che ha reso disponibile il loro esempio online.

  2. Diverse persone hanno obiettato a questo esempio perché se si fa sapere alla classe genitrice della sottoclasse, è possibile risolvere questo problema. Questo è vero se esiste un numero sufficiente di sottoclassi e se il genitore ne è a conoscenza. Ma la domanda originale era di creare un'API per la quale qualcun altro scriverà sottoclassi. In tal caso, in genere non è possibile aggiornare l'implementazione padre in modo che sia compatibile con le sottoclassi.

indennità

Comparator è anche interessante perché risolve correttamente la questione dell'implementazione di equals (). Meglio ancora, segue un modello per risolvere questo tipo di problema di eredità: il modello di progettazione della strategia. Le Typeclass di cui le persone di Haskell e Scala si entusiasmano sono anche il modello di strategia. L'ereditarietà non è né cattiva né sbagliata, è solo complicata. Per ulteriori approfondimenti, consulta l'articolo di Philip Wadler Come rendere il polimorfismo ad hoc meno ad hoc


1
SortedMap e SortedSet in realtà non cambiano le definizioni di equalscome la definiscono Map e Set. L'uguaglianza ignora completamente l'ordinamento, con l'effetto che, ad esempio, due SortedSet con gli stessi elementi ma diversi ordinamenti continuano a essere uguali.
user2357112 supporta Monica il

1
@ user2357112 Hai ragione e ho rimosso questo esempio. L'ordinamento di SortedMap.equals () con Map è un problema separato di cui procederò per lamentarmi. SortedMap è generalmente O (log2 n) e HashMap (l'implementazione canonica di Map) è O (1). Pertanto, utilizzeresti una SortedMap solo se ti interessa davvero ordinare. Per questo motivo credo che l'ordine sia abbastanza importante per essere un componente critico del test equals () nelle implementazioni SortedMap. Non dovrebbero condividere un'implementazione equals () con Map (lo fanno tramite AbstractMap in Java).
GlenPeterson,

3
"L'ereditarietà non è né male né male, è solo difficile." Capisco quello che stai dicendo, ma le cose difficili in genere portano a errori, bug e problemi. Quando riesci a realizzare le stesse cose (o quasi tutte le stesse cose) in un modo più affidabile, allora il modo più difficile è cattivo.
jpmc26,

7
Questo è un terribile esempio, Glen. Hai appena usato l'ereditarietà in un modo in cui non dovrebbe essere usato, non c'è da stupirsi che le classi non funzionino come previsto. Hai infranto il principio di sostituzione di Liskov fornendo un'astrazione errata (il punto 2D), ma solo perché l'eredità è cattiva nel tuo esempio sbagliato non significa che sia cattiva in generale. Sebbene questa risposta possa sembrare ragionevole, confonderà solo le persone che non si rendono conto che infrange la regola di eredità di base.
Andy,

3
ELI5 del Principio sostitutivo di Liskov dice: Se una classe Bfosse figlia di una classe Ae dovessi creare un'istanza di un oggetto di una classe B, dovresti essere in grado di trasmettere l' Boggetto della classe al suo genitore e utilizzare l'API della variabile di cast senza perdere i dettagli di implementazione di il bambino. Hai infranto la regola fornendo la terza proprietà. Come si prevede di accedere alle zcoordinate dopo aver eseguito il cast della Point3Dvariabile Point2D, quando la classe base non ha idea che tale proprietà esista? Se, lanciando una classe figlio alla sua base, rompi l'API pubblica, la tua astrazione è sbagliata.
Andy,

4

L'ereditarietà indebolisce l'incapsulamento

Quando si pubblica un'interfaccia con ereditarietà consentita, si aumenta sostanzialmente la dimensione dell'interfaccia. Ogni metodo sostituibile potrebbe essere sostituito e quindi dovrebbe essere considerato come callback fornito al costruttore. L'implementazione fornita dalla tua classe è semplicemente il valore predefinito del callback. Pertanto, dovrebbe essere fornito un qualche tipo di contratto che indichi quali sono le aspettative sul metodo. Ciò accade raramente ed è uno dei motivi principali per cui il codice orientato agli oggetti viene chiamato fragile.

Di seguito è riportato un esempio reale (semplificato) dal framework delle raccolte java, per gentile concessione di Peter Norvig ( http://norvig.com/java-iaq.html ).

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

Quindi cosa succede se lo facciamo sottoclasse?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

Abbiamo un bug: a volte aggiungiamo "dog" e la tabella hash ottiene una voce per "dogss". La causa era qualcuno che forniva un'implementazione di put che la persona che progettava la classe Hashtable non si aspettava.

L'ereditarietà interrompe l'estensibilità

Se consenti alla sottoclasse della tua classe, ti impegni a non aggiungere alcun metodo alla tua classe. Questo potrebbe altrimenti essere fatto senza rompere nulla.

Quando aggiungi nuovi metodi a un'interfaccia, chiunque abbia ereditato dalla tua classe dovrà implementare tali metodi.


3

Se si intende chiamare un metodo, è sufficiente assicurarsi che funzioni correttamente. Questo è tutto. Fatto.

Se un metodo è progettato per essere sovrascritto, devi anche riflettere attentamente sull'ambito del metodo: se l'ambito è troppo grande, la classe figlio dovrà spesso includere il codice incollato dal metodo genitore; se è troppo piccolo, molti metodi dovranno essere ignorati per avere desiderato nuove funzionalità - questo aggiunge complessità e inutile conteggio delle linee.

Pertanto, il creatore del metodo parent deve fare ipotesi su come la classe e i suoi metodi potrebbero essere sostituiti in futuro.

Tuttavia, l'autore sta parlando di un problema diverso nel testo citato:

Ma sappiamo che è fragile, perché la sottoclasse può facilmente fare ipotesi sul contesto in cui viene chiamato un metodo che ignora.

Considera il metodo ache viene normalmente chiamato dal metodo b, ma in alcuni casi rari e non ovvi dal metodo c. Se l'autore del metodo prioritario trascura il cmetodo e le sue aspettative a, è ovvio come le cose possano andare storte.

Pertanto è più importante che asia definito in modo chiaro e inequivocabile, ben documentato, "fa una cosa e la fa bene" - più che se fosse un metodo progettato solo per essere chiamato.

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.