LSP vs OCP / Liskov-Substitution VS Öffnen Schließen


48

Ich versuche, die SOLID-Prinzipien von OOP zu verstehen, und bin zu dem Schluss gekommen, dass LSP und OCP einige Ähnlichkeiten aufweisen (um nicht mehr zu sagen).

Das Open / Closed-Prinzip besagt, dass "Software-Entitäten (Klassen, Module, Funktionen usw.) für Erweiterungen offen, für Änderungen jedoch geschlossen sein sollten".

LSP gibt in einfachen Worten an, dass jede Instanz von Foodurch eine Instanz von ersetzt werden kann, von Barder abgeleitet wird, Foound dass das Programm genauso funktioniert.

Ich bin kein Pro-OOP-Programmierer, aber es scheint mir, dass LSP nur möglich ist, wenn Bar, abgeleitet von Foo, nichts daran geändert, sondern nur erweitert wird. Dies bedeutet, dass insbesondere Programm-LSP nur dann wahr ist, wenn OCP wahr ist, und OCP nur dann, wenn LSP wahr ist. Das bedeutet, dass sie gleich sind.

Korrigiere mich, wenn ich falsch liege. Ich möchte diese Ideen wirklich verstehen. Vielen Dank für eine Antwort.


4
Dies ist eine sehr enge Interpretation beider Konzepte. Offen / Geschlossen kann beibehalten werden und trotzdem gegen LSP verstoßen. Die Beispiele Rechteck / Quadrat oder Ellipse / Kreis sind gute Illustrationen. Beide halten sich an OCP, aber beide verletzen LSP.
Joel Etherton

1
Die Welt (oder zumindest das Internet) ist diesbezüglich verwirrt. kirkk.com/modularity/2009/12/solid-principles-of-class-design . Dieser Typ sagt, die Verletzung von LSP ist auch eine Verletzung von OCP. Und dann gibt der Autor in dem Buch "Software Engineering Design: Theorie und Praxis" auf Seite 156 ein Beispiel für etwas, das an OCP festhält, aber gegen LSP verstößt. Ich habe das aufgegeben.
Manoj R

@JoelEtherton Diese Paare verletzen LSP nur, wenn sie veränderlich sind. Im unveränderlichen Fall verletzt das Ableiten Squarevon RectangleLSP nicht. (Aber es ist wahrscheinlich immer noch schlechtes Design in dem unveränderlichen Fall, da Sie Quadrate haben können Rectangle, die keine sind, Squaredie nicht mit Mathematik übereinstimmen)
CodesInChaos

Einfache Analogie (aus der Sicht eines Bibliothekschreibers-Benutzers). LSP ist wie der Verkauf eines Produkts (einer Bibliothek), das behauptet, 100% dessen zu implementieren, was es sagt (auf der Benutzeroberfläche oder im Benutzerhandbuch), aber tatsächlich nicht (oder nicht mit dem übereinstimmt, was gesagt wird). OCP ist wie der Verkauf eines Produkts (einer Bibliothek) mit dem Versprechen, dass es aktualisiert (erweitert) werden kann, wenn neue Funktionen (wie Firmware) herauskommen, aber tatsächlich nicht ohne einen Werksservice aktualisiert werden kann.
rwong

Antworten:


119

Meine Güte, es gibt einige seltsame Missverständnisse darüber, was OCP und LSP sind, und einige sind darauf zurückzuführen, dass einige Terminologien und verwirrende Beispiele nicht übereinstimmen. Beide Prinzipien sind nur dann "dasselbe", wenn Sie sie auf die gleiche Weise implementieren. Muster folgen normalerweise den Prinzipien auf die eine oder andere Weise, mit wenigen Ausnahmen.

Die Unterschiede werden weiter unten erläutert, aber lassen Sie uns zunächst einen Blick auf die Prinzipien selbst werfen:

Open-Closed-Prinzip (OCP)

Laut Onkel Bob :

Sie sollten in der Lage sein, das Verhalten einer Klasse zu erweitern, ohne es zu ändern.

Beachten Sie, dass das Wort " Erweitern" in diesem Fall nicht unbedingt bedeutet, dass Sie die tatsächliche Klasse, die das neue Verhalten benötigt, in eine Unterklasse unterteilen sollten. Sehen Sie, wie ich bei der ersten Nichtübereinstimmung der Terminologie erwähnt habe? Das Schlüsselwort extendbedeutet nur Unterklassen in Java, die Prinzipien sind jedoch älter als in Java.

Das Original stammt von Bertrand Meyer aus dem Jahr 1988:

Software-Entitäten (Klassen, Module, Funktionen usw.) sollten zur Erweiterung geöffnet, zur Änderung jedoch geschlossen sein.

Hier ist es viel klarer, dass das Prinzip auf Software-Entitäten angewendet wird . Ein schlechtes Beispiel wäre, die Software-Entität zu überschreiben, da Sie den Code vollständig ändern, anstatt einen Erweiterungspunkt bereitzustellen. Das Verhalten der Software-Entität selbst sollte erweiterbar sein und ein gutes Beispiel dafür ist die Implementierung des Strategie-Musters (weil es meiner Meinung nach am einfachsten ist, das GoF-Pattern-Bündel zu zeigen):

// Context is closed for modifications. Meaning you are
// not supposed to change the code here.
public class Context {

    // Context is however open for extension through
    // this private field
    private IBehavior behavior;

    // The context calls the behavior in this public 
    // method. If you want to change this you need
    // to implement it in the IBehavior object
    public void doStuff() {
        if (this.behavior != null)
            this.behavior.doStuff();
    }

    // You can dynamically set a new behavior at will
    public void setBehavior(IBehavior behavior) {
        this.behavior = behavior;
    }
}

// The extension point looks like this and can be
// subclassed/implemented
public interface IBehavior {
    public void doStuff();
}

Im obigen Beispiel Contextist das für weitere Änderungen gesperrt . Die meisten Programmierer möchten die Klasse wahrscheinlich in Unterklassen unterteilen, um sie zu erweitern. Dies ist jedoch nicht der Fall, da davon ausgegangen wird, dass das Verhalten durch alles, was die Schnittstelle implementiert , geändert werden kann IBehavior.

