LSP vs OCP / Liskov Substitution VS Open Close


48

Próbuję zrozumieć SOLIDNE zasady OOP i doszedłem do wniosku, że LSP i OCP mają pewne podobieństwa (jeśli nie powiedzieć więcej).

zasada otwarta / zamknięta stwierdza: „jednostki oprogramowania (klasy, moduły, funkcje itp.) powinny być otwarte na rozszerzenie, ale zamknięte na modyfikację”.

LSP w prostych słowach stwierdza, że ​​dowolną instancję Foomożna zastąpić dowolną instancją, z Barktórej pochodzi, Fooa program będzie działał w ten sam sposób.

Nie jestem pro programistą OOP, ale wydaje mi się, że LSP jest możliwe tylko wtedy Bar, gdy wywodzi się z Fooniczego w nim nie zmienia, a jedynie go rozszerza. Oznacza to, że w szczególności program LSP jest prawdziwy tylko wtedy, gdy OCP jest prawdziwy, a OCP jest prawdziwy tylko wtedy, gdy LSP jest prawdziwy. Oznacza to, że są równi.

Popraw mnie, jeśli się mylę. Naprawdę chcę zrozumieć te pomysły. Wielkie dzięki za odpowiedź.


4
Jest to bardzo wąska interpretacja obu pojęć. Otwarte / zamknięte mogą być utrzymywane, ale nadal naruszają LSP. Przykłady Rectangle / Square lub Ellipse / Circle są dobrymi ilustracjami. Oba przestrzegają OCP, ale oba naruszają LSP.
Joel Etherton,

1
Świat (a przynajmniej Internet) jest zdezorientowany. kirkk.com/modularity/2009/12/solid-principles-of-class-design . Ten facet mówi, że naruszenie LSP jest również naruszeniem OCP. A potem w książce „Software Engineering Design: Theory and Practice” na stronie 156 autor podaje przykład czegoś, co przestrzega OCP, ale narusza LSP. Zrezygnowałem z tego.
Manoj R

@JeelEtherton Te pary naruszają LSP tylko wtedy, gdy można je modyfikować. W przypadku niezmiennej, wynikające Squarez Rectanglenie narusza LSP. (Ale prawdopodobnie nadal jest to zły projekt w niezmiennej sprawie, ponieważ możesz mieć kwadrat, Rectanglektóry nie jest taki, Squarektóry nie pasuje do matematyki)
CodesInChaos

Prosta analogia (z punktu widzenia pisarza biblioteki-użytkownika). LSP jest jak sprzedaż produktu (biblioteki), który twierdzi, że implementuje 100% tego, co mówi (w interfejsie lub instrukcji użytkownika), ale tak naprawdę nie (lub nie pasuje do tego, co zostało powiedziane). OCP jest jak sprzedaż produktu (biblioteki) z obietnicą, że można go zaktualizować (rozszerzyć), gdy pojawi się nowa funkcjonalność (jak oprogramowanie układowe), ale w rzeczywistości nie można go zaktualizować bez usługi fabrycznej.
rwong,

Odpowiedzi:


119

Rany, istnieją dziwne nieporozumienia na temat tego, co OCP i LSP, a niektóre są spowodowane niedopasowaniem niektórych terminologii i mylącymi przykładami. Obie zasady są tylko „tym samym”, jeśli zastosujesz je w ten sam sposób. Wzory zwykle są zgodne z tymi zasadami w ten czy inny sposób, z kilkoma wyjątkami.

Różnice zostaną wyjaśnione w dalszej części, ale najpierw przyjrzyjmy się samym zasadom:

Zasada otwartego i zamkniętego (OCP)

Według wujka Boba :

Powinieneś być w stanie przedłużyć zachowanie klas bez ich modyfikowania.

Zauważ, że słowo „ rozszerzenie” w tym przypadku niekoniecznie oznacza, że ​​należy podklasować klasę, która potrzebuje nowego zachowania. Zobacz, jak wspomniałem przy pierwszym niedopasowaniu terminologii? Słowo kluczowe extendoznacza tylko podklasę w Javie, ale zasady są starsze niż Java.

Oryginał pochodzi z Bertrand Meyer w 1988 roku:

Elementy oprogramowania (klasy, moduły, funkcje itp.) Powinny być otwarte dla rozszerzenia, ale zamknięte dla modyfikacji.

Tutaj jest o wiele wyraźniej, że zasada jest stosowana do bytów oprogramowania . Zły przykład to zastąpienie encji programowej, ponieważ modyfikujesz kod całkowicie zamiast dostarczać jakiegoś punktu rozszerzenia. Zachowanie samej jednostki programowej powinno być rozszerzalne, a dobrym przykładem jest implementacja wzorca strategii (ponieważ najłatwiej jest pokazać grupę wzorców GoF IMHO):

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

