Reguła Podstawiania Liskov (LSP)

Regułę Podstawiania Liskov sformułowała Barbara Liskov w roku 1988. Można ją w wolnym tłumaczeniu przedstawić następująco:

“Typ B jest podtypem typu A, jeśli program może używać obiektów typu B zamiast obiektów typu A nie zdając sobie z tego sprawy.”


Na pierwszy rzut oka dość niepozorna i oczywista zasada, miała (i ma) bardzo duże odbicie w programowaniu i projektowaniu obiektowym. Przyjrzyjmy się zasadzie Liskov na prostym przykładzie. Przykład ten ma na celu pokazanie, że pojęcie podtypu w projektowaniu i programowaniu obiektowym nie zawsze jest intuicyjne.

Przypuśćmy, że mamy klasę Prostokąt, czyli Rectangle. Prostokąty mają dwa parametry - długości obu boków. Dlatego też klasa Rectangle, oprócz możliwości narysowania się, zapewnia ustawianie i odczytywanie tych parametrów:

Rectangle.png

Teraz chcemy zdefiniować klasę Kwadrat, czyli Square. Wg matematyki kwadrat jest podtypem prostokąta (można powiedzieć, że każdy kwadrat jest prostokątem, ale nie każdy prostokąt jest kwadratem). Intuicyjne wydaje się tutaj wykorzystanie dziedziczenia, które definiuje przecież relację AKO (A Kind of - jest rodzajem). Zatem jeśli kwadrat jest rodzajem prostokąta, to klasa Square powinna dziedziczyć z klasy Rectangle.

Klasa Square dziedziczy po klasie Rectangle operacje do ustawiania długości obu boków, a kwadraty mają przecież wszystkie boki równej długości! Przez to klasa Square jest narażona na niespójność danych. Możemy zapewnić wewnętrzną spójność, przeciążając w klasie Square metody do ustawiania długości boków tak, aby zawsze oba boki były równej długości:

void Square::setLengthA(int newLengthA)
{
    lengthA = newLengthA;
    lengthB = newLengthA;
}
 
void Square::setLengthB(int newLengthB)
{
    lengthA = newLengthB;
    lengthB = newLengthB;
}

Wydaje nam się, że problem rozwiązaliśmy. Teraz kwadraty faktycznie zachowują wewnętrzną spójność. Załóżmy jednak, że ktoś korzysta z zdefiniowanych przez nas klas i napisze w swojej aplikacji taki oto kod:

void foo(Rectangle * rectangle)
{
    rectangle->setLengthA(5);
    rectangle->setLengthB(4);
 
    assert(20 == rectangle.getLengthA() * rectangle.getLengthB());
}

Funkcja ta ustawia boki czegoś, co uważa za prostokąt. Tu właśnie pojawia się prawdziwy problem. Jeśli jako parametr funkcji podamy obiekt klasy Rectangle, to wszystko będzie w porządku, ale dla obiektu klasy Square program się wysypie. Programista, który pisał ten kod zrobił bowiem (bardzo rozsądne) założenie, że zmiana jednego z boków prostokąta pozostawia jego drugi bok niezmieniony. To założenie nie sprawdza się dla naszej klasy Square, zatem nie może być tutaj użyta zamiast klasy Rectangle. Mówimy, że klasa Square nie spełnia Reguły Zastępowania Liskov.

Jaki z tego wniosek? Taki, że reguła zastępowania Liskov nie jest tak oczywista, jak może się wydawać i można ją nieświadomie złamać.

Techniką projektowania obiektowego, która jest związana z zasadą Liskov jest Projektowanie Przez Umowę (Design by Contract, w skrócie DBC). Technika ta została przedstawiona przez Berttranda Meyera w roku 1986 i zaaplikowana jako element języka jego autorstwa o nazwie Eiffel. DBC polega na spisaniu umowy danej funkcjonalności (klasy, metody, etc.) z resztą kodu. Umowa taka składa się z trzech punktów:

  • Warunki początkowe (preconditions) - zobowiązania otoczenia wobec funkcjonalności, które muszą być spełnione w chwili rozpoczęcia jej wykonywania (czyli to, czego funkcjonalność wymaga na początek).
  • Warunki końcowe (postconditions) - zobowiązania funkcjonalności wobec otoczenia, które muszą być spełnione wraz z zakończeniem działania funkcjonalności (czyli to, co funkcjonalność ma zapewnić na końcu).
  • Niezmienniki (invariants) - warunki które muszą być zapewnione przez cały czas trwania umowy.

Niezmienniki bardzo trudno jest sprawdzać w językach, które nie posiadają wbudowanego mechanizmu Projektowania Przez Umowę, zatem najczęściej określa się warunki początkowe i końcowe. Oto trywialny przykład prostej umowy dla metody wypij() klasy Butelka, zapisanej w języku OCL:

Butelka::wypij(ile : Integer)
pre : (jestOdkorkowana() = true) and (ileZostało() > ile)
post: ileZostało() = ileZostało()@pre - ile

Oznacza to mniej więcej tyle, że kiedy napijemy się z butelki, to zostanie w niej o tyle mniej płynu, ile wypiliśmy.

Przytoczenie tutaj Projektowania Przez Umowę ma sens, ponieważ zasadę Liskov można dość łatwo zinterpretować w kontekście tej techniki. Interpretacja ta brzmi:

“Typ A jest podtypem typu B, jeśli wymaga nie więcej niż on i zapewnia nie mniej.”

Jakie korzyście przynosi stosowanie się do reguły Liskov? Przede wszystkim brak niemiłych niespodzianek, takich jak te z przykładu. W językach takich jak C++, gdzie głównym zadaniem dziedziczenia nie jest rozszerzanie zachowania, lecz polimorfizm typów, może to mieć krytyczne znaczenie dla poprawnego działania aplikacji. Projektowanie oprogramowania, jak podkreśla wielu specjalistów w tej dziedzinie, to sztuka ciągłych kompromisów, dlatego może się zdarzyć, że reguły Liskov nie da się spełnić, albo nie jest to opłacalne. Kluczem jest tutaj zdolność projektanta do oceny sytuacji.

astralastral

Bibliography
1. Martin R. C. The Liskov Substitution Principle C++ Report, Vol. 8, March 1996.
2. Warmer J., Kleppe A. OCL - precyzyjne modelowanie w UML, Warszawa, WNT 2003.
O ile nie zaznaczono inaczej, treść tej strony objęta jest licencją Creative Commons Attribution-Share Alike 2.5 License.