Dh die Kontextklasse ist zur Änderung geschlossen, aber zur Erweiterung offen . Es folgt tatsächlich einem anderen Grundprinzip, da wir das Verhalten mit der Objektzusammensetzung anstelle der Vererbung setzen:

"Bevorzugen Sie die ' Objektzusammensetzung ' gegenüber der ' Klassenvererbung '." (Viererbande 1995: 20)

Ich werde den Leser über dieses Prinzip informieren, da es außerhalb des Rahmens dieser Frage liegt. Nehmen wir an, wir haben die folgenden Implementierungen der IBehavior-Schnittstelle, um mit dem Beispiel fortzufahren:

public class HelloWorldBehavior implements IBehavior {
    public void doStuff() {
        System.println("Hello world!");
    }
}

public class GoodByeBehavior implements IBehavior {
    public void doStuff() {
        System.out.println("Good bye cruel world!");
    }
}

Mithilfe dieses Musters können wir das Verhalten des Kontexts zur Laufzeit über die setBehaviorMethode als Erweiterungspunkt ändern .

// in your main method
Context c = new Context();

c.setBehavior(new HelloWorldBehavior());
c.doStuff();
// prints out "Hello world!"

c.setBehavior(new GoodByeBehavior());
c.doStuff();
// prints out "Good bye cruel world!"

Wenn Sie also die "geschlossene" Kontextklasse erweitern möchten, führen Sie dies durch, indem Sie ihre "offene" Abhängigkeit von der Zusammenarbeit in Unterklassen unterteilen. Dies ist eindeutig nicht dasselbe wie das Unterteilen des Kontexts selbst, es ist jedoch OCP. LSP erwähnt dies ebenfalls nicht.

Erweitern mit Mixins statt Vererbung

Es gibt andere Möglichkeiten, OCP durchzuführen als Unterklassen. Eine Möglichkeit besteht darin, Ihre Klassen durch die Verwendung von Mixins für eine Erweiterung offen zu halten . Dies ist z. B. in Sprachen nützlich, die eher prototypbasiert als klassenbasiert sind. Die Idee ist, ein dynamisches Objekt mit mehr Methoden oder Attributen nach Bedarf zu ändern, dh Objekte, die mit anderen Objekten gemischt oder "eingemischt" werden.

Hier ist ein Javascript-Beispiel für ein Mixin, das eine einfache HTML-Vorlage für Anker rendert:

// The mixin, provides a template for anchor HTML elements, i.e. <a>
var LinkMixin = {
    render: function() {
        return '<a href="' + this.link +'">'
            + this.content 
            + '</a>;
    }
}

// Constructor for a youtube link
var YoutubeLink = function(content, youtubeId) {
    this.content = content;
    this.setLink(this.youtubeId);
};
// Methods are added to the prototype
YoutubeLink.prototype = {
    setLink: function(youtubeid) {
        this.link = 'http://www.youtube.com/watch?v=' + youtubeid;
    }
};
// Extend YoutubeLink prototype with the LinkMixin using
// underscore/lodash extend
_.extend(YoutubeLink.protoype, LinkMixin);

// When used:
var ytLink = new YoutubeLink("Cool Movie!", "idOaZpX8lnA");

console.log(ytLink.render());
// will output: 
// <a href="http://www.youtube.com/watch?=vidOaZpX8lnA">Cool Movie!</a>

Die Objekte sollen dynamisch erweitert werden. Dies hat den Vorteil, dass Objekte Methoden gemeinsam nutzen können, auch wenn sie sich in völlig unterschiedlichen Domänen befinden. In dem obigen Fall können Sie leicht andere Arten von HTML-Ankern erstellen, indem Sie Ihre spezifische Implementierung mit der erweitern LinkMixin.

In Bezug auf OCP sind die "Mixins" Erweiterungen. Im obigen Beispiel YoutubeLinkist das unsere Software-Entität, die für Änderungen geschlossen, aber für Erweiterungen durch die Verwendung von Mixins geöffnet ist. Die Objekthierarchie ist abgeflacht, wodurch es unmöglich ist, nach Typen zu suchen. Dies ist jedoch keine schlechte Sache, und ich werde weiter unten erläutern, dass das Prüfen auf Typen im Allgemeinen eine schlechte Idee ist und die Idee mit Polymorphismus bricht.

Beachten Sie, dass es mit dieser Methode möglich ist, extendmehrere Objekte zu vererben, da die meisten Implementierungen mehrere Objekte einmischen können:

_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);

Das einzige, was Sie beachten müssen, ist, die Namen nicht zu kollidieren, dh Mixins definieren zufällig den gleichen Namen einiger Attribute oder Methoden, wie sie überschrieben werden. Nach meiner bescheidenen Erfahrung ist dies kein Problem, und wenn es doch passiert, ist es ein Hinweis auf ein fehlerhaftes Design.

Liskovs Substitutionsprinzip (LSP)

Onkel Bob definiert es einfach durch:

Abgeleitete Klassen müssen durch ihre Basisklassen ersetzt werden können.

Dieses Prinzip ist alt, in der Tat unterscheidet die Definition von Onkel Bob die Prinzipien nicht, da dadurch LSP immer noch eng mit OCP verwandt ist, da im obigen Strategiebeispiel derselbe Supertyp verwendet wird ( IBehavior). Schauen wir uns also die ursprüngliche Definition von Barbara Liskov an und sehen wir, ob wir noch etwas über dieses Prinzip herausfinden können, das wie ein mathematischer Satz aussieht:

Was hier gewünscht wird, ist etwa die folgende Substitutionseigenschaft: Wenn für jedes Objekt o1vom Typ Sein Objekt o2vom Typ vorhanden ist, Tso dass für alle Pin Bezug auf definierten Programme Tdas Verhalten von Punverändert bleibt, wenn o1für ersetzt wird, o2dann Sist es ein Subtyp von T.