W powyższym przykładzie Contextjest zablokowany dla dalszych modyfikacji. Większość programistów prawdopodobnie chciałaby podklasować klasę, aby ją rozszerzyć, ale tutaj nie robimy tego, ponieważ zakłada ona, że ​​jej zachowanie można zmienić za pomocą wszystkiego, co implementuje IBehaviorinterfejs.

Tj. Klasa kontekstu jest zamknięta do modyfikacji, ale otwarta do rozszerzenia . W rzeczywistości jest zgodny z inną podstawową zasadą, ponieważ przypisujemy zachowanie kompozycji obiektu zamiast dziedziczenia:

„Preferuj„ kompozycję obiektów ”nad„ dziedziczeniem klas ”.” (Gang of Four 1995: 20)

Pozwolę czytelnikowi przeczytać tę zasadę, ponieważ jest to poza zakresem tego pytania. Kontynuując przykład, powiedzmy, że mamy następujące implementacje interfejsu IBehavior:

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

Za pomocą tego wzorca możemy zmodyfikować zachowanie kontekstu w czasie wykonywania, za pomocą setBehaviormetody jako punktu rozszerzenia.

// 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!"

Dlatego za każdym razem, gdy chcesz rozszerzyć „zamkniętą” klasę kontekstową, zrób to przez podklasowanie jej „otwartej” zależności współpracy. To oczywiście nie jest to samo, co podklasowanie samego kontekstu, ale jest to OCP. LSP również nie wspomina o tym.

Rozszerzanie za pomocą Mixin zamiast dziedziczenia

Istnieją inne sposoby wykonywania OCP inne niż podklasowanie. Jednym ze sposobów jest utrzymanie otwartych klas na rozszerzenie poprzez zastosowanie mixin . Jest to przydatne np. W językach opartych na prototypach, a nie klasach. Chodzi o to, aby zmienić obiekt dynamiczny za pomocą większej liczby metod lub atrybutów, zgodnie z potrzebami, innymi słowy obiekty, które łączą się lub „łączą” z innymi obiektami.

Oto przykładowy mixin, który renderuje prosty szablon HTML dla kotwic:

// 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>

Chodzi o to, aby dynamicznie rozszerzać obiekty, a zaletą tego jest to, że obiekty mogą dzielić metody, nawet jeśli znajdują się w zupełnie innych domenach. W powyższym przypadku możesz łatwo tworzyć inne rodzaje kotwic HTML, rozszerzając swoją konkretną implementację za pomocą LinkMixin.

Pod względem OCP „mixiny” są rozszerzeniami. W powyższym przykładzie YoutubeLinkjest to nasza jednostka oprogramowania, która jest zamknięta dla modyfikacji, ale otwarta na rozszerzenia poprzez użycie mixin. Hierarchia obiektów jest spłaszczona, co uniemożliwia sprawdzenie typów. Jednak nie jest to naprawdę zła rzecz, i wyjaśnię dalej, że sprawdzanie typów jest ogólnie złym pomysłem i łamie pomysł z polimorfizmem.

Zauważ, że za pomocą tej metody można wykonać wielokrotne dziedziczenie, ponieważ większość extendimplementacji może mieszać wiele obiektów:

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

Jedyną rzeczą, o której musisz pamiętać, to nie kolidować nazw, tzn. Mixiny definiują tę samą nazwę niektórych atrybutów lub metod, które zostaną zastąpione. Z mojego skromnego doświadczenia wynika, że ​​nie stanowi to problemu, a jeśli tak się stanie, jest to oznaką wadliwego projektu.

Liskov's Substitution Principle (LSP)

Wujek Bob definiuje to po prostu przez:

Klasy pochodne muszą być substytucyjne dla klas podstawowych.

Ta zasada jest stara, w rzeczywistości definicja wuja Boba nie rozróżnia zasad, ponieważ sprawia, że ​​LSP jest nadal blisko spokrewniona z OCP przez fakt, że w powyższym przykładzie Strategii zastosowano ten sam nadtyp ( IBehavior). Spójrzmy więc na oryginalną definicję Barbary Liskov i zobaczmy, czy możemy dowiedzieć się czegoś więcej o tej zasadzie, która wygląda jak twierdzenie matematyczne:

Potrzebna jest tutaj następująca właściwość podstawienia: Jeśli dla każdego obiektu o1typu Sistnieje obiekt o2typu Ttaki, że dla wszystkich programów Pzdefiniowanych w kategoriach Tzachowanie się Ppozostaje niezmienione, gdy o1jest podstawiony, o2to Sjest podtyp T.

Przez chwilę wzruszamy ramionami, zauważ, że w ogóle nie wspomina o klasach. W JavaScript możesz śledzić LSP, nawet jeśli nie jest on wyraźnie oparty na klasach. Jeśli Twój program ma listę co najmniej kilku obiektów JavaScript, które:

  • musi być obliczony w ten sam sposób,
  • zachowują się tak samo i
  • w inny sposób są zupełnie inne

