Polimorfizm jest mechanizmem pozwalającym na zgrabne i eleganckie programowanie z użyciem dziedziczenia. Trzeba go jednak używać z rozwagą. Zobaczmy, jakie pułapki czyhają na nas, gdy korzystamy z dziedziczenia i polimorfizmu.

Prowadząc analizę rzadko zastanawiamy się nad tym, jak tworzony przez nas model dziedziny będzie wpływał na implementację. Opisany poniżej prosty przykład pokazuje jednak, że warto o tym pamiętać, szczególnie używając dziedziczenia, z którym nierozerwalnie związany jest mechanizm polimorfizmu.

Wyobraźmy sobie program wykonujący obliczenia na zbiorach różnych figur geometrycznych. Wśród dostępnych figur są między innymi kwadrat i prostokąt. Jak pamiętamy ze szkoły podstawowej, kwadrat to taki szczególny rodzaj prostokąta, którego oba boki są równe. Zatem każdy kwadrat jest prostokątem, choć nie każdy prostokąt jest kwadratem.

Rozważania te ilustruje model przedstawiony na rysunku 1., w którym zależność pomiędzy kwadratem i prostokątem przyjęła formę dziedziczenia. Wydaje się naturalne, że skoro kwadrat jest szczególnym rodzajem prostokąta, to klasa Kwadrat musi być specjalizacją klasy Prostokąt.

Rysunek 1. Kwadrat jest rodzajem prostokąta
Rysunek 1. Kwadrat jest rodzajem prostokąta

Powierzchnia prostokąta

Prześledźmy zatem, jak będzie się zachowywał kod programu opartego na takim modelu, przedstawiony na listingu 1. W programie tym na zmiennej p tworzymy obiekt klasy Prostokąt, ustawiamy długości jego boków, a następnie wyświetlamy powierzchnię prostokąta. Powinna ona wynosić 15.

Listing 1. Obliczanie powierzchni prostokąta

var p : Prostokąt;
p := new(Prostokąt);
p.setX(5);
p.setY(3);
print(p.powierzchnia());

Dokonajmy teraz jednej drobnej zmiany w kodzie naszego programu: wiersz

p := new(Prostokąt);

zamieńmy na:

p := new(Kwadrat);

Możemy to zrobić, mimo że p jest zmienną typu Prostokąt, ponieważ - zgodnie z zasadami dziedziczenia - na zmienną danej klasy możemy podstawić obiekt jej dowolnej podklasy.

Aby odpowiedzieć na pytanie, jaki wynik wygeneruje teraz nasz program, musimy najpierw zastanowić się, jak zadziałają metody setX i setY, służące do ustawiania długości boków. Skoro mamy do czynienia z kwadratem (który -- przypomnijmy -- ma oba boki równe), to metoda setX, oprócz ustawienia długości boku x, musi także odpowiednio ustawić długość boku y, aby zachować własność kwadratu. Dokładnie tak samo musi działać metoda setY -- gdyby zmieniła tylko długość boku y, to nasz kwadrat przestałby być kwadratem. Musimy więc w klasie Kwadrat przedefiniować obie metody. Nasz zmodyfikowany program wyświetli więc wynik 9, mimo że cały czas używamy zmiennej klasy Prostokąt!

Kłopoty z polimorfizmem

Coś tu jest nie w porządku. Intuicyjnie można by oczekiwać, że wszystkie obiekty ?pasujące? do zmiennej p będą generowały ten sam wynik, bo przecież wszystkie są prostokątami (kwadrat też w końcu jest prostokątem). Przyjrzyjmy się więc bliżej klasie Kwadrat. Tak na prawdę w obiektach tej klasy nie potrzebujemy pamiętać dwóch boków kwadratu, wystarczyłoby przechowywać jeden. Jednak dwa boki są dziedziczone z klasy Prostokąt i nie możemy się ich pozbyć. To właśnie konieczność manipulowania obydwoma bokami, które i tak muszą być równe, sprawiła, że musieliśmy przedefiniować metody setX i setY. Wywołując te metody na zmiennej p, faktycznie wywołaliśmy metody klasy Kwadrat, mimo że zmienna p jest typu Prostokąt. Polimorfizm objawia się właśnie tym, że wywoływana metoda jest brana nie z klasy podanej jako typ zmiennej, lecz z klasy, jaką faktycznie ma przypisany na zmienną obiekt. Tek skądinąd bardzo elegancki i przydatny mechanizm stał się w tym przypadku źródłem naszych kłopotów.

