Słyszałem, że Liskov Substitution Principle (LSP) jest podstawową zasadą projektowania obiektowego. Co to jest i jakie są przykłady jego użycia?
Słyszałem, że Liskov Substitution Principle (LSP) jest podstawową zasadą projektowania obiektowego. Co to jest i jakie są przykłady jego użycia?
Odpowiedzi:
Świetnym przykładem ilustrującym LSP (podanym przez wuja Boba w podcastu, który ostatnio słyszałem) było to, że czasami coś, co brzmi poprawnie w języku naturalnym, nie działa w kodzie.
W matematyce a Square
jest a Rectangle
. Rzeczywiście jest to specjalizacja prostokąta. „Jest” powoduje, że chcesz modelować to z dziedziczeniem. Jednak jeśli w kodzie, z którego się Square
wywodzisz Rectangle
, a Square
powinno być użyteczne wszędzie tam, gdzie oczekujesz Rectangle
. To powoduje dziwne zachowanie.
Wyobraź sobie, że posiadasz SetWidth
i SetHeight
metody w swojej Rectangle
klasie podstawowej; wydaje się to całkowicie logiczne. Jeśli jednak twoje Rectangle
odniesienie wskazywało na a Square
, to SetWidth
i SetHeight
nie ma sensu, ponieważ ustawienie jednego zmieniłoby drugie, aby je dopasować. W tym przypadku Square
test Liskowa nie powiedzie się, Rectangle
a abstrakcja Square
dziedziczenia Rectangle
jest zła.
Wszyscy powinniście sprawdzić inne bezcenne Motywacyjne plakaty SOLIDNE zasady .
Square.setWidth(int width)
został wdrożony w ten sposób this.width = width; this.height = width;
:? W takim przypadku gwarantuje się, że szerokość jest równa wysokości.
Zasada substytucji Liskowa (LSP, lsp) to koncepcja programowania obiektowego, która stwierdza:
Funkcje korzystające ze wskaźników lub referencji do klas podstawowych muszą mieć możliwość korzystania z obiektów klas pochodnych bez ich znajomości.
W jego sercu LSP dotyczy interfejsów i umów, a także tego, jak zdecydować, kiedy rozszerzyć klasę, a nie zastosować innej strategii, takiej jak kompozycja, aby osiągnąć swój cel.
Najskuteczniejszym sposobem Widziałem, aby zilustrować ten punkt był w Head First OOA & D . Przedstawiają scenariusz, w którym jesteś deweloperem projektu, który ma stworzyć ramy dla gier strategicznych.
Prezentują klasę reprezentującą tablicę, która wygląda następująco:
Wszystkie metody przyjmują współrzędne X i Y jako parametry w celu zlokalizowania położenia kafelka w dwuwymiarowej tablicy Tiles
. Umożliwi to twórcy gry zarządzanie jednostkami na planszy w trakcie gry.
Książka dalej zmienia wymagania, aby powiedzieć, że rama gry musi również obsługiwać plansze 3D, aby pomieścić gry, które mają lot. Tak więc wprowadzono ThreeDBoard
klasę, która się rozszerza Board
.
Na pierwszy rzut oka wydaje się to dobrą decyzją. Board
zawiera zarówno Height
a Width
właściwości i ThreeDBoard
zapewnia oś z.
Rozkłada się, gdy spojrzysz na wszystkich odziedziczonych członków Board
. Metody AddUnit
, GetTile
, GetUnits
i tak dalej, ma wszystkie parametry X i Y w Board
klasy, lecz ThreeDBoard
wymaga również parametr Z.
Musisz więc ponownie zaimplementować te metody za pomocą parametru Z. Parametr Z nie ma kontekstu dla Board
klasy, a odziedziczone metody z Board
klasy tracą swoje znaczenie. Jednostka kodu próbująca wykorzystać ThreeDBoard
klasę jako klasę podstawową Board
byłaby bardzo pechowa.
Może powinniśmy znaleźć inne podejście. Zamiast powiększenia Board
, ThreeDBoard
powinien składać się z Board
obiektów. Jeden Board
obiekt na jednostkę osi Z.
To pozwala nam korzystać z dobrych, obiektowych zasad, takich jak enkapsulacja i ponowne użycie, i nie narusza LSP.
Podstawialność jest zasadą w programowaniu obiektowym, mówiącą, że w programie komputerowym, jeśli S jest podtypem T, wówczas obiekty typu T można zastąpić obiektami typu S
zróbmy prosty przykład w Javie:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
Kaczka może latać, ponieważ jest ptakiem, ale co z tym:
public class Ostrich extends Bird{}
Struś jest ptakiem, ale nie może latać, klasa strusia jest podtypem ptaka Ptak, ale nie może używać metody latania, co oznacza, że łamiemy zasadę LSP.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Musisz rzucić obiekt na FlyingBirds, aby użyć muchy, co nie jest miłe, prawda?
Bird bird
, to znaczy, że nie może użyć fly()
. Otóż to. Zdanie a Duck
nie zmienia tego faktu. Jeśli klient tak FlyingBirds bird
, to nawet jeśli go przejdzie Duck
, powinien zawsze działać w ten sam sposób.
LSP dotyczy niezmienników.
Klasyczny przykład podaje następująca deklaracja pseudokodu (pominięte implementacje):
class Rectangle {
int getHeight()
void setHeight(int value)
int getWidth()
void setWidth(int value)
}
class Square : Rectangle { }
Teraz mamy problem, chociaż interfejs pasuje. Powodem jest to, że naruszyliśmy niezmienniki wynikające z matematycznej definicji kwadratów i prostokątów. Sposób działania pobierających i ustawiających Rectangle
powinien spełniać następujące niezmienniki:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
Jednak ten niezmiennik musi zostać naruszony przez poprawną implementację Square
, dlatego nie jest prawidłowym zamiennikiem Rectangle
.
Robert Martin ma doskonały artykuł na temat zasady substytucji Liskowa . Omawia subtelne i niezbyt subtelne sposoby naruszania zasady.
Niektóre istotne części artykułu (zauważ, że drugi przykład jest mocno skondensowany):
Prosty przykład naruszenia LSP
Jednym z najbardziej rażących naruszeń tej zasady jest wykorzystanie C ++ Run-Time Type Information (RTTI) w celu wybrania funkcji na podstawie typu obiektu. to znaczy:
void DrawShape(const Shape& s) { if (typeid(s) == typeid(Square)) DrawSquare(static_cast<Square&>(s)); else if (typeid(s) == typeid(Circle)) DrawCircle(static_cast<Circle&>(s)); }
Najwyraźniej
DrawShape
funkcja jest źle sformułowana. Musi wiedzieć o każdej możliwej pochodnejShape
klasy i musi być zmieniana za każdym razem, gdyShape
tworzone są nowe pochodne . Rzeczywiście, wiele osób uważa strukturę tej funkcji za anatemę dla projektowania obiektowego.Kwadrat i prostokąt, bardziej subtelne naruszenie.
Istnieją jednak inne, znacznie bardziej subtelne sposoby naruszania LSP. Rozważ aplikację, która korzysta z
Rectangle
klasy w sposób opisany poniżej:class Rectangle { public: void SetWidth(double w) {itsWidth=w;} void SetHeight(double h) {itsHeight=w;} double GetHeight() const {return itsHeight;} double GetWidth() const {return itsWidth;} private: double itsWidth; double itsHeight; };
[...] Wyobraź sobie, że pewnego dnia użytkownicy domagają się możliwości manipulowania kwadratami oprócz prostokątów. [...]
Oczywiście, kwadrat jest prostokątem dla wszystkich normalnych celów i celów. Ponieważ utrzymuje się relacja ISA, logiczne jest modelowanie
Square
klasy jako pochodnejRectangle
. [...]
Square
odziedziczy funkcjeSetWidth
iSetHeight
. Funkcje te są całkowicie nieodpowiednie dla aSquare
, ponieważ szerokość i wysokość kwadratu są identyczne. To powinna być znacząca wskazówka, że istnieje problem z projektem. Istnieje jednak sposób na uniknięcie problemu. Możemy zastąpićSetWidth
iSetHeight
[...]Ale rozważ następującą funkcję:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Jeśli przekażemy odwołanie do
Square
obiektu do tej funkcji,Square
obiekt zostanie uszkodzony, ponieważ wysokość nie zostanie zmieniona. Jest to wyraźne naruszenie LSP. Funkcja nie działa dla pochodnych jej argumentów.[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
Jeśli warunek wstępny klasy dziecięcej jest silniejszy niż warunek podstawowy klasy rodzicielskiej, nie można zastąpić rodzica dzieckiem bez naruszenia warunku wstępnego. Stąd LSP.
LSP jest konieczny tam, gdzie jakiś kod uważa, że wywołuje metody typu T
, i może nieświadomie wywoływać metody typu S
, w którym S extends T
(tj. S
Dziedziczy, wywodzi się z podtypu lub jest jego podtypem T
).
Dzieje się tak na przykład wtedy, gdy funkcja z parametrem wejściowym typu T
jest wywoływana (tzn. Wywoływana) z wartością argumentu typu S
. Lub, gdy identyfikator typu T
, ma przypisaną wartość typu S
.
val id : T = new S() // id thinks it's a T, but is a S
LSP wymaga oczekiwań (tj. Niezmienników) dla metod typu T
(np. Rectangle
), Nie należy ich naruszać, gdy zamiast tego wywoływane są metody typu S
(np. Square
).
val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation
Nawet typ z niezmiennymi polami wciąż ma niezmienniki, np. Niezmienne układy prostokątów oczekują, że wymiary będą niezależnie modyfikowane, ale niezmienne układacze kwadratów naruszają to oczekiwanie.
class Rectangle( val width : Int, val height : Int )
{
def setWidth( w : Int ) = new Rectangle(w, height)
def setHeight( h : Int ) = new Rectangle(width, h)
}
class Square( val side : Int ) extends Rectangle(side, side)
{
override def setWidth( s : Int ) = new Square(s)
override def setHeight( s : Int ) = new Square(s)
}
LSP wymaga, aby każda metoda tego podtypu S
miała przeciwwariantny parametr wejściowy i wyjściowy efekt kowariantny.
Kontrawariant oznacza, że wariancja jest sprzeczna z kierunkiem dziedziczenia, tj. Typ Si
każdego parametru wejściowego każdej metody podtypu S
, musi być taki sam lub nadtyp typu Ti
odpowiedniego parametru wejściowego odpowiedniej metody nadtypu T
.
Kowariancja oznacza, że wariancja jest w tym samym kierunku dziedziczenia, tzn. Rodzaj So
wyniku każdej metody podtypu S
musi być taki sam lub podtypu typu To
odpowiedniego wyniku odpowiedniej metody nadtypu T
.
Wynika to z faktu, że jeśli program wywołujący myśli, że ma typ T
, myśli, że wywołuje metodę T
, wówczas dostarcza argumenty typu Ti
i przypisuje dane wyjściowe do typu To
. Gdy faktycznie wywołuje odpowiednią metodę S
, każdy Ti
argument wejściowy jest przypisywany do Si
parametru wejściowego, a dane So
wyjściowe są przypisywane do typu To
. Zatem jeśli Si
nie byłyby sprzeczne z wrt Ti
, to podtyp Xi
, który nie byłby podtypem, Si
mógłby zostać przypisany Ti
.
Ponadto w przypadku języków (np. Scala lub Cejlon), które mają adnotacje wariancji w miejscu definicji parametrów polimorfizmu typu (tj. Rodzajowych), ko- lub przeciwny kierunek adnotacji wariancji dla każdego parametru typu T
musi być przeciwny lub taki sam odpowiednio do każdego parametru wejściowego lub wyjściowego (każdej metody T
), który ma typ parametru type.
Dodatkowo dla każdego parametru wejściowego lub wyjściowego, który ma typ funkcji, wymagany kierunek wariancji jest odwrócony. Ta reguła jest stosowana rekurencyjnie.
Podpisywanie jest właściwe tam, gdzie niezmienniki można wyliczyć.
Trwa wiele badań nad tym, jak modelować niezmienniki, aby były one wymuszane przez kompilator.
Typestate (patrz strona 3) deklaruje i wymusza niezmienniki stanu ortogonalne do wpisywania. Alternatywnie niezmienniki można wymusić, przekształcając twierdzenia na typy . Na przykład, aby potwierdzić, że plik jest otwarty przed jego zamknięciem, wówczas File.open () może zwrócić typ OpenFile, który zawiera metodę close (), która nie jest dostępna w File. Tic-krzyżyk API może być kolejny przykład stosując typowanie wymusić niezmienników w czasie kompilacji. System typów może być nawet kompletny w Turinga, np . Scala . Języki i dowody twierdzeń o typie zależnym formalizują modele pisania wyższego rzędu.
Ze względu na potrzebę abstrakcji semantyki zamiast rozszerzenia , spodziewam się, że zastosowanie typowania do modelowania niezmienników, tj. Ujednoliconej semantyki denotacyjnej wyższego rzędu, jest lepsze niż typestate. „Rozszerzenie” oznacza nieograniczony, permutowany skład nieskoordynowanego, modułowego rozwoju. Ponieważ wydaje mi się, że jest antytezą zjednoczenia, a tym samym stopni swobody, mieć dwa wzajemnie zależne modele (np. Typy i typowanie) do wyrażania wspólnej semantyki, których nie można zjednoczyć ze sobą w celu rozszerzenia kompozycji . Na przykład rozszerzenie podobne do problemu wyrażenia zostało ujednolicone w dziedzinie podtytułu, przeciążenia funkcji i parametrycznych domen typowania.
Moja teoretyczna pozycja jest taka, że aby istniała wiedza (patrz sekcja „Centralizacja jest ślepa i nieodpowiednia”), nigdy nie będzie ogólnego modelu, który mógłby wymusić 100% pokrycie wszystkich możliwych niezmienników w języku komputerowym kompletnym Turinga. Aby istniała wiedza, istnieje wiele nieoczekiwanych możliwości, tzn. Nieporządek i entropia muszą zawsze rosnąć. To jest siła entropii. Aby udowodnić wszystkie możliwe obliczenia potencjalnego rozszerzenia, należy z góry obliczyć wszystkie możliwe rozszerzenia.
Dlatego istnieje Twierdzenie Haltinga, tzn. Nie można rozstrzygnąć, czy każdy możliwy program w języku programowania Turinga zakończy się. Można udowodnić, że jakiś określony program kończy się (taki, w którym wszystkie możliwości zostały zdefiniowane i obliczone). Nie można jednak udowodnić, że wszelkie możliwe rozszerzenia tego programu kończą się, chyba że możliwości rozszerzenia tego programu nie są kompletne w Turingu (np. Przez wpisywanie zależne). Ponieważ podstawowym wymogiem dla kompletności Turinga jest nieograniczona rekurencja , intuicyjne jest zrozumienie, w jaki sposób twierdzenia Gödela i paradoks Russella odnoszą się do rozszerzenia.
Interpretacja tych twierdzeń uwzględnia je w uogólnionym pojęciowym rozumieniu siły entropicznej:
Istnieje lista kontrolna do ustalenia, czy naruszasz Liskov.
Lista kontrolna:
Ograniczenie historii : Podczas przesłonięcia metody nie wolno modyfikować właściwości niemodyfikowalnych w klasie podstawowej. Spójrz na ten kod i zobaczysz, że Nazwa jest zdefiniowana jako niemodyfikowalna (zestaw prywatny), ale SubType wprowadza nową metodę, która pozwala ją modyfikować (poprzez odbicie):
public class SuperType
{
public string Name { get; private set; }
public SuperType(string name, int age)
{
Name = name;
Age = age;
}
}
public class SubType : SuperType
{
public void ChangeName(string newName)
{
var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);
}
}
Istnieją jeszcze 2 inne elementy: Kontrawariancja argumentów metody i Kowariancja typów zwracanych . Ale nie jest to możliwe w C # (jestem programistą C #), więc nie obchodzi mnie to.
Odniesienie:
Widzę prostokąty i kwadraty w każdej odpowiedzi i jak naruszać LSP.
Chciałbym pokazać, w jaki sposób można dostosować LSP do rzeczywistego przykładu:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return $result;
}
}
Ten projekt jest zgodny z LSP, ponieważ zachowanie pozostaje niezmienione niezależnie od implementacji, którą wybraliśmy.
I tak, możesz naruszyć LSP w tej konfiguracji, wykonując jedną prostą zmianę:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return ['result' => $result]; // This violates LSP !
}
}
Teraz podtypy nie mogą być używane w ten sam sposób, ponieważ nie dają już tego samego wyniku.
Database::selectQuery
obsługi tylko podzbioru SQL obsługiwanego przez wszystkie silniki DB. To nie jest praktyczne ... To powiedziawszy, przykład jest nadal łatwiejszy do zrozumienia niż większość innych tutaj używanych.
LSP jest regułą dotyczącą umowy klauzul: jeśli klasa podstawowa spełnia kontrakt, wówczas klasy pochodne LSP muszą również spełniać tę umowę.
W pseudo-python
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
spełnia LSP, jeśli za każdym razem, gdy wywołujesz Foo na obiekcie pochodnym, daje dokładnie takie same wyniki jak wywoływanie Foo na obiekcie bazowym, o ile arg jest taki sam.
2 + "2"
). Być może mylisz „silnie wpisany” z „statycznie wpisany”?
Długa historia krótkiego, zostawmy prostokąty prostokątów i kwadratów kwadratów, praktyczny przykład przy przedłużaniu klasę nadrzędną, trzeba też zachować dokładną nadrzędnego API lub jego przedłużenia.
Załóżmy, że masz podstawową pozycję ItemsRepository.
class ItemsRepository
{
/**
* @return int Returns number of deleted rows
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
return $numberOfDeletedRows;
}
}
I rozszerzająca go podklasa:
class BadlyExtendedItemsRepository extends ItemsRepository
{
/**
* @return void Was suppose to return an INT like parent, but did not, breaks LSP
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
// we broke the behaviour of the parent class
return;
}
}
Wtedy możesz mieć klienta pracującego z API Base ItemsRepository i polegającego na nim.
/**
* Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
*
* Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
* but if the sub-class won't abide the base class API, the client will get broken.
*/
class ItemsService
{
/**
* @var ItemsRepository
*/
private $itemsRepository;
/**
* @param ItemsRepository $itemsRepository
*/
public function __construct(ItemsRepository $itemsRepository)
{
$this->itemsRepository = $itemsRepository;
}
/**
* !!! Notice how this is suppose to return an int. My clients expect it based on the
* ItemsRepository API in the constructor !!!
*
* @return int
*/
public function delete()
{
return $this->itemsRepository->delete();
}
}
LSP jest uszkodzony, gdy zastępując nadrzędnego klasę z PODKLASA przerw zamówienia API .
class ItemsController
{
/**
* Valid delete action when using the base class.
*/
public function validDeleteAction()
{
$itemsService = new ItemsService(new ItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is an INT :)
}
/**
* Invalid delete action when using a subclass.
*/
public function brokenDeleteAction()
{
$itemsService = new ItemsService(new BadlyExtendedItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is a NULL :(
}
}
Możesz dowiedzieć się więcej na temat pisania oprogramowania, które można konserwować w moim kursie: https://www.udemy.com/enterprise-php/
Funkcje wykorzystujące wskaźniki lub odwołania do klas podstawowych muszą mieć możliwość korzystania z obiektów klas pochodnych bez znajomości tego.
Kiedy po raz pierwszy przeczytałem o LSP, założyłem, że miał on na celu bardzo ścisły sens, zasadniczo utożsamiając go z implementacją interfejsu i rzutowaniem bezpiecznym dla typu. Co oznaczałoby, że LSP jest albo zapewniony, albo nie przez sam język. Na przykład, w tym ścisłym znaczeniu, ThreeDBoard jest z pewnością substytutem dla Board, jeśli chodzi o kompilator.
Po przeczytaniu więcej na temat tej koncepcji odkryłem, że LSP jest ogólnie interpretowany szerzej.
Krótko mówiąc, co oznacza, że kod klienta „wie”, że obiekt za wskaźnikiem jest typu pochodnego, a nie typ wskaźnika, nie ogranicza się do bezpieczeństwa typu. Zgodność z LSP można również przetestować poprzez zbadanie rzeczywistego zachowania obiektów. Oznacza to badanie wpływu argumentów stanu i metody obiektu na wyniki wywołań metody lub rodzajów wyjątków zgłaszanych przez obiekt.
Wracając do przykładu, teoretycznie można sprawić , że metody Board będą działać dobrze na ThreeDBoard. W praktyce jednak bardzo trudno będzie zapobiec różnicom w zachowaniu, które klient może nie obsługiwać poprawnie, bez ingerowania w funkcje, które ma dodać ThreeDBoard.
Mając tę wiedzę, ocena przestrzegania LSP może być doskonałym narzędziem w określaniu, kiedy skład jest bardziej odpowiednim mechanizmem rozszerzania istniejącej funkcjonalności, a nie dziedziczeniem.
Myślę, że każdy w pewnym sensie opisał, czym technicznie jest LSP: Zasadniczo chcesz być w stanie oderwać się od szczegółów podtypu i bezpiecznie korzystać z nadtypów.
Więc Liskov ma 3 podstawowe zasady:
Reguła podpisu: Powinna istnieć poprawna implementacja każdej operacji nadtypu w podtypie składniowo. Coś, co kompilator będzie mógł sprawdzić. Istnieje niewielka reguła dotycząca zgłaszania mniejszej liczby wyjątków i bycia co najmniej tak samo dostępnym, jak metody nadtypu.
Metoda Reguła: Implementacja tych operacji jest poprawna semantycznie.
Reguła właściwości: Wykracza to poza indywidualne wywołania funkcji.
Wszystkie te właściwości muszą zostać zachowane, a dodatkowa funkcjonalność podtypu nie powinna naruszać właściwości nadtypu.
Jeśli załatwisz te trzy rzeczy, oderwasz się od podstawowych rzeczy i piszesz luźno powiązany kod.
Źródło: Programowanie w Javie - Barbara Liskov
Ważnym przykładem zastosowania LSP jest testowanie oprogramowania .
Jeśli mam klasę A, która jest podklasą B zgodną z LSP, mogę ponownie użyć zestawu testów B do przetestowania A.
Aby w pełni przetestować podklasę A, prawdopodobnie muszę dodać jeszcze kilka przypadków testowych, ale przynajmniej mogę ponownie użyć wszystkich przypadków testowych nadklasy B.
Sposobem na osiągnięcie tego jest zbudowanie tego, co McGregor nazywa „równoległą hierarchią testowania”: moja ATest
klasa odziedziczy BTest
. Potrzebna jest zatem pewna forma iniekcji, aby upewnić się, że przypadek testowy działa z obiektami typu A, a nie typu B (wystarczy prosty wzór szablonu).
Zauważ, że ponowne użycie pakietu super-testów dla wszystkich implementacji podklasy jest w rzeczywistości sposobem na sprawdzenie, czy te implementacje podklasy są zgodne z LSP. Zatem można również argumentować, że należy uruchomić pakiet testów nadklasy w kontekście dowolnej podklasy.
Zobacz także odpowiedź na pytanie Stackoverflow „ Czy mogę zaimplementować serię testów wielokrotnego użytku w celu przetestowania implementacji interfejsu? ”
Zilustrujmy w Javie:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
class Car extends TransportationDevice
{
@Override
void startEngine() { ... }
}
Tutaj nie ma problemu, prawda? Samochód jest zdecydowanie urządzeniem transportowym i tutaj możemy zobaczyć, że zastępuje on metodę startEngine () swojej nadklasy.
Dodajmy kolejne urządzenie transportowe:
class Bicycle extends TransportationDevice
{
@Override
void startEngine() /*problem!*/
}
Teraz wszystko nie idzie zgodnie z planem! Tak, rower jest urządzeniem transportowym, jednak nie ma silnika i dlatego nie można zaimplementować metody startEngine ().
Są to problemy, do których prowadzi naruszenie zasady substytucji Liskowa, i najczęściej można je rozpoznać za pomocą metody, która nic nie robi, a nawet nie może zostać wdrożona.
Rozwiązaniem tych problemów jest poprawna hierarchia dziedziczenia, aw naszym przypadku rozwiązalibyśmy problem, różnicując klasy urządzeń transportowych z silnikami i bez. Chociaż rower jest środkiem transportu, nie ma silnika. W tym przykładzie nasza definicja urządzenia transportowego jest błędna. Nie powinien mieć silnika.
Możemy zmienić naszą klasę TransportDevice w następujący sposób:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
}
Teraz możemy rozszerzyć TransportDevice dla urządzeń niezmotoryzowanych.
class DevicesWithoutEngines extends TransportationDevice
{
void startMoving() { ... }
}
I rozszerz Urządzenia transportowe dla urządzeń zmotoryzowanych. Tutaj bardziej odpowiednie jest dodanie obiektu Engine.
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
W ten sposób nasza klasa samochodów staje się bardziej wyspecjalizowana, przy jednoczesnym przestrzeganiu zasady substytucji Liskowa.
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
Nasza klasa rowerów jest również zgodna z zasadą substytucji Liskowa.
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
Takie sformułowanie LSP jest zdecydowanie zbyt silne:
Jeżeli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T taki, że dla wszystkich programów P określonych w T, zachowanie P pozostaje niezmienione, gdy o1 jest zastąpione o2, wówczas S jest podtypem T.
Co w zasadzie oznacza, że S to kolejna, całkowicie zamknięta implementacja dokładnie tej samej rzeczy co T. I mógłbym być odważny i zdecydować, że wydajność jest częścią zachowania P ...
Zasadniczo każde użycie późnego wiązania narusza LSP. Chodzi o to, że OO polega na uzyskaniu innego zachowania, gdy zamieniamy jeden obiekt na inny!
Formuła cytowana przez wikipedię jest lepsza, ponieważ właściwość zależy od kontekstu i niekoniecznie obejmuje całe zachowanie programu.
W bardzo prostym zdaniu możemy powiedzieć:
Klasa potomna nie może naruszać jej charakterystyk klasy podstawowej. Musi sobie z tym poradzić. Można powiedzieć, że jest to tak samo jak podtyp.
Liskov's Substitution Principle (LSP)
Cały czas projektujemy moduł programu i tworzymy pewne hierarchie klas. Następnie rozszerzamy niektóre klasy, tworząc pewne klasy pochodne.
Musimy upewnić się, że nowe klasy pochodne rozszerzają się bez zastępowania funkcjonalności starych klas. W przeciwnym razie nowe klasy mogą wywoływać niepożądane efekty, gdy zostaną użyte w istniejących modułach programu.
Zasada podstawienia Liskowa stwierdza, że jeśli moduł programu korzysta z klasy Base, wówczas odwołanie do klasy Base można zastąpić klasą Derived bez wpływu na funkcjonalność modułu programu.
Przykład:
Poniżej znajduje się klasyczny przykład, w którym naruszono zasadę substytucji Liskowa. W tym przykładzie zastosowano 2 klasy: Prostokąt i Kwadrat. Załóżmy, że obiekt Rectangle jest używany gdzieś w aplikacji. Rozszerzamy aplikację i dodajemy klasę Square. Klasa kwadratowa jest zwracana przez wzorzec fabryczny, oparty na niektórych warunkach i nie wiemy dokładnie, jaki typ obiektu zostanie zwrócony. Ale wiemy, że to prostokąt. Otrzymujemy obiekt prostokąta, ustawiamy szerokość na 5 i wysokość na 10 i otrzymujemy obszar. W przypadku prostokąta o szerokości 5 i wysokości 10 obszar powinien wynosić 50. Zamiast tego wynik wyniesie 100
// Violation of Likov's Substitution Principle
class Rectangle {
protected int m_width;
protected int m_height;
public void setWidth(int width) {
m_width = width;
}
public void setHeight(int height) {
m_height = height;
}
public int getWidth() {
return m_width;
}
public int getHeight() {
return m_height;
}
public int getArea() {
return m_width * m_height;
}
}
class Square extends Rectangle {
public void setWidth(int width) {
m_width = width;
m_height = width;
}
public void setHeight(int height) {
m_width = height;
m_height = height;
}
}
class LspTest {
private static Rectangle getNewRectangle() {
// it can be an object returned by some factory ...
return new Square();
}
public static void main(String args[]) {
Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);
r.setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base
// class
System.out.println(r.getArea());
// now he's surprised to see that the area is 100 instead of 50.
}
}
Wniosek:
Ta zasada jest tylko rozszerzeniem zasady Open Close i oznacza, że musimy upewnić się, że nowe klasy pochodne rozszerzają klasy podstawowe bez zmiany ich zachowania.
Zobacz także: Zasada otwartego zamknięcia
Kilka podobnych koncepcji lepszej struktury: Konwencja o konfiguracji
Niektóre uzupełnienia:
Zastanawiam się, dlaczego nikt nie napisał o niezmienniku, warunkach wstępnych i warunkach końcowych klasy podstawowej, które muszą być przestrzegane przez klasy pochodne. Aby pochodna klasa D była całkowicie odporna na działanie klasy podstawowej B, klasa D musi spełniać pewne warunki:
Tak więc pochodna musi być świadoma trzech powyższych warunków narzuconych przez klasę podstawową. Dlatego zasady podtypów są z góry ustalone. Co oznacza, że stosunek „JEST A” będzie przestrzegany tylko wtedy, gdy podtyp przestrzega pewnych zasad. Zasady te, w postaci niezmienników, warunków wstępnych i warunków dodatkowych, powinny zostać określone w formalnej „ umowie projektowej ”.
Dalsze dyskusje na ten temat dostępne na moim blogu: Liskov Substytucja
LSP w prostych słowach stwierdza, że obiekty tej samej nadklasy powinny mieć możliwość wymiany między sobą bez niszczenia czegokolwiek.
Na przykład, jeśli mamy Cat
oraz Dog
klasę pochodzącą z Animal
klasy, wszelkie funkcje wykorzystujące klasę zwierzę powinno mieć możliwość korzystania z Cat
lub Dog
i zachowywać się normalnie.
Czy wdrożenie ThreeDBoard pod względem tablicy będzie tak przydatne?
Być może możesz chcieć traktować plastry ThreeDBoard w różnych płaszczyznach jako planszę. W takim przypadku możesz wyodrębnić interfejs (lub klasę abstrakcyjną) dla tablicy, aby umożliwić wiele implementacji.
Jeśli chodzi o interfejs zewnętrzny, możesz wyróżnić interfejs Board zarówno dla TwoDBoard, jak i ThreeDBoard (chociaż żadna z powyższych metod nie pasuje).
Kwadrat to prostokąt, którego szerokość równa się wysokości. Jeśli kwadrat ustawia dwa różne rozmiary dla szerokości i wysokości, narusza to niezmiennik kwadratowy. Można to obejść poprzez wprowadzenie efektów ubocznych. Ale jeśli prostokąt miał setSize (wysokość, szerokość) z warunkiem wstępnym 0 <wysokość i 0 <szerokość. Pochodna metoda podtypu wymaga wysokość == szerokość; silniejszy warunek wstępny (i to narusza lsp). To pokazuje, że chociaż kwadrat jest prostokątem, nie jest prawidłowym podtypem, ponieważ warunek wstępny jest wzmocniony. Obejście (ogólnie rzecz biorąc, zła rzecz) powoduje efekt uboczny, co osłabia stan postu (co narusza lsp). setWidth na podstawie ma warunek słupka 0 <szerokość. Wyprowadzony osłabia go o wysokości == szerokości.
Dlatego kwadrat o zmiennym rozmiarze nie jest prostokątem o zmiennym rozmiarze.
Zasada ta została wprowadzona przez Barbarę Liskov w 1987 roku i rozszerza zasadę otwartego zamknięcia, koncentrując się na zachowaniu nadklasy i jej podtypów.
Jego znaczenie staje się oczywiste, gdy weźmiemy pod uwagę konsekwencje jego naruszenia. Rozważ aplikację korzystającą z następującej klasy.
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
Wyobraź sobie, że pewnego dnia klient oprócz prostokątów wymaga także manipulowania kwadratami. Ponieważ kwadrat jest prostokątem, klasę kwadratu należy wyprowadzić z klasy Prostokąt.
public class Square : Rectangle
{
}
W ten sposób napotkamy jednak dwa problemy:
Kwadrat nie potrzebuje zmiennych wysokości i szerokości dziedziczonych z prostokąta, co może powodować znaczne marnotrawstwo pamięci, jeśli musimy stworzyć setki tysięcy kwadratowych obiektów. Właściwości ustawiania szerokości i wysokości dziedziczone z prostokąta są nieodpowiednie dla kwadratu, ponieważ szerokość i wysokość kwadratu są identyczne. Aby ustawić zarówno wysokość, jak i szerokość na tę samą wartość, możemy utworzyć dwie nowe właściwości w następujący sposób:
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
Teraz, gdy ktoś ustawi szerokość kwadratowego obiektu, jego wysokość odpowiednio się zmieni i na odwrót.
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
Przejdźmy do przodu i rozważmy tę inną funkcję:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
Gdybyśmy przekazali odwołanie do obiektu kwadratowego do tej funkcji, naruszylibyśmy LSP, ponieważ funkcja nie działa dla pochodnych jej argumentów. Szerokość i wysokość właściwości nie są polimorficzne, ponieważ nie zostały zadeklarowane jako wirtualne w prostokącie (kwadratowy obiekt zostanie uszkodzony, ponieważ wysokość nie zostanie zmieniona).
Jednak deklarując, że właściwości setera są wirtualne, napotkamy kolejne naruszenie, OCP. W rzeczywistości utworzenie kwadratu klasy pochodnej powoduje zmiany w prostokącie klasy podstawowej.
Najczystszym wyjaśnieniem LSP, które do tej pory znalazłem, jest „Zasada podstawienia Liskowa mówi, że obiekt klasy pochodnej powinien być w stanie zastąpić obiekt klasy podstawowej bez powodowania błędów w systemie lub modyfikowania zachowania klasy podstawowej „ stąd . W artykule podano przykładowy kod naruszenia LSP i jego naprawienia.
Powiedzmy, że używamy prostokąta w naszym kodzie
r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);
W naszej klasie geometrii dowiedzieliśmy się, że kwadrat jest specjalnym rodzajem prostokąta, ponieważ jego szerokość jest taka sama jak jego wysokość. Stwórzmy również Square
klasę na podstawie tych informacji:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Gdybyśmy wymienić Rectangle
z Square
naszego pierwszego kodu, a potem będzie przerwa:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
To dlatego, że Square
ma nowy warunek nie mieliśmy w Rectangle
klasie: width == height
. Według LSP Rectangle
instancje powinny być zastępowalne Rectangle
instancjami podklasy. Jest tak, ponieważ te instancje przechodzą sprawdzanie typu dlaRectangle
instancji i dlatego powodują nieoczekiwane błędy w kodzie.
To był przykład części „warunków wstępnych, których nie można wzmocnić w podtypie” w artykule wiki . Podsumowując, naruszenie LSP prawdopodobnie spowoduje błędy w kodzie w pewnym momencie.
LSP mówi, że „Obiekty powinny być zastępowalne według ich podtypów”. Z drugiej strony zasada ta wskazuje
Klasy potomne nigdy nie powinny łamać definicji typów klas nadrzędnych.
a poniższy przykład pomaga lepiej zrozumieć LSP.
Bez LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
return; //it isn`t rendered in this case
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Naprawianie przez LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
showAd();//it has a specific behavior based on its requirement
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Zachęcam do zapoznania się z artykułem: Naruszenie zasady substytucji Liskowa (LSP) .
Możesz znaleźć wyjaśnienie, czym jest Zasada Zastępstwa Liskowa, ogólne wskazówki pomagające odgadnąć, czy już ją naruszyłeś, oraz przykład podejścia, które pomoże ci zwiększyć bezpieczeństwo w hierarchii klas.
ZASADA SUBSTYTUCJI LISKOWA (z książki Marka Seemanna) stwierdza, że powinniśmy być w stanie zastąpić jedną implementację interfejsu inną bez przerywania ani klienta, ani implementacji. Ta zasada pozwala sprostać wymaganiom, które pojawią się w przyszłości, nawet jeśli możemy ” przewidzieć je dzisiaj.
Jeśli odłączymy komputer od ściany (implementacja), ani gniazdko sieciowe (interfejs), ani komputer (klient) nie ulegną awarii (w rzeczywistości, jeśli jest to laptop, może nawet działać na baterie przez pewien czas) . Jednak w przypadku oprogramowania klient często oczekuje, że usługa będzie dostępna. Jeśli usługa została usunięta, otrzymujemy wyjątek NullReferenceException. Aby poradzić sobie z tego typu sytuacją, możemy stworzyć implementację interfejsu, który „nic nie robi”. Jest to wzorzec projektowy znany jako Null Object [4] i odpowiada w przybliżeniu odłączeniu komputera od ściany. Ponieważ używamy luźnego sprzężenia, możemy zastąpić prawdziwą implementację czymś, co nic nie robi bez powodowania problemów.
Zasada podstawienia Likowa stwierdza, że jeśli moduł programu korzysta z klasy Base, wówczas odwołanie do klasy Base można zastąpić klasą Derived bez wpływu na funkcjonalność modułu programu.
Cel - typy pochodne muszą całkowicie zastępować typy podstawowe.
Przykład - typy zwracanych wariantów w java.
Oto fragment tego postu który ładnie wyjaśnia rzeczy:
[..] aby zrozumieć niektóre zasady, ważne jest, aby zdawać sobie sprawę z tego, kiedy zostały naruszone. To właśnie teraz zrobię.
Co oznacza naruszenie tej zasady? Oznacza to, że obiekt nie spełnia umowy narzuconej przez abstrakcję wyrażoną za pomocą interfejsu. Innymi słowy, oznacza to, że źle zidentyfikowałeś swoje abstrakcje.
Rozważ następujący przykład:
interface Account
{
/**
* Withdraw $money amount from this account.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
private $balance;
public function withdraw(Money $money)
{
if (!$this->enoughMoney($money)) {
return;
}
$this->balance->subtract($money);
}
}
Czy to naruszenie LSP? Tak. Wynika to z faktu, że umowa konta mówi nam, że konto zostanie wycofane, ale nie zawsze tak jest. Co powinienem zrobić, aby to naprawić? Właśnie modyfikuję umowę:
interface Account
{
/**
* Withdraw $money amount from this account if its balance is enough.
* Otherwise do nothing.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
Voilà, teraz umowa jest spełniona.
To subtelne naruszenie często narzuca klientowi zdolność do odróżnienia zastosowanych konkretnych obiektów. Na przykład biorąc pod uwagę pierwszą umowę Konta, może wyglądać następująco:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
I to automatycznie narusza zasadę otwartego zamknięcia [to znaczy wymogu wypłaty pieniędzy. Ponieważ nigdy nie wiadomo, co się stanie, jeśli obiekt naruszający umowę nie ma wystarczającej ilości pieniędzy. Prawdopodobnie nic nie zwraca, prawdopodobnie zostanie zgłoszony wyjątek. Musisz więc sprawdzić, czy to hasEnoughMoney()
nie jest częścią interfejsu. Zatem ta wymuszona kontrola zależna od konkretnej klasy stanowi naruszenie OCP].
Ten punkt dotyczy także błędnego przekonania, które dość często spotykam na temat naruszenia LSP. Mówi: „jeśli zachowanie rodzica zmieniło się u dziecka, narusza to LSP”. Nie robi to jednak - o ile dziecko nie naruszy umowy rodzica.