... wtedy obiekty są uważane za mające ten sam „typ” i nie ma to tak naprawdę znaczenia dla programu. Jest to zasadniczo polimorfizm . W sensie ogólnym; nie powinieneś znać faktycznego podtypu, jeśli używasz jego interfejsu. OCP nie mówi nic na ten temat. Wskazuje także na błąd projektowy, który popełniają większość początkujących programistów:

Ilekroć odczuwasz potrzebę sprawdzenia podtypu obiektu, najprawdopodobniej robisz to NIEPRAWIDŁOWO.

Okej, więc może nie być źle przez cały czas, ale jeśli masz ochotę sprawdzić jakieś typy za pomocą instanceoflub wyliczeń, być może program jest dla ciebie bardziej skomplikowany niż powinien. Lecz nie zawsze tak jest; szybkie i brudne włamania, aby wszystko działało, są w porządku ustępstwem, które można sobie wyobrazić, jeśli rozwiązanie jest wystarczająco małe, a jeśli ćwiczysz bezlitosne refaktoryzowanie , może ulec poprawie, gdy wymagają tego zmiany.

Istnieją różne sposoby rozwiązania tego „błędu projektowego”, w zależności od rzeczywistego problemu:

  • Superklasa nie wywołuje wymagań wstępnych, zmuszając osobę dzwoniącą do zrobienia tego zamiast tego.
  • Superklasa nie ma ogólnej metody wymaganej przez osobę dzwoniącą.

Oba są typowymi „błędami” przy projektowaniu kodu. Istnieje kilka różnych refaktoryzacji, takich jak metoda pull-up lub refaktoryzacja do wzorca takiego jak wzorzec gościa .

W rzeczywistości bardzo podoba mi się wzorzec Visitor, ponieważ może on zająć się dużym spaghetti z instrukcją if, a jego implementacja jest łatwiejsza niż myślisz o istniejącym kodzie. Powiedzmy, że mamy następujący kontekst:

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"

Wyniki instrukcji if mogą zostać przetłumaczone na ich odwiedzających, ponieważ każda z nich zależy od decyzji i kodu do uruchomienia. Możemy wyodrębnić je w następujący sposób:

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

W tym momencie, jeśli programista nie wiedział o wzorcu gościa, zamiast tego zaimplementował klasę Context, aby sprawdzić, czy jest ona pewnego rodzaju. Ponieważ klasy Visitor mają canDometodę logiczną , implementator może użyć tego wywołania metody, aby ustalić, czy jest to właściwy obiekt do wykonania zadania. Klasa kontekstowa może wykorzystywać wszystkich odwiedzających (i dodawać nowych) w następujący sposób:

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

Oba wzorce są zgodne z OCP i LSP, jednak oba wskazują różne rzeczy na ich temat. Jak więc wygląda kod, jeśli narusza jedną z zasad?

Naruszenie jednej zasady, ale przestrzeganie drugiej

Istnieją sposoby na złamanie jednej z zasad, ale nadal należy przestrzegać drugiej. Poniższe przykłady wydają się wymyślone, nie bez powodu, ale w rzeczywistości zauważyłem, że pojawiają się one w kodzie produkcyjnym (a nawet gorzej):

Obserwuje OCP, ale nie LSP

Powiedzmy, że mamy podany kod:

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() { ... }
}

Ten fragment kodu działa zgodnie z zasadą otwartego i zamkniętego. Jeśli wywołamy GetPersonsmetodę kontekstu , otrzymamy grupę osób z własnymi implementacjami. Oznacza to, że IPerson jest zamknięty dla modyfikacji, ale otwarty dla rozszerzenia. Gdy jednak musimy go użyć, sytuacja zmienia się w mroczny sposób:

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

Musisz wykonać sprawdzanie typów i konwersję typów! Pamiętasz, jak wspomniałem powyżej, że sprawdzanie typu jest złe ? O nie! Ale nie bój się, jak wspomniano powyżej, albo dokonaj refaktoryzacji typu pull-up, albo zaimplementuj wzorzec Visitor. W takim przypadku możemy po prostu zrobić refaktoryzację po dodaniu ogólnej metody:

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

Korzyścią jest teraz to, że nie musisz już znać dokładnego typu, zgodnie z LSP:

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

Podąża za LSP, ale nie OCP

Spójrzmy na kod, który następuje po LSP, ale nie OCP, jest trochę wymyślony, ale proszę o cierpliwość w tym, to bardzo subtelny błąd:

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

