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 extend
oznacza 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 Context
jest 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 IBehavior
interfejs.
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ą setBehavior
metody 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 YoutubeLink
jest 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ść extend
implementacji 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 o1
typu S
istnieje obiekt o2
typu T
taki, że dla wszystkich programów P
zdefiniowanych w kategoriach T
zachowanie się P
pozostaje niezmienione, gdy o1
jest podstawiony, o2
to S
jest 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ą instanceof
lub 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ą canDo
metodę 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 GetPersons
metodę 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 doStuff
metoda 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. doStuff
Metoda 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 doStuff
metody modyfikacji i upewnienia się, że pozostanie ona zamknięta poprzez oznaczenie jej final
słowem kluczowym java . To słowo kluczowe uniemożliwia dalszą podklasę klasy (w języku C # można użyć sealed
tego 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.