Lasst uns eine Weile mit den Schultern zucken, denn es werden überhaupt keine Klassen erwähnt. In JavaScript können Sie LSP tatsächlich folgen, obwohl es nicht explizit klassenbasiert ist. Wenn Ihr Programm eine Liste von mindestens ein paar JavaScript-Objekten enthält, die:

  • muss auf die gleiche Weise berechnet werden,
  • dasselbe Verhalten haben und
  • sind sonst irgendwie ganz anders

... dann haben die Objekte den gleichen "Typ" und es ist für das Programm nicht wirklich wichtig. Dies ist im Wesentlichen Polymorphismus . Im allgemeinen Sinne; Sie sollten den tatsächlichen Untertyp nicht kennen müssen, wenn Sie dessen Schnittstelle verwenden. OCP sagt dazu nichts explizites. Es zeigt auch tatsächlich einen Designfehler auf, den die meisten unerfahrenen Programmierer machen:

Wann immer Sie den Drang verspüren, den Untertyp eines Objekts zu überprüfen, tun Sie dies höchstwahrscheinlich FALSCH.

Okay, es könnte also nicht immer falsch sein, aber wenn Sie den Drang haben, eine Typprüfung mit instanceofoder Aufzählungen durchzuführen, ist das Programm möglicherweise etwas komplizierter, als es sein muss. Dies ist jedoch nicht immer der Fall; Schnelle und schmutzige Hacks, um die Dinge zum Laufen zu bringen, sind in meinen Augen eine gute Konzession, wenn die Lösung klein genug ist und wenn Sie gnadenloses Refactoring praktizieren , wird sie möglicherweise verbessert, sobald Änderungen dies erfordern.

Abhängig vom eigentlichen Problem gibt es verschiedene Möglichkeiten, um diesen "Designfehler" zu umgehen:

  • Die Superklasse ruft nicht die Voraussetzungen auf, sondern zwingt den Aufrufer dazu.
  • Der Superklasse fehlt eine generische Methode, die der Aufrufer benötigt.

Beides sind gängige Code-Design- "Fehler". Sie können verschiedene Refactorings durchführen, z. B. die Pull-up-Methode oder die Refactor-Funktion für ein Muster wie das Visitor-Muster .

Ich mag das Besuchermuster sehr, da es für große if-Statement-Spaghetti geeignet ist und einfacher zu implementieren ist, als Sie es für vorhandenen Code halten würden. Angenommen, wir haben den folgenden Kontext:

public class Context {

    public void doStuff(string query) {

        // outcome no. 1
        if (query.Equals("Hello")) {
            System.out.println("Hello world!");
        } 

        // outcome no. 2
        else if (query.Equals("Bye")) {
            System.out.println("Good bye cruel world!");
        }

        // a change request may require another outcome...

    }

}

// usage:
Context c = new Context();

c.doStuff("Hello");
// prints "Hello world"

c.doStuff("Bye");
// prints "Bye"

Die Ergebnisse der if-Anweisung können in ihre eigenen Besucher übersetzt werden, da jede von einer Entscheidung und einem auszuführenden Code abhängt. Wir können diese wie folgt extrahieren:

public interface IVisitor {
    public bool canDo(string query);
    public void doStuff();
}

// outcome 1
public class HelloVisitor implements IVisitor {
    public bool canDo(string query) {
        return query.Equals("Hello");
    }
    public void doStuff() {
         System.out.println("Hello World");
    }
}

// outcome 2
public class ByeVisitor implements IVisitor {
    public bool canDo(string query) {
        return query.Equals("Bye");
    }
    public void doStuff() {
        System.out.println("Good bye cruel world");
    }
}

Wenn der Programmierer zu diesem Zeitpunkt nichts über das Besuchermuster wusste, implementierte er stattdessen die Context-Klasse, um zu überprüfen, ob es sich um einen bestimmten Typ handelt. Da die Visitor-Klassen über eine boolesche canDoMethode verfügen , kann der Implementierer mithilfe dieses Methodenaufrufs feststellen, ob es sich um das richtige Objekt für die Ausführung der Aufgabe handelt. Die Kontextklasse kann alle Besucher wie folgt verwenden (und neue hinzufügen):

public class Context {
    private ArrayList<IVisitor> visitors = new ArrayList<IVisitor>();

    public Context() {
        visitors.add(new HelloVisitor());
        visitors.add(new ByeVisitor());
    }

    // instead of if-statements, go through all visitors
    // and use the canDo method to determine if the 
    // visitor object is the right one to "visit"
    public void doStuff(string query) {
        for(IVisitor visitor : visitors) {
            if (visitor.canDo(query)) {
                visitor.doStuff();
                break;
                // or return... it depends if you have logic 
                // after this foreach loop
            }
        }
    }

    // dynamically adds new visitors
    public void addVisitor(IVisitor visitor) {
        if (visitor != null)
            visitors.add(visitor);
    }
}

Beide Muster folgen OCP und LSP, zeigen jedoch beide unterschiedliche Dinge an. Wie sieht Code aus, wenn er gegen eines der Prinzipien verstößt?

Ein Prinzip verletzen, aber dem anderen folgen

Es gibt Möglichkeiten, eines der Prinzipien zu brechen, aber das andere muss befolgt werden. Die folgenden Beispiele scheinen aus gutem Grund erfunden zu sein, aber ich habe tatsächlich gesehen, dass sie im Produktionscode auftauchen (und sogar schlechter):

Folgt OCP, aber nicht LSP

Nehmen wir an, wir haben den angegebenen Code:

public interface IPerson {}

public class Boss implements IPerson {
    public void doBossStuff() { ... }
}

public class Peon implements IPerson {
    public void doPeonStuff() { ... }
}

public class Context {
    public Collection<IPerson> getPersons() { ... }
}

Dieser Code folgt dem Open-Closed-Prinzip. Wenn wir die GetPersonsMethode des Kontexts aufrufen , erhalten wir eine Reihe von Personen, die alle ihre eigenen Implementierungen haben. Das bedeutet, dass IPerson für Änderungen geschlossen, aber für Erweiterungen geöffnet ist. Es wird jedoch dunkel, wenn wir es benutzen müssen:

// in some routine that needs to do stuff with 
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
    // now we have to check the type... :-P
    if (person instanceof Boss) {
        ((Boss) person).doBossStuff();
    }
    else if (person instanceof Peon) {
        ((Peon) person).doPeonStuff();
    }
}

Sie müssen Typüberprüfung und Typkonvertierung durchführen! Weißt du noch, wie ich oben erwähnt habe, wie schlecht die Typenprüfung ist ? Ach nein! Aber keine Angst, wie bereits erwähnt, führen Sie entweder ein Pull-up-Refactoring durch oder implementieren Sie ein Besuchermuster. In diesem Fall können wir einfach ein Pull-Up-Refactoring durchführen, nachdem wir eine allgemeine Methode hinzugefügt haben:

public class Boss implements IPerson {
    // we're adding this general method
    public void doStuff() {
        // that does the call instead
        this.doBossStuff();
    }
    public void doBossStuff() { ... }
}


public interface IPerson {
    // pulled up method from Boss
    public void doStuff();
}

// do the same for Peon

Der Vorteil ist nun, dass Sie nach LSP nicht mehr den genauen Typ kennen müssen:

// in some routine that needs to do stuff with 
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
    // yay, no type checking!
    person.doStuff();
}

Folgt LSP, aber nicht OCP

Schauen wir uns einen Code an, der auf LSP folgt, aber nicht auf OCP. Er ist ein bisschen erfunden, aber bei mir ist es ein sehr subtiler Fehler:

public class LiskovBase {
    public void doStuff() {
        System.out.println("My name is Liskov");
    }
}

public class LiskovSub extends LiskovBase {
    public void doStuff() {
        System.out.println("I'm a sub Liskov!");
    }
}

public class Context {
    private LiskovBase base;

    // the good stuff
    public void doLiskovyStuff() {
        base.doStuff();
    }

    public void setBase(LiskovBase base) { this.base = base }
}

Der Code führt LSP aus, da der Kontext LiskovBase verwenden kann, ohne den tatsächlichen Typ zu kennen. Sie würden denken, dieser Code folgt auch OCP, aber schauen Sie genau hin, ist die Klasse wirklich geschlossen ? Was wäre, wenn die doStuffMethode mehr als nur eine Zeile ausdrucken würde?

Die Antwort auf OCP lautet einfach: NEIN , das liegt nicht daran, dass wir in diesem Objektdesign den Code vollständig durch etwas anderes überschreiben müssen. Dies öffnet die Möglichkeit zum Ausschneiden und Einfügen von Würmern, da Sie Code aus der Basisklasse kopieren müssen, um die Arbeit zu starten. Die doStuffMethode kann zwar erweitert werden, wurde jedoch für Änderungen nicht vollständig geschlossen.