Kod wykonuje LSP, ponieważ kontekst może korzystać z LiskovBase bez znajomości faktycznego typu. Można by pomyśleć, że ten kod jest zgodny z OCP, ale spójrz dokładnie, czy klasa jest naprawdę zamknięta ? Co jeśli doStuffmetoda nie tylko wydrukowała wiersz?

Odpowiedź, jeśli wynika z OCP, brzmi po prostu: NIE , nie dlatego, że w tym projekcie obiektu musimy całkowicie zastąpić kod czymś innym. Otwiera to puszkę robaków typu „wklej i wklej”, ponieważ musisz skopiować kod z klasy podstawowej, aby wszystko działało. doStuffMetoda na pewno jest otwarta do rozszerzenia, ale nie było całkowicie zamknięte dla modyfikacji.

Możemy zastosować do tego wzorzec metody Szablon . Wzorzec metody szablonów jest tak powszechny w frameworkach, że mógłbyś go używać nie wiedząc o tym (np. Komponenty Java Swing, formularze i komponenty C # itp.). Oto jeden ze sposobów zamknięcia doStuffmetody modyfikacji i upewnienia się, że pozostanie ona zamknięta poprzez oznaczenie jej finalsłowem kluczowym java . To słowo kluczowe uniemożliwia dalszą podklasę klasy (w języku C # można użyć sealedtego samego).

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!";
    }
}

Ten przykład jest zgodny z OCP i wydaje się głupiutki, ale wyobraź sobie, że to przeskalowane z większą ilością kodu do obsługi. Wciąż widzę kod wdrożony w środowisku produkcyjnym, w którym podklasy całkowicie przesłaniają wszystko, a przesłonięty kod jest w większości wycinany i wklejany między implementacjami. Działa, ale podobnie jak w przypadku całego powielania kodu, jest to również konfiguracja do koszmarów związanych z konserwacją.

Wniosek

Mam nadzieję, że to wszystko wyjaśnia niektóre pytania dotyczące OCP i LSP oraz różnic / podobieństw między nimi. Łatwo je odrzucić jako takie same, ale powyższe przykłady powinny pokazać, że nie są.

Zauważ, że zbierając z powyższego przykładowego kodu:

  • OCP polega na zablokowaniu działającego kodu, ale nadal utrzymuje go w jakiś sposób za pomocą pewnego rodzaju punktów rozszerzeń.

    Ma to na celu uniknięcie powielania kodu poprzez enkapsulację kodu, który zmienia się jak w przykładzie wzorca metody szablonu. Pozwala to również szybko zawieść, ponieważ przełamywanie zmian jest bolesne (tj. Zmiana jednego miejsca, złamanie go wszędzie indziej). Ze względu na utrzymanie koncepcji kapsułkowanie zmian jest dobrą rzeczą, ponieważ zmiany zawsze się zdarzają.

  • LSP polega na umożliwieniu użytkownikowi obsługi różnych obiektów, które implementują nadtyp, bez sprawdzania, jaki jest rzeczywisty typ. Na tym właśnie polega polimorfizm .

    Ta zasada stanowi alternatywę dla sprawdzania i konwersji typów, które mogą wymknąć się spod kontroli wraz ze wzrostem liczby typów, i można to osiągnąć poprzez refaktoryzację typu pull-up lub zastosowanie wzorców, takich jak Visitor.


7
To dobre wytłumaczenie, ponieważ nie upraszcza OCP, sugerując, że zawsze oznacza implementację przez dziedziczenie. Jest to nadmierne uproszczenie, które łączy OCP i SRP w umysłach niektórych ludzi, podczas gdy tak naprawdę mogą to być dwie całkowicie odrębne koncepcje.
Eric King,

5
To jedna z najlepszych odpowiedzi na wymianę stosów, jaką kiedykolwiek widziałem. Chciałbym móc to zagłosować 10 razy. Dobra robota i dziękuję za doskonałe wyjaśnienie.
Bob Horn

Tam dodałem napis na Javascript, który nie jest językiem programowania opartym na klasach, ale nadal może śledzić LSP i edytował tekst, więc mam nadzieję, że czyta bardziej płynnie. Uff!
Spoike,

Chociaż twój cytat wuja Boba z LSP jest prawidłowy (tak samo jak jego strona internetowa), czy nie powinno być odwrotnie? Czy nie powinno być stwierdzone, że „klasy podstawowe powinny być substytucyjne dla klas pochodnych”? Na LSP test „zgodności” jest przeprowadzany względem klasy pochodnej, a nie klasy bazowej. Mimo to nie jestem ojczystym językiem angielskim i myślę, że mogą istnieć pewne szczegóły dotyczące frazy, której mogę brakować.
Alfa

