W 1988 roku Barbara Liskov [1] sformułowała zasadę zastąpień. Brzmi ona następująco:
What
is wanted here is something like the following substitution property:
If for each object o1 of type S there is an object o2 of type T such
that for all programs P defined in terms of T, the behavior of P is
unchanged when o1 is substituted for o2 then S is subtype of T.
Definicja
ta mówi, że metoda przyjmująca jako parametr egzemplarz klasy A musi
działać poprawnie także dla wszystkich klas, które dziedziczą po A,
nieznając ich typów.
Ciekawa zależność ujawnia się, gdy
przeanalizujemy metodę, która nie spełnia tego warunku. Taka metoda
przyjmuje jako parametr egzemplarz pewnej klasy, ale żeby działać
poprawnie musi znać jego dokładny typ. To oczywiście jest pogwałceniem
zasady
otwarty-zamknięty, gdyż taka metoda będzie zmieniana za każdym
razem, gdy będziemy dodawać nową klasę dziedziczącą po typie parametru.
Wzorcowe
rozwiązanie tego problemu używa takich właściwości programowania
obiektowego jak abstrakcja i polimorfizm. W językach obiektowych tych
własności dostarcza dziedziczenie.
Zasada zastąpień B. Liskov odpowiada
na kilka pytań dotyczących wykorzystywania dziedziczenia. Określa,
jakie są cechy charakterystyczne najlepszej hierarchii dziedziczenia.
Wskazuje przyczyny tego, co może sprawiać, że utworzona hierarchia
dziedziczenia nie jest zgodna z zasadą
otwarty-zamknięty.
Klasycznym
przykładem obrazującym
zasadę zastąpień B. Liskov jest hierarchia figur
geometrycznych a w szczególności relacja między prostokątem i
kwadratem. Z matematycznego punktu widzenia każdy kwadrat jest
prostokątem. To stwierdzenie szybko utrwala nas w przekonaniu, że w
hierarchii dziedziczenia, kwadrat powinien być podklasą prostokątu.
Przyjrzyjmy się przykładowej realizacji, którą prezentuje przykład 1.
Przykład [C#] 1. Przykładowa realizacja klasy reprezentującej prostokąt.
public class Rectangle : IRectangle
{
private double width;
private double height;
public virtual double Width
{
get { return width; }
set { width = value; }
}
public virtual double Height
{
get { return height; }
set { height = value; }
}
}Przykład [C#] 2. Interfejs klasy reprezentującej prostokąt.
public interface IRectangle
{
double Width { get; set; }
double Height { get; set; }
}Mając
przed oczami przykład 1 zastanówmy się jak będzie wyglądała klasa
Square dziedzicząca po klasie
Rectangle. Bedzie miała własności takie
jak prostokąt, czyli wysokść i szerokość. Ale czy to nie za dużo?
Przecież kwadrat ma zawsze wysokość i szerokość taką samą. Tą
niedogodność możemy usnąć zmieniając realizacje własciwości
Width i
Height (mimo, że są zbędne nie możemy ich usunąć, gdyż są dziedziczone
z klasy
Rectangle). Realizację klasy
Square przedstawiono w przykładzie 3.
Przykład [C#] 3. Przykładowa realizacja klasy reprezentującej kwadrat.
public class Square : Rectangle
{
public override double Width
{
get { return base.Width; }
set
{
base.Width = value;
base.Height = value;
}
}
public override double Height
{
get { return base.Height; }
set
{
base.Height = value;
base.Width = value;
}
}
}W klasie
Square z przykładu 3 zmieniono realizacje właściwości
Width i
Height.
Już w klasie
Rectangle poczyniono pewne założenia i właściwości te
oznaczono jako wirtualne. Taka realizacja zapewnia, że zmiana wysokości kwadratu zmienia również jego szerokość i vice versa.
Zdefiniowaliśmy zatem hierarchię dziedziczenia, w której klasa
Rectangle jest
klasą bazową, a klasa
Square dziedziczy po niej. Teraz w kontekście
zasady zastapień B. Liskov przeanalizujmy metodę z przykładu 4.
Przykład [C#] 4. Metoda naruszająca
zasadę zastąpień B. Liskov.
public void AdjustWidth( Rectangle rct, double aspectRatio )
{
rct.Width = rct.Height * aspectRatio;
Debug.Assert( rct.Width / rct.Height == aspectRatio );
}Zadaniem
metody z przykładu 4 jest dostosowanie szerokości prostokątu tak, aby
jego wysokość i szerokość były w stosunku podanym jako drugi parametr.
Metoda ta jako pierwszy swój parametr przyjmuje egzemplarz klasy
Rectangle. Zgodnie z
zasadą zastapień B. Liskov powinna działać
poprawnie również dla klas dziedziczących po tej klasie. W naszym
przypadku klasą potomną jest klasa
Square.
Czy metoda ta
rzeczywiście zadziała? Otóz nie! Jak łatwo zauważyć, podając stosunek
inny niż 1 oraz egzemplarz klasy
Square, asercja nie będzie
spełniona. Zatem jaki błąd popełniono? Czy programista pisząc tą metodę
mógł założyć, że asercja zawsze będzie spełniona?
Spójrzmy
na ten problem z trochę innej strony. Bertrand Meyer [2] pisze o
projektowaniu według umowy (ang. Design by Contract, DbC). Technika ta
ma swoje korzenie w formalnych metodach weryfikacji programów.
Rozszerza ona definicję metody o warunki początkowe i końcowe
(kontrakt) oraz klasy o niezmienniki. Warunek początkowy (ang.
precondition) określa warunek, jaki klient musi spełnić, aby wykonanie
metody przebiegło pomyślnie. Natomiast warunek końcowy (ang.
postcondition) określa warunek, jaki będzie spełniony po wykonaniu
metody. Ostatnim elementem jest niezmiennik klasy (ang. class
invariant), który określa warunek, który dla danej klasy musi być
zawsze spełniony.
Wracając do metody z przykładu 4 zastanówmy
się, jaki warunek końcowy powinna mieć operacja przypisywania
szerokości prostokątowi? Otóż powinna zagwarantować, że szerokość
kwadratu zostanie zmieniona na nową wartość, natomiast jego wysokość
pozostanie niezmieniona. Jak łatwo zauważyć, warunek ten nie jest
spełniony w wypadku realizacji właściwości
Width w klasie
Square.
Bertrand Meyer[2] pisze:
When redefining
a routine [in derivative], you may only replace its precondition by a
weaker one, and its postcondition by a stronger one.
Przeciażenie
właściwości
Width klasy
Square nie jest zgodne z tym co pisze Meyer.
Warunek końcowy tej właściwości jest słabszy od warunku końcowego
właściwości
Width z klasy bazowej, gdyż nie zapewnia niezmienności
wysokości. Czy wynika z tego, że kwadrat nie jest prostokątem? Otóz
nie! Oczywiście kwadrat jest prostokątem, natomiast nie jest nim z
punktu widzenia dziedziczenia. Należy podkreślić zasadniczą róznicę.
Zachowanie klasy
Square jest inne niż klasy
Rectangle. Aby być zgodnym
z
zasadą zastapień B. Liskov, wszystkie klasy dziedziczące z danej
klasy bazowej muszą być z nią zgodne pod względem zachowań. Tego
oczekują klienci używajacy klasy bazowej.
[1] Liskov B.: Data Abstraction and Hierarchy. SIGPLAN Notices, 1988. 23, 5.
[2] Meyer B.: Programowanie zorientowane obiektowo. Gliwice. Helion, 2005.