Darauf können wir das Template-Methodenmuster anwenden . Das Template-Methodenmuster ist in Frameworks so verbreitet, dass Sie es möglicherweise verwendet haben, ohne es zu wissen (z. B. Java-Swing-Komponenten, C # -Formulare und -Komponenten usw.). Hier ist eine Möglichkeit, die doStuffÄnderungsmethode zu schließen und sicherzustellen, dass sie geschlossen bleibt, indem Sie sie mit dem finalSchlüsselwort von Java markieren . Dieses Schlüsselwort verhindert, dass die Klasse weiter in Unterklassen unterteilt wird (in C # können Sie sealeddasselbe tun).

public class LiskovBase {
    // this is now a template method
    // the code that was duplicated
    public final void doStuff() {
        System.out.println(getStuffString());
    }

    // extension point, the code that "varies"
    // in LiskovBase and it's subclasses
    // called by the template method above
    // we expect it to be virtual and overridden
    public string getStuffString() {
        return "My name is Liskov";
    }
}

public class LiskovSub extends LiskovBase {
    // the extension overridden
    // the actual code that varied
    public string getStuffString() {
        return "I'm sub Liskov!";
    }
}

Dieses Beispiel folgt OCP und scheint albern, was es ist, aber stellen Sie sich dies mit mehr Code skaliert zu behandeln. Ich sehe immer wieder, wie Code in der Produktion bereitgestellt wird, wobei Unterklassen alles außer Kraft setzen und der überschriebene Code meist zwischen den Implementierungen eingefügt wird. Es funktioniert, aber wie bei allen Code-Duplikaten ist es auch ein Setup für Wartungs-Albträume.

Fazit

Ich hoffe, dies alles klärt einige Fragen in Bezug auf OCP und LSP und die Unterschiede / Ähnlichkeiten zwischen ihnen. Es ist einfach, sie als gleich zu entlassen, aber die obigen Beispiele sollten zeigen, dass sie nicht so sind.

Beachten Sie, dass aus dem obigen Beispielcode Folgendes hervorgeht:

  • Bei OCP geht es darum, den Arbeitscode zu sperren, ihn aber mit einigen Erweiterungspunkten offen zu halten.

    Dies dient zur Vermeidung von Codeduplizierungen, indem der Code eingekapselt wird, der sich wie im Beispiel des Musters der Vorlagenmethode ändert. Es kann auch schnell scheitern, da das Brechen von Änderungen schmerzhaft ist (dh eine Stelle ändern, es überall anders brechen). Aus Wartungsgründen ist das Konzept der Kapselung von Änderungen eine gute Sache, da Änderungen immer vorkommen .

  • Bei LSP geht es darum, den Benutzer mit verschiedenen Objekten zu beauftragen, die einen Supertyp implementieren, ohne den tatsächlichen Typ zu überprüfen. Darum geht es inhärent beim Polymorphismus .

    Dieses Prinzip bietet eine Alternative zur Typprüfung und -konvertierung, die mit zunehmender Anzahl von Typen außer Kontrolle geraten kann und durch Pull-up-Refactoring oder das Anwenden von Mustern wie z. B. Visitor erreicht werden kann.


7
Dies ist eine gute Erklärung, da dies die OCP nicht zu stark vereinfacht, da impliziert wird, dass die Implementierung immer durch Vererbung erfolgt. Es ist diese übermäßige Vereinfachung, die OCP und SRP in den Köpfen einiger Menschen verbindet, obwohl es sich tatsächlich um zwei völlig getrennte Konzepte handeln kann.
Eric King

5
Dies ist eine der besten Stapelaustausch-Antworten, die ich je gesehen habe. Ich wünschte, ich könnte es 10 mal stimmen. Gut gemacht und vielen Dank für die hervorragende Erklärung.
Bob Horn

Dort habe ich einen Klappentext für Javascript hinzugefügt, der keine klassenbasierte Programmiersprache ist, aber dennoch LSP folgen kann, und den Text so bearbeitet, dass er hoffentlich flüssiger gelesen wird. Puh!
Spoike

Während Ihr Zitat von Onkel Bob von LSP korrekt ist (genau wie seine Website), sollte es nicht umgekehrt sein? Sollte nicht angegeben werden, dass "Basisklassen für ihre abgeleiteten Klassen substituierbar sein sollten"? Auf LSP wird der Test der "Kompatibilität" gegen die abgeleitete Klasse und nicht gegen die Basisklasse durchgeführt. Trotzdem bin ich kein englischer Muttersprachler und ich denke, dass es einige Details zu der Phrase geben kann, die mir fehlen könnte.
Alpha

@Alpha: Das ist eine gute Frage. Die Basisklasse kann immer durch abgeleitete Klassen ersetzt werden, da sonst die Vererbung nicht funktioniert. Der Compiler (zumindest in Java und C #) wird sich beschweren, wenn Sie ein Mitglied (Methode oder Attribut / Feld) aus der erweiterten Klasse auslassen, das implementiert werden muss. LSP soll Sie davon abhalten, Methoden hinzuzufügen, die nur lokal für die abgeleiteten Klassen verfügbar sind, da der Benutzer dieser abgeleiteten Klassen über diese Bescheid wissen muss. Wenn der Code wächst, ist es schwierig, solche Methoden zu verwalten.
Spoike

15

Dies ist etwas, das viel Verwirrung stiftet. Ich ziehe es vor, diese Prinzipien etwas philosophisch zu betrachten, weil es viele verschiedene Beispiele dafür gibt, und manchmal erfassen konkrete Beispiele nicht wirklich ihr gesamtes Wesen.

Was OCP versucht zu beheben

Angenommen, wir müssen einem bestimmten Programm Funktionen hinzufügen. Der einfachste Weg, dies zu tun, besteht insbesondere für Personen, die geschult wurden, prozedural zu denken, darin, eine if-Klausel hinzuzufügen, wo immer dies erforderlich ist, oder etwas Ähnliches.

Die Probleme damit sind

  1. Es ändert den Fluss des vorhandenen Arbeitscodes.
  2. Es erzwingt in jedem Fall eine neue bedingte Verzweigung. Angenommen, Sie haben eine Liste mit Büchern, von denen einige zum Verkauf stehen, und Sie möchten alle durchlaufen und deren Preis ausdrucken, sodass der gedruckte Preis im Falle eines Verkaufs die Zeichenfolge enthält. " (IM ANGEBOT)".

Sie können dies tun, indem Sie ein zusätzliches Feld zu allen Büchern mit dem Namen "is_on_sale" hinzufügen. Anschließend können Sie dieses Feld beim Drucken eines Buchpreises aktivieren. Alternativ können Sie verkaufte Bücher aus der Datenbank mit einem anderen Typ instanziieren, der gedruckt wird "(ON SALE)" in der Preiskette (kein perfektes Design, aber es liefert den Punkt nach Hause).

Das Problem bei der ersten prozeduralen Lösung ist ein zusätzliches Feld für jedes Buch und in vielen Fällen eine zusätzliche redundante Komplexität. Die zweite Lösung erzwingt Logik nur dort, wo sie tatsächlich benötigt wird.

Bedenken Sie nun, dass in vielen Fällen unterschiedliche Daten und Logik erforderlich sein können, und Sie werden sehen, warum es eine gute Idee ist, beim Entwerfen Ihrer Klassen auf OCP zu achten oder auf geänderte Anforderungen zu reagieren.

Inzwischen sollten Sie die Hauptidee haben: Versuchen Sie, sich in eine Situation zu versetzen, in der neuer Code als polymorphe Erweiterungen und nicht als prozedurale Änderungen implementiert werden kann.

Aber keine Angst davor haben, den Kontext zu analysieren und herauszufinden, ob die Nachteile die Vorteile überwiegen, denn selbst ein Prinzip wie OCP kann ein 20-Klassen-Chaos aus einem 20-Zeilen-Programm machen, wenn es nicht sorgfältig behandelt wird .

Was LSP versucht zu beheben

Wir alle lieben die Wiederverwendung von Code. Eine Krankheit, die folgt, ist, dass viele Programme es nicht vollständig verstehen, bis zu dem Punkt, an dem sie gebräuchliche Codezeilen blind faktorisieren, nur um unlesbare Komplexität und redundante enge Kopplung zwischen Modulen zu erzeugen, die, abgesehen von ein paar Codezeilen, nichts gemeinsam haben, was die konzeptionelle Arbeit betrifft, die zu erledigen ist.

Das größte Beispiel hierfür ist die Wiederverwendung von Schnittstellen . Sie haben es wahrscheinlich selbst gesehen. Eine Klasse implementiert eine Schnittstelle nicht, weil sie eine logische Implementierung ist (oder eine Erweiterung bei konkreten Basisklassen), sondern weil die Methoden, die sie zu diesem Zeitpunkt deklariert, für sie die richtigen Signaturen haben.

Aber dann stößt du auf ein Problem. Wenn Klassen Schnittstellen nur unter Berücksichtigung der Signaturen der von ihnen deklarierten Methoden implementieren, können Sie Instanzen von Klassen von einer konzeptionellen Funktionalität an Stellen übergeben, die völlig andere Funktionen erfordern, die nur von ähnlichen Signaturen abhängen.

Das ist nicht so schrecklich, aber es sorgt für viel Verwirrung und wir haben die Technologie, um zu verhindern, dass wir solche Fehler machen. Wir müssen Schnittstellen als API + -Protokoll behandeln . Die API wird in Deklarationen angezeigt, und das Protokoll wird in vorhandenen Verwendungen der Schnittstelle angezeigt. Wenn wir zwei konzeptionelle Protokolle haben, die dieselbe API verwenden, sollten sie als zwei verschiedene Schnittstellen dargestellt werden. Andernfalls geraten wir in den DRY-Dogmatismus und erschaffen ironischerweise nur schwer zu pflegenden Code.

Jetzt sollten Sie in der Lage sein, die Definition perfekt zu verstehen. LSP sagt: Vererben Sie nicht von einer Basisklasse und implementieren Sie Funktionen in den Unterklassen, mit denen andere Orte, die von der Basisklasse abhängen, nicht auskommen.


1
Ich habe mich angemeldet, um über diese und Spoikes Antworten abstimmen zu können - tolle Arbeit.
David Culp

7

Meinem Verständnis nach:

OCP sagt: "Wenn Sie neue Funktionen hinzufügen möchten, erstellen Sie eine neue Klasse, die eine vorhandene erweitert, anstatt sie zu ändern."

LSP sagt: "Wenn Sie eine neue Klasse erstellen, die eine vorhandene Klasse erweitert, stellen Sie sicher, dass diese vollständig mit ihrer Basis austauschbar ist."

Ich denke, sie ergänzen sich, aber sie sind nicht gleich.


4

Während es stimmt, dass OCP und LSP beide mit Modifikation zu tun haben, ist die Art der Modifikation, von der OCP spricht, nicht die, von der LSP spricht.

Das Ändern in Bezug auf OCP ist die physische Aktion eines Entwicklers , der Code in eine vorhandene Klasse schreibt .

LSP behandelt die Verhaltensänderung, die eine abgeleitete Klasse im Vergleich zu ihrer Basisklasse mit sich bringt, und die Laufzeitänderung der Programmausführung, die durch die Verwendung der Unterklasse anstelle der Superklasse verursacht werden kann.

Obwohl sie aus der Ferne ähnlich aussehen könnten, ist OCP! = LSP. Tatsächlich denke ich, dass dies die einzigen 2 SOLID-Prinzipien sind, die nicht in Bezug aufeinander verstanden werden können.


2

LSP gibt in einfachen Worten an, dass jede Instanz von Foo durch jede Instanz von Bar ersetzt werden kann, die von Foo abgeleitet ist, ohne dass die Programmfunktionalität verloren geht.

Das ist falsch. LSP gibt an, dass die Klassenleiste kein Verhalten einführen sollte, das nicht erwartet wird, wenn der Code Foo verwendet, wenn die Leiste von Foo abgeleitet ist. Es hat nichts mit Funktionsverlust zu tun. Sie können Funktionen entfernen, aber nur, wenn Code, der Foo verwendet, nicht von dieser Funktionalität abhängt.

Letztendlich ist dies jedoch in der Regel schwer zu erreichen, da Code, der Foo verwendet, die meiste Zeit von seinem Verhalten abhängt. Das Entfernen verstößt also gegen LSP. Eine solche Vereinfachung ist jedoch nur ein Teil von LSP.


Ein sehr häufiger Fall ist, dass das ersetzte Objekt Nebenwirkungen beseitigt : z. ein Dummy-Logger, der nichts ausgibt, oder ein zum Testen verwendetes Scheinobjekt.
Nutzlos

0

Über Objekte, die möglicherweise verletzen

Um den Unterschied zu verstehen, sollten Sie die Themen beider Prinzipien verstehen. Es ist kein abstrakter Teil des Codes oder der Situation, der oder die gegen einen Grundsatz verstößt. Es ist immer eine bestimmte Komponente - Funktion, Klasse oder ein Modul -, die gegen OCP oder LSP verstoßen kann.

Wer kann gegen LSP verstoßen

Man kann nur dann prüfen, ob der LSP defekt ist, wenn es eine Schnittstelle mit einem Vertrag und eine Implementierung dieser Schnittstelle gibt. Wenn die Implementierung nicht mit der Schnittstelle oder allgemein mit dem Vertrag übereinstimmt, ist der LSP defekt.

Einfachstes Beispiel:

class Container {
    // Should add the object to the container.
    void addObject(object) {
        internalArray.append(object);
    }

    int size() {
        return internalArray.size();
    }
}

class CustomContainer extends Container {
    @Override void addObject(object) {
        System.console.print("Skipping object! Ha-ha!");
    }
}

void fillWithRandomNumbers(Container container) {
    while (container.size() < 42) {
        container.addObject(Randomizer.getNumber())
    }
}

Der Vertrag addObjectsieht eindeutig vor, dass das Argument an den Container angehängt werden muss. Und CustomContainerbricht diesen Vertrag eindeutig. Somit CustomContainer.addObjectverletzt die Funktion LSP. Somit CustomContainerverletzt die Klasse LSP. Die wichtigste Konsequenz ist, dass CustomContainernicht weitergegeben werden kann fillWithRandomNumbers(). Containerkann nicht durch ersetzt werden CustomContainer.

Denken Sie an einen sehr wichtigen Punkt. Es ist nicht dieser ganze Code, der LSP bricht, es ist speziell CustomContainer.addObjectund allgemein CustomContainer, der LSP bricht. Wenn Sie angeben, dass gegen LSP verstoßen wird, sollten Sie immer zwei Dinge angeben:

  • Die Entität, die gegen LSP verstößt.
  • Der Vertrag, der von der Entität gebrochen wird.

Das ist es. Nur ein Vertrag und dessen Umsetzung. Ein Downcast im Code sagt nichts über eine LSP-Verletzung aus.

Wer kann OCP verletzen

Man kann nur dann prüfen, ob OCP verletzt wird, wenn es einen begrenzten Datensatz und eine Komponente gibt, die Werte aus diesem Datensatz verarbeitet. Wenn sich die Grenzen des Datensatzes im Laufe der Zeit ändern und dies eine Änderung des Quellcodes der Komponente erfordert, verstößt die Komponente gegen OCP.

Hört sich komplex an. Versuchen wir ein einfaches Beispiel:

enum Platform {
    iOS,
    Android
}

class PlatformDescriber {
    String describe(Platform platform) {
        switch (platform) {
            case iOS: return "iPhone OS, v10.0.1";
            case Android: return "Android, v7.1";
        }
    }
}

Der Datensatz ist die Menge der unterstützten Plattformen. PlatformDescriberist die Komponente, die Werte aus diesem Datensatz verarbeitet. Das Hinzufügen einer neuen Plattform erfordert die Aktualisierung des Quellcodes von PlatformDescriber. Somit PlatformDescriberverstößt die Klasse gegen OCP.

Ein anderes Beispiel:

class Shop {
    void sellItemToCustomer(item, customer) {
        // some buisiness logic here
        ...
        logger.logItemSold()
    }
}

class Logger {
    void logItemSold() {
        logger.logToStdErr("an item was sold")
        logger.logToRemote("an item was sold")
        logger.logToDatabase("an item was sold")
    }
}

Der "Datensatz" ist die Gruppe von Kanälen, in denen ein Protokolleintrag hinzugefügt werden soll. Loggerist die Komponente, die für das Hinzufügen von Einträgen zu allen Kanälen verantwortlich ist. Um die Unterstützung für eine andere Art der Protokollierung hinzuzufügen, muss der Quellcode von aktualisiert werden Logger. Somit Loggerverstößt die Klasse gegen OCP.

Beachten Sie, dass der Datensatz in beiden Beispielen nicht semantisch festgelegt ist. Es kann sich im Laufe der Zeit ändern. Eine neue Plattform kann entstehen. Möglicherweise wird ein neuer Protokollierungskanal erstellt. Sollte Ihre Komponente in diesem Fall aktualisiert werden, verstößt sie gegen OCP.

Die Grenzen ausreizen

Nun der knifflige Teil. Vergleichen Sie die obigen Beispiele mit den folgenden:

enum GregorianWeekDay {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}

String translateToRussian(GregorianWeekDay weekDay) {
    switch (weekDay) {
        case Monday: return "Понедельник";
        case Tuesday: return "Вторник";
        case Wednesday: return "Среда";
        case Thursday: return "Четверг";
        case Friday: return "Пятница";
        case Saturday: return "Суббота";
        case Sunday: return "Воскресенье";
    }
}

Sie könnten denken, dass es translateToRussiangegen die OCP verstößt. Aber eigentlich ist es nicht. GregorianWeekDayhat ein bestimmtes Limit von genau 7 Wochentagen mit genauen Namen. Und das Wichtigste ist, dass sich diese Grenzen semantisch nicht im Laufe der Zeit ändern können. Es wird immer 7 Tage in der Gregorianischen Woche geben. Es wird immer Montag, Dienstag usw. geben. Dieser Datensatz ist semantisch festgelegt. Es ist nicht möglich, dass translateToRussiander Quellcode geändert werden muss. Somit wird OCP nicht verletzt.

Jetzt sollte klar sein, dass eine anstrengende switchAussage nicht immer ein Hinweis auf ein gebrochenes OCP ist.

Der Unterschied

Fühle jetzt den Unterschied:

  • Das Thema von LSP ist "eine Implementierung von Schnittstelle / Vertrag". Wenn die Implementierung nicht dem Vertrag entspricht, bricht sie LSP. Es ist nicht wichtig, ob sich diese Implementierung im Laufe der Zeit ändert oder nicht, ob sie erweiterbar ist oder nicht.
  • Das Thema von OCP ist "eine Möglichkeit, auf eine Änderung der Anforderungen zu reagieren". Wenn für die Unterstützung eines neuen Datentyps der Quellcode der Komponente geändert werden muss, die diese Daten verarbeitet, bricht diese Komponente OCP ab. Es ist nicht wichtig, ob die Komponente ihren Vertrag bricht oder nicht.

Diese Bedingungen sind vollständig orthogonal.

Beispiele

In @ Spoikes Antwort ist das Prinzip des Verstoßes gegen das eine, aber das Befolgen des anderen Teils völlig falsch.

Im ersten Beispiel forverletzt der -loop-Teil eindeutig OCP, da er nicht ohne Änderung erweiterbar ist. Es gibt jedoch keinen Hinweis auf eine LSP-Verletzung. Und es ist nicht einmal klar, ob der ContextVertrag getPersons erlaubt, irgendetwas außer Bossoder zurückzugeben Peon. Selbst wenn ein Vertrag angenommen wird, der die IPersonRückgabe einer Unterklasse ermöglicht , gibt es keine Klasse, die diese Nachbedingung außer Kraft setzt und gegen sie verstößt. Wenn getPersons eine Instanz einer dritten Klasse forzurückgibt , erledigt die -loop ihre Arbeit ohne Fehler. Diese Tatsache hat jedoch nichts mit LSP zu tun.

Nächster. Im zweiten Beispiel wird weder gegen LSP noch gegen OCP verstoßen. Auch hier hat das ContextTeil nichts mit LSP zu tun - kein definierter Vertrag, keine Unterklassen, keine abbrechenden Überschreibungen. Es ist nicht Context, wer LSP gehorchen sollte, es LiskovSubsollte nicht den Vertrag seiner Basis brechen. Ist die Klasse in Bezug auf OCP wirklich geschlossen? - ja ist es. Es ist keine Änderung erforderlich, um es zu erweitern. Offensichtlich besagt der Name des Erweiterungspunkts, dass Sie alles tun können, ohne Einschränkungen . Das Beispiel ist im wirklichen Leben nicht sehr nützlich, verstößt aber eindeutig nicht gegen OCP.

Lassen Sie uns versuchen, einige korrekte Beispiele mit echter Verletzung von OCP oder LSP zu machen.

Folgen Sie OCP, aber nicht LSP

interface Platform {
    String name();
    String version();
}

class iOS implements Platform {
    @Override String name() { return "iOS"; }
    @Override String version() { return "10.0.1"; }
}

interface PlatformSerializer {
    String toJson(Platform platform);
}

class HumanReadablePlatformSerializer implements PlatformSerializer {
    String toJson(Platform platform) {
        return platform.name() + ", v" + platform.version();
    }
}

Hier sind HumanReadablePlatformSerializerkeine Änderungen erforderlich, wenn eine neue Plattform hinzugefügt wird. Somit folgt OCP.

Der Vertrag sieht jedoch vor, dass toJsonein ordnungsgemäß formatierter JSON zurückgegeben werden muss. Die Klasse macht das nicht. Aus diesem Grund kann es nicht an eine Komponente übergeben werden, die PlatformSerializerzum Formatieren des Hauptteils einer Netzwerkanforderung verwendet wird. Damit HumanReadablePlatformSerializerverstößt LSP.

Folgen Sie LSP, aber nicht OCP

Einige Änderungen zum vorherigen Beispiel:

class Android implements Platform {
    @Override String name() { return "Android"; }
    @Override String version() { return "7.1"; }
}
class HumanReadablePlatformSerializer implements PlatformSerializer {
    String toJson(Platform platform) {
        return "{ "
                + "\"name\": \"" + platform.name() + "\","
                + "\"version\": \"" + platform.version() + "\","
                + "\"most-popular\": " + isMostPopular(platform) + ","
                + "}"
    }

    boolean isMostPopular(Platform platform) {
        return (platform instanceof Android)
    }
}

Der Serializer gibt eine korrekt formatierte JSON-Zeichenfolge zurück. Also keine LSP-Verletzung hier.

Wenn die Plattform jedoch am meisten genutzt wird, muss in JSON eine entsprechende Angabe vorhanden sein. In diesem Beispiel wird OCP durch die HumanReadablePlatformSerializer.isMostPopularFunktion verletzt , da iOS eines Tages zur beliebtesten Plattform wird. Formal bedeutet dies, dass der Satz der am häufigsten verwendeten Plattformen derzeit als "Android" definiert ist und isMostPopularmit diesem Datensatz nur unzureichend umgeht. Der Datensatz ist nicht semantisch festgelegt und kann sich im Laufe der Zeit frei ändern. HumanReadablePlatformSerializerDer Quellcode von muss im Falle einer Änderung aktualisiert werden.

Möglicherweise stellen Sie in diesem Beispiel auch eine Verletzung der Einzelverantwortung fest. Ich habe es absichtlich so gemacht, dass ich beide Prinzipien am selben Subjekt demonstrieren kann. Um SRP zu reparieren, können Sie die isMostPopularFunktion auf eine externe Ebene extrahieren Helperund einen Parameter hinzufügen PlatformSerializer.toJson. Aber das ist eine andere Geschichte.


0

LSP und OCP sind nicht dasselbe.

LSP spricht über die Richtigkeit des Programms in seiner jetzigen Form . Wenn eine Instanz eines Subtyps beim Einsetzen in den Code für Vorgängertypen die Programmkorrektheit beeinträchtigen würde, liegt ein Verstoß gegen LSP vor. Möglicherweise müssen Sie einen Test nachahmen, um dies zu zeigen, aber Sie müssen die zugrunde liegende Codebasis nicht ändern. Sie validieren das Programm selbst, um festzustellen, ob es LSP erfüllt.

OCP spricht über die Richtigkeit von Änderungen im Programmcode, das Delta von einer Quellversion zur anderen. Verhalten sollte nicht geändert werden. Es sollte nur erweitert werden. Das klassische Beispiel ist die Feldaddition. Alle vorhandenen Felder arbeiten weiterhin wie bisher. Das neue Feld fügt nur Funktionalität hinzu. Das Löschen eines Feldes ist jedoch in der Regel eine Verletzung von OCP. Hier validieren Sie das Programmversions-Delta, um festzustellen, ob es OCP erfüllt.

Das ist also der Hauptunterschied zwischen LSP und OCP. Ersteres überprüft nur die aktuelle Codebasis , letzteres überprüft nur das Codebasis-Delta von einer Version zur nächsten . Als solche sind sie nicht die gleiche Sache sein können, werden sie definiert als Validierung verschiedene Dinge.

Ich gebe Ihnen einen formelleren Beweis: Zu sagen, dass "LSP impliziert OCP" ein Delta impliziert (da OCP ein anderes als das im trivialen Fall erfordert), erfordert LSP jedoch kein Delta. Das ist also eindeutig falsch. Umgekehrt können wir "OCP impliziert LSP" einfach widerlegen, indem wir sagen, OCP ist eine Aussage über Deltas, daher sagt sie nichts über eine Aussage über ein vorhandenes Programm aus. Dies ergibt sich aus der Tatsache, dass Sie ein beliebiges Delta erstellen können, indem Sie mit einem beliebigen Programm beginnen. Sie sind völlig unabhängig.


-1

Ich würde es aus Sicht des Kunden betrachten. Wenn der Client Funktionen einer Schnittstelle verwendet und diese Funktion intern von Klasse A implementiert wurde, wird es eine Klasse B geben, die Klasse A erweitert. Wenn ich morgen Klasse A von dieser Schnittstelle entferne und Klasse B setze, sollte Klasse B dies tun Stellen Sie dem Client dieselben Funktionen zur Verfügung. Ein Standardbeispiel ist eine Entenklasse, die schwimmt, und wenn ToyDuck Duck erweitert, sollte sie auch schwimmen und sich nicht darüber beklagen, dass sie nicht schwimmen kann, da ToyDuck sonst keine erweiterte Entenklasse haben sollte.


Es wäre sehr konstruktiv, wenn die Leute Kommentare abgeben würden, während sie eine Antwort ablehnen. Schließlich sind wir alle hier, um Wissen zu teilen, und ein Urteil ohne richtigen Grund zu fällen, wird keinen Zweck erfüllen.
AKS

dies scheint nicht zu bieten alles wesentliche über gemacht Punkte und erläuterte vor 6 Antworten
gnat

1
Es hört sich so an, als ob Sie nur eines der Prinzipien erklären, das L, das ich denke. Für was ist es in Ordnung, aber die Frage nach einem Vergleich / Kontrast von zwei verschiedenen Prinzipien gestellt. Das ist wahrscheinlich der Grund, warum jemand es abgelehnt hat.
StarWeaver
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.