Come si definisce che un metodo può essere sostituito da un impegno più strong rispetto alla definizione di un metodo che può essere chiamato?

36

Da: link

Erich Gamma: I still think it's true even after ten years. Inheritance is a cool way to change behavior. But we know that it's brittle, because the subclass can easily make assumptions about the context in which a method it overrides is getting called. There's a tight coupling between the base class and the subclass, because of the implicit context in which the subclass code I plug in will be called. Composition has a nicer property. The coupling is reduced by just having some smaller things you plug into something bigger, and the bigger object just calls the smaller object back. From an API point of view defining that a method can be overridden is a stronger commitment than defining that a method can be called.

Non capisco cosa intenda. Qualcuno potrebbe spiegarlo?

    
posta q126y 04.12.2015 - 16:26
fonte

5 risposte

63

Un impegno è qualcosa che riduce le tue opzioni future. Pubblicare un metodo implica che gli utenti lo chiamino, pertanto non è possibile rimuovere questo metodo senza compromettere la compatibilità. Se lo avessi mantenuto private , non potevano (direttamente) chiamarlo, e potresti un giorno rifarlo via senza problemi. Pertanto, pubblicare un metodo è un impegno più strong che non pubblicarlo. Pubblicare un metodo sovrascrivibile è un impegno ancora più strong. I tuoi utenti possono chiamarlo, e possono creare nuove classi dove il metodo non fa quello che pensi che faccia!

Ad esempio, se pubblichi un metodo di pulizia, puoi assicurarti che le risorse siano correttamente deallocate finché gli utenti ricordano di chiamare questo metodo come l'ultima cosa che fanno. Ma se il metodo è sovrascrivibile, qualcuno potrebbe sovrascriverlo in una sottoclasse e non chiamare super . Di conseguenza, un terzo utente potrebbe utilizzare quella classe e causare una perdita di risorse anche se è doverosamente chiamato cleanup() alla fine ! Ciò significa che non puoi più garantire la semantica del tuo codice, che è una cosa molto brutta.

In sostanza, non è più possibile fare affidamento su alcun codice eseguito in metodi sovrascrivibili dall'utente, poiché alcuni intermediari potrebbero ignorarlo. Ciò significa che devi implementare la tua routine di pulizia interamente in private metodi, senza alcun aiuto da parte dell'utente. Pertanto, di solito è una buona idea pubblicare solo final elementi a meno che non siano esplicitamente destinati a scavalcare gli utenti API.

    
risposta data 04.12.2015 - 16:40
fonte
30

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

Se pubblichi una richiamata, dai anche un contratto unilaterale:
Quando e come sarà chiamato?

E se pubblichi una funzione sovrascrivibile, è in una volta sola, quindi dai un contratto bilaterale:
Quando verrà chiamato e cosa deve fare se viene chiamato?

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

Un esempio di rinnegamento di un contratto di questo tipo su due lati è il passaggio da show e hide a setVisible(boolean) in java.awt.Component .

    
risposta data 04.12.2015 - 17:51
fonte
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 eseguiamo la sottoclasse in modo che diventi 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, otteniamo false (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 super-classe si aspetta che funzioni.

  2. In genere è impossibile implementare equals () in una sottoclasse significativamente diversa in un modo compatibile con la sua classe genitore.

Quando scrivi una classe che intendi consentire alle persone di sottoclasse, è davvero una buona idea scrivere un contratto su come ogni metodo dovrebbe comportarsi. Ancora meglio sarebbe una serie di test unitari che le persone potrebbero eseguire contro le loro implementazioni dei metodi sovrascritti 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 un contratto ben definito è Comparatore . Ignora semplicemente cosa dice su .equals() per i motivi sopra descritti. Ecco un esempio di come il comparatore può fare cose .equals() non può .

Note

  1. L'articolo 8 di Josh Bloch "Effective Java" era la fonte di questo esempio, ma Bloch usa un ColorPoint che aggiunge un colore al posto del terzo asse e usa il doppio invece degli interi. L'esempio di Bloch in Java è sostanzialmente duplicato da Odersky / Spoon / Venners che ha reso il loro esempio disponibile online.

  2. Diverse persone hanno obiettato a questo esempio perché se si consente alla classe genitrice di conoscere la sottoclasse, è possibile risolvere questo problema. Questo è vero se c'è un numero abbastanza piccolo di sottoclassi e se il genitore ne sa tutto. Ma la domanda iniziale riguardava la creazione di un'API per la quale qualcun altro scriverà sottoclassi. In questo caso, generalmente non è possibile aggiornare l'implementazione genitore per essere compatibile con le sottoclassi.

Bonus

Il comparatore è anche interessante perché funziona correttamente sulla questione dell'implementazione di equals (). Meglio ancora, segue uno schema per risolvere questo tipo di problema dell'ereditarietà: il modello di progettazione della strategia. I Typeclasses che le persone di Haskell e Scala sono entusiaste sono anche il modello di strategia. L'ereditarietà non è male o sbagliata, è solo complicata. Per ulteriori informazioni, consultare il documento di Philip Wadler Come per rendere il polimorfismo ad-hoc meno ad hoc

    
risposta data 04.12.2015 - 18:06
fonte
4

Inheritance Weakens Encapsulation

Quando pubblichi un'interfaccia con l'ereditarietà consentita, aumenti sostanzialmente la dimensione della tua interfaccia. Ogni metodo sovrascrivibile potrebbe essere sostituito e quindi dovrebbe essere considerato come un callback fornito al costruttore. L'implementazione fornita dalla tua classe è semplicemente il valore predefinito del callback. Pertanto, dovrebbe essere fornito un tipo di contratto che indichi quali sono le aspettative sul metodo. Questo accade raramente ed è una delle ragioni principali per cui il codice orientato agli oggetti è definito fragile.

Di seguito è riportato un esempio reale (semplificato) dal framework delle raccolte java, per gentile concessione di Peter Norvig ( link ).

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 suddividiamo questa 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: Occasionalmente aggiungiamo "dog" e l'hashtable 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.

Estensibilità delle interruzioni di ereditarietà

Se consenti alla sottoclasse della 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.

    
risposta data 05.12.2015 - 01:52
fonte
3

Se si intende chiamare un metodo, è necessario 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 copiato dalla copia dal metodo padre; se è troppo piccolo, molti metodi dovranno essere sovrascritti per avere la nuova funzionalità desiderata - questo aggiunge complessità e inutile conteggio delle righe.

Quindi il creatore del metodo genitore deve fare supposizioni 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:

But we know that it's brittle, because the subclass can easily make assumptions about the context in which a method it overrides is getting called.

Considera il metodo a che viene normalmente chiamato dal metodo b , ma in alcuni casi rari e non ovvi dal metodo c . Se l'autore del metodo di sovrascrittura trascura il metodo c e le sue aspettative su a , è ovvio come le cose possono andare storte.

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

    
risposta data 05.12.2015 - 04:13
fonte

Leggi altre domande sui tag