@Alpha: To dobre pytanie. Klasę podstawową zawsze można zastąpić klasami pochodnymi, w przeciwnym razie dziedziczenie nie zadziałałoby. Kompilator (przynajmniej w Javie i C #) będzie narzekał, jeśli pominiesz członka (metodę lub atrybut / pole) z rozszerzonej klasy, która musi zostać zaimplementowana. LSP ma powstrzymywać cię przed dodawaniem metod, które są dostępne tylko lokalnie w klasach pochodnych, ponieważ wymaga to, aby użytkownik tych klas pochodnych wiedział o nich. W miarę wzrostu kodu trudno byłoby utrzymać takie metody.
Spoike 14.12

15

Jest to coś, co powoduje wiele zamieszania. Wolę rozważać te zasady nieco filozoficznie, ponieważ istnieje wiele różnych przykładów, a czasem konkretne przykłady nie oddają w istocie całej ich istoty.

Co OCP próbuje naprawić

Powiedzmy, że musimy dodać funkcjonalność do danego programu. Najłatwiejszym sposobem, aby to zrobić, szczególnie dla osób, które zostały przeszkolone do myślenia proceduralnego, jest dodanie klauzuli if wszędzie tam, gdzie jest to potrzebne, lub czegoś podobnego.

Problemy z tym są

  1. Zmienia przepływ istniejącego, działającego kodu.
  2. Wymusza nowe rozgałęzienie warunkowe w każdym przypadku. Załóżmy na przykład, że masz listę książek, a niektóre z nich są na wyprzedaży i chcesz iterować je wszystkie i wydrukować ich cenę, tak aby w przypadku wyprzedaży drukowana cena zawierała ciąg „ (NA WYPRZEDAŻY)".

Możesz to zrobić, dodając dodatkowe pole do wszystkich książek o nazwie „is_on_sale”, a następnie możesz sprawdzić to pole, drukując cenę dowolnej książki, lub alternatywnie możesz utworzyć wystąpienie książek o wyprzedażach z bazy danych przy użyciu innego typu, który drukuje „(W SPRZEDAŻY)” w przedziale cenowym (nie jest to idealny projekt, ale zapewnia sens domu).

Problem z pierwszym rozwiązaniem proceduralnym jest dodatkowym polem dla każdej książki i w wielu przypadkach dodatkową zbędną złożonością. Drugie rozwiązanie wymusza logikę tylko tam, gdzie jest rzeczywiście wymagana.

Teraz rozważ fakt, że może istnieć wiele przypadków, w których wymagane są różne dane i logika, a zobaczysz, dlaczego warto pamiętać o OCP podczas projektowania klas lub reagowania na zmiany wymagań, jest dobrym pomysłem.

Do tej pory powinieneś wpaść na główny pomysł: postaraj się znaleźć w sytuacji, w której nowy kod może zostać zaimplementowany jako rozszerzenia polimorficzne, a nie modyfikacje proceduralne.

Ale nigdy nie bój się analizować kontekstu i sprawdź, czy wady przeważają nad korzyściami, ponieważ nawet taka zasada, jak OCP, może sprawić, że 20-klasowy bałagan z programu 20-liniowego, jeśli nie zostanie potraktowany ostrożnie .

Co LSP próbuje naprawić

Wszyscy lubimy używać kodu ponownie. Następująca choroba polega na tym, że wiele programów nie rozumie tego całkowicie, do tego stopnia, że ​​ślepo fakturują wspólne wiersze kodu tylko po to, aby stworzyć nieczytelne złożoności i nadmiarowe ścisłe sprzężenie między modułami, które oprócz kilku wierszy kodu, nie mają ze sobą nic wspólnego, jeśli chodzi o koncepcyjne prace do wykonania.

Największym tego przykładem jest ponowne użycie interfejsu . Prawdopodobnie sam to widziałeś; klasa implementuje interfejs nie dlatego, że jest jego logiczną implementacją (lub rozszerzeniem w przypadku konkretnych klas bazowych), ale dlatego, że metody, które deklaruje w tym momencie, mają odpowiednie podpisy, jeśli chodzi o to.

Ale wtedy napotykasz problem. Jeśli klasy implementują interfejsy tylko biorąc pod uwagę podpisy metod, które deklarują, wówczas jesteś w stanie przekazać instancje klas z jednej funkcjonalności pojęciowej do miejsc, które wymagają zupełnie innej funkcjonalności, które tylko zależą od podobnych podpisów.

To nie jest takie okropne, ale powoduje wiele zamieszania, a my mamy technologię, która pozwala nam uniknąć takich błędów. Musimy traktować interfejsy jako API + protokół . Interfejs API jest widoczny w deklaracjach, a protokół jest widoczny w istniejących zastosowaniach interfejsu. Jeśli mamy 2 protokoły koncepcyjne, które korzystają z tego samego interfejsu API, powinny być reprezentowane jako 2 różne interfejsy. W przeciwnym razie wpadniemy w DRY dogmatyzm i, jak na ironię, trudniej jest utrzymać kod.

Teraz powinieneś być w stanie doskonale zrozumieć definicję. LSP mówi: Nie dziedzicz po klasie podstawowej i nie implementuj funkcji w tych podklasach, z którymi inne miejsca, które zależą od klasy podstawowej, nie będą się dogadać.


1
Zapisałem się tylko po to, by móc głosować na to i odpowiedzi Spoike - świetna robota.
David Culp,

7

Z mojego zrozumienia:

OCP mówi: „Jeśli dodasz nową funkcjonalność, utwórz nową klasę, rozszerzając istniejącą, zamiast ją zmieniać”.

LSP mówi: „Jeśli tworzysz nową klasę rozszerzającą istniejącą klasę, upewnij się, że jest ona całkowicie wymienna z jej bazą”.

Myślę więc, że się uzupełniają, ale nie są równe.


4

Chociaż prawdą jest, że zarówno OCP, jak i LSP mają do czynienia z modyfikacją, rodzaj modyfikacji, o której mówi OCP, nie jest tą, o której mówi LSP.

Modyfikacja w odniesieniu do OCP to fizyczne działanie programisty piszącego kod w istniejącej klasie.

LSP zajmuje się modyfikacją zachowania wprowadzoną przez klasę pochodną w porównaniu z klasą podstawową oraz zmianą czasu wykonywania programu, która może być spowodowana użyciem podklasy zamiast nadklasy.

Więc chociaż mogą wyglądać podobnie z odległości OCP! = LSP. W rzeczywistości myślę, że mogą to być jedyne 2 SOLIDNE zasady, których nie można zrozumieć w kategoriach siebie.


2

LSP w prostych słowach stwierdza, że ​​każdą instancję Foo można zastąpić dowolną instancją Bar, która pochodzi od Foo bez utraty funkcjonalności programu.

To jest źle. LSP stwierdza, że ​​klasa Bar nie powinna wprowadzać zachowania, którego nie należy oczekiwać, gdy kod korzysta z Foo, gdy Bar pochodzi z Foo. Nie ma to nic wspólnego z utratą funkcjonalności. Możesz usunąć funkcjonalność, ale tylko wtedy, gdy kod korzystający z Foo nie zależy od tej funkcjonalności.

Ale ostatecznie jest to zwykle trudne do osiągnięcia, ponieważ przez większość czasu kod używający Foo zależy od całego jego zachowania. Więc usunięcie go narusza LSP. Ale uproszczenie w ten sposób jest tylko częścią LSP.


Bardzo częstym przypadkiem jest to, że podstawiony obiekt usuwa skutki uboczne : np. fikcyjny rejestrator, który nic nie wyświetla, lub próbny obiekt użyty do testowania.
Bezużyteczne

0

O obiektach, które mogą naruszać

Aby zrozumieć różnicę, powinieneś zrozumieć tematy obu zasad. To nie jest jakaś abstrakcyjna część kodu lub sytuacja, która może naruszać jakąś zasadę. Zawsze jest jakiś określony komponent - funkcja, klasa lub moduł - który może naruszać OCP lub LSP.

Kto może naruszać LSP

Można sprawdzić, czy LSP jest zepsuty tylko wtedy, gdy istnieje interfejs z jakąś umową i implementacją tego interfejsu. Jeśli implementacja nie jest zgodna z interfejsem lub, ogólnie rzecz biorąc, z umową, LSP jest zepsuty.

Najprostszy przykład:

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

Umowa wyraźnie stanowi, że addObjectnależy dołączyć swój argument do kontenera. I CustomContainerwyraźnie łamie ten kontrakt. Zatem CustomContainer.addObjectfunkcja narusza LSP. Zatem CustomContainerklasa narusza LSP. Najważniejszą konsekwencją jest to, że CustomContainernie można tego przekazać fillWithRandomNumbers(). Containernie można go zastąpić CustomContainer.

Pamiętaj o bardzo ważnym punkcie. To nie cały kod łamie LSP, to konkretnie CustomContainer.addObjecti ogólnie CustomContainerłamie LSP. Kiedy stwierdzisz, że LSP jest naruszony, zawsze powinieneś podać dwie rzeczy:

  • Podmiot, który narusza LSP.
  • Umowa zerwana przez podmiot.

Otóż ​​to. Tylko umowa i jej realizacja. Spuszczony kod nie mówi nic o naruszeniu LSP.

Kto może naruszać OCP

Można sprawdzić, czy OCP jest naruszony tylko wtedy, gdy istnieje ograniczony zestaw danych i składnik, który obsługuje wartości z tego zestawu danych. Jeśli limity zestawu danych mogą się zmieniać w czasie, co wymaga zmiany kodu źródłowego komponentu, wówczas komponent narusza OCP.

Brzmi skomplikowanie. Spróbujmy prostego przykładu:

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

Zestaw danych to zestaw obsługiwanych platform. PlatformDescriberjest komponentem, który obsługuje wartości z tego zestawu danych. Dodanie nowej platformy wymaga zaktualizowania kodu źródłowego PlatformDescriber. Zatem PlatformDescriberklasa narusza OCP.

Inny przykład:

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

„Zestaw danych” to zestaw kanałów, do których należy dodać wpis do dziennika. Loggerto komponent odpowiedzialny za dodawanie wpisów do wszystkich kanałów. Dodanie obsługi innego sposobu rejestrowania wymaga zaktualizowania kodu źródłowego Logger. Zatem Loggerklasa narusza OCP.

Zauważ, że w obu przykładach zestaw danych nie jest czymś semantycznie ustalonym. Z czasem może się zmieniać. Może pojawić się nowa platforma. Może pojawić się nowy kanał logowania. Jeśli twój komponent powinien zostać zaktualizowany, kiedy to się stanie, narusza OCP.

Przekraczanie granic

Teraz trudna część. Porównaj powyższe przykłady z następującymi:

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 "Воскресенье";
    }
}

