Wie definiert man, dass eine Methode eine stärkere Verpflichtung überschreiben kann als die Definition, dass eine Methode aufgerufen werden kann?

36

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

Erich Gamma: Ich denke, das stimmt auch nach zehn Jahren noch. Vererbung ist eine coole Möglichkeit, das Verhalten zu ändern. Aber wir wissen, dass es spröde ist, weil die Unterklasse leicht Annahmen über den Kontext treffen kann, in dem eine Methode aufgerufen wird, die sie überschreibt. Aufgrund des impliziten Kontexts, in dem der von mir eingesteckte Unterklassencode aufgerufen wird, besteht eine enge Kopplung zwischen der Basisklasse und der Unterklasse. Zusammensetzung hat eine schönere Eigenschaft. Die Kopplung wird reduziert, indem Sie nur einige kleinere Dinge in etwas Größeres stecken, und das größere Objekt ruft das kleinere Objekt zurück. Aus API-Sicht ist die Definition, dass eine Methode überschrieben werden kann, eine stärkere Verpflichtung als die Definition, dass eine Methode aufgerufen werden kann.

Ich verstehe nicht, was er meint. Könnte es bitte jemand erklären?

q126y
quelle

Antworten:

63

Eine Verpflichtung ist etwas, das Ihre zukünftigen Möglichkeiten einschränkt. Das Veröffentlichen einer Methode impliziert, dass Benutzer sie aufrufen. Daher können Sie diese Methode nicht entfernen, ohne die Kompatibilität zu beeinträchtigen. Wenn Sie es behalten würden private, könnten sie es nicht (direkt) nennen, und Sie könnten es eines Tages ohne Probleme umgestalten. Daher ist das Veröffentlichen einer Methode eine stärkere Verpflichtung als das Nichtveröffentlichen. Das Veröffentlichen einer überschreibbaren Methode ist eine noch stärkere Verpflichtung. Ihre Benutzer können es aufrufen und neue Klassen erstellen, in denen die Methode nicht das tut, was Sie denken!

Wenn Sie beispielsweise eine Bereinigungsmethode veröffentlichen, können Sie sicherstellen, dass die Zuweisung von Ressourcen ordnungsgemäß aufgehoben wird, solange Benutzer daran denken, diese Methode als letztes aufzurufen. Wenn die Methode überschreibbar ist, überschreibt sie möglicherweise jemand in einer Unterklasse und ruft sie nicht aufsuper . Infolgedessen könnte ein dritter Benutzer diese Klasse verwenden und ein Ressourcenleck verursachen , obwohl er cleanup()am Ende pflichtbewusst angerufen hat ! Dies bedeutet, dass Sie die Semantik Ihres Codes nicht mehr garantieren können, was eine sehr schlechte Sache ist.

Grundsätzlich können Sie sich nicht mehr auf Code verlassen, der mit vom Benutzer überschreibbaren Methoden ausgeführt wird, da dieser möglicherweise von einem Mittelsmann überschrieben wird. Dies bedeutet, dass Sie Ihre Bereinigungsroutine vollständig in privateMethoden implementieren müssen , ohne die Hilfe des Benutzers. Daher ist es normalerweise eine gute Idee, nur finalElemente zu veröffentlichen , sofern diese nicht ausdrücklich für das Überschreiben durch API-Benutzer vorgesehen sind.

Kilian Foth
quelle
11
Dies ist möglicherweise das beste Argument gegen die Vererbung, das ich je gelesen habe. Von all den Gründen, die ich dagegen gesehen habe, bin ich noch nie auf diese beiden Argumente gestoßen (Koppeln und Unterbrechen der Funktionalität durch Überschreiben), aber beide sind sehr schlagkräftige Argumente gegen Vererbung.
David Arno
5
@DavidArno Ich glaube nicht, dass es ein Argument gegen die Vererbung ist. Ich denke, es ist ein Argument gegen "alles standardmäßig überschreibbar machen". Vererbung ist an sich nicht gefährlich, es bedenkenlos einzusetzen.
Svick
15
Obwohl dies nach einem guten Punkt klingt, kann ich nicht wirklich sehen, wie "ein Benutzer seinen eigenen Buggy-Code hinzufügen kann" ein Argument ist. Durch die Aktivierung der Vererbung können Benutzer fehlende Funktionen hinzufügen, ohne die Aktualisierbarkeit zu verlieren. Diese Maßnahme kann Fehler verhindern und beheben. Wenn ein Benutzercode auf Ihrer API beschädigt ist, handelt es sich nicht um einen API-Fehler.
Sebb
4
Sie können dieses Argument leicht in ein anderes verwandeln: Der erste Codierer führt ein Aufräumargument durch, macht jedoch Fehler und räumt nicht alles auf. Der zweite Codierer überschreibt die Bereinigungsmethode und leistet gute Arbeit. Der dritte Codierer verwendet die Klasse und weist keine Ressourcenlecks auf, obwohl der erste Codierer ein Durcheinander verursacht hat.
Pieter B
6
@Doval In der Tat. Aus diesem Grund ist es eine Farce, dass die Vererbung in nahezu jedem einführenden OOP-Buch und in jeder Klasse die erste Lektion ist.
Kevin Krumwiede
30