Zasada, którą tu złamaliśmy, jest znana jako reguła podstawiania Liskovej (ang. Liskov Substitution Principle; LSP). Z reguły tej jasno wynika, że przedefiniowując metodę w podklasie, nie możemy zmieniać ani wyłączać zachowania metody nadklasy -- możemy co najwyżej to zachowanie rozszerzać o aspekty specyficzne dla podklasy. Jeśli spełnimy tą regułę, będziemy pewni, że wywołując metodę na zmiennej pewnej klasy, zawsze otrzymamy taki sam wynik, niezależnie od tego, czy zmienna ta wskazuje na obiekt tej klasy, czy jednej z jej podklas.

Reguła podstawiania Liskovej

Funkcja odwołująca się do nadklasy musi umieć korzystać także z obiektów podklas, nie wiedząc o tym, z jakiej konkretnie klasy obiektem ma do czynienia.

Reguła podstawiania Liskovej jest -- jak widać -- dość subtelna i nie jest automatycznie weryfikowana przez kompilatory (które nie są w stanie sprawdzić na etapie kompilacji, czy zachowanie dwóch metod jest takie samo). Dlatego łatwo jest tą regułę złamać. Konsekwencją niedotrzymania tej reguły jest zwykle nieelegancki kod, w którym -- wywołując jakąś metodę -- musimy sprawdzać, korzystając z mechanizmu refleksji (ang. reflection), jakiej faktycznie klasy jest obiekt wskazywany przez zmienną.

Dziedziczenie zachowania

Co zatem możemy zrobić, aby reguła podstawiania była spełniona? Okazuje się, że musimy zrezygnować z dziedziczenia przedstawionego na rysunku 1, mimo że intuicyjnie wydaje się ono poprawne. Jednak fakt, że każdy kwadrat jest prostokątem (w sensie matematycznym), nie oznacza wcale, że obiekt klasy Kwadrat jest jednocześnie obiektem klasy Prostokąt. Jest tak dlatego, że relacja dziedziczenia (bycia szczególnym przypadkiem) dotyczy zachowania obiektów. Tymczasem zachowanie obiektu klasy Kwadrat jest zupełnie inne, niż obiektu klasy Prostokąt. Zatem w sensie zachowania, kwadrat nie jest prostokątem!

Jak zatem powinien wyglądać nasz model, aby reguła podstawiania Liskovej nie była złamana? Skoro kwadrat nie jest rodzajem prostokąta, to może poprawna będzie sytuacja odwrotna, przedstawiona na rysunku 2? Sprawdzenie, że i w tym przypadku reguła podstawiania Liskovej zostałaby złamana, pozostawiamy Czytelnikom jako ćwiczenie.

Rysunek 2. Prostokąt jest rodzajem kwadratu 
Rysunek 2. Prostokąt jest rodzajem kwadratu

Pozostaje nam zatem traktowanie kwadratu i prostokąta jako dwóch niezależnych od siebie klas. Jedynym bezpiecznym sposobem uwspólnienia pewnych aspektów zachowania tych figur jest dodanie wspólnej abstrakcyjnej nadklasy Figura, tak jak na rysunku 3. Taka nadklasa może być przydatna wtedy, gdy będziemy chcieli wykonywać różne operacje (np. obliczać powierzchnię, rysować) na wszystkich figurach, niezależnie od ich typu. Dzięki niej można także łatwo dodawać do modelu kolejne rodzaje figur (podklasy klasy Figura) bez konieczności modyfikowania istniejącego kodu.

Rysunek 3. Prostokąt i kwadrat jako niezależne klasy
Rysunek 3. Prostokąt i kwadrat jako niezależne klasy

Przedstawiony przykład figur geometrycznych jest raczej akademicki. Jednak reguła podstawiania daje o sobie znać także w praktyce. Pamiętajmy więc o niej, gdy podczas tworzenia modelu przyjdzie nam do głowy, że na przykład konto emerytalne jest szczególnym przypadkiem (podklasą) konta bankowego. Konto bankowe udostępnia funkcję wypłaty pieniędzy. Z konta emerytalnego sami wypłacać nie możemy -- musielibyśmy więc w podklasie wyłączyć zachowanie nadklasy, łamiąc regułę podstawiania i prosząc się o kłopoty podczas implementacji systemu.

W następnym odcinku

Za miesiąc przyjrzymy się bliżej powiązaniom pomiędzy klasami. Zobaczymy, jak poprawnie opisywać powiązania i jak sterować ich zachowaniem.

Szymon Zioło

Artykuł został opublikowany w Software Developer's Journal nr 9/2009. 

Dodaj komentarz