Możesz myśleć, że translateToRussiannarusza OCP. Ale tak naprawdę nie jest. GregorianWeekDayma określony limit dokładnie 7 dni tygodnia z dokładnymi nazwami. Ważne jest to, że ograniczenia te semantycznie nie mogą się zmieniać w czasie. W gregoriańskim tygodniu zawsze będzie 7 dni. Zawsze będzie poniedziałek, wtorek itp. Ten zestaw danych jest semantycznie ustalony. Nie jest możliwe, aby translateToRussiankod źródłowy wymagał modyfikacji. W ten sposób OCP nie jest naruszane.

Teraz powinno być jasne, że wyczerpujące switchstwierdzenie nie zawsze oznacza złamanie OCP.

Różnica

Teraz poczuj różnicę:

  • Przedmiotem LSP jest „wdrożenie interfejsu / umowy”. Jeśli wdrożenie nie jest zgodne z umową, powoduje uszkodzenie LSP. Nie jest ważne, czy ta implementacja może się zmieniać z czasem, czy może być rozszerzalna, czy nie.
  • Przedmiotem OCP jest „sposób reagowania na zmianę wymagań”. Jeśli obsługa nowego typu danych wymaga zmiany kodu źródłowego komponentu, który obsługuje te dane, wówczas ten komponent łamie OCP. Nie ma znaczenia, czy składnik zrywa umowę, czy nie.