Wenn Sie eine normale Funktion veröffentlichen, geben Sie einen einseitigen Vertrag ab:
Was macht die Funktion, wenn sie aufgerufen wird?

Wenn Sie einen Rückruf veröffentlichen, geben Sie auch einen einseitigen Vertrag ab:
Wann und wie wird er aufgerufen?

Und wenn Sie eine überschreibbare Funktion veröffentlichen, ist dies beides gleichzeitig, sodass Sie einen beidseitigen Vertrag abschließen:
Wann wird sie aufgerufen, und was muss sie tun, wenn sie aufgerufen wird?

Selbst wenn Ihre Benutzer Ihre API nicht missbrauchen (indem sie ihren Teil des Vertrags brechen , dessen Aufdeckung möglicherweise unerschwinglich ist), können Sie leicht erkennen, dass Letzterer wesentlich mehr Dokumentation benötigt und alles , was Sie dokumentieren, eine Verpflichtung ist, die Grenzen setzt Ihre weiteren Möglichkeiten.

Ein Beispiel für die Ablehnung eines solchen zweiseitigen Vertrags ist der Wechsel von showund hidenach setVisible(boolean)in java.awt.Component .

Deduplizierer
quelle
+1. Ich bin nicht sicher, warum die andere Antwort akzeptiert wurde. es macht einige interessante Punkte, aber es ist definitiv nicht die richtige Antwort auf diese Frage, da es definitiv nicht das ist, was mit der zitierten Passage gemeint ist.
Ruakh
Dies ist die richtige Antwort, aber ich verstehe das Beispiel nicht. Das Ersetzen von show und hide durch setVisible (boolean) scheint Code zu beschädigen, der auch keine Vererbung verwendet. Vermisse ich etwas?
Eigenschaf
3
@eigensheep: showund hidees gibt sie immer noch, sie sind einfach @Deprecated. Die Änderung unterbricht also keinen Code, der sie lediglich aufruft. Wenn Sie sie jedoch überschrieben haben, werden Ihre Überschreibungen nicht von Clients aufgerufen, die auf das neue "setVisible" migrieren. (Ich habe noch nie Schaukel verwendet, so dass ich weiß nicht , wie üblich ist es diejenigen außer Kraft zu setzen, aber da es vor langer Zeit passiert ist , stelle ich mir den Grund , dass Deduplicator erinnert es ist , dass es biss ihn / sie schmerzlich.)
Ruakh
12

Kilian Foths Antwort ist ausgezeichnet. Ich möchte nur das kanonische Beispiel * hinzufügen, warum dies ein Problem ist. Stellen Sie sich eine Integer-Point-Klasse vor:

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);
    }
}

Lassen Sie es uns als 3D-Punkt unterteilen.

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 einfach! Verwenden wir unsere Punkte:

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

Sie wundern sich wahrscheinlich, warum ich ein so einfaches Beispiel poste. Hier ist der Haken:

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

Wenn wir den 2D-Punkt mit dem äquivalenten 3D-Punkt vergleichen, erhalten wir Wahr, aber wenn wir den Vergleich umkehren, erhalten wir Falsch (weil p2a fehlschlägt instanceof Point3D).

Fazit

  1. Es ist normalerweise möglich, eine Methode in einer Unterklasse so zu implementieren, dass sie nicht mehr mit den Erwartungen der Superklasse kompatibel ist.

  2. Es ist im Allgemeinen unmöglich, equals () in einer signifikant anderen Unterklasse auf eine Weise zu implementieren, die mit der übergeordneten Klasse kompatibel ist.

Wenn Sie eine Klasse schreiben, deren Unterklassen Sie zulassen möchten, empfiehlt es sich, einen Vertrag für das Verhalten der einzelnen Methoden zu erstellen. Noch besser wäre eine Reihe von Unit-Tests, mit denen die Leute ihre Implementierung überschriebener Methoden testen könnten, um zu beweisen, dass sie nicht gegen den Vertrag verstoßen. Fast niemand macht das, weil es zu viel Arbeit ist. Aber wenn es dich interessiert, ist es das, was du tun musst.

Ein gutes Beispiel für einen gut formulierten Vertrag ist Comparator . Ignorieren Sie einfach, worüber es .equals()aus den oben beschriebenen Gründen sagt . Hier ist ein Beispiel dafür, wie Comparator das nicht .equals()kann .

Anmerkungen

  1. Josh Blochs "Effective Java" Item 8 war die Quelle dieses Beispiels, aber Bloch verwendet einen ColorPoint, der anstelle einer dritten Achse eine Farbe hinzufügt und anstelle von Ints Doubles verwendet. Das Java-Beispiel von Bloch wird im Wesentlichen von Odersky / Spoon / Venners dupliziert , die ihr Beispiel online zur Verfügung gestellt haben.

  2. Mehrere Personen haben Einwände gegen dieses Beispiel erhoben, da Sie dieses Problem beheben können, wenn Sie die übergeordnete Klasse über die Unterklasse informieren. Das ist wahr, wenn es eine ausreichende Anzahl von Unterklassen gibt und wenn die Eltern über alle Bescheid wissen. Die ursprüngliche Frage war jedoch, eine API zu erstellen, für die jemand anderes Unterklassen schreibt. In diesem Fall können Sie die übergeordnete Implementierung im Allgemeinen nicht so aktualisieren, dass sie mit den Unterklassen kompatibel ist.

Bonus

Comparator ist auch deshalb interessant, weil es darum geht, equals () korrekt zu implementieren. Besser noch, es folgt einem Muster zur Behebung dieser Art von Vererbungsproblemen: dem Strategieentwurfsmuster. Die Typeclasses, auf die sich Haskell und Scala freuen, sind auch das Strategiemuster. Vererbung ist nicht schlecht oder falsch, sie ist nur knifflig. Für weitere Lesung Besuche Philip Wadler Papier Wie Ad-hoc - Polymorphismus weniger ad hoc machen

GlenPeterson
quelle
1
SortedMap und SortedSet ändern jedoch nicht die Definitionen von equalsMap und Set. Gleichheit ignoriert die Reihenfolge vollständig, so dass beispielsweise zwei SortedSets mit denselben Elementen, aber unterschiedlichen Sortierreihenfolgen immer noch gleich verglichen werden.
user2357112 unterstützt Monica
1
@ user2357112 Sie haben recht und ich habe dieses Beispiel entfernt. Die Kompatibilität von SortedMap.equals () mit Map ist ein separates Problem, über das ich mich beschweren werde. SortedMap ist im Allgemeinen O (log2 n) und HashMap (die kanonische Implementierung von Map) ist O (1). Daher würden Sie eine SortedMap nur verwenden, wenn Sie sich wirklich für die Bestellung interessieren. Aus diesem Grund glaube ich, dass die Reihenfolge wichtig genug ist, um eine kritische Komponente des equals () - Tests in SortedMap-Implementierungen zu sein. Sie sollten keine equals () -Implementierung mit Map gemeinsam nutzen (dies erfolgt über AbstractMap in Java).
GlenPeterson
3
"Vererbung ist nicht schlecht oder falsch, sie ist nur knifflig." Ich verstehe, was Sie sagen, aber heikle Dinge führen normalerweise zu Fehlern, Fehlern und Problemen. Wenn Sie die gleichen Dinge erreichen können (oder fast alle die gleichen Dinge) in einer zuverlässigeren Art und Weise, dann ist der komplizierte Weg ist schlecht.
jpmc26
7
Dies ist ein schreckliches Beispiel, Glen. Sie haben die Vererbung so verwendet, dass sie nicht verwendet werden sollte. Kein Wunder, dass die Klassen nicht so funktionieren, wie Sie es beabsichtigt haben. Sie haben das Substitutionsprinzip von Liskov gebrochen, indem Sie eine falsche Abstraktion (den 2D-Punkt) angegeben haben, aber nur, weil die Vererbung in Ihrem falschen Beispiel schlecht ist, heißt das nicht, dass sie im Allgemeinen schlecht ist. Obwohl diese Antwort vernünftig erscheint, verwirrt sie nur diejenigen, die nicht wissen, dass sie die grundlegendste Vererbungsregel verletzt.
Andy
3
ELI5 des Liskov-Substituton-Prinzips besagt: Wenn eine Klasse Bein Kind einer Klasse ist Aund Sie ein Objekt einer Klasse instanziieren, sollten Sie Bdas Klassenobjekt Bin das übergeordnete Objekt umwandeln und die API der umwandelten Variablen verwenden können, ohne Implementierungsdetails von zu verlieren das Kind. Sie haben die Regel verletzt, indem Sie die dritte Eigenschaft bereitgestellt haben. Wie planen Sie, auf die zKoordinate zuzugreifen , nachdem Sie die Point3DVariable in umgewandelt haben Point2D, wenn die Basisklasse keine Ahnung hat, dass eine solche Eigenschaft vorhanden ist? Wenn Sie durch das Umwandeln einer untergeordneten Klasse in die Basis die öffentliche API beschädigen, ist Ihre Abstraktion falsch.
Andy
4