Warunki te są całkowicie ortogonalne.

Przykłady

W @ odpowiedź Spoike za Naruszenie jedną zasadę, ale po drugiej strony jest całkowicie błędne.

W pierwszym przykładzie forczęść-Loop wyraźnie narusza OCP, ponieważ nie można go rozszerzać bez modyfikacji. Ale nic nie wskazuje na naruszenie LSP. I nawet nie jest jasne, czy Contextumowa zezwala na zwrot przez PayPersons cokolwiek oprócz Bosslub Peon. Nawet przy założeniu kontraktu, który pozwala IPersonna zwrócenie dowolnej podklasy, nie ma klasy, która nadpisuje ten warunek końcowy i narusza go. Co więcej, jeśli getPersons zwróci instancję jakiejś trzeciej klasy, for-loop wykona swoją pracę bez żadnych awarii. Ale ten fakt nie ma nic wspólnego z LSP.

Kolejny. W drugim przykładzie ani LSP, ani OCP nie są naruszone. Ponownie, Contextczęść po prostu nie ma nic wspólnego z LSP - brak zdefiniowanej umowy, brak podklasy, brak nadpisywania łamania. To nie jest ten, Contextkto powinien być posłuszny LSP, nie LiskovSubpowinien zerwać kontraktu jego bazy. Jeśli chodzi o OCP, czy klasa jest naprawdę zamknięta? - Tak to jest. Nie jest wymagana modyfikacja, aby go rozszerzyć. Oczywiście nazwa punktu rozszerzenia mówi: rób co chcesz, bez ograniczeń . Ten przykład nie jest zbyt przydatny w prawdziwym życiu, ale wyraźnie nie narusza OCP.

Spróbujmy podać kilka poprawnych przykładów z prawdziwym naruszeniem OCP lub LSP.

Postępuj zgodnie z OCP, ale nie 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();
    }
}

Tutaj HumanReadablePlatformSerializernie wymaga żadnych modyfikacji po dodaniu nowej platformy. Tak więc następuje OCP.

Ale umowa wymaga, toJsonaby zwracał poprawnie sformatowany JSON. Klasa tego nie robi. Z tego powodu nie można go przekazać komponentowi, który używa PlatformSerializerdo sformatowania treści żądania sieciowego. W ten sposób HumanReadablePlatformSerializernarusza LSP.