Vererbung schwächt die Kapselung

Wenn Sie eine Schnittstelle veröffentlichen, deren Vererbung zulässig ist, erhöhen Sie die Größe Ihrer Schnittstelle erheblich. Jede überschreibbare Methode könnte ersetzt werden und sollte daher als Rückruf für den Konstruktor betrachtet werden. Die von Ihrer Klasse bereitgestellte Implementierung ist lediglich der Standardwert des Rückrufs. Daher sollte ein Vertrag abgeschlossen werden, aus dem die Erwartungen an die Methode hervorgehen. Dies kommt selten vor und ist ein Hauptgrund, warum objektorientierter Code als spröde bezeichnet wird.

Nachfolgend finden Sie ein reales (vereinfachtes) Beispiel aus dem Java-Kollektions-Framework, mit freundlicher Genehmigung von 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);
        }
    }
}

Was passiert also, wenn wir dies unterordnen?

/** 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);
    }
}

Wir haben einen Bug: Hin und wieder fügen wir "dog" hinzu und die Hashtabelle bekommt einen Eintrag für "dogss". Die Ursache war, dass jemand eine Implementierung von put bereitstellte, die die Person, die die Hashtable-Klasse entwarf, nicht erwartet hatte.

Vererbung unterbricht die Erweiterbarkeit

Wenn Sie zulassen, dass Ihre Klasse in Unterklassen unterteilt wird, verpflichten Sie sich, Ihrer Klasse keine Methoden hinzuzufügen. Dies könnte sonst geschehen, ohne etwas zu zerbrechen.

Wenn Sie einer Schnittstelle neue Methoden hinzufügen, muss jeder, der von Ihrer Klasse geerbt hat, diese Methoden implementieren.

Eigenschaf
quelle
3

Wenn eine Methode aufgerufen werden soll, müssen Sie nur sicherstellen, dass sie ordnungsgemäß funktioniert. Das ist es. Getan.

Wenn eine Methode überschrieben werden soll, müssen Sie auch den Bereich der Methode sorgfältig abwägen: Wenn der Bereich zu groß ist, muss die untergeordnete Klasse häufig kopierten Code aus der übergeordneten Methode einschließen. Wenn es zu klein ist, müssen viele Methoden überschrieben werden, um die gewünschte neue Funktionalität zu erhalten. Dies erhöht die Komplexität und die unnötige Zeilenanzahl.

Daher muss der Ersteller der übergeordneten Methode Annahmen treffen, wie die Klasse und ihre Methoden in Zukunft möglicherweise überschrieben werden.

Der Autor spricht jedoch über ein anderes Thema im zitierten Text:

Aber wir wissen, dass es spröde ist, weil die Unterklasse leicht Annahmen über den Kontext treffen kann, in dem eine Methode aufgerufen wird, die sie überschreibt.

Betrachten Sie eine Methode, adie normalerweise von method aufgerufen wird b, in einigen seltenen und nicht offensichtlichen Fällen jedoch von method c. Wenn der Autor der überschreibenden Methode die cMethode und ihre Erwartungen überblickt a, ist es offensichtlich, wie etwas schief gehen kann.

Deshalb ist es wichtiger, dass aklar und eindeutig definiert, gut dokumentiert, "eins und das auch gut" ist - mehr als wenn es eine Methode wäre, die nur zum Aufrufen gedacht wäre.

Regnerisch
quelle