Postępuj zgodnie z LSP, ale nie OCP

Niektóre modyfikacje poprzedniego przykładu:

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

Serializator zwraca poprawnie sformatowany ciąg JSON. Więc nie ma tu naruszenia LSP.

Istnieje jednak wymóg, aby jeśli platforma była w większości wykorzystywana, wówczas w JSON powinno znajdować się odpowiednie wskazanie. W tym przykładzie HumanReadablePlatformSerializer.isMostPopularfunkcja OCP została naruszona przez funkcję, ponieważ kiedyś iOS stanie się najpopularniejszą platformą. Formalnie oznacza to, że zestaw najczęściej używanych platform jest na razie zdefiniowany jako „Android” i isMostPopularnieodpowiednio obsługuje ten zestaw danych. Zestaw danych nie jest semantycznie ustalony i z czasem może się swobodnie zmieniać. HumanReadablePlatformSerializerkod źródłowy wymaga aktualizacji w przypadku zmiany.

W tym przykładzie możesz również zauważyć naruszenie zasady pojedynczej odpowiedzialności. Uczyniłem to celowo, aby móc zademonstrować obie zasady na tym samym podmiocie. Aby naprawić SRP, możesz wyodrębnić isMostPopularfunkcję do niektórych zewnętrznych Helperi dodać parametr do PlatformSerializer.toJson. Ale to inna historia.


0

LSP i OCP nie są takie same.

LSP mówi o poprawności programu , gdyż stoi . Jeśli wystąpienie podtypu złamałoby poprawność programu, gdy zostanie podstawione w kodzie typów przodków, oznacza to naruszenie LSP. Być może będziesz musiał wykonać próbny test, aby to pokazać, ale nie będziesz musiał zmieniać podstawowej bazy kodu. Sprawdzasz poprawność samego programu, aby sprawdzić, czy spełnia on LSP.

OCP mówi o poprawności zmian w kodzie programu, delcie z jednej wersji źródłowej do drugiej. Zachowanie nie powinno być modyfikowane. Należy go tylko rozszerzyć. Klasycznym przykładem jest dodawanie pól. Wszystkie istniejące pola nadal działają jak poprzednio. Nowe pole tylko dodaje funkcjonalność. Usunięcie pola jest jednak zwykle naruszeniem OCP. Tutaj sprawdzasz poprawność wersji programu, aby sprawdzić, czy spełnia on OCP.

To jest kluczowa różnica między LSP a OCP. Pierwszy sprawdza poprawność tylko podstawy kodu w obecnej formie , drugi sprawdza poprawność tylko delty podstawy kodu z jednej wersji do następnej . Jako takie nie mogą być tym samym, są zdefiniowane jako sprawdzanie różnych rzeczy.

Dam ci bardziej formalny dowód: powiedzenie „LSP implikuje OCP” oznaczałoby deltę (ponieważ OCP wymaga jednego innego niż w trywialnym przypadku), ale LSP nie wymaga takiego. To jest oczywiście nieprawda. I odwrotnie, możemy obalić „OCP implikuje LSP”, po prostu mówiąc, że OCP jest instrukcją o delcie, a zatem nie mówi nic o instrukcji nad programem w miejscu. Wynika to z faktu, że możesz utworzyć DOWOLNĄ deltę zaczynając od DOWOLNEGO programu. Są całkowicie niezależni.


-1

Spojrzałbym na to z punktu widzenia klienta. jeśli klient korzysta z funkcji interfejsu i wewnętrznie ta funkcja została zaimplementowana przez klasę A. Załóżmy, że istnieje klasa B, która rozszerza klasę A, to jutro jeśli usunę klasę A z tego interfejsu i ustawię klasę B, to klasa B powinna zapewniają również te same funkcje klientowi. Standardowym przykładem jest klasa Kaczki, która pływa, a jeśli ToyDuck rozszerzy Kaczkę, to powinna również pływać i nie narzeka, że ​​nie umie pływać, w przeciwnym razie ToyDuck nie powinien mieć rozszerzonej klasy Kaczki.


Byłoby bardzo konstruktywne, gdyby ludzie komentowali również podczas głosowania nad odpowiedzią. W końcu wszyscy jesteśmy tutaj, aby dzielić się wiedzą, a zwykłe wydawanie osądów bez uzasadnionego powodu nie służy żadnemu celowi.
AKS

wydaje się, że nie oferuje to nic istotnego w porównaniu z punktami przedstawionymi i wyjaśnionymi w poprzednich 6 odpowiedziach
komnata

1
Wygląda na to, że wyjaśniasz tylko jedną z zasad, myślę, że L. Na to, co jest w porządku, ale pytanie dotyczy porównania / kontrastu dwóch różnych zasad. Prawdopodobnie dlatego ktoś to ocenił.
StarWeaver,
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.