116
ROZDZIAŁ 8. SRP — ZASADA POJEDYNCZEJ ODPOWIEDZIALNOŚCI
Możemy postrzegać klasę ModemImplementation jako węzeł lub brodawkę. Zauważmy jednak, że wszystkie zależności płyną od niej na zewnątrz. Żadna klasa nie musi zależeć od tej klasy. Żaden program, z wyjątkiem procedury main, nie musi wiedzieć, że ta klasa istnieje. W ten sposób brzydką część wystawiliśmy „za płot”. Jej brzydota nie musi wyciekać i zanieczyszczać reszty aplikacji.
Trwałość Na rysunku 8.4 pokazano typowe naruszenie zasady SRP. Klasa Employee zawiera reguły biznesowe oraz zarządza utrwalaniem obiektów. Tych dwóch zadań prawie nigdy nie należy mieszać. Reguły biznesowe zwykle często się zmieniają, a chociaż zadania utrwalania nie zmieniają się tak często, to zmieniają się z zupełnie innych powodów. Wiązanie reguł biznesowych z podsystemem utrwalania jest proszeniem się o kłopoty.
Rysunek 8.4. Sprzężony podsystem utrwalania
Na szczęście, jak przekonaliśmy się w rozdziale 4., stosowanie praktyki produkcji sterowanej testami zwykle zmusza do rozdzielenia tych dwóch odpowiedzialności na długo przedtem, zanim projekt zacznie brzydko pachnieć. Jednak w przypadkach, gdy testy nie wymuszają separacji, a zapachy sztywności i kruchości nie staną się dość silne, projekt należy zrefaktoryzować z wykorzystaniem wzorców projektowych Fasada lub Pełnomocnik. W ten sposób należy rozdzielić te dwie odpowiedzialności.
Wniosek Zasada SRP jest najprostszą z zasad i jedną z tych, których właściwe stosowanie jest najtrudniejsze. Łączenie obowiązków jest czymś, co robimy w sposób naturalny. W większości projektowanie oprogramowania sprowadza się właśnie do wyszukiwania i oddzielania tych obowiązków od siebie. Pozostałe zasady, które omówimy, w istocie w taki czy inny sposób sięgają do tej kwestii.
Bibliografia 1. Tom DeMarco, Structured Analysis and System Specification. Yourdon Press Computing Series, Englewood Cliff, NJ: 1979. 2. Meilir Page-Jones, The Practical Guide to Structured Systems Design, wydanie drugie. Englewood Cliff, NJ: Yourdon Press Computing Series, 1988.
R OZDZIAŁ 9
OCP — zasada otwarte-zamknięte
Holenderskie drzwi — (rzeczownik) drzwi podzielone na dwie części w poziomie, tak że każda z części może pozostać otwarta lub zamknięta — The American Heritage® Dictionary of the English Language, wydanie czwarte, 2000
Jak powiedział Ivar Jacobson: „Wszystkie systemy zmieniają się w czasie swojego cyklu życia. Trzeba o tym pamiętać podczas prowadzenia prac nad systemami, od których oczekuje się, że przetrwają dłużej niż ich pierwsza wersja1”. Jak można tworzyć projekty, które pozostaną stabilne w obliczu zmian i które będą trwać dłużej niż ich pierwsza wersja? Wskazówki na ten temat dał nam Bertrand Meyer w 1988 roku, kiedy wymyślił słynną dziś zasadę otwarte-zamknięte2.
OCP — zasada otwarte-zamknięte Encje oprogramowania (klasy, moduły, funkcje itp.) powinny być otwarte na rozbudowę, ale zamknięte dla modyfikacji. Gdy pojedyncza zmiana programu powoduje kaskadę zmian w modułach zależnych, to projekt wydziela woń sztywności. Zasada OCP radzi nam, aby zrefaktoryzować system tak, by dalsze zmiany tego rodzaju nie powodowały kolejnych modyfikacji. Jeśli zasada OCP zostanie właściwie zastosowana, to 1
[Jacobson 92], str. 21.
2
[Meyer 97], str. 57.
118
ROZDZIAŁ 9. OCP — ZASADA OTWARTE-ZAMKNIĘTE
nowe zmiany tego typu uzyskuje się przez dodanie nowego kodu, a nie przez zmianę starego kodu, który już działa. Stosowanie tej zasady może wydawać się nieosiągalnym ideałem — ale istnieje kilka stosunkowo prostych i skutecznych strategii zbliżania się do tego ideału.
Opis Moduły, które są zgodne z zasadą otwarte-zamknięte, charakteryzują się dwoma podstawowymi atrybutami. Są one: 1. Otwarte na rozszerzenia. Oznacza to, że zachowanie modułu może być rozszerzone. W miarę zmieniania się wymagań aplikacji możemy rozszerzać moduł o nowe zachowania, które pozwalają na sprostanie tym wymaganiom. Inaczej mówiąc, możemy zmieniać to, co moduł robi. 2. Zamknięte na modyfikacje. Rozszerzanie zachowań modułu nie skutkuje zmianami w źródłowym lub binarnym kodzie modułu. Binarna, wykonywalna wersja modułu, niezależnie od tego, czy jest to biblioteka konsolidowana, czy biblioteka DLL, czy biblioteka .jar w Javie, pozostaje nienaruszona. Mogłoby się wydawać, że te dwie cechy są sprzeczne. Normalnym sposobem rozszerzania modułu o nowe zachowania jest wprowadzanie zmian w kodzie źródłowym tego modułu. Moduł, którego nie można zmieniać, zwykle uważa się za moduł o ustalonym zachowaniu. Jak to możliwe, aby modyfikować zachowania modułu bez zmiany jego kodu źródłowego? Jak możemy zmienić to, co moduł robi, bez zmieniania modułu?
Kluczem jest abstrakcja W C++, Javie lub dowolnym innym języku OOPL3 możliwe jest tworzenie abstrakcji, które są trwałe, a jednocześnie reprezentują niepowiązaną grupę możliwych zachowań. Są to abstrakcyjne klasy bazowe, natomiast niepowiązane grupy możliwych zachowań są reprezentowane przez wszystkie możliwe klasy pochodne. Istnieje możliwość, aby moduł manipulował abstrakcją. Taki moduł może być zamknięty dla modyfikacji, ponieważ zależy od abstrakcji, która jest ustalona. Pomimo to zachowania tego modułu można rozszerzać poprzez tworzenie nowych pochodnych abstrakcji. Na rysunku 9.1 pokazano prosty projekt, który nie przestrzega zasady OCP. Zarówno klasa Client, jak i Server są konkretne. Klasa Client używa klasy Server. Gdybyśmy chcieli, aby obiekt Client używał innego obiektu serwera, to musielibyśmy zmienić klasę Client, wprowadzając w niej nazwę nowej klasy serwera.
Rysunek 9.1. Klasa Client nie jest otwarta ani zamknięta
Na rysunku 9.2 pokazano odpowiednik projektu z rysunku 9.1, ale taki, który spełnia zasadę OCP. W tym przypadku klasa ClientInterface jest klasą abstrakcyjną zawierającą abstrakcyjne funkcje składowe. Klasa Client korzysta z tej abstrakcji, jednak obiekty klasy Client będą używały obiektów pochodnych klasy Server. Gdybyśmy chcieli, aby obiekty Client używały innego obiektu serwera, to możemy utworzyć pochodną klasy ClientInterface. Klasa Client może pozostać bez zmian. 3
Język programowania obiektowego — ang. Object-oriented programming language.
APLIKACJA SHAPE
119
Klasa Client ma do wykonania pewną pracę. Może opisywać tę pracę w kategoriach abstrakcyjnego interfejsu udostępnianego przez klasę ClientInterface. Podtypy klasy ClientInterface mogą implementować ten interfejs w dowolny sposób. Tak więc zachowanie określone w klasie Client może być rozszerzane i modyfikowane poprzez stworzenie nowych klas pochodnych klasy ClientInterface.
Rysunek 9.2. Wzorzec Strategia: klasa Client jest równocześnie otwarta i zamknięta
Czytelnik może się zastanawiać, dlaczego nazwałem klasę ClientInterface w taki sposób. Dlaczego nie nazwałem jej AbstractServer? Powodem tej decyzji, jak przekonasz się później, jest fakt, że klasy abstrakcyjne są bliżej powiązane ze swoimi klientami niż z klasami, które je implementują. Alternatywną strukturę pokazano na rysunku 9.3. Klasa Policy zawiera zbiór konkretnych funkcji publicznych, które implementują określoną strategię. Podobnie do funkcji klasy Client z rysunku 9.2 te funkcje opisują pewną pracę do wykonania w kategoriach abstrakcyjnych interfejsów. Jednak w tym przypadku abstrakcyjne interfejsy są częścią samej klasy Policy. W C++ byłyby one czystymi funkcjami wirtualnymi, natomiast w Javie — metodami abstrakcyjnymi. Funkcje te są implementowane w typach podrzędnych klasy Policy. Tak więc zachowania określone wewnątrz klasy Policy mogą być rozszerzane lub modyfikowane poprzez stworzenie nowych pochodnych klasy Policy.
Rysunek 9.3. Wzorzec Metoda szablonowa — klasa bazowa jest otwarta i zamknięta
Te dwa wzorce są najbardziej popularnymi sposobami spełnienia zasady OCP. Reprezentują czytelną separację generycznej funkcjonalności od szczegółowej implementacji tej funkcjonalności.
Aplikacja Shape Przykład zaprezentowany poniżej był prezentowany w wielu książkach poświęconym projektom obiektowym. To osławiony przykład „Shape”. Zazwyczaj jest on stosowany do pokazania, jak działa polimorfizm. Jednak tym razem użyjemy go do wyjaśnienia zasady OCP. Mamy aplikację, która musi mieć możliwość rysowania okręgów i prostokątów na standardowym GUI. Okręgi i kwadraty muszą być rysowane w określonej kolejności. Po utworzeniu listy okręgów i kwadratów w odpowiedniej kolejności program powinien przeglądać tę listę w takiej samej kolejności i rysować każdy okrąg lub kwadrat.
120
ROZDZIAŁ 9. OCP — ZASADA OTWARTE-ZAMKNIĘTE
Naruszenie zasady OCP W języku C, stosując techniki proceduralne, które nie są zgodne z OCP, możemy rozwiązać ten problem tak, jak pokazano na listingu 9.1. Widzimy tam zestaw struktur danych, które mają taki sam pierwszy element, ale poza tym są różne. Pierwszy element każdej struktury jest kodem typu, który identyfikuje strukturę danych jako okrąg lub kwadrat. Funkcja DrawAllShapes przegląda tablicę wskaźników na te struktury danych, sprawdza kod typu, a następnie wywołuje właściwą funkcję (DrawCircle lub DrawSquare). Listing 9.1. Proceduralne rozwiązanie problemu kwadrat/okrąg --shape.h--------------------------------------enum ShapeType {circle, square}; struct Shape { ShapeType itsType; };
--circle.h--------------------------------------struct Circle { ShapeType itsType; double itsRadius; Point itsCenter; };
void DrawCircle(struct Circle*);
--square.h--------------------------------------struct Square { ShapeType itsType; double itsSide; Point itsTopLeft; };
void DrawSquare(struct Square*);
--drawAllShapes.cc------------------------------typedef struct Shape *ShapePointer;
void DrawAllShapes(ShapePointer list[], int n) { int i; for (i=0; i
itsType) { case square: DrawSquare((struct Square*)s); break;
}
}
case circle: DrawCircle((struct Circle*)s); break; }
Funkcja DrawAllShapes nie spełnia zasady OCP, ponieważ nie można jej zamknąć dla nowych rodzajów figur. Gdybym chciał rozszerzyć tę funkcję tak, aby móc narysować listę figur, która zawiera trójkąty, musiałbym zmodyfikować tę funkcję. W rzeczywistości musiałbym zmodyfikować funkcję dla każdego nowego rodzaju figury, którą chciałbym narysować.
APLIKACJA SHAPE
121
Oczywiście ten program jest tylko prostym przykładem. W praktyce instrukcja switch w funkcji DrawAllShapes byłaby powtarzana w kółko w różnych funkcjach w aplikacji, a każda robiłaby coś innego. Mogłyby być to funkcje do przeciągania figur, rozciągania ich, przenoszenia, usuwania itp. Dodanie nowej figury do takiej aplikacji oznacza polowanie na wszystkie miejsca, w których występują takie instrukcje switch (lub ciągi instrukcji if-else), i dodawanie do każdej z instrukcji nowej figury. Co więcej, jest bardzo mało prawdopodobne, aby wszystkie instrukcje switch oraz klauzule if/else miały taką czytelną strukturę jak ta w funkcji DrawAllShapes. Jest o wiele bardziej prawdopodobne, że predykaty instrukcji if będą połączone za pomocą operatorów logicznych lub że klauzule case instrukcji switch będą ze sobą połączone tak, aby „uprościć” lokalne podejmowanie decyzji. W pewnych patologicznych sytuacjach mogą istnieć funkcje, które wykonują dokładnie te same operacje na obiektach Square co na obiektach Circle. W takich funkcjach może nawet brakować instrukcji switch/case lub łańcuchów instrukcji if/else. W takim przypadku problem znalezienia i zrozumienia wszystkich miejsc, w których trzeba dodać nową figurę, może nie być trywialny. Rozważmy także rodzaj zmian, jakie trzeba by było wprowadzić. Trzeba by dodać nową składową do typu wyliczeniowego ShapeType. Ponieważ wszystkie figury zależą od deklaracji w tym typie wyliczeniowym, trzeba by je wszystkie na nowo skompilować4. Trzeba by także skompilować wszystkie moduły, które zależą od modułu Shape. Tak więc nie tylko musielibyśmy zmienić kod źródłowy wszystkich instrukcji switch/case lub łańcuchów if/else, ale także musielibyśmy zmienić pliki binarne (poprzez ponowną kompilację) wszystkich modułów, które używają dowolnej struktury danych z modułu Shape. Zmiana plików binarnych oznacza, że wszystkie pliki DLL, biblioteki współdzielone lub inne rodzaje komponentów binarnych trzeba zainstalować na nowo. Prosty akt dodania nowej figury do aplikacji powoduje kaskadę kolejnych zmian w wielu modułach źródłowych i jeszcze więcej zmian w modułach i komponentach binarnych. Jest oczywiste, że dodanie nowej figury wywiera olbrzymi wpływ na aplikację. Zły projekt. Spróbujmy jeszcze raz przeanalizować aplikację. Rozwiązanie z listingu 9.1 jest sztywne, ponieważ dodanie funkcji Triangle powoduje konieczność ponownej kompilacji i instalacji funkcji Shape, Square, Circle i DrawAllShapes. Jest również kruche, ponieważ zawiera wiele instrukcji switch-case lub if-else, które zarówno trudno odnaleźć, jak i odszyfrować. Jest niemobilne, ponieważ każdy, kto spróbuje wykorzystać funkcję DrawAllShapes w innym programie, będzie zmuszony przenieść razem z nią funkcje Square i Circle nawet wtedy, gdy ten nowy program nie potrzebuje ich. A zatem kod z listingu 9.1 wykazuje wiele zapachów złego projektu.
Zachowanie zgodności z zasadą OCP Na listingu 9.2 pokazano rozwiązanie problemu kwadratów i okręgów, które jest zgodne z zasadą OCP. W tym przypadku napisaliśmy klasę abstrakcyjną o nazwie Shape. Ta klasa abstrakcyjna zawiera pojedynczą metodę abstrakcyjną Draw. Zarówno Circle, jak i Square są klasami pochodnymi klasy Shape. Listing 9.2. Obiektowe rozwiązanie problemu kwadrat/okrąg class Shape { public: virtual void Draw() const = 0; }; 4
Zmiany w typach wyliczeniowych mogą spowodować zmiany rozmiaru zmiennych wykorzystywanych do ich przechowywania. Dlatego w przypadku podjęcia decyzji o tym, że nie trzeba kompilować innych deklaracji figur, należy zachować szczególną ostrożność.
122
ROZDZIAŁ 9. OCP — ZASADA OTWARTE-ZAMKNIĘTE
class Square : public Shape { public: virtual void Draw() const; }; class Circle : public Shape { public: virtual void Draw() const; }; void DrawAllShapes(vector& list) { vector::iterator i; for (i=list.begin(); i != list.end(); i++) (*i)->Draw(); }
Zwróćmy uwagę, że aby rozszerzyć zachowanie funkcji DrawAllShapes z listingu 9.2 w taki sposób, by narysować nową figurę, wystarczy dodać nową pochodną klasy Shape. W tym celu nie trzeba zmieniać funkcji DrawAllShapes. A zatem funkcja DrawAllShapes jest zgodna z zasadą OCP. Jej zachowanie można rozszerzyć bez wprowadzania modyfikacji. W rzeczywistości dodanie klasy Triangle nie ma absolutnie żadnego wpływu na żaden z modułów, które są tu pokazane. Jest oczywiste, że pewne części systemu trzeba zmienić w celu obsługi klasy Triangle, ale cały kod pokazany powyżej jest odporny na zmiany. W rzeczywistej aplikacji klasa Shape miałaby znacznie więcej metod. Pomimo to dodanie nowej figury do aplikacji w dalszym ciągu jest dość proste, ponieważ wystarczy tylko utworzyć nową pochodną i zaimplementować wszystkie jej funkcje. Nie ma potrzeby przeglądania całej aplikacji i wyszukiwania miejsc, które wymagają wprowadzenia zmian. To rozwiązanie nie jest kruche. Rozwiązanie nie jest również sztywne. Nie trzeba modyfikować żadnych istniejących modułów źródłowych i, z jednym wyjątkiem, nie trzeba przebudowywać żadnych modułów binarnych. Zmodyfikować trzeba tylko ten moduł, który tworzy nową pochodną klasy Shape. Zwykle ta czynność jest wykonywana w funkcji main, w jakiejś funkcji wywoływanej przez main albo w jakiejś metodzie obiektu tworzonego przez funkcję main5. I na koniec: pokazane rozwiązanie nie jest niemobilne. Funkcja DrawAllShapes może być wykorzystana w dowolnej aplikacji i aby z niej skorzystać, nie trzeba dołączać klas Square lub Circle. A zatem zaprezentowane rozwiązanie nie wykazuje żadnego z wymienionych wcześniej atrybutów złego projektu. Ten program jest zgodny z zasadą OCP. Zmienia się go poprzez dodawanie nowego kodu, a nie poprzez zmianę istniejącego kodu. Z tego powodu w programie nie ma potrzeby wprowadzania kaskady zmian tak jak w przypadku programu, który nie jest zgodny z zasadą OCP. Jedynymi wymaganymi zmianami jest dodawanie nowego modułu oraz zmiany w funkcji main związane z tworzeniem egzemplarzy nowych obiektów.
Przyznaję się. Kłamałem Poprzedni przykład pokazuje sytuację idealną. Zastanówmy się, co by się stało z funkcją DrawAllShapes z listingu 9.2, gdybyśmy zdecydowali, że wszystkie okręgi powinny być narysowane przed wszystkimi kwadratami. Funkcja DrawAllShapes nie jest zamknięta na zmiany tego rodzaju. Aby zaimplementować taką zmianę, należy przejść do funkcji DrawAllShapes, a następnie zeskanować listę — najpierw w celu wyszukania wszystkich okręgów, a następnie ponownie w celu znalezienia wszystkich kwadratów.
Przewidywanie i „naturalna” struktura Gdybyśmy przewidzieli tego rodzaju zmianę, to moglibyśmy wymyślić abstrakcję, która ochroniłaby nas przed problemami wymienionymi wcześniej. Abstrakcje wybrane na listingu 9.2 są raczej przeszkodą dla tego typu zmian niż pomocą. Może się to wydawać zaskakujące. W końcu cóż bardziej naturalnego 5
Takie obiekty są tzw. fabrykami. Więcej informacji na ich temat można znaleźć w rozdziale 21.
APLIKACJA SHAPE
123
moglibyśmy wymyślić od klasy bazowej Shape z klasami pochodnymi Square i Circle? Dlaczego ten naturalny model nie jest najlepszym wzorcem, jaki można zastosować? Odpowiedź jest taka, że ten model nie jest naturalny w systemie, w którym kolejność jest bardziej znacząca niż typ figury. To prowadzi nas do niepokojącego wniosku. Ogólnie rzecz biorąc, bez względu na to, jak „zamknięty” jest określony moduł, zawsze będzie jakaś zmiana, w stosunku do której nie jest on zamknięty. Nie istnieje taki model, który byłby naturalny we wszystkich kontekstach. Ponieważ domknięcie nie może być pełne, musi być strategiczne. Oznacza to, że projektant musi wybrać takie rodzaje zmian, wobec których decyduje się zamknąć swój projekt. Musi odgadnąć najbardziej prawdopodobne rodzaje zmian, a następnie zbudować abstrakcje, które będą chronić go przed tymi zmianami. To wymaga pewnej umiejętności przewidywania, która pochodzi z doświadczenia. Doświadczony projektant zna użytkowników i branżę na tyle dobrze, aby właściwie ocenić prawdopodobieństwo wystąpienia różnego rodzaju zmian. Następnie stosuje zasadę OCP dla najbardziej prawdopodobnych zmian. Nie jest to łatwe. Umiejętność ta sprowadza się do odgadywania możliwych rodzajów zmian, jakie będą wprowadzane w aplikacji z biegiem czasu. Kiedy deweloper dobrze odgadnie te zmiany, jest zwycięzcą. Kiedy odgadnie nieprawidłowo, poniesie porażkę. I na pewno bardzo często będzie mu się zdarzało zgadywać źle. Co więcej, zachowanie zgodności z zasadą OCP jest kosztowne. Stworzenie odpowiednich abstrakcji wymaga czasu i wysiłku. Opracowane abstrakcje zwiększają również złożoność projektu oprogramowania. Istnieje ograniczenie liczby abstrakcji, na które deweloper może sobie pozwolić. Oczywiście chcemy ograniczyć stosowanie zasady OCP do zmian, które są prawdopodobne. Skąd wiadomo, że zmiany są prawdopodobne? Należy przeprowadzić odpowiednie analizy, zadać odpowiednie pytania oraz wykorzystać doświadczenie i zdrowy rozsądek. I przede wszystkim należy czekać do chwili, aż wystąpią zmiany.
Umieszczanie „haczyków” W jaki sposób zabezpieczyć się przed zmianami? W poprzednim wieku stosowano prostą zasadę. Umieszczano „haczyki” dla zmian, które przewidywano. Sądzono, że dzięki temu oprogramowanie stanie się elastyczne. Jednak haczyki, które wprowadzano, często były nieprawidłowe. Co gorsza, wykazywały woń zbytecznej złożoności, którą trzeba było obsłużyć i utrzymać nawet wtedy, gdy nie było dla niej zastosowania. To nie jest dobre. Nie chcemy, aby projekt był przeładowany dużą ilością niepotrzebnych abstrakcji. Przeciwnie, często czekamy, aż abstrakcja stanie się potrzebna, i dopiero wtedy ją wprowadzamy. Nabierz mnie raz... Jest takie stare powiedzenie: „Nabierz mnie raz, hańba dla ciebie. Nabierz mnie dwa razy, hańba dla mnie”. To powiedzenie niesie wielką mądrość dla projektowania oprogramowania. Aby zabezpieczyć nasze oprogramowanie przed ładowaniem niepotrzebną złożonością, możemy sobie pozwolić, by dać się nabrać raz. Oznacza to, że początkowo możemy napisać oprogramowanie tak, jakbyśmy oczekiwali, że nie będzie się ono zmieniać. Kiedy nastąpi zmiana, implementujemy abstrakcje, które chronią nas przed przyszłymi zmianami tego rodzaju. Krótko mówiąc, przyjmujemy pierwszą kulę, a następnie staramy się zadbać o to, abyśmy nie zostali trafieni żadną z kul pochodzących z tej broni. Stymulowanie zmian. Jeśli zdecydujemy się przyjąć pierwszą kulę, to z korzyścią dla nas jest sytuacja, w której kule latają wcześnie i często. Chcemy wiedzieć, jakie rodzaje zmian są prawdopodobne, zanim znajdziemy się zbyt daleko na ścieżce rozwoju. Im więcej czasu zajmie nam określenie prawdopodobnych zmian, tym trudniejsze będzie stworzenie właściwych abstrakcji.
124
ROZDZIAŁ 9. OCP — ZASADA OTWARTE-ZAMKNIĘTE
Dlatego trzeba stymulować zmiany. Robi się to za pomocą różnych mechanizmów, które omówiliśmy w rozdziale 2. Najpierw piszemy testy. Testowanie jest jednym z rodzajów używania systemu. Dzięki pisaniu testów
najpierw zmuszamy system do tego, by był sprawdzalny. Dzięki temu zmiany w sprawdzalności nie zaskoczą nas później. Utworzymy bowiem abstrakcje, dzięki którym system stanie się sprawdzalny. Jak się przekonamy, wiele z tych abstrakcji będzie chronić nas później przed innymi rodzajami zmian. Produkcja oprogramowania powinna odbywać się w bardzo krótkich cyklach — rzędu dni zamiast tygodni. Tworzymy funkcjonalności przed infrastrukturą i często prezentujemy te funkcjonalności interesariuszom. Najpierw tworzymy najbardziej istotne funkcjonalności. Wersje dystrybucyjne publikujemy wcześnie i często. Prezentujemy je naszym klientom i użytkownikom tak szybko i tak często, jak to możliwe.
Stosowanie abstrakcji w celu uzyskania jawnego domknięcia A zatem przyjęliśmy pierwszą kulę. Użytkownik chce narysować wszystkie okręgi przed dowolnymi z kwadratów. Teraz chcemy się zabezpieczyć przed dowolnymi zmianami tego rodzaju w przyszłości. W jaki sposób zamknąć funkcję DrawAllShapes na zmiany w kolejności rysowania? Należy pamiętać, że domknięcie bazuje na abstrakcji. Aby więc zamknąć funkcję DrawAllShapes przed zmianami kolejności, potrzebujemy jakiejś „abstrakcji kolejności”. To abstrakcja dostarczy abstrakcyjnego interfejsu, przez który mogą być wyrażane wszelkie możliwe strategie kolejności. Strategia kolejności implikuje, że jeśli mamy dowolne dwa obiekty, to zawsze możemy określić, który z nich powinien być narysowany jako pierwszy. Możemy zdefiniować abstrakcyjną metodę klasy Shape o nazwie Precedes. Ta funkcja przyjmuje inny obiekt klasy Shape jako argument i zwraca wynik typu bool. Funkcja zwraca wynik true, jeśli obiekt Shape, który otrzymuje komunikat, powinien być narysowany przed obiektem Shape przekazanym w roli argumentu. W języku C++ taką funkcję mógłby reprezentować przeciążony operator <. Na listingu 9.3 pokazano, jak mogłaby wyglądać klasa Shape, gdyby zaimplementowano w niej metody zarządzania kolejnością. Teraz kiedy mamy sposób określenia względnej kolejności dwóch obiektów Shape, możemy je posortować i narysować we właściwej kolejności. Na listingu 9.4 pokazano kod C++, który realizuję tę funkcjonalność. Listing 9.3. Klasa Shape z metodami zarządzania kolejnością class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; };
bool operator<(const Shape& s) {return Precedes(s);}
Listing 9.4. Funkcja DrawAllShapes z obsługą kolejności template class Lessp // narzędzie sortowania kontenerów wskaźników. { public: bool operator()(const P p, const P q) {return (*p) < (*q);} }; void DrawAllShapes(vector& list) { vector orderedList = list;
APLIKACJA SHAPE
125
sort(orderedList.begin(), orderedList.end(), Lessp());
}
vector::const_iterator i; for (i=orderedList.begin(); i != orderedList.end(); i++) (*i)->Draw();
W ten sposób uzyskaliśmy mechanizmy porządkowania obiektów Shape oraz rysowania ich we właściwej kolejności. W dalszym ciągu nie mamy jednak odpowiedniej abstrakcji do zarządzania kolejnością. W obecnej formie pojedyncze obiekty Shape w celu określenia porządku będą musiały przesłaniać metodę Precedes. Jak mogłoby to działać? Jaki kod moglibyśmy napisać w metodzie Circle::Precedes, aby zapewnić narysowanie okręgów przed kwadratami? Rozważmy kod z listingu 9.5. Listing 9.5. Porządkowanie okręgów bool Circle::Precedes(const Shape& s) const { if (dynamic_cast(s)) return true; else return false; }
Jak można zauważyć, ta funkcja oraz wszystkie funkcje Precedes zdefiniowane w innych klasach pochodnych klasy Shape nie są zgodne z zasadą OCP. Nie ma sposobu, aby zamknąć je na nowe pochodne klasy Shape. Za każdym razem po utworzeniu nowej pochodnej klasy Shape trzeba będzie zmienić wszystkie funkcje Precedes()6. Oczywiście to nie ma znaczenia, jeśli nowe pochodne klasy Shape nigdy nie będą tworzone. Z drugiej strony, gdyby były tworzone często, to ten projekt powodowałby konieczność wykonywania sporej pracy. Tak jak wspominałem — przyjmujemy pierwszą kulę.
Zastosowanie podejścia „sterowania danymi” w celu uzyskania domknięcia Jeśli trzeba zamknąć pochodne klasy Shape przed koniecznością wiedzy o sobie nawzajem, możemy użyć podejścia bazującego na tabeli. Jedną z możliwości implementacji takiego podejścia pokazano na listingu 9.6. Listing 9.6. Mechanizm porządkowania typów bazujący na tabeli #include #include #include using namespace std; class Shape { public: virtual void Draw() const = 0; bool Precedes(const Shape&) const; bool operator<(const Shape& s) const {return Precedes(s);} private: static const char* typeOrderTable[]; }; const char* Shape::typeOrderTable[] = 6
Istnieje możliwość rozwiązania tego problem za pomocą wzorca projektowego Acykliczny wizytator (ang. Acyclic visitor) opisanego w rozdziale 29. Pokazywanie tego rozwiązania teraz byłoby trochę przedwczesne. Pod koniec rozdziału 29. przypomnę Ci, abyś powrócił do tego przykładu.
126
{
};
ROZDZIAŁ 9. OCP — ZASADA OTWARTE-ZAMKNIĘTE
typeid(Circle).name(), typeid(Square).name(), 0
// Ta funkcja wyszukuje nazwy klas w tabeli. // Tabela definiuje kolejność, w jakiej // mają być narysowane figury. Figura, której nie ma w tabeli, // zawsze poprzedza te, które w tabeli są. // bool Shape::Precedes(const Shape& s) const { const char* thisType = typeid(*this).name(); const char* argType = typeid(s).name(); bool done = false; int thisOrd = -1; int argOrd = -1; for (int i=0; !done; i++) { const char* tableEntry = typeOrderTable[i]; if (tableEntry != 0) { if (strcmp(tableEntry, thisType) == 0) thisOrd = i; if (strcmp(tableEntry, argType) == 0) argOrd = i; if ((argOrd >= 0) && (thisOrd >= 0)) done = true; } else // table entry == 0 done = true; } return thisOrd < argOrd; }
Zastosowanie takiego podejścia pozwoliło pomyślnie zamknąć funkcję DrawAllShapes przed problemami porządkowania oraz wszystkie pochodne klasy Shape przed tworzeniem nowych pochodnych klasy Shape lub zmianami w strategii porządkowania obiektów Shape (np. zmianą kolejności w taki sposób, że kwadraty będą rysowane przed okręgami). Jedynym elementem, który nie jest zamknięty przed problemami kolejności różnych obiektów Shape, jest tabela. Tabelę tę można umieścić w osobnym module, oddzielonym od innych modułów. Dzięki temu zmiany, które są w niej wprowadzone, nie mają wpływu na żadne inne moduły. W języku C++ moglibyśmy wybrać tabelę, która ma być zastosowana w fazie konsolidacji.
Wniosek Pod wieloma względami zasada OCP stanowi centrum projektowania obiektowego. Zgodność z tą zasadą daje największe korzyści, jakie przynosi zastosowanie technologii obiektowej (np. elastyczność, wymienność i łatwość konserwacji). Jednak zgodności z tą zasadą nie uzyskuje się wyłącznie dzięki zastosowaniu obiektowego języka programowania. Nie jest też dobrym pomysłem stosowanie szalonych abstrakcji do każdej części aplikacji. Deweloperzy powinni dążyć do tego, aby abstrakcje były stosowane tylko do tych części programu, które często się zmieniają. Przeciwdziałanie przedwczesnym abstrakcjom jest tak samo ważne jak same abstrakcje.
Bibliografia 1. Ivar Jacobson, et al. Object-Oriented Software Engineering, Reading, MA: Addison-Wesley, 1992. 2. Bertrand Meyer, Object-Oriented Software Construction, wydanie drugie, Upper Saddle River, NJ: Prentice Hall, 1997.
R OZDZIAŁ 10
LSP — zasada podstawiania Liskov
Podstawowymi mechanizmami stojącymi za zasadą OCP są abstrakcja i polimorfizm. W językach o typowaniu statycznym, takich jak C++ i Java, jednym z kluczowych mechanizmów wspierających abstrakcję i polimorfizm jest dziedziczenie. To dzięki dziedziczeniu możemy tworzyć klasy pochodne, które implementują abstrakcyjne metody klas bazowych. Jakie są zasady projektowania, które regulują to konkretne zastosowanie dziedziczenia? Jakie są cechy najlepszych hierarchii dziedziczenia? Jakie są pułapki mogące spowodować, że utworzone hierarchie nie są zgodne z zasadą OCP? Odpowiedzią na te pytania jest zasada podstawiania Liskov (ang. Liskov Substitution Principle — LSP).
LSP — zasada podstawiania Liskov Zasadę LSP można wyrazić następująco: Musi być możliwość podstawienia typów pochodnych za ich typy bazowe. Barbara Liskov po raz pierwszy zapisała tę zasadę w 1988 roku1. Napisała: Poszukujemy następującej właściwości podstawiania: Jeśli dla każdego obiektu o 1 typu S istnieje obiekt o2 typu T taki, że dla wszystkich programów P zdefiniowanych w kategoriach T zachowanie P pozostanie niezmienione, gdy o1 zostanie podstawione za o2, to S jest podtypem T. Znaczenie tej zasady staje się oczywiste, jeśli weźmie się pod uwagę konsekwencje jej naruszenia. Załóżmy, że mamy funkcję f, która pobiera jako argument wskaźnik lub referencję do jakiejś klasy bazowej B. Załóżmy również, że istnieje jakaś pochodna D klasy B, która gdy zostanie przekazana do f w „przebraniu” B, powoduje, że funkcja f zachowuje się nieprawidłowo. W takim przypadku klasa D narusza zasadę LSP. Jest oczywiste, że klasa D jest krucha w obecności f. 1
[Liskov 88].
128
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
Autorzy funkcji f mogą ulec pokusie stworzenia pewnego rodzaju testu dla D tak, aby funkcja f zachowywała się poprawnie, gdy zostanie do niej przekazany obiekt klasy D. Taki test narusza zasadę OCP, ponieważ funkcja f nie jest zamknięta na wszystkie pochodne klasy B. Tego rodzaju testy są świadectwem brzydkiego zapachu kodu, który powstał w wyniku błędu niedoświadczonych deweloperów (lub, co gorsza, deweloperów, którzy się śpieszą) w reakcji na naruszenie zasady LSP.
Prosty przykład naruszenia zasady LSP Naruszenie zasady LSP często wynika z wykorzystania informacji o typie dostępnej w fazie wykonywania programu (ang. Run-Time Type Information — RTTI) w sposób rażąco naruszający zasadę OCP. Bardzo często do określenia typu obiektu w celu wybrania zachowania odpowiedniego do tego typu wykorzystywane są jawne instrukcje if lub łańcuchy instrukcji if-else. Rozważmy kod z listingu 10.1. Listing 10.1. Naruszenie zasady LSP powodujące naruszenie zasady OCP struct Point {double x,y;}; struct Shape { enum ShapeType {square, circle} itsType; Shape(ShapeType t) : itsType(t) {} }; struct Circle : public Shape { Circle() : Shape(circle) {}; void Draw() const; Point itsCenter; double itsRadius; }; struct Square : public Shape { Square() : Shape(square) {}; void Draw() const; Point itsTopLeft; double itsSide; }; void DrawShape(const Shape& s) { if (s.itsType == Shape::square) static_cast(s).Draw(); else if (s.itsType == Shape::circle) static_cast(s).Draw(); }
Jest oczywiste, że funkcja DrawShape z listingu 10.1 narusza zasadę OCP. Musi „wiedzieć” o każdej możliwej pochodnej klasy Shape i musi być zmieniana po utworzeniu każdej nowej pochodnej klasy Shape. Rzeczywiście, wiele osób słusznie uzna strukturę tej funkcji za przeciwieństwo dobrego projektu. Co mogłoby skłonić programistę do napisania takiej funkcji? Spróbujmy wcielić się w postać inżyniera Jerzego. Jerzy studiuje techniki obiektowe i doszedł do wniosku, że koszty stosowania polimorfizmu są zbyt wysokie 2. Z tego powodu zdefiniował klasę Shape bez żadnych funkcji wirtualnych. Klasy (struktury) Square i Circle są pochodnymi klasy Shape i mają funkcje Draw(), ale nie przesłaniają one funkcji z klasy Shape. Ponieważ nie można podstawić obiektów Circle i Square za Shape, funkcja DrawShape musi sprawdzać argument wejściowy Shape, określić jego typ, a następnie wywołać odpowiednią metodę Draw. 2
Na stosunkowo szybkiej maszynie te koszty są rzędu 1 ns na jedno wywołanie metody. Trudno zatem zgodzić się z punktem widzenia Jerzego.
KWADRATY I PROSTOKĄTY — BARDZIEJ SUBTELNE NARUSZENIE ZASADY LSP
129
To, że obiektów Square i Circle nie można podstawić za Shape, jest naruszeniem zasady LSP. To naruszenie wymusza naruszenie zasady OCP przez funkcję DrawShape. A zatem naruszenie zasady LSP jest ukrytym naruszeniem zasady OCP.
Kwadraty i prostokąty — bardziej subtelne naruszenie zasady LSP Oczywiście istnieją inne, o wiele bardziej subtelne sposoby naruszania zasady LSP. Rozważmy aplikację, która wykorzystuje klasę Rectangle zamieszczoną na listingu 10.2. Listing 10.2. Klasa Rectangle class Rectangle { public: void SetWidth(double w) void SetHeight(double h) double GetHeight() const double GetWidth() const private: Point itsTopLeft; double itsWidth; double itsHeight; };
{itsWidth=w;} {itsHeight=w;} {return itsHeight;} {return itsWidth;}
Wyobraźmy sobie, że ta aplikacja dobrze działa i jest zainstalowana w wielu miejscach. Tak jak w przypadku wszystkich programów, które odniosły sukces, jej użytkownicy od czasu do czasu żądają zmian. Pewnego dnia użytkownicy zaczęli domagać się możliwości manipulowania kwadratami oprócz prostokątów. Często uważa się, że dziedziczenie jest relacją IS-A. Innymi słowy, jeśli o nowym rodzaju obiektu można powiedzieć, że spełnia relację IS-A z obiektem starego typu, to klasa nowego obiektu powinna być pochodną klasy starego obiektu. Dla wszystkich normalnych zastosowań i celów kwadrat jest prostokątem. W związku z tym logiczne jest postrzeganie klasy Square jako pochodnej klasy Rectangle (patrz rysunek 10.1).
Rysunek 10.1. Klasa Square dziedziczy po klasie Rectangle
Takie zastosowanie relacji IS-A czasami uważa się za jedną z podstawowych technik analizy obiektowej3: kwadrat jest prostokątem, a zatem klasa Square powinna dziedziczyć po klasie Rectangle. Jednak takie myślenie może prowadzić do pewnych subtelnych, a jednak znaczących problemów. Ogólnie rzecz biorąc, tego typu problemy nie są przewidywane do czasu, aż widzimy je w kodzie. Pierwszą wskazówką informującą, że coś poszło nie tak, może być fakt, że klasa Square nie potrzebuje obydwu zmiennych składowych itsHeight oraz itsWidth. Pomimo tego odziedziczy je z klasy Rectangle. Oczywiście jest to marnotrawstwo. W wielu przypadkach to marnotrawstwo jest bez znaczenia. Ale jeśli trzeba tworzyć setki tysięcy obiektów Square (np. w programie CAD/CAE, w którym każdy pin każdego komponentu złożonego układu jest rysowany jako kwadrat), to marnotrawstwo może być znaczące. 3
Termin ten jest często używany, ale rzadko definiowany.
130
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
Załóżmy na chwilę, że nie bardzo interesuje nas ekonomiczna gospodarka pamięcią. Są też inne problemy, które wynikają z faktu dziedziczenia klasy Square z klasy Rectangle. Klasa Square odziedziczy funkcje SetWidth i SetHeight. Funkcje te są nieodpowiednie dla klasy Square, ponieważ szerokość i wysokość kwadratu są identyczne. To wyraźnie wskazuje na występowanie problemu. Istnieje jednak sposób, aby ominąć ten problem. Można przesłonić metody SetWidth i SetHeight w następujący sposób: void Square::SetWidth(double w) { Rectangle::SetWidth(w); Rectangle::SetHeight(w); } void Square::SetHeight(double h) { Rectangle::SetHeight(h); Rectangle::SetWidth(h); }
Teraz gdy ktoś ustawi szerokość obiektu Square, jego wysokość odpowiednio się zmieni. Z kolei gdy ktoś ustawi wysokość, razem z nią zmieni się także szerokość. A zatem niezmienniki4 klasy Square pozostają nienaruszone. Obiekt Square pozostanie poprawnym kwadratem z punktu widzenia matematyki. Square s; s.SetWidth(1); // Na szczęście ustawia także wysokość na 1. s.SetHeight(2); // Ustawia szerokość i wysokość na 2. To dobrze.
Rozważmy jednak następującą funkcję: void f(Rectangle& r) { r.SetWidth(32); // wywołuje Rectangle::SetWidth }
Jeśli do tej funkcji przekażemy referencję do obiektu Square, to obiekt Square znajdzie się w nieprawidłowym stanie, ponieważ wysokość się nie zmieni. To jawne naruszenie zasady LSP. Funkcja f nie działa z obiektami będącymi pochodnymi jej argumentów. Powodem niepowodzenia był fakt, że metody SetWidth i SetHeight nie zostały zadeklarowane w klasie Rectangle jako wirtualne. Z tego powodu nie są one polimorficzne. Można to łatwo naprawić. Jednak gdy utworzenie klasy pochodnej powoduje konieczność wprowadzania zmian w klasie bazowej, często oznacza to, że projekt jest wadliwy. Z pewnością projekt narusza zasadę OCP. Można by na to odpowiedzieć, że prawdziwą wadą projektu było niezadeklarowanie metod SetWidth i SetHeight jako wirtualnych i że teraz to naprawiamy. Jest to jednak trudne do udowodnienia, ponieważ ustawienie wysokości i szerokości prostokąta to niezwykle prymitywne operacje. Nie istnieje sensowne wytłumaczenie, aby były zadeklarowane jako wirtualne, skoro nie przewidzieliśmy istnienia klasy Square. Załóżmy, że zaakceptowaliśmy ten argument i naprawiliśmy klasy. Otrzymaliśmy kod pokazany na listingu 10.3. Listing 10.3. Wewnętrznie spójne klasy Rectangle i Square class Rectangle { public: virtual void SetWidth(double w) {itsWidth=w;} virtual void SetHeight(double h) {itsHeight=h;} double GetHeight() const {return itsHeight;} double GetWidth() const {return itsWidth;} private: 4
Są to właściwości, które zawsze muszą być prawdziwe, niezależnie od stanu.
KWADRATY I PROSTOKĄTY — BARDZIEJ SUBTELNE NARUSZENIE ZASADY LSP
};
131
Point itsTopLeft double itsHeight; double itsWidth;
class Square : public Rectangle { public: virtual void SetWidth(double w); virtual void SetHeight(double h); }; void Square::SetWidth(double w) { Rectangle::SetWidth(w); Rectangle::SetHeight(w); } void Square::SetHeight(double h) { Rectangle::SetHeight(h); Rectangle::SetWidth(h); }
Prawdziwy problem Wydaje się, że klasy Square i Rectangle działają teraz właściwie. Niezależnie od operacji, jaką wykonujemy na obiekcie Square, pozostanie on prawidłowym matematycznie kwadratem. Niezależnie od operacji, jaką wykonamy na obiekcie Rectangle, pozostanie on prawidłowym matematycznie prostokątem. Co więcej, można przekazać obiekt Square do funkcji, która akceptuje wskaźnik lub referencję do obiektu Rectangle, a obiekt Square nadal będzie zachowywał się jak kwadrat i pozostanie wewnętrznie spójny. Tak więc można wyciągnąć wniosek, że projekt jest teraz wewnętrznie spójny i prawidłowy. Jednak ten wniosek będzie nieprawdziwy. Projekt, który jest wewnętrznie spójny, niekoniecznie jest spójny z poziomu wszystkich jego użytkowników! Przeanalizujmy następującą funkcję g: void g(Rectangle& r) { r.SetWidth(5); r.SetHeight(4); assert(r.Area() == 20); }
Ta funkcja wywołuje metody SetWidth i SetHeight z argumentem, który z jej punktu widzenia jest obiektem klasy Rectangle. Funkcja działa prawidłowo dla obiektów Rectangle, ale w przypadku przekazania obiektu Square zgłasza błąd asercji. A zatem istnieje realny problem. Autor funkcji g założył, że zmiana szerokości obiektu Rectangle nie zmieni jego wysokości. Założenie, że zmiana szerokości prostokąta nie wpływa na jego wysokość, jest oczywiście rozsądne! Pomimo tego nie wszystkie obiekty, które mogą być przekazane jako obiekty Rectangle, spełniają to założenie. Jeśli przekażemy egzemplarz klasy Square do takiej funkcji jak g (której autor przyjął takie założenie), to funkcja ta nie będzie działać prawidłowo. Funkcja g jest krucha pod względem hierarchii Square-Rectangle. Funkcja g pokazuje, że istnieją funkcje, które pobierają wskaźniki bądź referencje do obiektów Rectangle, a pomimo to nie potrafią prawidłowo działać na obiektach klasy Square. Ponieważ dla tych funkcji obiektów Square nie można podstawić za Rectangle, relacja pomiędzy klasami Square i Rectangle narusza zasadę LSP. Można by się sprzeczać, że problem tkwi w funkcji g — że jej autor nie miał prawa przyjmować założenia o niezależności wysokości i szerokości. Autor funkcji g nie zgodziłby się z tym. Funkcja g pobiera obiekt klasy Rectagle jako swój argument. Istnieją niezmienniki, deklaracje prawdy, które oczywiście
132
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
mają zastosowanie do klasy o nazwie Rectangle. Jeden z tych niezmienników mówi nam, że wysokość jest niezależna od szerokości. Autor funkcji g miał pełne prawo do asercji tego niezmiennika. To autor klasy Square naruszył reguły wymienionego niezmiennika. Co ciekawe, autor klasy Square nie naruszył niezmienników klasy Square. Poprzez dziedziczenie klasy Square z klasy Rectangle autor klasy Square naruszył niezmiennik klasy Rectangle!
Poprawność nie jest wrodzona Zasada LSP prowadzi do bardzo ważnej konkluzji: Poprawność modelu analizowanego w odosobnieniu nie może być sensownie zweryfikowana. Poprawność modelu może być wyrażona wyłącznie w kategoriach jego klientów. Na przykład gdy analizowaliśmy ostateczną wersję klas Square i Rectangle w izolacji od siebie, doszliśmy do wniosku, że są one wewnętrznie spójne i prawidłowe. Jednak gdy spojrzeliśmy na nie z punktu widzenia programisty, który przyjął rozsądne założenia dotyczące klasy bazowej, model okazał się błędny. Kiedy rozważamy, czy dany projekt jest odpowiedni, czy nie, nie możemy analizować rozwiązania w izolacji. Trzeba analizować je pod kątem racjonalnych założeń przyjętych przez użytkowników tego projektu5. Kto wie, jakie rozsądne założenia przyjmą użytkownicy projektu? Większość takich założeń nie jest łatwa do przewidzenia. Rzeczywiście, gdybyśmy starali się je wszystkie przewidzieć, skończyłoby się na stworzeniu systemu z zapachem niepotrzebnej złożoności. Z tego względu podobnie jak w przypadku innych zasad często lepiej poczekać ze wszystkimi naruszeniami zasady LSP oprócz oczywistych do czasu wykrycia odpowiedniej wrażliwości.
Relacja IS-A dotyczy zachowania A zatem co się stało? Dlaczego z pozoru rozsądny model klas Square i Rectangle zawiódł? Czyż kwadrat nie jest prostokątem? Czy nie zachodzi pomiędzy nimi relacja IS-A? Otóż nie z punktu widzenia autora funkcji g! Kwadrat rzeczywiście jest prostokątem, ale z punktu widzenia funkcji g obiekt Square definitywnie nie jest obiektem Rectangle. Dlaczego tak jest? Ponieważ zachowanie obiektu Square nie jest spójne z oczekiwaniami funkcji g w zakresie zachowania obiektu Rectangle. Pod względem zachowania obiekt Square nie jest obiektem Rectangle, a to jest zachowanie, które ma dla tego oprogramowania kluczowe znaczenie. Zasada LSP wyraźnie pokazuje, że w projekcie obiektowym relacja IS-A dotyczy zachowania, jakie można rozsądnie założyć i od jakiego klienci są uzależnieni.
Projektowanie według kontraktu Wielu deweloperów może czuć się nieswojo z pojęciem zachowania, które jest „rozsądnie założone”. Skąd możemy wiedzieć, czego naprawdę oczekują klienci? Istnieje technika podejmowania takich rozsądnych założeń, a tym samym egzekwowania zasady LSP jawnie. Ta technika nosi nazwę projekt według kontraktu (ang. design by contract — DBC) i została opisana przez Bertranda Meyera6. W przypadku zastosowania techniki DBC autor klasy wyraźnie formułuje kontrakt dla tej klasy. Kontrakt informuje autora kodu dowolnego klienta o zachowaniach, na których można polegać. Kontrakt określa się poprzez deklarację warunków wstępnych i końcowych dla każdej metody. Warunki 5
Często można zauważyć, że te rozsądne założenia są przyjmowane w testach jednostkowych pisanych dla klasy bazowej. To kolejny dobry powód, by stosować techniki programowania sterowanego testami.
6
[Meyer 97], rozdział 11., str. 331.
REALNY PRZYKŁAD
133
wstępne muszą być spełnione, aby można było wykonać metodę. Po zakończeniu działania metoda gwarantuje spełnienie warunków końcowych. Warunek końcowy metody Rectangle::SetWidth(double w) można sformułować następująco: assert((itsWidth == w) && (itsHeight == old.itsHeight));
W tym przykładzie old jest wartością obiektu Rectangle przed wywołaniem metody SetWidth. Według Meyera dla warunków wstępnych i końcowych klas pochodnych zachodzi następująca reguła: Ponowna deklaracja reguły (w klasie potomnej) może zastępować pierwotny warunek wstępny tylko warunkiem równym lub słabszym, natomiast warunek końcowy tylko warunkiem równym lub mocniejszym7. Innymi słowy, w przypadku korzystania z obiektu za pośrednictwem interfejsu jego klasy bazowej użytkownik zna jedynie warunki wstępne i końcowe klasy bazowej. Tak więc obiekty pochodne nie mogą oczekiwać od użytkowników przestrzegania warunków wstępnych, które są silniejsze niż te, które wynikają z klasy bazowej. Oznacza to, że muszą one akceptować wszystko to, co może akceptować klasa bazowa. Ponadto klasy pochodne muszą spełniać wszystkie warunki końcowe klasy bazowej. Oznacza to, że ich zachowania i wyjścia nie mogą naruszać żadnego z ograniczeń ustanowionych dla klasy bazowej. Użytkownicy klasy bazowej nie mogą być zmyleni wynikami zwracanymi przez klasę pochodną. Warunek końcowy metody Square::SetWidth(double w) jest oczywiście słabszy8 od warunku końcowego metody Rectangle::SetWidth(double w), ponieważ nie wymusza on ograniczenia (itsHeight == old.its Height). A zatem metoda SetWidth klasy Square narusza kontrakt klasy bazowej. Niektóre języki, na przykład Eiffel, zawierają bezpośrednie wsparcie dla warunków wstępnych i końcowych. Wystarczy je zadeklarować, a zadanie ich egzekwowania będzie należało do systemu wykonawczego. Taka własność nie jest dostępna ani w języku C++, ani w Javie. W tych językach musimy ręcznie rozpatrzyć warunki wstępne i końcowe dla każdej z metod i zadbać o to, aby reguła Meyera nie została naruszona. Co więcej, bardzo pomocne może być udokumentowanie tych warunków wstępnych i końcowych w komentarzach dla każdej metody.
Specyfikowanie kontraktów w testach jednostkowych Kontrakty można również definiować poprzez pisanie testów jednostkowych. Dzięki dokładnemu testowaniu zachowania klasy testy jednostkowe sprawiają, że zachowanie klasy staje się czytelne. Autorzy kodu klientów mogą przeglądać testy jednostkowe, aby się dowiedzieć, co można racjonalnie założyć na temat wykorzystywanych klas.
Realny przykład Dość kwadratów i prostokątów! Czy zasada LSP może mieć wpływ na realne oprogramowanie? Przyjrzyjmy się studium przypadku pochodzącego z projektu, w którym pracowałem kilka lat temu.
Motywacja Na początku lat dziewięćdziesiątych kupiłem zewnętrzną bibliotekę, w której było kilka klas kontenerowych. Kontenery w przybliżeniu odpowiadały konstrukcjom Bag i Set z języka Smalltalk. Dostępne były dwie odmiany struktury Set i dwie podobne odmiany struktury Bag. Odmiana pierwsza nosiła nazwę „ograniczonej” (ang. bounded) i bazowała na tablicach. Druga odmiana była określana jako „nieograniczona” (ang. unbounded) i bazowała na listach. 7
[Meyer 97], str. 573. Reguła ponownej deklaracji asercji (1).
8
Określenie „słabszy” może być mylące. X jest słabszy niż Y, jeśli X nie wymusza wszystkich ograniczeń Y. Nie ma znaczenia, jak wiele nowych ograniczeń wymusza X.
134
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
W konstruktorze klasy BoundedSet była określona maksymalna liczba elementów, jakie może zawierać zbiór. Pamięć na te elementy była alokowana jako tablica wewnątrz obiektu BoundedSet. Zatem jeśli tworzenie obiektu BoundedSet zakończyło się powodzeniem, można było mieć pewność, że ilość pamięci jest wystarczająca. Ponieważ rozwiązanie bazowało na tablicy, było bardzo szybkie. Podczas normalnego działania nie były wykonywane żadne operacje alokacji pamięci. Ponieważ pamięć była rezerwowana z góry, można było mieć pewność, że operacje na obiektach BoundedSet nie spowodują przepełnienia sterty. Z drugiej strony, było to marnotrawstwo pamięci, gdyż rzadko można było całkowicie wykorzystać całą przestrzeń, która została zarezerwowana. Z kolei dla klasy UnboundedSet nie deklarowano maksymalnej liczby elementów. Obiekt klasy UnboundedSet akceptował elementy, jeśli tylko była dostępna pamięć na stercie. Z tego powodu rozwiązanie było bardzo elastyczne. Było ekonomiczne również pod tym względem, że zużywało tylko tyle pamięci, ile było niezbędne do przechowania elementów, które obiekt zawierał w danym momencie. Struktura była jednak wolna, ponieważ w ramach normalnej pracy musiały być wykonywane operacje rezerwowania i zwalniania pamięci. I wreszcie istniało niebezpieczeństwo, że podczas normalnego działania może dojść do wyczerpania miejsca na stercie. Nie byłem zadowolony z interfejsów tych zewnętrznych klas. Nie chciałem, aby kod mojej aplikacji był od nich zależny, ponieważ przewidywałem, że będę chciał je zastąpić później lepszymi klasami. Z tego powodu opakowałem zewnętrzne kontenery własnym abstrakcyjnym interfejsem, jak pokazano na rysunku 10.2.
Rysunek 10.2. Warstwa adaptera klasy kontenerowej
Stworzyłem klasę abstrakcyjną o nazwie Set, która udostępniała czysto wirtualne metody Add, Delete i IsMember, jak pokazano na listingu 10.4. Struktura ta unifikowała nieograniczone i ograniczone odmiany dwóch klas z biblioteki zewnętrznej i pozwalała na dostęp do nich za pośrednictwem wspólnego interfejsu. A zatem niektóre klienty mogły przyjmować argument typu Set& i mogły nie przejmować się tym, czy zbiór, z którym pracują, to odmiana ograniczona, czy nieograniczona (patrz funkcja PrintSet na listingu 10.5). Listing 10.4. Abstrakcyjna klasa Set template
T>
void Add(const T&) = 0; void Delete(const T&) = 0; bool IsMember(const T&) const = 0;
Listing 10.5. Funkcja PrintSet template void PrintSet(const Set& s) {
REALNY PRZYKŁAD
}
135
for (Iteratori(s); i; i++ cout << (*i) << endl;
Dużą zaletą tego rozwiązania jest brak konieczności zwracania uwagi na to, jaki typ obiektu Set jest wykorzystywany. Oznacza to, że programista może zdecydować, jakiego rodzaju obiekt Set jest potrzebny, w każdym konkretnym przypadku. Decyzja ta nie ma wpływu na żadne z funkcji klienckich. Programista może wybrać wersję UnboundedSet w przypadku, gdy istnieją ograniczenia pamięci, a szybkość działania nie ma kluczowego znaczenia, oraz wybrać wersję BoundedSet, kiedy ma dużo pamięci, a podstawowe znaczenie ma szybkość. Funkcje klienckie będą operować na tych obiektach za pośrednictwem interfejsu klasy bazowej Set i dlatego nie będą „wiedziały” o konkretnym typie wykorzystywanego obiektu Set i nie będzie on miał dla nich znaczenia.
Problem Do tej hierarchii chciałem dodać klasę PersistentSet. Klasa PersistentSet miała reprezentować zbiór, który można zapisać do strumienia, a następnie ponownie go odczytać, na przykład przez inną aplikację. Niestety, jedyny kontener z biblioteki zewnętrznej, do którego miałem dostęp, a który równocześnie zapewniał funkcje utrwalania, nie był klasą template. Zamiast tego przyjmował obiekty pochodne abstrakcyjnej klasy bazowej PersistentObject. Utworzyłem hierarchię, którą pokazano na rysunku 10.3.
Rysunek 10.3. Hierarchia z klasą PersistentSet
Zwróćmy uwagę, że klasa PersistentSet zawiera egzemplarz klasy reprezentującej trwały zbiór z biblioteki zewnętrznej, do którego deleguje wszystkie swoje metody. Jeśli więc wywołamy metodę Add na obiekcie PersistentSet, spowoduje to oddelegowanie tego wywołania do odpowiedniej metody obiektu klasy zewnętrznej. Z pozoru takie rozwiązanie może wydawać się właściwe. Istnieje jednak dość kłopotliwy problem. Elementy dodawane do trwałego zbioru muszą być pochodnymi klasy PersistentObject. Ponieważ klasa PersistentSet po prostu deleguje wywołania do zewnętrznego trwałego zbioru, to dowolny element dodawany do obiektu PersistentSet musi być pochodną klasy PersistentObject. Jednak interfejs klasy Set nie ma takiego ograniczenia. Kiedy klient dodaje składowe do klasy bazowej Set, nie może mieć pewności, czy obiekt Set jest w rzeczywistości typu PersistentSet. W związku z tym klient nie ma możliwości dowiedzenia się, czy elementy, które dodaje, powinny być pochodnymi klasy PersistentObject. Rozważmy kod metody PersistentSet::Add() z listingu 10.6. Listing 10.6. template void PersistentSet::Add(const T& t) { PersistentObject& p = dynamic_cast(t); itsThirdPartyPersistentSet.Add(p); }
136
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
Z tego kodu wynika, że próba dodania obiektu, który nie jest pochodną klasy PersistentObject, do mojego obiektu PersistentSet spowoduje błąd wykonania. Wywołanie dynamic_cast spowoduje zgłoszenie wyjątku bad_cast. Żaden z istniejących klientów abstrakcyjnej klasy bazowej Set nie przewiduje zgłaszania wyjątków przez metodę Add. Ponieważ funkcje te nie potrafią prawidłowo obsłużyć pochodnej klasy Set, to ta zmiana w hierarchii narusza zasadę LSP. Czy to jest problem? Oczywiście, że tak. Funkcje, które wcześniej nie zawodziły, gdy przekazywano do nich pochodne klasy Set, mogą teraz generować błędy wykonania, gdy zostanie do nich przekazany obiekt PersistentSet. Debugowanie tego rodzaju problemu jest stosunkowo trudne, ponieważ błąd wykonania występuje bardzo daleko od rzeczywistej logicznej usterki. Wadą w logice jest decyzja o przekazaniu obiektu PersistentSet do funkcji albo dodanie do obiektu PersistentSet funkcji, która nie jest pochodną klasy PersistentObject. W każdym przypadku rzeczywista decyzja może być oddalona o wiele milionów instrukcji od właściwego wywołania metody Add. Znalezienie tego błędu może być bardzo trudne. Jego naprawa może być jeszcze trudniejsza.
Rozwiązanie niezgodne z zasadą LSP Jak rozwiązać ten problem? Kilka lat temu rozwiązałem go poprzez zastosowanie pewnej konwencji. Oznacza to, że nie rozwiązałem go w kodzie źródłowym. Zamiast tego przyjąłem konwencję, zgodnie z którą obiekty PersistentSet i PersistentObject były nieznane dla aplikacji jako całości. Były one znane tylko dla konkretnego modułu. Ten moduł był odpowiedzialny za odczyt i zapis wszystkich kontenerów do trwałego magazynu i z powrotem. Kiedy zachodziła konieczność zapisu kontenera, jego zawartość była kopiowana do właściwych pochodnych klasy PersistentObject, a później dodawana do obiektów PersistentSet zapisywanych do strumienia. Kiedy zachodziła potrzeba odczytu kontenera ze strumienia, proces był odwracany. Obiekt PersistentSet był odczytywany ze strumienia, a następnie obiekty PersistentObject były usuwane z obiektu PersistentSet i kopiowane do zwykłych obiektów (nietrwałych), które następnie były dodawane do zwykłego zbioru Set. To rozwiązanie może wydawać się zbyt restrykcyjne, ale był to jedyny sposób, który potrafiłem wymyślić, aby nie dopuścić do pojawiania się obiektów PersistentSet na interfejsach funkcji, które chciałyby dodać do obiektów PersistentSet nietrwałe zbiory. Co więcej, to rozwiązanie wprowadzało zależność reszty aplikacji od mechanizmu utrwalania. Czy rozwiązanie sprawdziło się? Niekoniecznie. Konwencja została naruszona w kilku miejscach aplikacji przez deweloperów, którzy nie rozumieli konieczności jej stosowania. Na tym polega problem z konwencjami — stale trzeba je tłumaczyć wszystkim programistom. Jeśli deweloper nie zapoznał się z konwencją lub nie zgadza się z nią, to może dojść do jej naruszenia. A jedno naruszenie może spowodować problem w całej strukturze.
Rozwiązanie zgodne z zasadą LSP W jaki sposób rozwiązałbym ten problem teraz? Zadbałbym o to, aby klasa PersistentSet nie była związana relacją IS-A z klasą Set, ponieważ nie jest to właściwa pochodna klasy Set. Zatem rozdzieliłbym hierarchie, ale nie zrobiłbym tego całkowicie. Niektóre cechy klas Set i PersistentSet są wspólne. W rzeczywistości tylko metoda Add stwarza problemy z zasadą LSP. W konsekwencji stworzyłbym hierarchię, w której zarówno klasa Set, jak i PersistentSet byłyby rodzeństwem znajdującym się pod abstrakcyjnym interfejsem, który pozwala na testowanie przynależności, iteracje itp. (patrz rysunek 10.4). To pozwoliłoby na iterowanie po obiektach PersistentSet, testowanie przynależności itp. Jednak hierarchia ta nie pozwoliłaby na dodawanie obiektów, które nie są pochodnymi klasy PersistentObject, do klasy PersistentSet.
WYDZIELANIE ZAMIAST DZIEDZICZENIA
137
Rysunek 10.4. Rozwiązanie zgodne z zasadą LSP
Wydzielanie zamiast dziedziczenia Kolejnym ciekawym i dość zagadkowym przykładem dziedziczenia jest przypadek klas Line i LineSegment9. Rozważmy kod z listingów 10.7 i 10.8. Z pozoru te dwie klasy wydają się być naturalnymi kandydatami do publicznego dziedziczenia. Klasa LineSegment potrzebuje deklaracji wszystkich składowych i funkcji, które są zadeklarowane w klasie Line. Oprócz tego klasa LineSegment zawiera nową, własną funkcję składową GetLength oraz przesłania znaczenie funkcji IsOn. Pomimo tego klasy te naruszają zasadę LSP w subtelny sposób. Listing 10.7. geometry/line.h #ifndef GEOMETRY_LINE_H #define GEOMETRY_LINE_H #include "geometry/point.h" class Line { public: Line(const Point& p1, const Point& p2); double double Point Point virtual bool
GetSlope() GetIntercept() GetP1() GetP2() IsOn(const Point&)
const; const; // Y Intercept const {return itsP1;}; const {return itsP2;}; const;
private: Point itsP1; Point itsP2;
}; #endif
Listing 10.8. geometry/lineseg.h #ifndef GEOMETRY_LINESEGMENT_H #define GEOMETRY_LINESEGMENT_H class LineSegment : public Line { public: LineSegment(const Point& p1, const Point& p2); double GetLength() const; virtual bool IsOn(const Point&) const; }; #endif 9
Pomimo podobieństwa tego przykładu do przykładu z kwadratami i okręgami ten przykład pochodzi z rzeczywistej aplikacji i był przedmiotem dyskusji dotyczącej realnych problemów.
138
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
Użytkownik klasy Line ma prawo oczekiwać, że wszystkie punkty współliniowe należą do obiektu Line. Na przykład punkt zwracany przez funkcję Intercept to punkt, w którym linia przecina oś Y. Ponieważ jest to punkt współliniowy, to użytkownicy klasy Line mogą oczekiwać, że jest spełniony warunek IsOn(Intercept()) == true. Jednak w przypadku wielu egzemplarzy klasy LineSegment to twierdzenie nie jest spełnione. Dlaczego to takie ważne? Czy nie można zastosować dziedziczenia klasy LineSegment z klasy Line i zaakceptować tych subtelnych problemów? To indywidualna decyzja. Istnieją rzadkie przypadki, gdy bardziej wskazane jest zaakceptowanie subtelnej wady polimorficznego zachowania niż podejmowanie prób manipulowania projektem, aby osiągnąć pełną zgodność z zasadą LSP. Przyjęcie kompromisu zamiast dążenia do doskonałości jest dylematem inżynierów. Dobry inżynier wie, kiedy kompromis przynosi większy zysk od dążenia do perfekcji. Jednak ze zgodności z zasadą LSP nie powinno się rezygnować z błahych powodów. Uzyskanie gwarancji, że klasa potomna zawsze będzie działać, gdy są używane jej klasy bazowe, jest skutecznym sposobem zarządzania złożonością. Gdy nie ma takiej gwarancji, to każdą klasę potomną trzeba rozpatrywać indywidualnie. W przypadku klas Line i LineSegment istnieje proste rozwiązanie, które ilustruje ważne narzędzie programowania obiektowego. Jeśli mamy dostęp zarówno do klasy Line, jak i LineSegment, to możemy wydzielić ich wspólne elementy do abstrakcyjnej klasy bazowej. Na listingach od 10.9 do 10.11 pokazano sposób wyodrębnienia klasy bazowej LinearObject z klas Line i LineSegment. Listing 10.9. geometry/linearobj.h #ifndef GEOMETRY_LINEAR_OBJECT_H #define GEOMETRY_LINEAR_OBJECT_H #include "geometry/point.h" class LinearObject { public: LinearObject(const Point& p1, const Point& p2); double GetSlope() const; double GetIntercept() const; Point GetP1() const {return itsP1;}; Point GetP2() const {return itsP2;}; virtual int IsOn(const Point&) const = 0; // metoda abstrakcyjna. private: Point itsP1; Point itsP2;
}; #endif
Listing 10.10. geometry/line.h #ifndef GEOMETRY_LINE_H #define GEOMETRY_LINE_H #include "geometry/linearobj.h" class Line : public LinearObject { public: Line(const Point& p1, const Point& p2); virtual bool IsOn(const Point&) const; }; #endif
Listing 10.11. geometry/lineseg.h #ifndef GEOMETRY_LINESEGMENT_H #define GEOMETRY_LINESEGMENT_H #include "geometry/linearobj.h"
HEURYSTYKI I KONWENCJE
139
class LineSegment : public LinearObject { public: LineSegment(const Point& p1, const Point& p2);
}; #endif
double GetLength() const; virtual bool IsOn(const Point&) const;
Klasa LinearObject reprezentuje zarówno klasę Line, jak i LineSegment. Dostarcza większości funkcjonalności i składowych danych do obu klas potomnych. Wyjątkiem jest metoda IsOn, która jest czysto wirtualna. Klientom klasy LinearObject nie wolno zakładać, że całkowicie rozumieją zakres obiektu, którego używają. W związku z tym mogą otrzymywać zarówno obiekty klasy Line, jak i LineSegment. Ponadto klienty klasy Line nigdy nie będą korzystać z klasy LineSegment. Wyodrębnianie jest narzędziem projektowym, które najłatwiej stosować, zanim zostaną napisane duże ilości kodu. Gdyby było dużo klientów klasy Line pokazanej na listingu 10.7, wyodrębnienie klasy LinearObject z pewnością nie byłoby łatwe. Jednak kiedy wyodrębnianie jest możliwe, daje ono potężne możliwości. Jeśli można wydzielić cechy z dwóch klas potomnych, to istnieje duże prawdopodobieństwo, że później powstaną inne klasy, które także będą potrzebowały tych cech. Oto co o technice wyodrębniania napisali Rebecca Wirfs-Brock, Brian Wilkerson i Lauren Wiener: Można stwierdzić, że jeśli zbiór klas obsługuje wspólny zakres odpowiedzialności, to powinny one dziedziczyć ten zakres odpowiedzialności ze wspólnej klasy nadrzędnej. Jeśli wspólna klasa nadrzędna nie istnieje, należy ją stworzyć, a następnie przenieść do niej wspólny zakres odpowiedzialności. Jest oczywiste, że taka klasa jest przydatna — już udowodniliśmy, że jej odpowiedzialność będzie dziedziczona przez inne klasy. Czyż nie można prognozować, że w wyniku późniejszej rozbudowy systemu mogą powstać nowe podklasy, które będą spełniać te same obowiązki w nowy sposób? Najczęściej nową nadklasę implementuje się jako klasę abstrakcyjną 10. Na listingu 10.12 pokazano możliwy sposób wykorzystania atrybutów klasy LinearObject przez nieprzewidzianą klasę Ray. Klasę Ray można podstawić za klasę LinearObject i żaden użytkownik klasy LinearObject nie będzie miał problemów z jej obsługą. Listing 10.12. geometry/ray.h #ifndef GEOMETRY_RAY_H #define GEOMETRY_RAY_H class Ray : public LinearObject { public: Ray(const Point& p1, const Point& p2); virtual bool IsOn(const Point&) const; }; #endif
Heurystyki i konwencje Istnieje kilka prostych heurystyk, które mogą dać nam wskazówki dotyczące naruszeń zasady LSP. Wszystkie dotyczą klas pochodnych, które w jakiś sposób usuwają funkcjonalności ze swoich klas bazowych. Klasy pochodnej, która realizuje mniej operacji niż jej klasa bazowa, zwykle nie da się podstawić za tę klasę bazową, dlatego stanowi ona naruszenie zasady LSP.
10
[WirfsBrock 90], str. 113.
140
ROZDZIAŁ 10. LSP — ZASADA PODSTAWIANIA LISKOV
Zdegenerowane funkcje w klasach pochodnych Rozważmy kod z listingu 10.13. Funkcja f jest zaimplementowana w klasie Base. Jednak w klasie Derived jest ona zdegenerowana. Przypuszczalnie autor klasy Derived stwierdził, że funkcja f nie pełni żadnej użytecznej roli w klasie Derived. Niestety, użytkownicy klasy Base nie wiedzą, że nie powinni wywoływać f, dlatego taka implementacja jest naruszeniem zasady LSP. Listing 10.13. Zdegenerowana funkcja w klasie pochodnej public class Base { public void f() {/*jakiś kod*/} } public class Derived extends Base { public void f() {} }
Obecność zdegenerowanych funkcji w klasach pochodnych nie zawsze wskazuje na naruszenie zasady LSP. Warto jednak przyjrzeć się tym funkcjom, jeśli występują.
Zgłaszanie wyjątków z klas pochodnych Inną formą naruszenia zasady LSP jest dodanie do metod klas pochodnych wyjątków, których klasy bazowe nie zgłaszają. Jeśli użytkownicy klas bazowych nie oczekują wyjątków, to w przypadku dodania ich do metod klas pochodnych tych klas pochodnych nie można podstawić za klasę bazową. Albo należy zmodyfikować oczekiwania użytkowników, albo klasy pochodne nie powinny zgłaszać wyjątków.
Wniosek Zasada OCP stanowi centralny punkt wielu własności projektowania obiektowego. Zastosowanie się do tej zasady poprawia łatwość utrzymania aplikacji, możliwości wielokrotnego wykorzystywania komponentów oraz elastyczność. A zatem naruszenie zasady LSP jest ukrytym naruszeniem zasady OCP. Dzięki możliwości podstawiania podtypów moduł, który jest zaimplementowany w kontekście klasy bazowej, można rozszerzać bez modyfikowania. Programiści zwykle oczekują takiej zamienności domyślnie. W związku z tym kontrakt typu bazowego musi być dobrze i wyraźnie rozumiany, a najlepiej jeśli jest wyraźnie egzekwowany przez kod. Definicja relacji IS-A jest zbyt szeroka, aby mogła służyć za określenie podtypu. Prawdziwa definicja podtypu to „możliwość podstawienia”, gdzie podstawienie jest zdefiniowane za pomocą jawnego lub niejawnego kontraktu.
Bibliografia 1. Bertrand Meyer, Object-Oriented Software Construction, wydanie drugie, Upper Saddle River, NJ: Prentice Hall, 1997. 2. Rebecca Wirfs-Brock et al., Designing Object-Oriented Software, Englewood Cliffs, NJ: Prentice Hall, 1990. 3. Barbara Liskov, Data Abstraction and Hierarchy, „SIGPLAN Notices”, 23(5) (maj 1988).
R OZDZIAŁ 11
DIP — zasada odwracania zależności
Nigdy więcej nie pozwól, aby ważne interesy kraju zależały od tysięcy chwiejnych możliwości — ludzkich słabości — sir Thomas Noon Talfourd (1795 – 1854)
DIP — zasada odwracania zależności a. Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. I jedne, i drugie powinny zależeć od abstrakcji. b. Abstrakcje nie powinny zależeć od szczegółów. To szczegóły powinny zależeć od abstrakcji. Przez lata wiele osób pytało mnie, dlaczego używam słowa „odwracanie” w nazwie tej zasady. To dlatego, że w bardziej tradycyjnych metodykach wytwarzania oprogramowania, takich jak analiza i projektowanie strukturalne, obowiązuje tendencja do tworzenia struktur, w których moduły wysokopoziomowe zależą od modułów niskopoziomowych, natomiast strategie zależą od szczegółów. Jednym z celów tych metodyk jest określenie hierarchii podprogramów, która opisuje sposób, w jaki moduły wysokopoziomowe wywołują moduły niskopoziomowe. Dobrym przykładem takiej hierarchii jest pierwotny projekt programu Copy zaprezentowany na rysunku 7.1. Struktura zależności dobrze zaprojektowanego programu obiektowego jest „odwrócona” w stosunku do struktury zależności, która zwykle wynika z tradycyjnych metod proceduralnych. Rozważmy konsekwencje zależności modułów wysokopoziomowych od modułów niskopoziomowych. To moduły wysokopoziomowe zawierają ważne decyzje dotyczące strategii oraz modele biznesowe aplikacji. Moduły te określają tożsamość aplikacji. Jednak gdy moduły te zależą od modułów niskopoziomowych, to zmiany w modułach niższego poziomu mogą mieć bezpośredni wpływ na moduły na wyższym poziomie i mogą wymusić wprowadzenie zmian w tych modułach.
142
ROZDZIAŁ 11. DIP — ZASADA ODWRACANIA ZALEŻNOŚCI
Taka sytuacja jest absurdem! To moduły wysokopoziomowe, te, które zawierają ważne decyzje dotyczące strategii, powinny mieć wpływ na moduły niskopoziomowe zawierające szczegóły. Moduły, które zawierają wysokopoziomowe reguły biznesowe, powinny mieć pierwszeństwo przed modułami zawierającymi szczegóły implementacji i powinny być od nich niezależne. Moduły wysokopoziomowe po prostu nie powinny w żaden sposób zależeć od modułów niskopoziomowych. Co więcej, chcemy mieć możliwość wielokrotnego używania modułów wysokopoziomowych — tych, które tworzą strategię. Dziś dość dobrze potrafimy używać wielokrotnie modułów niskopoziomowych. Zwykle mają one postać bibliotek podprogramów. Gdy moduły wysokopoziomowe zależą od modułów niskopoziomowych, użycie tych modułów wysokopoziomowych w różnych kontekstach staje się bardzo trudne. Jednak gdy moduły wysokopoziomowe są niezależne od modułów niskiego poziomu, wówczas moduły wysokopoziomowe mogą być dość łatwo wykorzystywane wielokrotnie. Zasada ta stanowi centrum projektowania frameworków.
Podział na warstwy Według Boocha „... wszystkie dobrze zorganizowane architektury obiektowe mają jasno określone warstwy; każda warstwa zapewnia określony spójny zestaw usług za pośrednictwem dobrze zdefiniowanego i kontrolowanego interfejsu”1. Naiwna interpretacja tego stwierdzenia może prowadzić projektanta do stworzenia struktury podobnej do przedstawionej na rysunku 11.1. Na tym diagramie wysokopoziomowa warstwa Strategia wykorzystuje warstwę niższego poziomu Mechanizm, a ta z kolei wykorzystuje warstwę szczegółów Narzędzia. Choć na pierwszy rzut oka taka struktura może się wydawać właściwa, ma ona ukrytą cechę wrażliwości warstwy Strategia na zmiany w warstwie Narzędzia. Zależność jest przechodnia. Warstwa Strategia zależy od czegoś, co zależy od warstwy Narzędzia, a zatem warstwa Strategia w przechodni sposób zależy od warstwy Narzędzia. To bardzo niefortunne.
Rysunek 11.1. Naiwny podział na warstwy
Bardziej prawidłowy model pokazano na rysunku 11.2. Każda z warstw wyższego poziomu deklaruje abstrakcyjny interfejs dla usług, które są jej niezbędne. Warstwy niższego szczebla są następnie realizowane na podstawie tych abstrakcyjnych interfejsów. Każda klasa wyższego poziomu wykorzystuje kolejną w hierarchii warstwę za pośrednictwem abstrakcyjnego interfejsu. Dzięki temu górne warstwy nie zależą od warstw niższych. Zamiast tego niższe warstwy zależą od abstrakcyjnych interfejsów usług zadeklarowanych w warstwach wyższych. Powoduje to wyeliminowanie nie tylko zależności warstwy Strategia od warstwy Narzędzia, ale nawet bezpośredniej zależności warstwy Strategia od warstwy Mechanizm.
Odwrócenie własności Zwróćmy uwagę, że inwersja w tym przypadku nie dotyczy tylko zależności, ale także własności interfejsu. Często uważamy, że biblioteki narzędziowe posiadają własne interfejsy. Ale kiedy zastosujemy zasadę DIP, to okazuje się, że klienty mają tendencję do posiadania abstrakcyjnych interfejsów, które są wykorzystywane przez ich serwery. 1
[Booch 96], str. 54.
PODZIAŁ NA WARSTWY
143
Rysunek 11.2. Odwrócone warstwy
Czasami przypomina to zasadę obowiązującą w Hollywood: „Nie dzwoń do nas. To my zadzwonimy do ciebie”2. Moduły niższego poziomu dostarczają implementacji interfejsów, które są zadeklarowane w modułach wyższego poziomu i stamtąd są wywoływane. Dzięki zastosowaniu tej odwróconej własności na warstwę Strategia nie mają wpływu żadne zmiany w warstwach Mechanizm lub Narzędzia. Co więcej, warstwę strategii można wykorzystać w dowolnym kontekście, który definiuje moduły niższego poziomu oraz jest zgodny z interfejsem usług strategii. Tak więc przez odwrócenie zależności stworzyliśmy strukturę, która jest jednocześnie bardziej elastyczna, trwała i mobilna.
Zależność od abstrakcji Nieco naiwną, ale dającą duże możliwości interpretacją zasady DIP jest prosta heurystyka: „Stosuj zależność od abstrakcji”. Krótko mówiąc, ta heurystyka mówi, że nie powinniśmy zależeć od konkretnej klasy oraz że wszystkie relacje w programie powinny kończyć się na klasie abstrakcyjnej lub interfejsie. Zgodnie z tą heurystyką: Żadna zmienna nie powinna zawierać wskaźnika lub referencji do konkretnej klasy. Żadna klasa nie powinna być klasą pochodną konkretnej klasy. Żadna metoda nie powinna przesłaniać zaimplementowanej metody żadnej ze swoich klas bazowych.
Oczywiście ta heurystyka jest naruszana zwykle przynajmniej raz w każdym programie. Gdzieś trzeba stworzyć egzemplarze konkretnych klas. Niezależnie od tego, w jakim module to zrobimy, moduł ten będzie zależał od tych konkretnych klas3. Ponadto nie wydaje się, aby istniały racjonalne powody 2
[Sweet 85].
3
W rzeczywistości istnieją sposoby obejścia tego ograniczenia, jeśli do stworzenia klas można użyć ciągów znaków. Takie możliwości daje na przykład Java. Podobne mechanizmy istnieją również w kilku innych językach. W takich językach nazwy konkretnych klas mogą być przekazywane do programu jako dane konfiguracji.
144
ROZDZIAŁ 11. DIP — ZASADA ODWRACANIA ZALEŻNOŚCI
przestrzegania tej heurystyki w odniesieniu do klas, które są konkretne, ale nieulotne. Jeśli konkretna klasa nie będzie się zbytnio zmieniać i nie przewidujemy tworzenia podobnych do niej klas pochodnych, to zależność od takiej klasy nie przynosi zbyt wielkiej szkody. Na przykład w większości systemów klasa opisująca ciąg znaków jest konkretna. W Javie istnieje konkretna klasa String. Ta klasa jest nieulotna. Oznacza to, że nie zmienia się zbyt często. Z tego powodu nie ma problemu, aby moduły od niej zależały. Jednak większość konkretnych klas, które piszemy w ramach aplikacji, to klasy ulotne. Należy unikać bezpośrednich zależności od tych konkretnych klas. Ich zmienność można wyizolować za pomocą abstrakcyjnego interfejsu. Nie jest to rozwiązanie kompletne. Czasami interfejs zmiennej klasy musi się zmienić. Taka zmiana musi być propagowana do abstrakcyjnego interfejsu, który reprezentuje klasę. Zmiany tego rodzaju naruszają izolację abstrakcyjnego interfejsu. To jest powód, dla którego przytoczona powyżej heurystyka jest nieco naiwna. Z drugiej strony, jeśli przyjmiemy szerszą perspektywę, zgodnie z którą klasy klienckie deklarują potrzebne im interfejsy usług, to jedynym momentem, gdy interfejs się zmieni, będzie sytuacja, w której klient potrzebuje tej zmiany. Zmiany w klasach, które implementują abstrakcyjny interfejs, nie mają wpływu na klientów.
Prosty przykład Inwersję zależności można zastosować wszędzie tam, gdzie jedna klasa przesyła komunikat do innej klasy. Dla przykładu rozważmy przypadek obiektu Button i obiektu Lamp. Obiekt Button komunikuje się ze środowiskiem zewnętrznym. Po otrzymaniu komunikatu Poll określa, czy użytkownik go „wcisnął”. Nie ma dla niego znaczenia, jaki jest mechanizm wykrywania. Może to być ikona przycisku w graficznym interfejsie użytkownika, fizyczny przycisk wciskany palcem lub nawet czujnik ruchu w domowym systemie zabezpieczeń. Obiekt Button wykrywa, że użytkownik go uaktywnił bądź zdezaktywował. Obiekt Lamp wywiera wpływ na zewnętrzne środowisko. Po otrzymaniu komunikatu TurnOn włącza światło. W przypadku otrzymania komunikatu TurnOn obiekt Lamp wyłącza światło. Fizyczny mechanizm tej operacji jest nieistotny. Może to być dioda LED na konsoli komputera, lampy rtęciowa na parkingu, a nawet laser w drukarce laserowej. W jaki sposób należy zaprojektować system, aby obiekt Button zarządzał obiektem Lamp? Naiwny projekt pokazano na rysunku 11.3. Obiekt Button otrzymuje komunikaty Poll, sprawdza, czy przycisk jest wciśnięty, a następnie wysyła do obiektu Lamp komunikat TurnOn lub TurnOff.
Rysunek 11.3. Naiwny model zależności klas Button i Lamp
Dlaczego ten model jest naiwny? Przeanalizujmy kod w Javie, który wynika z tego modelu (listing 11.1). Zwróćmy uwagę, że klasa Button zależy bezpośrednio od klasy Lamp. Z tej zależności wynika, że zmiany w klasie Lamp będą miały wpływ na klasę Button. Co więcej, nie będzie możliwości wykorzystania klasy Button do sterowania klasą Motor. W takim projekcie obiekty Button kontrolują obiekty Lamp i tylko obiekty Lamp. Listing 11.1. Button.java public class Button { private Lamp itsLamp; public void poll()
PROSTY PRZYKŁAD
{
}
}
145
if (/*jakiś warunek*/) itsLamp.turnOn();
Pokazane rozwiązanie narusza zasadę DIP. Wysokopoziomowa strategia aplikacji nie została oddzielona od niskopoziomowej implementacji. Abstrakcje nie zostały oddzielone od szczegółów. Bez takiej separacji wysokopoziomowa strategia automatycznie zależy od modułów niskopoziomowych, natomiast abstrakcje automatycznie zależą od szczegółów.
Wyszukiwanie potrzebnych abstrakcji Czym jest wysokopoziomowa strategia? Jest to abstrakcja, która leży u podstaw aplikacja — zbiór prawd, które się nie zmieniają, gdy zmieniają się szczegóły. To jest system wewnątrz systemu — to jego metafora. W przykładzie z klasami Button i Lamp abstrakcją bazową jest wykrycie gestu użytkownika oznaczającego włączenie (wyłączenie) i przekazanie tego gestu do obiektu docelowego. Jaki mechanizm jest wykorzystywany do wykrywania gestu użytkownika? To nieistotne. Jaki jest obiekt docelowy? Nieistotne. Są to szczegóły, które nie mają wpływu na abstrakcję. Projekt z rysunku 11.3 można poprawić poprzez odwrócenie zależności od obiektu Lamp. Na rysunku 11.4 możemy zobaczyć, że klasa Button zawiera teraz powiązanie do klasy określonej jako ButtonServer. ButtonServer dostarcza abstrakcyjnych metod, które klasa Button może wykorzystać, aby coś włączyć bądź wyłączyć. Klasa Lamp implementuje interfejs ButtonServer. Zatem teraz klasa Lamp wykonuje operacje zależne. Inne klasy od niej nie zależą.
Rysunek 11.4. Zasada odwracania zależności zastosowana do klasy Lamp
Projekt pokazany na rysunku 11.4 umożliwia obiektowi klasy Button sterowanie dowolnym urządzeniem, które implementuje interfejs ButtonServer. Taki układ daje nam olbrzymią elastyczność. Oznacza on również, że obiekty Button mogą być wykorzystane do sterowania obiektami, które jeszcze nie istnieją. Jednak to rozwiązanie wprowadza również ograniczenie na dowolny obiekt, który ma być sterowany za pomocą obiektu Button. Taki obiekt musi implementować interfejs ButtonServer. Jest to niefortunne, ponieważ takie obiekty być może powinny być również sterowane przez obiekt Switch albo jakiś inny obiekt niż Button. Dzięki odwróceniu kierunku zależności i zdefiniowaniu obiektu Lamp jako zależnego od innych (a nie odwrotnie, gdy to inne obiekty zależały od klasy Lamp) spowodowaliśmy zależność obiektu Lamp od innego szczegółu — obiektu Button. Czy na pewno? Klasa Lamp rzeczywiście zależy od interfejsu ButtonServer, ale interfejs ButtonServer nie zależy od klasy Button. Obiektem Lamp może sterować dowolny obiekt, który „wie”, jak operować na interfejsie ButtonServer. A zatem zależność istnieje tylko w nazwie. Problem ten można rozwiązać, zmieniając nazwę ButtonServer na bardziej ogólną, na przykład SwitchableDevice. Możemy także zadbać o to, aby klasy Button i interfejs SwitchableDevice były przechowywane w osobnych bibliotekach. Dzięki temu użycie interfejsu SwitchableDevice nie będzie musiało wiązać się z użyciem klasy Button.
146
ROZDZIAŁ 11. DIP — ZASADA ODWRACANIA ZALEŻNOŚCI
W tym przypadku żadna klasa nie jest właścicielem interfejsu. Mamy ciekawą sytuację, w której interfejs może być używany przez wiele różnych klientów i implementowany przez wiele różnych serwerów. Zatem interfejs może pozostać niezależny — nie musi należeć do żadnej z grup. W języku C++ należałoby go umieścić w osobnej przestrzeni nazw i bibliotece. W Javie należałoby stworzyć odrębny pakiet4.
Przykład programu Furnace Spróbujmy przyjrzeć się bardziej interesującemu przykładowi. Rozważmy oprogramowanie, które steruje piecem. Program może odczytać aktualną temperaturę z kanału We-Wy i wysłać instrukcję włączenia bądź wyłączenia pieca poprzez wysłanie polecenia do innego kanału We-Wy. Struktura algorytmu mogłaby wyglądać podobnie do kodu zamieszczonego na listingu 11.2. Listing 11.2. Prosty algorytm termostatu #define #define #define #define
TERMOMETER 0x86 FURNACE 0x87 ENGAGE 1 DISENGAGE 0
void Regulate(double minTemp, double maxTemp) { for(;;) { while (in(THERMOMETER) > minTemp) wait(1); out(FURNACE,ENGAGE);
}
}
while (in(THERMOMETER) < maxTemp) wait(1); out(FURNACE,DISENGAGE);
Wysokopoziomowy zamiar algorytmu jest czytelny, ale kod jest zaśmiecony dużą ilością niskopoziomowych szczegółów. Takiego kodu nigdy nie można by wykorzystać w innym sprzęcie sterującym. Być może nie jest to duża strata, ponieważ ten kod nie jest zbyt rozbudowany. Ale nawet w tym przypadku szkoda tracić algorytm, który można by wykorzystać wielokrotnie. Powinniśmy raczej odwrócić zależności i stworzyć projekt podobny do tego, który pokazano na rysunku 11.5.
Rysunek 11.5. Uniwersalny regulator 4
W językach dynamicznych, takich jak Smalltalk, Python czy Ruby, taki interfejs nie istniałby jako osobna jednostka w kodzie źródłowym.
PRZYKŁAD PROGRAMU FURNACE
147
Z powyższego rysunku widać, że funkcja regulatora pobiera dwa argumenty. Obydwa są interfejsami. Interfejs Thermometer pozwala na czytanie, natomiast interfejs Heater może być uaktywniany i dezaktywowany. To wszystko, czego potrzebuje algorytm Regulate. Teraz można go zapisać tak, jak pokazano na listingu 11.3. Listing 11.3. Uniwersalny regulator void Regulate(Thermometer& t, Heater& h, double minTemp, double maxTemp) { for(;;) { while (t.Read() > minTemp) wait(1); h.Engage();
}
}
while (t.Read() < maxTemp) wait(1); h.Disengage();
W zaprezentowanym kodzie odwrócono zależności. Dzięki temu wysokopoziomowa strategia regulacji nie jest uzależniona od żadnego z konkretnych szczegółów termometru lub pieca. Algorytm można bez trudu wykorzystać wielokrotnie.
Polimorfizm dynamiczny i statyczny Udało nam się odwrócić zależności i przekształcić funkcję Regulate tak, aby była uniwersalna, dzięki wykorzystaniu dynamicznego polimorfizmu (tzn. abstrakcyjnych klas bądź interfejsów). Istnieje jednak inny sposób. Można skorzystać ze statycznej formy polimorfizmu dzięki użyciu szablonów języka C++. Rozważmy kod z listingu 11.4. Listing 11.4. Wykorzystanie polimorfizmu statycznego do odwrócenia zależności template class Regulate(THERMOMETER& t, HEATER& h, double minTemp, double maxTemp) { for(;;) { while (t.Read() > minTemp) wait(1); h.Engage();
}
}
while (t.Read() < maxTemp) wait(1); h.Disengage();
Za pomocą tego kodu uzyskaliśmy to samo odwrócenie zależności bez narzutu (lub elastyczności) polimorfizmu dynamicznego. W języku C++ wszystkie metody Read, Engage i Disengage mogą być niewirtualne. Co więcej, wszystkie klasy, które deklarują te metody, mogą być wykorzystane przez szablon. Klasy te nie muszą dziedziczyć ze wspólnej klasy bazowej. Ponieważ Regulate jest szablonem, to nie zależy od żadnej konkretnej implementacji tych funkcji. Wystarczy tylko, aby klasa podstawiona za HEATER miała metody Engage i Disengage, natomiast klasa podstawiona za THERMOMETER miała funkcję Read. Tak więc te klasy muszą implementować interfejs zdefiniowany przez szablon. Inaczej mówiąc, zarówno szablon Regulate, jak i klasy wykorzystywane przez Regulate muszą uzgodnić ten sam interfejs. Obie strony kontraktu zależą od tego uzgodnienia.
148
ROZDZIAŁ 11. DIP — ZASADA ODWRACANIA ZALEŻNOŚCI
Polimorfizm statyczny dobrze nadaje się do eliminowania zależności od kodu źródłowego, ale to nie rozwiązuje tylu problemów co polimorfizm dynamiczny. Wady zastosowania szablonów to: (1) typów HEATER i THERMOMETER nie można zmienić w czasie wykonywania programu oraz (2) wykorzystanie nowego typu HEATER lub THERMOMETER zmusza do ponownej kompilacji i instalacji. Zatem jeśli nie istnieją bardzo rygorystyczne wymagania co do szybkości, to powinniśmy preferować polimorfizm dynamiczny.
Wniosek Tradycyjne programowanie proceduralne tworzy strukturę zależności, w której strategia zależy od szczegółów. Jest to niefortunne, ponieważ strategia jest wtedy wrażliwa na zmiany w szczegółach. Programowanie obiektowe odwraca tę strukturę zależności w taki sposób, że zarówno szczegóły, jak i strategie zależą od abstrakcji, a klienty często dysponują interfejsami usług. To odwrócenie zależności jest znakiem rozpoznawczym dobrego projektu obiektowego. Nie ma znaczenia, w jakim języku jest napisany program. Jeśli jego zależności są odwrócone, to ma on projekt obiektowy. Jeśli zależności nie są odwrócone, to ma projekt proceduralny. Zasada odwrócenia zależności jest podstawowym niskopoziomowym mechanizmem, który gwarantuje uzyskanie wielu korzyści oferowanych przez technologie obiektowe. Właściwe stosowanie tej zasady jest niezbędne do stworzenia frameworków wielokrotnego użytku. Jest to również bardzo ważne w przypadku budowy kodu, który jest odporny na zmiany. Ponieważ abstrakcje i szczegóły są od siebie odizolowane, kod jest znacznie łatwiejszy w utrzymaniu.
Bibliografia 1. Grady Booch, Object Solutions, Menlo Park, CA: Addison-Wesley, 1996. 2. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19955. 3. Richard Sweet, The Mesa Programming Environment, „SIGPLAN Notices”, 20(7) (lipiec 1988), 216 – 229.
5
Wydanie polskie: Wzorce projektowe. Elementy programowania obiektowego wielokrotnego użytku, Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum.
R OZDZIAŁ 12
ISP — zasada segregacji interfejsów Stosowanie tej zasady służy eliminacji wad wynikających z „grubych” interfejsów. Klasy, które mają „grube” interfejsy, to klasy, których interfejsy nie są spójne. Innymi słowy, interfejsy takich klas mogą być podzielone na grupy metod. Każda grupa obsługuje inny zbiór klientów. Zatem niektóre klienty wykorzystują jedną grupę funkcji składowych, natomiast inne korzystają z innych grup. Zasada ISP potwierdza, że istnieją obiekty, które wymagają niespójnych interfejsów. Sugeruje jednak, że klienty nie powinny „wiedzieć” o nich, że należą one do jednej klasy. Zamiast tego powinny posługiwać się abstrakcyjnymi klasami bazowymi, które mają spójne interfejsy.
Zaśmiecanie interfejsów Rozważmy przykład systemu zabezpieczeń. W tym systemie mamy obiekty Door — można je zamykać i otwierać, a ponadto „wiedzą” one, czy są otwarte, czy zamknięte (patrz listing 12.1). Listing 12.1. Klasa Door class Door { public: virtual void Lock() = 0; virtual void Unlock() = 0; virtual bool IsDoorOpen() = 0; };
Ta klasa jest abstrakcyjna, dzięki czemu klienty mogą korzystać z obiektów, które są zgodne z interfejsem Door i nie muszą zależeć od konkretnej implementacji klasy Door. Przypuśćmy teraz, że jedna z takich implementacji, klasa TimedDoor, musi wygenerować alarm dźwiękowy, kiedy drzwi pozostaną otwarte zbyt długo. Aby to osiągnąć, obiekt TimedDoor komunikuje się z innym obiektem o nazwie Timer (patrz listing 12.2). Listing 12.2. Obiekt Timer class Timer { public: void Register(int timeout, TimerClient* client); }; class TimerClient { public: virtual void TimeOut() = 0; };
Kiedy obiekt chce uzyskać informację o zbyt długim czasie otwarcia, wywołuje funkcję Register obiektu Timer. Argumentami tej funkcji jest długość limitu czasowego oraz wskaźnik na obiekt TimerClient, którego funkcja TimeOut będzie wywołana w przypadku, gdy upłynie limit czasu.
150
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
W jaki sposób można skłonić obiekt klasy TimerClient do komunikacji z obiektem TimedDoor tak, aby kod wewnątrz klasy TimedDoor uzyskał informację o tym, że upłynął limit czasu? Jest na to kilka sposobów. Naiwny projekt pokazano na rysunku 12.1. Wymuszamy w nim, aby klasa Door, a w związku z tym także klasa TimedDoor, dziedziczyła po klasie TimerClient. W ten sposób obiekt klasy TimerClient może się zarejestrować w obiekcie klasy Timer i otrzymać komunikat TimeOut.
Rysunek 12.1. Interfejs TimerClient na szczycie hierarchii
Chociaż takie rozwiązanie jest powszechnie stosowane, to są z nim związane pewne problemy. Jednym z najważniejszych jest to, że klasa Door zależy teraz od klasy TimerClient. Nie wszystkie odmiany klasy Door wymagają mechanizmu czasowego. Pierwotna abstrakcja klasy Door nie ma z nim nic wspólnego. W przypadku tworzenia pochodnych klasy Door niewymagających odmierzania czasu należałoby dostarczyć zdegenerowanych implementacji metody TimeOut, co stanowi potencjalne naruszenie zasady LSP. Co więcej, aplikacje wykorzystujące te pochodne będą musiały zaimportować definicję klasy TimerClient, mimo że nie jest ona używana. Takie rozwiązanie wydaje zapach niepotrzebnej złożoności i niepotrzebnej redundancji. Jest to przykład zaśmiecania interfejsu — problemu, który jest powszechnie znany w językach o typowaniu statycznym, takich jak C++ i Java. Interfejs klasy Door został zanieczyszczony metodą, której nie potrzebował. Metoda została włączona do interfejsu, mimo że wymagała jej tylko jedna klasa pochodna. Gdybyśmy konsekwentnie stosowali tę praktykę, to za każdym razem, gdy klasa pochodna wymagałaby jakiejś metody, dodawalibyśmy ją do klasy bazowej. To jeszcze bardziej zaśmieciłoby interfejs klasy bazowej, przez co stałby się on „gruby”. Co więcej, każdorazowe dodanie metody do klasy bazowej spowodowałoby konieczność zaimplementowania tej metody w klasach potomnych (lub zgody na wywołanie metody domyślnej z klasy bazowej). Rzeczywiście, popularną praktyką jest dodawanie tych interfejsów do klasy bazowej i definiowanie zdegenerowanych implementacji. Tylko grupa klas, które rzeczywiście wymagają specyficznej implementacji, taką implementację definiuje. Dzięki temu klasy pochodne nie są obciążone koniecznością implementacji zbędnych interfejsów. Jak dowiedzieliśmy się wcześniej, taka praktyka narusza zasadę LSP, co prowadzi do problemów z utrzymaniem oraz z wielokrotnym wykorzystywaniem oprogramowania.
Odrębne klienty oznaczają odrębne interfejsy Door i TimerClient to interfejsy, które są używane przez całkowicie różne klienty. Z interfejsu TimerClient korzysta klasa Timer, natomiast interfejs Door jest używany przez klasy wykonujące operacje na drzwiach.
Ponieważ klienty są odrębne, interfejsy także powinny być oddzielne. Dlaczego? Ponieważ klienty wywierają wpływ na interfejsy, które one wykorzystują.
ISP — ZASADA SEGREGACJI INTERFEJSÓW
151
Siła oddziaływania klientów na interfejsy Kiedy mówimy o siłach, które powodują zmiany w oprogramowaniu, zwykle myślimy o tym, jaki wpływ na użytkowników będą miały zmiany w interfejsach. Na przykład jeśli zmieni się interfejs TimerClient, będziemy zainteresowani zmianami we wszystkich użytkownikach interfejsu TimerClient. Istnieje jednak siła działająca w przeciwnym kierunku. Czasami to użytkownik wymusza zmiany w interfejsie. Na przykład niektórzy użytkownicy klasy Timer mogą zarejestrować więcej niż jedno żądanie zarządzania czasem. Rozważmy sposób działania obiektu klasy TimedDoor. Kiedy wykryje sytuację otwarcia drzwi, wysyła komunikat Register do obiektu Timer z żądaniem obsługi limitu czasu. Zanim jednak upłynie ten limit czasu, drzwi zamykają się, pozostają przez chwilę zamknięte, a następnie otwierają się ponownie. To sprawia, że rejestrowane jest nowe żądanie obsługi limitu czasu, zanim upłynie poprzednie. W końcu upływa pierwszy limit czasu i następuje wywołanie funkcji Timeout interfejsu TimedDoor. Obiekt klasy Door zgłasza fałszywy alarm. Problem ten można skorygować, stosując konwencję pokazaną na listingu 12.3. Do każdej operacji rejestracji limitu czasowego dołączono unikatowy identyfikator timeOutId. Ten identyfikator powtórzono w wywołaniu TimeOut interfejsu TimerClient. Dzięki temu każda pochodna interfejsu TimerClient ma informację o tym, na jakie żądanie obsługi limitu czasu odpowiada. Listing 12.3. Klasa Timer z identyfikatorem class Timer { public: void Register(int timeout, int timeOutId, TimerClient* client); }; class TimerClient { public: virtual void TimeOut(int timeOutId) = 0; };
Bez wątpienia ta zmiana będzie dotyczyć wszystkich użytkowników interfejsu TimerClient. Akceptujemy tę niedogodność, ponieważ brak identyfikatora timeOutId jest przeoczeniem, które wymaga korekty. Jednak zastosowanie projektu pokazanego na rysunku 12.1 spowoduje także, że wprowadzenie korekty będzie miało wpływ na interfejs Door oraz na wszystkich klientów interfejsu Door! Takie rozwiązanie wykazuje cechy sztywności i lepkości. Dlaczego błąd w interfejsie TimerClient ma jakikolwiek wpływ na pochodne interfejsu Door, które nie wymagają obsługi limitu czasu? Kiedy zmienna w jednej części programu ma wpływ na inne, pozornie niezwiązane z nią części programu, koszt i reperkusje takich zmian są nieprzewidywalne, a ryzyko popełnienia niezamierzonego błędu przy okazji wprowadzania zmiany dramatycznie wzrasta.
ISP — zasada segregacji interfejsów Klienty nie powinny być zmuszone do zależności od metod, których nie używają. Kiedy klasy klienckie są zmuszane do zależności od metod, z których nie korzystają, to te klasy klienckie muszą dostosowywać się do zmian w tych metodach. Powoduje to zbędne sprzężenia pomiędzy wszystkimi klientami. Mówiąc inaczej, gdy klient zależy od klasy zawierającej metody, których ten klient nie używa, ale używają ich inne klienty, to na tego klienta będą miały wpływ zmiany w klasie wymuszane przez te inne klienty. Chcielibyśmy uniknąć takich sprzężeń, o ile to możliwe, dlatego staramy się oddzielać od siebie interfejsy.
152
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
Interfejsy klas a interfejsy obiektów Rozważmy ponownie klasę TimedDoor. Mamy tu do czynienia z obiektem, który ma dwa odrębne interfejsy używane przez dwie osobne klasy klienckie — Timer oraz użytkowników interfejsu Door. Te dwa interfejsy muszą być zaimplementowane w tym samym obiekcie, ponieważ implementacja obu interfejsów operuje na tych samych danych. Zatem w jaki sposób można zapewnić zgodność z zasadą ISP? W jaki sposób można oddzielić od siebie interfejsy, kiedy muszą być zaimplementowane łącznie? Kluczem do rozwiązania tego problemy jest fakt, że klienty obiektu nie wymagają dostępu do niego za pośrednictwem interfejsu tego obiektu. Zamiast tego mogą one uzyskać dostęp do obiektu za pomocą delegacji albo klasy bazowej obiektu.
Separacja przez delegację Jednym z rozwiązań może być stworzenie obiektu potomnego implementującego interfejs TimerClient, który będzie delegował wywołania do klasy TimedDoor. Takie rozwiązanie przedstawiono na rysunku 12.2.
Rysunek 12.2. Adapter obsługi limitu czasowego
Kiedy obiekt klasy TimedDoor chce zarejestrować żądanie obsługi limitu czasowego w klasie Timer, tworzy egzemplarz klasy DoorTimerAdapter i rejestruje go w klasie Timer. Kiedy obiekt klasy Timer wysyła komunikat TimeOut do obiektu klasy DoorTimerAdapter, to ten obiekt deleguje komunikat do obiektu klasy TimedDoor. Takie rozwiązanie jest zgodne z zasadą ISP i zapobiega sprzęganiu klientów interfejsu Door z interfejsem Timer. Nawet gdyby w interfejsie Timer wprowadzono zmianę pokazaną na listingu 12.3, to nie miałoby to wpływu na żadnego z użytkowników interfejsu Door. Co więcej, klasa TimedDoor nie musi mieć takiego samego interfejsu jak klasa TimerClient. Klasa DoorTimerAdapter może przetłumaczyć interfejs TimerClient na interfejs TimedDoor. Zatem jest to bardzo uniwersalne rozwiązanie (patrz listing 12.4). Listing 12.4. TimedDoor.cpp class TimedDoor : public Door { public: virtual void DoorTimeOut(int timeOutId); }; class DoorTimerAdapter : public TimerClient { public: DoorTimerAdapter(TimedDoor& theDoor) : itsTimedDoor(theDoor) {} virtual void TimeOut(int timeOutId)
PRZYKŁAD INTERFEJSU UŻYTKOWNIKA BANKOMATU
153
{itsTimedDoor.DoorTimeOut(timeOutId);}
};
private: TimedDoor& itsTimedDoor;
Przedstawione rozwiązanie jest jednak trochę nieeleganckie. Wymaga ono stworzenia nowego obiektu za każdym razem, gdy rejestrujemy obsługę limitu czasowego. Ponadto delegacja wymaga bardzo niewielkich, ale jednak niezerowych zasobów w postaci czasu wykonywania i pamięci. W niektórych dziedzinach (na przykład we wbudowanych systemach czasu rzeczywistego) te zasoby są na tyle cenne, że może to stanowić problem.
Separacja przez wielokrotne dziedziczenie Na rysunku 12.3 i listingu 12.5 pokazano, jak można użyć dziedziczenia wielokrotnego do zapewnienia zgodności z zasadą ISP. W tym modelu interfejs TimedDoor dziedziczy zarówno po interfejsie Door, jak i po interfejsie TimerClient. Chociaż klienty obu klas bazowych mogą korzystać z interfejsu TimedDoor, żaden z nich nie jest zależny od tego interfejsu. Oznacza to, że klienty korzystają z tego samego obiektu za pośrednictwem odrębnych interfejsów.
Rysunek 12.3. Interfejs TimedDoor z wielokrotnym dziedziczeniem Listing 12.5. TimedDoor.cpp class TimedDoor : public Door, public TimerClient { public: virtual void TimeOut(int timeOutId); };
Według mnie to rozwiązanie jest najlepsze. Rozwiązanie z rysunku 12.2 byłoby lepsze od tego, które pokazano na rysunku 12.3, tylko wtedy, gdyby tłumaczenie interfejsów za pośrednictwem obiektu DoorTimerAdapter było konieczne lub gdyby były potrzebne różne tłumaczenia w różnych sytuacjach.
Przykład interfejsu użytkownika bankomatu Rozważmy teraz nieco bardziej rozbudowany przykład — tradycyjny problem dotyczący bankomatów (ang. Automatic Teller Machine — ATM). Interfejs użytkownika bankomatu musi być bardzo elastyczny. Wyjście musi być tłumaczone na wiele języków. Może być prezentowane na ekranie lub na tablecie brajlowskim. Może być też czytane za pomocą syntezatora mowy. Oczywiście można to osiągnąć poprzez stworzenie abstrakcyjnej klasy bazowej, która ma metody abstrakcyjne dla wszystkich komunikatów, które muszą być zaprezentowane przez interfejs (rysunek 12.4).
154
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
Rysunek 12.4. Różne typy interfejsów użytkowników bankomatu
Załóżmy również, że każda transakcja, którą może wykonywać bankomat, jest zamknięta jako pochodna klasy Transaction. Możemy mieć więc takie klasy jak DepositTransaction, WithdrawalTransaction oraz TransferTransaction. Każda z klas wywołuje metody interfejsu UI. Na przykład aby zwrócić się do użytkownika o wprowadzenie kwoty do wpłaty, obiekt DepositTransaction wywołuje metodę RequestDepositAmount klasy UI. Podobnie aby zapytać użytkownika o kwotę, jaką chce przelać pomiędzy rachunkami, obiekt TransferTransaction wywołuje metodę RequestTransferAmount klasy UI. Model ten zaprezentowano na diagramie na rysunku 12.5.
Rysunek 12.5. Hierarchia transakcji wykonywanych przez bankomat
Zwróćmy uwagę, że przedstawiona sytuacja jest dokładnie taka, jakiej każe nam unikać zasada ISP. Każda z transakcji wywołuje metodę interfejsu UI, z której nie korzysta żadna inna klasa. Stwarza to możliwość, że zmiany wprowadzone w jednej z pochodnych klasy Transaction wymuszą odpowiednie zmiany w interfejsie UI, co wpłynie na wszystkie inne pochodne klasy Transaction oraz wszystkie inne klasy, które zależą od interfejsu UI. Takie rozwiązanie wykazuje cechy sztywności i kruchości. Na przykład gdybyśmy chcieli dodać klasę PayGasBillTransaction, musielibyśmy dodać nowe metody do interfejsu UI, aby obsłużyć unikatowe komunikaty wyświetlane w tej transakcji. Niestety, ponieważ klasy DepositTransaction, WithdrawalTransaction i TransferTransaction zależą od interfejsu UI, to wszystkie trzy trzeba na nowo skompilować. Co gorsza, jeśli operacje były dystrybuowane jako komponenty w osobnych bibliotekach DLL lub bibliotekach współdzielonych, to składniki te będą musiały być zainstalowane na nowo, pomimo że nie zmieniła się w nich logika. Wyraźnie czuć woń lepkości.
PRZYKŁAD INTERFEJSU UŻYTKOWNIKA BANKOMATU
155
Tego niefortunnego sprzężenia można uniknąć przez rozdzielenie interfejsu UI do odrębnych interfejsów, takich jak DepositUI, WithdrawUI oraz TransferUI. Te oddzielone interfejsy mogą być wielokrotnie dziedziczone i tworzyć ostateczny interfejs UI. Opisany model pokazano na rysunku 12.6 i listingu 12.6.
Rysunek 12.6. Rozdzielony interfejs użytkownika bankomatu Listing 12.6. Rozdzielony interfejs użytkownika bankomatu class DepositUI { public: virtual void RequestDepositAmount() = 0; }; class DepositTransaction : public Transaction { public: DepositTransaction(DepositUI& ui) : itsDepositUI(ui) {}
};
virtual void Execute() { ... itsDepositUI.RequestDepositAmount(); ... } private: DepositUI& itsDepositUI;
class WithdrawalUI { public:
156
};
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
virtual void RequestWithdrawalAmount() = 0;
class WithdrawalTransaction : public Transaction { public: WithdrawalTransaction(WithdrawalUI& ui) : itsWithdrawalUI(ui) {}
};
virtual void Execute() { ... itsWithdrawalUI.RequestWithdrawalAmount(); ... } private: WithdrawalUI& itsWithdrawalUI;
class TransferUI { public: virtual void RequestTransferAmount() = 0; }; class TransferTransaction : public Transaction { public: TransferTransaction(TransferUI& ui) : itsTransferUI(ui) {}
};
virtual void Execute() { ... itsTransferUI.RequestTransferAmount(); ... } private: TransferUI& itsTransferUI;
class UI : public DepositUI , public WithdrawalUI , public TransferUI { public: virtual void RequestDepositAmount(); virtual void RequestWithdrawalAmount(); virtual void RequestTransferAmount(); };
Każde utworzenie nowej pochodnej klasy Transaction wymaga odpowiedniej klasy bazowej abstrakcyjnego interfejsu UI, a zatem interfejs UI oraz wszystkie jego pochodne muszą się zmienić. Jednak interfejsy te nie są często stosowane. Prawdopodobnie są one używane tylko przez program główny lub inny proces odpowiedzialny za zainicjowanie systemu i stworzenie konkretnego egzemplarza UI. Zatem wpływ dodania nowych klas bazowych UI na resztę aplikacji jest minimalny. Uważna analiza listingu 12.6 pokazuje jeden z problemów ze zgodnością z ISP, który nie był oczywisty w przykładzie TimedDoor. Zwróćmy uwagę, że każda transakcja musi w jakiś sposób dowiedzieć się o swojej konkretnej wersji interfejsu UI. Klasa DepositTransaction musi wiedzieć o interfejsie DepositUI, klasa WithdrawTransaction musi wiedzieć o interfejsie WithdrawUI itp. Na listingu 12.6 problem ten rozwiązałem poprzez wymuszenie konstruowania każdej transakcji z referencją do określonego interfejsu UI. Zauważmy, że to pozwala na zastosowanie idiomu z listingu 12.7.
PRZYKŁAD INTERFEJSU UŻYTKOWNIKA BANKOMATU
157
Listing 12.7. Idiom inicjalizacji interfejsu UI Gui; // obiekt globalny; void f() { DepositTransaction dt(Gui); }
To rozwiązanie jest wygodne, ale zmusza do tego, by każda transakcja przechowywała składową oznaczającą referencję do swojego interfejsu UI. Innym sposobem rozwiązania tego problemu jest stworzenie zbioru globalnych stałych, jak pokazano na listingu 12.8. Stosowanie zmiennych globalnych nie zawsze jest objawem złego projektu. W tym przypadku zmienne globalne dają wyraźną korzyść łatwego dostępu. Ponieważ są to referencje, to nie można ich w żaden sposób zmienić. W związku z tym nie mogą być zmieniane w sposób, który mógłby zaskoczyć innych użytkowników. Listing 12.8. Wydzielenie globalnych wskaźników // w jakimś module, który będzie skonsolidowany // z resztą aplikacji. static UI Lui; // obiekt nieglobalny; DepositUI& GdepositUI = Lui; WithdrawalUI& GwithdrawalUI = Lui; TransferUI& GtransferUI = Lui;
// W module depositTransaction.h class WithdrawalTransaction : public Transaction { public:
};
virtual void Execute() { ... GwithdrawalUI.RequestWithdrawalAmount(); ... }
W języku C++ można by ulec pokusie umieszczenia wszystkich zmiennych globalnych z listingu 12.8 w pojedynczej klasie, tak aby zapobiec zaśmiecaniu globalnej przestrzeni nazw. Takie podejście zaprezentowano na listingu 12.9. Rozwiązanie to ma jednak niefortunny efekt. Aby skorzystać z interfejsu UIGlobals, trzeba włączyć plik nagłówkowy ui_globals.h. To z kolei powoduje włączenie nagłówków depositUI.h, withdrawUI.h i transferUI.h. Oznacza to, że każdy moduł, który zamierza skorzystać z dowolnego z interfejsów UI w przechodni sposób, zależy od nich wszystkich. To jest dokładnie taka sytuacja, której staramy się unikać poprzez stosowanie zasady ISP. Jeśli zostanie wprowadzona zmiana do dowolnego z interfejsów UI, trzeba ponownie skompilować wszystkie moduły zawierające instrukcję #include "ui_globals.h". Klasa UIGlobals spowodowała ponowne scalenie interfejsów, które z tak wielkim trudem rozdzieliliśmy! Listing 12.9. Opakowanie obiektów globalnych w klasie // w pliku ui_globals.h #include "depositUI.h" #include "withdrawalUI.h" #include "transferUI.h" class UIGlobals {
158
};
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
public: static WithdrawalUI& withdrawal; static DepositUI& deposit; static TransferUI& transfer
// w pliku ui_globals.cc static UI Lui; // obiekt nieglobalny; DepositUI& UIGlobals::deposit = Lui; WithdrawalUI& UIGlobals::withdrawal = Lui; TransferUI& UIGlobals::transfer = Lui;
Poliady i monady Rozważmy funkcję g, która wymaga dostępu zarówno do interfejsu DepositUI, jak i TransferUI. Weźmy pod uwagę także to, że chcemy przekazać do tej funkcji referencje do interfejsów użytkownika. Czy powinniśmy zapisać taką funkcję w następujący sposób: void g(DepositUI&, TransferUI&);
Czy też powinniśmy zapisać ją tak: void g(UI&);
Pokusa, aby zapisać funkcję w tej drugiej postaci (monadycznej), jest bardzo silna. W końcu wiemy, że w pierwszej (poliadycznej) formie oba argumenty odwołują się do tego samego obiektu. Ponadto gdybyśmy użyli formy poliadycznej, jej wywołanie mogłoby wyglądać następująco: g(ui, ui);
Taki przykład nie wydaje się właściwy. Tak czy inaczej, forma poliadyczna jest często bardziej preferowana od monadycznej. Forma monadyczna wymusza od funkcji g zależności od wszystkich interfejsów, które zawiera interfejs UI. A zatem zmiana interfejsu WithdrawUI będzie miała wpływ na funkcję g oraz wszystkich klientów funkcji g. To znacznie gorsze niż wywołanie g(ui,ui)! Ponadto nie możemy mieć pewności, że obydwa argumenty funkcji g będą zawsze odwoływały się do tego samego obiektu! W przyszłości obiekty interfejsów mogą być rozdzielone z jakiegoś powodu. Funkcja g nie musi wiedzieć, że wszystkie interfejsy zostały połączone w jeden obiekt. Z tego powodu osobiście wolę stosować formę poliadyczną dla takich funkcji. Grupowanie klientów. Klienty często mogą być grupowane według metod usługowych, które wywołują. Takie grupowanie umożliwia tworzenie oddzielnych interfejsów dla każdej grupy zamiast dla każdego klienta. To znacznie zmniejsza liczbę interfejsów, które usługa musi zaimplementować, a także zapobiega sytuacji, w której usługa zależy od każdego typu klienta. Czasami metody wywoływane przez różne grupy klientów nakładają się na siebie. Jeśli ten zakład jest niewielki, to interfejsy dla grup powinny pozostać oddzielne. Wspólne funkcje powinny być zadeklarowane we wszystkich nakładających się na siebie interfejsach. Klasa serwera odziedziczy wspólne funkcje z każdego z tych interfejsów, ale zaimplementuje je tylko raz. Zmieniające się interfejsy. W czasie utrzymywania aplikacji obiektowych interfejsy do istniejących klas i komponentów często się zmieniają. Czasami zmiany te mają ogromny wpływ na system. Zmuszają do ponownej kompilacji i wdrażania bardzo dużej części systemu. Wpływ ten można złagodzić poprzez dodawanie nowych interfejsów do istniejących obiektów zamiast zmieniania interfejsów istniejących. Klienty starego interfejsu, które chcą uzyskać dostęp do metod nowego interfejsu, mogą odpytać obiekt o ten interfejs tak, jak pokazano na listingu 12.10.
BIBLIOGRAFIA
159
Listing 12.10. Odpytywanie obiektów o określony interfejs void Client(Service* s) { if (NewService* ns = dynamic_cast(s)) { }
}
// użycie nowego interfejsu usługi
Tak jak w przypadku wszystkich zasad należy uważać, aby nie przesadzić z ich stosowaniem. Widmo klasy z setkami różnych interfejsów, z których niektóre są posegregowane według klienta, a inne według wersji, jest dość przerażające.
Wniosek „Grube” klasy powoduję dziwaczne i szkodliwe sprzężenia pomiędzy klasami klienckimi. Gdy jeden z klientów wymusza zmiany w grubej klasie, zmiany te mają wpływ na wszystkie pozostałe klasy klienckie. Z tego względu klienty powinny zależeć tylko od tych metod, które faktycznie wywołują. Można to osiągnąć poprzez rozdzielenie interfejsu grubej klasy na wiele interfejsów specyficznych dla poszczególnych klientów. Każdy interfejs specyficzny dla klienta deklaruje tylko te funkcje, które wywołuje ten konkretny klient lub grupa klientów. W takiej sytuacji grube klasy mogą dziedziczyć i implementować wszystkie interfejsy specyficzne dla klientów. To eliminuje zależność klientów od metod, których one nie wywołują, i pozwala klientom zachować niezależność od siebie.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19951.
1
Wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum.
160
ROZDZIAŁ 12. ISP — ZASADA SEGREGACJI INTERFEJSÓW
CZĘŚĆ III Studium przypadku: system płacowy
Nadszedł czas na pierwsze poważne studium przypadku. Do tej pory analizowaliśmy praktyki i zasady. Omówiliśmy istotę projektowania. Pisaliśmy o testowaniu i planowaniu. Teraz nadszedł czas, aby wykonać jakąś konkretną pracę. W następnych kilku rozdziałach przeanalizujemy projekt i implementację systemu listy płac. W dalszej części książki zamieszczono szczątkowy opis tego systemu. W ramach tego projektu i implementacji skorzystamy z kilku różnych wzorców projektowych. Wśród nich są Polecenia (ang. Command), Metoda szablonowa (ang. Template method), Strategia (ang. Strategy), Singleton, Pusty obiekt (ang. Null object), Fabryka (ang. Factory) i Fasada (ang. Facade). Wzorce te będą tematem kilku kolejnych rozdziałów. Następnie w rozdziale 18. przeanalizujemy projekt i implementację problemu listy płac. Istnieje kilka sposobów czytania tego studium przypadku. Zapoznanie się z kolejnymi rozdziałami w celu poznania wzorców projektowych, a następnie zaob-
serwowanie sposobów ich zastosowania do rozwiązania problemu listy płac. Czytelnicy, którzy znają wzorce projektowe i nie są zainteresowani ich przeglądem, mogą przejść
bezpośrednio do rozdziału 18. Przeczytanie najpierw rozdziału 18., a następnie powrót do wcześniejszych rozdziałów opisujących
wykorzystane wzorce projektowe. Przeczytanie rozdziału 18. fragmentami. W przypadku napotkania wzorca, którego czytelnik nie zna,
można przeczytać rozdział, który opisuje ten wzorzec, a następnie powrócić do rozdziału 18. W istocie nie istnieją sztywne reguły. Należy wybrać strategię, która wydaje się najbardziej odpo-
wiednia. Można również zastosować własną.
162
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
Szczątkowa specyfikacja systemu płacowego Poniżej zamieszczono kilka notatek, które zrobiliśmy podczas rozmów z klientem. System składa się z bazy danych pracowników firmy oraz związanych z nimi danych, takich jak karty czasu pracy. System powinien wypłacać wynagrodzenie wszystkim pracownikom. Pracownikom muszą być wypłacane wynagrodzenia w prawidłowej wysokości i na czas — zgodnie z określonymi metodami. Ponadto muszą być naliczane różne potrącenia od wynagrodzeń. Niektórzy pracownicy pracują na godziny. Wypłaca się im wynagrodzenie według stawki godzino-
wej, która jest ustawiana w jednym z pól w rekordzie pracownika. Pracownicy dostarczają dzienne karty pracy, w których są zarejestrowane daty oraz liczba przepracowanych godzin. Jeśli pracują więcej niż 8 godzin dziennie, to za dodatkowe godziny są opłacani według stawki wynoszącej 1,5 raza więcej od ich normalnej stawki. Wynagrodzenia są im wypłacane w każdy piątek. Niektórzy pracownicy otrzymują „płaskie” wynagrodzenie. Wypłata następuje ostatniego roboczego dnia w miesiącu. Ich miesięczne wynagrodzenie jest zawarte w jednym z pól w rekordzie pracownika. Niektórzy pracownicy otrzymują prowizję na podstawie zrealizowanej przez nich sprzedaży. Dostarczają dokumenty potwierdzające sprzedaż i zawierające datę sprzedaży oraz kwotę. Stawka prowizji jest zapisana w jednym z pól rekordu pracownika. Wynagrodzenia są im wypłacane w każdy piątek. Pracownicy mają możliwość wyboru metody wypłaty. Mogą otrzymywać czeki, które są wysyłane pod wskazane adresy pocztowe. Mogą odebrać czeki osobiście od płatnika lub mogą zażądać przelania pieniędzy na wskazany rachunek bankowy. Niektórzy pracownicy należą do związku zawodowego. W rekordzie pracownika istnieje pole dotyczące wysokości należnych składek. Składki są potrącane z ich uposażenia. Ponadto związek zawodowy od czasu do czasu może pobierać dodatkowe opłaty od indywidualnych członków związku. Związek zawodowy dostarcza zleceń potrąceń co tydzień. Wskazane kwoty muszą być potrącone z kolejnej wypłaty wskazanego pracownika. Aplikacja płacowa jest uruchamiana tylko raz każdego dnia roboczego i dokonuje wypłat określonej grupie pracowników. System uzyska informacje dotyczące daty wypłaty. Na tej podstawie będzie mógł obliczyć wynagrodzenia od ostatniej wypłaty zrealizowanej dla pracownika do wskazanej daty.
Ćwiczenie Zanim przejdziemy dalej, zachęcam czytelników, aby teraz zaprojektowali opisany system płacowy samodzielnie. Można naszkicować kilka wstępnych diagramów UML. Jeszcze lepiej będzie, jeśli spróbujesz zaimplementować kilka pierwszych przypadków użycia, stosując technikę „najpierw test”. Należy stosować zasady i praktyki poznane do tej pory i dążyć do stworzenia zrównoważonego i „zdrowego” projektu. Jeśli masz zamiar to zrobić, powinieneś przyjrzeć się opisanym poniżej przypadkom użycia. W przeciwnym razie należy je pominąć. Będą zaprezentowane ponownie w rozdziale opisującym rozwiązanie systemu płacowego.
Przypadek użycia nr 1: dodawanie nowego pracownika Nowy pracownik jest dodawany w wyniku transakcji AddEmp. Transakcja zawiera nazwisko pracownika, adres oraz numer przypisany do pracownika. Transakcja może przyjąć jedną z następujących trzech form: AddEmp "" "" H AddEmp "" "" S AddEmp "" "" C
Następuje utworzenie rekordu pracownika i przypisanie wartości odpowiednich pól.
SZCZĄTKOWA SPECYFIKACJA SYSTEMU PŁACOWEGO
Alternatywa: Błąd w strukturze transakcji. Jeśli struktura transakcji jest nieodpowiednia, to jest wyświetlany komunikat o błędzie i nie są podejmowane żadne działania.
Przypadek użycia nr 2: usuwanie pracownika Pracownicy są usuwani w wyniku otrzymania transakcji DelEmp. Transakcja ta ma następujący format: DelEmp
Po otrzymaniu tej transakcji następuje usunięcie odpowiedniego rekordu pracownika. Alternatywa: Nieprawidłowy bądź nieznany identyfikator EmpID. Jeśli pole identyfikatora ma nieprawidłową strukturę lub jeśli nie odwołuje się do prawidłowego rekordu pracownika, to transakcja powoduje wyświetlenie komunikatu o błędzie i nie jest podejmowane żadne inne działanie.
Przypadek użycia nr 3: dostarczenie karty pracy Po otrzymaniu transakcji TimeCard system utworzy rekord karty czasu pracy i powiąże go z rekordem właściwego pracownika. TimeCard
Alternatywa 1: Wskazany pracownik nie pracuje według stawki godzinowej. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Alternatywa 2: Błąd w strukturze transakcji. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań.
Przypadek użycia nr 4: dostarczenie raportu sprzedaży Po otrzymaniu transakcji SalesReceipt system utworzy nowy rekord raportu sprzedaży i powiąże go z rekordem właściwego pracownika. SalesReceipt
Alternatywa 1: Wskazany pracownik nie jest uprawniony do prowizji od sprzedaży. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Alternatywa 2: Błąd w strukturze transakcji. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań.
163
164
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
Przypadek użycia nr 5: dostarczenie informacji o opłacie na rzecz związku zawodowego Po otrzymaniu transakcji ServiceCharge system utworzy rekord opłaty na rzecz związku zawodowego i powiąże go z rekordem właściwego członka związku zawodowego. ServiceCharge
Alternatywa: Nieprawidłowy format transakcji. Jeśli transakcja ma nieprawidłowy format lub jeśli identyfikator nie odnosi się do członka związku zawodowego, to następuje wyświetlenie odpowiedniego komunikatu o błędzie.
Przypadek użycia nr 6: zmiana danych pracownika Po otrzymaniu transakcji ChgEmp system zmodyfikuje szczegółowe dane w rekordzie wskazanego pracownika. Transakcja ma kilka możliwych odmian. ChgEmp Name ChgEmp Address ChgEmp Hourly ChgEmp Salaried ChgEmp Commissioned ChgEmp Hold ChgEmp Direct ChgEmp Mail ChgEmp Member Dues ChgEmp NoMember
Zmiana nazwiska pracownika Zmiana adresu pracownika Zmiana sposobu wynagradzania przy stawce godzinowej Zmiana sposobu wynagradzania przy pensji miesięcznej Zmiana sposobu wynagradzania przy prowizji od sprzedaży Osobisty odbiór czeku Przelew na rachunek bankowy Przesłanie czeku pocztą Ustawia opcję członkostwa pracownika w związku zawodowym Usuwa opcję członkostwa pracownika w związku zawodowym
Alternatywa: Błędy transakcji. Jeśli struktura transakcji jest nieprawidłowa lub jeśli identyfikator nie odnosi się do rzeczywistego pracownika albo identyfikator odnosi się do już istniejącego członka związku zawodowego, to następuje wyświetlenie odpowiedniego komunikatu o błędzie i system nie podejmuje żadnych innych działań.
Przypadek użycia nr 7: wygenerowanie listy płac na dzień Po otrzymaniu transakcji Payday system wyszukuje wszystkich pracowników, dla których tego dnia powinna być zrealizowana wypłata. Następnie system określa, jaką kwotę powinien wypłacić każdemu z nich, i realizuje wypłatę zgodnie z wybranym sposobem wypłaty. Payday
SZCZĄTKOWA SPECYFIKACJA SYSTEMU PŁACOWEGO
165
R OZDZIAŁ 13
Wzorce projektowe Polecenie i Aktywny obiekt
Żaden człowiek nie otrzymał od natury prawa do wydawania poleceń innym ludziom — Denis Diderot (1713 – 1784)
Spośród wszystkich wzorców projektowych, które opisano przez ostatnie lata, wzorzec Polecenie (ang. cornmand) zrobił na mnie wrażenie jednego z najprostszych i najbardziej eleganckich. Jednak jak się wkrótce przekonamy, jego prostota jest pozorna. Zakres zastosowań wzorca Polecenie jest prawdopodobnie nieograniczony. Prostota wzorca projektowego Polecenie, jak pokazano na rysunku 13.1, jest niemal śmieszna. Na listingu 13.1 przedstawiono kod, który nie robi zbyt wiele. Istnienie wzorca projektowego składającego się z zaledwie jednego interfejsu z jedną metodą wydaje się absurdalne.
Rysunek 13.1. Wzorzec Polecenie Listing 13.1. Command.java public interface Command { public void do(); }
166
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
Jednak w rzeczywistości prezentowany wzorzec przekracza pewną granicę. To w przekroczeniu tej granicy istnieje interesująca złożoność wzorca Polecenie. Większość klas łączy zestaw metod z odpowiadającymi im zbiorami zmiennych. Wzorzec Polecenie nie realizuje tego schematu. W tym przypadku wzorzec obejmuje funkcję wolną od jakichkolwiek zmiennych. Według ścisłych reguł projektowania obiektowego to jest niedopuszczalne — takie rozwiązania są sprzeczne z przyjętą konwencją dekompozycji funkcjonalności. Wzorzec podnosi rolę funkcji do roli klasy. To istne bluźnierstwo! Z drugiej strony, na granicy dwóch paradygmatów można zaobserwować interesujące rzeczy.
Proste polecenia Kilka lat temu pracowałem jako konsultant dla dużej firmy produkującej fotokopiarki. Moim zadaniem była pomoc jednemu z zespołów projektowych przy projektowaniu i implementacji wbudowanego oprogramowania sterującego działaniem nowej kopiarki. Wpadliśmy na pomysł, aby zastosować wzorzec projektowy Polecenie w celu zarządzania urządzeniami. Stworzyliśmy hierarchię podobną do tej, którą przedstawiono na rysunku 13.2.
Rysunek 13.2. Proste polecenia dla oprogramowania kopiarki
Rola tych klas powinna być oczywista. Wywołanie metody do() dla polecenia RelayOnCommand powoduje włączenie określonego przekaźnika. Wywołanie metody do() dla polecenia MotorOffCommand powoduje wyłączenie określonego silnika. Adres silnika lub przekaźnika jest przekazywany do obiektu za pośrednictwem argumentu jego konstruktora. Dzięki takiej strukturze możemy przekazywać obiekty poleceń pomiędzy komponentami systemu i wykonywać na nich funkcje do () bez konieczności dokładnej znajomości rodzaju reprezentowanych poleceń. Takie rozwiązanie prowadzi do interesujących uproszczeń. System był sterowany zdarzeniami. Przekaźniki są otwierane i zamykane, silniki uruchamiane lub zatrzymywane, a sprzęgła załączane i rozłączane na podstawie określonych zdarzeń, które zachodzą w tym systemie. Wiele z tych zdarzeń było wykrywanych przez czujniki. Na przykład kiedy czujnik optyczny wykrył, że arkusz papieru dotarł do określonego punktu, trzeba było uaktywnić odpowiednie sprzęgło. Można zaimplementować ten mechanizm poprzez związanie odpowiedniego obiektu klasy ClutchOnCommand z obiektem kontrolującym określony czujnik optyczny (patrz rysunek 13.3).
Rysunek 13.3. Polecenie sterowane przez czujnik
TRANSAKCJE
167
Ta prosta struktura ma bardzo ważną zaletę: klasa Sensor nie musi „wiedzieć”, co robi. Za każdym razem, gdy wykrywa zdarzenie, wywołuje funkcję do() obiektu klasy Comnand, z którym jest związana. Oznacza to, że obiekty klasy Sensor nie muszą nic „wiedzieć” o poszczególnych sprzęgłach czy przekaźnikach. Nie muszą „znać” mechanicznej struktury toru arkuszy papieru. Ich funkcja staje się niezwykle prosta. Złożoność problemu określenia, które przekaźniki należy zamknąć w reakcji na zdarzenia wykrywane przez poszczególne czujniki, przeniesiono na poziom funkcji inicjującej. Na pewnym etapie procesu inicjalizacji systemu należy związać poszczególne obiekty klasy Sensor z odpowiednimi obiektami Command. W ten sposób całe okablowanie1 znajduje się w jednym miejscu i jest przeniesione poza główną treść systemu. Co więcej, można by nawet utworzyć prosty plik tekstowy opisujący, które obiekty Sensor są związane z którymi obiektami klasy Command. Program inicjujący mógłby odczytać zawartość tego pliku i odpowiednio skonfigurować system. W ten sposób okablowanie tego systemu może być określone całkowicie poza właściwym programem i może być modyfikowane bez konieczności ponownej kompilacji. Dzięki hermetyzacji pojęcia polecenia wzorzec ten umożliwił wyeliminowanie sprzężenia połączeń logicznych systemu z poszczególnymi urządzeniami. To olbrzymia korzyść.
Transakcje Innym powszechnym zastosowaniem wzorca Polecenie — tym, które przyda się do rozwiązania problemu systemu listy płac — jest tworzenie i realizacja transakcji. Wyobraźmy sobie na przykład, że piszemy program, który utrzymuje bazę danych pracowników (patrz rysunek 13.4). Istnieje szereg działań, które użytkownicy mogą wykonywać na tej bazie danych. Mogą dodawać nowych pracowników, usuwać pracowników istniejących albo zmieniać im atrybuty.
Rysunek 13.4. Baza danych pracowników
1
Połączenia logiczne pomiędzy czujnikami a poleceniami.
168
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
Gdy użytkownik zdecyduje się dodać nowego pracownika, musi podać wszystkie informacje potrzebne do pomyślnego stworzenia rekordu pracownika. Przed przetworzeniem tych informacji system musi zweryfikować, czy informacje te są poprawne pod względem składni i semantyki. Zastosowanie wzorca Polecenie może pomóc w wykonaniu tego zadania. Obiekt Command pełni rolę repozytorium niezweryfikowanych danych, implementuje metody sprawdzania poprawności oraz implementuje metody, które na koniec wykonują transakcję. Dla przykładu rozważmy projekt pokazany na rysunku 13.5. Klasa AddEmployeeTransaction zawiera te same pola danych co klasa Employee. Zawiera również wskaźnik do obiektu PayClassification. Te pola i obiekt są tworzone na podstawie danych, które użytkownik podaje podczas wydawania systemowi polecenia dodania nowego pracownika.
Rysunek 13.5. Transakcja AddEmployee
Metoda validate przegląda wszystkie dane i sprawdza, czy mają one sens. Sprawdza je pod kątem poprawności składniowej i semantycznej. Może nawet sprawdzić, czy dane w transakcji są spójne z istniejącym stanem bazy danych. Na przykład może sprawdzić, czy taki pracownik wcześniej nie został dodany do bazy danych. Metoda execute korzysta ze zweryfikowanych danych w celu zaktualizowania bazy danych. W tym prostym przykładzie utworzony zostanie nowy obiekt Employee, do którego zostaną załadowane pola z obiektu AddEmployeeTransaction. Obiekt PayClassification zostanie przeniesiony lub skopiowany do obiektu Employee.
Fizyczny i czasowy podział kodu Największą korzyścią wynikającą z tego projektu jest wyraźny podział kodu na część odpowiedzialną za pobieranie danych od użytkownika, część weryfikującą i operującą na tych danych oraz właściwe obiekty biznesowe. Na przykład można oczekiwać, że dane potrzebne do dodania nowego pracownika będą pochodziły z okna dialogowego w jakimś interfejsie GUI. Pomieszanie kodu GUI z algorytmami walidacji i wykonywania transakcji byłoby niekorzystne. Takie sprzężenie uniemożliwiłoby wykorzystanie kodu sprawdzającego poprawność i wykonującego transakcje w innych interfejsach. Dzięki wydzieleniu kodu wykonania transakcji i walidacji do klasy AddEmployeeTransaction udało się fizycznie oddzielić ten kod od interfejsu dostarczania danych. Co więcej, oddzieliliśmy kod, który „wie”, jak manipulować logistyką bazy danych, od obiektów biznesowych.
Czasowy podział kodu Kod weryfikujący poprawność danych i kod wykonawczy zostały rozdzielone także w inny sposób. Po dostarczeniu danych nie ma powodu, aby metody walidacji i przetwarzania danych były wykonywane natychmiast. Obiekty transakcji mogą być umieszczone na liście, a następnie walidowane i przetwarzane znacznie później.
AKTYWNY OBIEKT
169
Załóżmy, że mamy bazę danych, która w ciągu dnia nie może być zmieniana. Zmiany mogą być wprowadzane wyłącznie w godzinach pomiędzy północą a pierwszą w nocy. Byłoby niefortunne, gdyby trzeba było czekać do północy, a potem spieszyć się, aby wpisać wszystkie polecenia przed pierwszą. Wygodniej byłoby wpisać wszystkie polecenia, zwalidować je natychmiast, a następnie przetworzyć później po północy. Wzorzec Polecenie daje nam taką możliwość.
Metoda Undo Na rysunku 13.6 dodano metodę undo() do wzorca Polecenie. Wydaje się oczywiste, że jeśli można zaimplementować metodę do() pochodnej interfejsu Command w celu zapamiętania szczegółów operacji do wykonania, to można także zaimplementować metodę undo(), która cofnie tę operację i przywróci system do stanu pierwotnego.
Rysunek 13.6. Odmiana wzorca Polecenie z metodą Undo
Dla przykładu wyobraźmy sobie aplikację umożliwiającą użytkownikowi rysowanie na ekranie figur geometrycznych. Na pasku narzędzi są przyciski, które pozwalają użytkownikowi rysować okręgi, kwadraty, prostokąty itp. Załóżmy, że użytkownik klika przycisk rysowania okręgu. System tworzy obiekt DrawCircleCommand, a następnie wywołuje metodę do() tego obiektu. Obiekt DrawCircleCommand śledzi mysz użytkownika, czekając na kliknięcie w oknie rysowania. Po tym kliknięciu ustawia punkt kliknięcia jako środek okręgu i przechodzi do rysowania animowanego okręgu o środku w tym punkcie oraz promieniu wynikającym z bieżącej pozycji myszy. Kiedy użytkownik kliknie ponownie, obiekt DrawCircleCommand zatrzymuje animowanie okręgu i dodaje odpowiedni obiekt okręgu do listy figur aktualnie wyświetlanych na ekranie. Obiekt ten zapamiętuje również identyfikator nowego okręgu w pewnej prywatnej zmiennej. Następnie zwraca sterowanie z metody do(). Następnie system umieszcza wykonane polecenie DrawCirlceCommand na stosie zrealizowanych komend. Po jakimś czasie użytkownika klika przycisk Undo na pasku narzędzi. System ściąga ze stosu zrealizowane polecenie i wywołuje na uzyskanym w ten sposób obiekcie Command metodę undo(). Po otrzymaniu komunikatu undo() obiekt DrawCircleCommand usuwa okrąg odpowiadający zapisanemu identyfikatorowi z listy obiektów aktualnie wyświetlanych w obszarze rysowania. Dzięki zastosowaniu tej techniki można łatwo zaimplementować polecenie Cofnij w prawie każdej aplikacji. Kod, dzięki któremu możliwe jest cofnięcie polecenia, jest zawsze przydatny jako uzupełnienie kodu, dzięki któremu polecenie można wykonać.
Aktywny obiekt Jednym z moich ulubionych zastosowań wzorca projektowego Polecenie jest jego użycie we wzorcu Aktywny obiekt (ang. Active object)2. Jest to bardzo stara technika implementacji wielu wątków sterowania. Jest ona używana w takiej czy innej formie do zapewnienia prostego jądra wielozadaniowości w tysiącach instalacji przemysłowych.
2
[Lavender 96].
170
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
Idea jest bardzo prosta. Rozważmy listingi 13.2 i 13.3. Obiekt ActiveObjectEngine utrzymuje listę obiektów Command. Użytkownicy mogą dodawać nowe polecenia do silnika albo mogą wywołać polecenie run(). Działanie funkcji run() sprowadza się do przeglądania listy oraz uruchamiania i usuwania poleceń. Listing 13.2. ActiveObjectEngine.java import java.util.LinkedList; import java.util.Iterator; public class ActiveObjectEngine { LinkedList itsCommands = new LinkedList();
}
public void addCommand(Command c) { itsCommands.add(c); } public void run() { while (!itsCommands.isEmpty()) { Command c = (Command) itsCommands.getFirst(); itsCommands.removeFirst(); c.execute(); } }
Listing 13.3. Command.java public interface Command { public void execute() throws Exception; }
Powyższy kod być może nie wygląda zbyt imponująco. Wyobraźmy sobie jednak, co by się stało, gdyby jeden z obiektów Command na liście sklonował samego siebie, a następnie umieściłby tego klona z powrotem na liście. Lista nigdy by się nie opróżniła, a metoda run() nigdy nie zwróciłaby sterowania. Rozważmy przypadek testowy z listingu 13.4. Kod tworzy obiekt o nazwie SleepCommand. Kod między innymi przekazuje opóźnienie 1000 ms do konstruktora obiektu SleepCommand. Następnie umieszcza obiekt SleepCommand wewnątrz obiektu ActiveObjectEngine. Po wywołaniu run() oczekuje upływu określonej liczby milisekund. Listing 13.4. TestSleepCommand.java import junit.framework.*; import junit.swingui.TestRunner; public class TestSleepCommand extends TestCase { public static void main(String[] args) { TestRunner.main(new String[]{"TestSleepCommand"}); } public TestSleepCommand(String name) { super(name); } private boolean commandExecuted = false; public void testSleep() throws Exception {
AKTYWNY OBIEKT
}
}
171
Command wakeup = new Command() { public void execute() {commandExecuted = true;} }; ActiveObjectEngine e = new ActiveObjectEngine(); SleepCommand c = new SleepCommand(1000,e,wakeup); e.addCommand(c); long start = System.currentTimeMillis(); e.run(); long stop = System.currentTimeMillis(); long sleepTime = (stop-start); assert("Oczekiwano wartości SleepTime " + sleepTime + "> 1000", sleepTime > 1000); assert("Oczekiwano wartości SleepTime " + sleepTime + "< 1100", sleepTime < 1100); assert("Polecenie wykonano", commandExecuted);
Spróbujmy przyjrzeć się temu przypadkowi testowemu nieco bliżej. Konstruktor klasy SleepCommand zawiera trzy argumenty. Pierwszy oznacza czas opóźnienia wyrażony w milisekundach. Drugi to obiekt ActiveObjectEngine, w którym polecenie będzie uruchomione. Trzeci argument to kolejny obiekt Command o nazwie wakeup. Koncepcja jest taka, aby obiekt SleepCommand odczekał określoną liczbę milisekund, a następnie wywołał polecenie wakeup. Implementację polecenia SleepCommand zamieszczono na listingu 13.5. Po uruchomieniu obiekt SleepCommand sprawdza, czy nie został uruchomiony wcześniej. Jeśli nie, to rejestruje czas uruchomienia. Jeśli czas opóźnienia jeszcze nie upłynął, obiekt umieszcza siebie z powrotem na liście obiektu ActiveObjectEngine. Jeśli czas opóźnienia upłynął, umieszcza na liście obiektu ActiveObjectEngine obiekt wakeup. Listing 13.5. SleepCommand.java public class SleepCommand implements Command { private Command wakeupCommand = null; private ActiveObjectEngine engine = null; private long sleepTime = 0; private long startTime = 0; private boolean started = false; public SleepCommand(long milliseconds, ActiveObjectEngine e, Command wakeupCommand) { sleepTime = milliseconds; engine = e; this.wakeupCommand = wakeupCommand; } public void execute() throws Exception { long currentTime = System.currentTimeMillis(); if (!started) { started = true; startTime = currentTime; engine.addCommand(this); } else if ((currentTime - startTime) < sleepTime) { engine.addCommand(this); } else {
172
}
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
}
}
engine.addCommand(wakeupCommand);
Możemy znaleźć analogię pomiędzy tym programem a programem wielowątkowym, który czeka na zdarzenie. Kiedy wątek w programie wielowątkowym oczekuje na zdarzenie, zazwyczaj wywołuje jakieś polecenie systemu operacyjnego, które blokuje wątek do czasu wystąpienia zdarzenia. Program z listingu 13.5 nie blokuje się. Zamiast tego, jeśli zdarzenie, na które oczekuje ((currentTime - startTime) < sleepTime) jeszcze nie wystąpiło, po prostu umieszcza siebie z powrotem na liście obiektu ActiveObjectEngine. Budowanie systemów wielowątkowych z wykorzystaniem tej techniki było i nadal będzie bardzo powszechną praktyką. Wątki tego typu są określana jako zadania RTC (ang. run-to-completion — dosł. uruchom do zakończenia), ponieważ każdy egzemplarz obiektu Command jest wykonywany w całości przed uruchomieniem następnego egzemplarza klasy Command. Nazwa RTC sugeruje, że egzemplarze klasy Command nie blokują się. Fakt, że wszystkie egzemplarze Command działają do zakończenia, daje wątkom RTC interesującą korzyść polegającą na współdzieleniu tego samego stosu wykonywania. W przeciwieństwie do wątków w tradycyjnym systemie wielowątkowym nie jest konieczne definiowanie lub określanie osobnego stosu wykonywania dla każdego wątku RTC. Może to być potężnym atutem w systemach z ograniczoną pamięcią, w których jest uruchomionych wiele wątków. Wracając do naszego przykładu: na listingu 13.6 pokazano prosty program, który korzysta z klasy SleepCommand i działa wielowątkowo. Program nosi nazwę DelayedTyper. Listing 13.6. DelayedTyper.java public class DelayedTyper implements Command { private long itsDelay; private char itsChar; private static ActiveObjectEngine engine = new ActiveObjectEngine(); private static boolean stop = false; public static void main(String args[]) { engine.addCommand(new DelayedTyper(100,'1')); engine.addCommand(new DelayedTyper(300,'3')); engine.addCommand(new DelayedTyper(500,'5')); engine.addCommand(new DelayedTyper(700,'7')); Command stopCommand = new Command() { public void execute() {stop=true;} };
}
engine.addCommand( new SleepCommand(20000,engine,stopCommand)); engine.run();
public DelayedTyper(long delay, char c) { itsDelay = delay; itsChar = c; } public void execute() throws Exception { System.out.print(itsChar); if (!stop) delayAndRepeat();
BIBLIOGRAFIA
}
173
} private void delayAndRepeat() throws Exception { engine.addCommand(new SleepCommand(itsDelay,engine,this); }
Zwróćmy uwagę, że klasa DelayedTyper implementuje interfejs Command. Metoda execute wyświetla znak, który został przekazany do konstruktora, sprawdza flagę stopu, a jeśli nie została ona ustawiona, wywołuje metodę delayAndRepeat. Metoda delayAndRepeat tworzy obiekt SleepCommand z wykorzystaniem wartości opóźnienia przekazanej w momencie tworzenia obiektu. Następnie umieszcza obiekt SleepCommand wewnątrz obiektu ActiveObjectEngine. Działanie tego obiektu Command jest łatwe do przewidzenia. Działanie sprowadza się do „zawieszenia” w pętli, która na przemian wyświetla wprowadzony znak i czeka przez określony czas opóźnienia. Pętla kończy działanie, kiedy zostanie ustawiona flaga stop. Główny program tworzy kilka egzemplarzy klasy DelayedTyper i umieszcza je na liście obiektu ActiveObjectEngine, przy czym każdy egzemplarz ma ustawiony własny znak i własne opóźnienie. Następnie wywoływany jest obiekt SleepCommand, który po pewnym czasie ustawia flagę stop. Uruchomienie tego programu powoduje wyświetlenie ciągu znaków złożonego z jedynek, trójek, piątek i siódemek. Uruchomienie go ponownie powoduje wyświetlenie podobnego, ale innego ciągu znaków. Oto wyniki dwóch uruchomień: 135711311511371113151131715131113151731111351113711531111357... 135711131513171131511311713511131151731113151131711351113117...
Ciągi są różne, ponieważ zegar procesora i zegar czasu rzeczywistego nie są idealnie zsynchronizowane. Ten rodzaj niedeterministycznych zachowań jest cechą charakterystyczną systemów wielowątkowych. Zachowania niedeterministyczne są również źródłem wielu problemów, cierpienia i bólu. Każdy, kto pracował z wbudowanymi systemami czasu rzeczywistego, wie, jak trudne do debugowania są niedeterministyczne zachowania.
Wniosek Prostota wzorca projektowego Polecenie nie zaprzecza jego wszechstronności. Wzorzec ten może być stosowany w wielu obszarach — począwszy od transakcji w bazach danych, poprzez zarządzanie urządzeniami, aż do systemów wielowątkowych oraz zarządzania poleceniami i cofania poleceń w interfejsach GUI. Niektórzy uważają, że wzorzec Polecenie łamie paradygmat obiektowy, ponieważ koncentruje się bardziej na funkcjach niż na klasach. W pewnym stopniu to może być prawdą, ale w realnym świecie dewelopera wzorzec projektowy Polecenie może być bardzo przydatny.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19953. 2. R.G. Lavender i D.C. Schmidt, Active Object: An Object Behavioral Pattern for Concurrent Programming, w Pattern Languages of Program Design (J.O. Copliena, J. Vlissidesa i N. Kertha). Reading, MA: Addison-Wesley, 1996.
3
Wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum.
174
ROZDZIAŁ 13. WZORCE PROJEKTOWE POLECENIE I AKTYWNY OBIEKT
R OZDZIAŁ 14
Metoda szablonowa i Strategia: dziedziczenie a delegacja
Najlepszą strategią w życiu jest pracowitość — chińskie przysłowie
Na początku lat dziewięćdziesiątych, kiedy programowanie obiektowe dopiero zyskiwało popularność, wszyscy byliśmy pod wrażeniem pojęcia dziedziczenia. Znaczenie tej nowej własności było ogromne. Dzięki dziedziczeniu możliwe stało się programowanie według różnic! Oznacza to, że można było wziąć klasę, która realizowała jakąś przydatną funkcjonalność, a następnie stworzyć podklasę i zmienić tylko te fragmenty, które nam się nie podobały. Wielokrotne wykorzystywanie kodu sprowadzało się do dziedziczenia po nim! Można było ustalić całe taksonomie struktur programowych. Każdy poziom takiej taksonomii korzystał z kodu z poziomów wyższych. To był odważny, nowy świat. Podobnie jak jest w przypadku większości odważnych nowych światów, tak i ten okazał się nieco zbyt wyidealizowany. Już w połowie lat dziewięćdziesiątych okazało się, że z zastosowaniem dziedziczenia można było bardzo łatwo przesadzić, a nadużywanie tej techniki było bardzo kosztowne. Gamma, Helm, Johnson i Vlissides posunęli się nawet do sformułowania tezy kompozycja obiektów jest ważniejsza od dziedziczenia klas1. W związku z tym zaczęto wycofywać się ze stosowania dziedziczenia. Często zastępowano je kompozycją lub delegacją. W tym rozdziale opisano dwa wzorce, które uosabiają różnicę między dziedziczeniem a delegacją. Wzorce Metoda szablonowa (ang. Template method) i Strategia (ang. Strategy) rozwiązują podobne problemy i często mogą być stosowane zamiennie. Jednak wzorzec Metoda szablonowa wykorzystuje dziedziczenie, natomiast wzorzec Strategia używa delegacji. 1
[GOF 95], str. 20.
176
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
Oba wzorce rozwiązują problem oddzielenia ogólnego algorytmu od szczegółowego kontekstu. W produkcji oprogramowania problem ten występuje bardzo często. Istnieje algorytm, który ma ogólne zastosowanie. Aby zapewnić zgodność z zasadą odwracania zależności (DIP), chcemy zadbać o to, by ogólny algorytm nie zależał od szczegółowej implementacji. Zamiast tego chcemy, aby zarówno ogólny algorytm, jak i szczegółowa implementacja zależały od abstrakcji.
Metoda szablonowa Rozważmy wszystkie programy, które dotychczas napisaliśmy. Większość z nich prawdopodobnie zawiera podstawową strukturę pętli głównej. Initialize(); while (!done()) // główna pętla { Idle(); // wykonuj przydatne działania. } Cleanup();
Najpierw inicjujemy aplikację. Następnie wchodzimy w główną pętlę. W głównej pętli robimy to, co program musi zrobić. Możemy przetwarzać zdarzenia GUI lub na przykład wykonywać działania na rekordach bazy danych. Na koniec, po zakończeniu operacji, wychodzimy z głównej pętli i „sprzątamy po sobie”. Ta struktura jest tak popularna, że można ją zaimplementować w klasie o nazwie Application. Następnie można korzystać z tej klasy w każdym nowym programie, który piszemy. Pomyślcie o tym. Nigdy więcej nie będziemy zmuszeni do napisania głównej pętli ponownie 2! Dla przykładu rozważmy program z listingu 14.1. Mamy tam elementy standardowego programu. Najpierw są inicjowane obiekty InputStreamReader i BufferedReader. Następnie jest główna pętla, która odczytuje temperaturę w stopniach Fahrenheita z obiektu BufferedReader, a następnie wyświetla wartość po konwersji na stopnie Celsjusza. Na koniec generowany jest końcowy komunikat. Listing 14.1. Program do konwersji stopni Fahrenheita na Celsjusza import java.io.*; public class ftocraw { public static void main(String[] args) throws Exception { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); boolean done = false; while (!done) { String fahrString = br.readLine(); if (fahrString == null || fahrString.length() == 0) done = true; else { double fahr = Double.parseDouble(fahrString); double celcius = 5.0/9.0*(fahr-32); System.out.println("F=" + fahr + ", C=" + celcius); } } System.out.println("Program ftoc zakończył pracę."); } }
2
Wydaje mi się, że nadawałbym się na sprzedawcę.
METODA SZABLONOWA
177
W tym programie są wszystkie elementy struktury głównej pętli. Krótka inicjalizacja, wykonanie pracy w pętli głównej, a następnie posprzątanie i zakończenie pracy. Tę podstawową strukturę programu ftoc można rozdzielić, stosując wzorzec projektowy Metoda szablonowa. Wzorzec ten pozwala na umieszczenie całego generycznego kodu w zaimplementowanej metodzie abstrakcyjnej klasy bazowej. Zaimplementowana metoda obejmuje ogólny algorytm, ale wszystkie szczegóły są realizowane przez metody abstrakcyjne klasy bazowej. Zatem można by umieścić strukturę głównej pętli w abstrakcyjnej klasie bazowej o nazwie Application (patrz listing 14.2). Listing 14.2. Application.java public abstract class Application { private boolean isDone = false; protected abstract void init(); protected abstract void idle(); protected abstract void cleanup(); protected void setDone() {isDone = true;} protected boolean done() {return isDone;}
}
public void run() { init(); while (!done()) idle(); cleanup(); }
Powyższa klasa opisuje generyczną aplikację z główną pętlą. Główna pętla została zaimplementowana w funkcji run. Można także zauważyć, że wszystkie operacje zostały przeniesione do abstrakcyjnych metod init, idle i cleanup. Metoda init jest odpowiedzialna za wszystkie operacje inicjalizacji. Metoda idle realizuje główną pracę programu. Będzie wywoływana wielokrotnie do czasu wywołania metody setDone. Metoda cleanup wykonuje działania, które należy wykonać przed zakończeniem działania programu. Klasę ftoc można przepisać w taki sposób, aby dziedziczyła po klasie Application i wypełniała metody abstrakcyjne. Klasę w tej postaci zaprezentowano na listingu 14.3. Listing 14.3. ftocTemplateMethod.java import java.io.*; public class ftocTemplateMethod extends Application { private InputStreamReader isr; private BufferedReader br;
}
public static void main(String[] args) throws Exception { (new ftocTemplateMethod()).run(); protected void init() { isr = new InputStreamReader(System.in); br = new BufferedReader(isr); }
178
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
protected void idle() { String fahrString = readLineAndReturnNullIfError(); if (fahrString == null || fahrString.length() == 0) setDone(); else { double fahr = Double.parseDouble(fahrString); double celcius = 5.0/9.0*(fahr-32); System.out.println("F=" + fahr + ", C=" + celcius); } } protected void cleanup() { System.out.println("Program ftoc zakończył pracę!"); } private String readLineAndReturnNullIfError() { String s; try { s = br.readLine(); } catch(IOException e) { s = null; } return s; } }
Ze względu na obsługę wyjątków kod stał się nieco dłuższy, ale wyraźnie widać sposób zastosowania wzorca Metoda szablonowa do programu ftoc.
Nadużywanie wzorca W tej chwili niektórzy czytelnicy pewnie pomyśleli: Czy on mówi poważnie? Czy naprawdę oczekuje, że będę stosował taką klasę Application do wszystkich nowych aplikacji? Nie zyskuję w ten sposób niczego, a tylko problem nadmiernie się skomplikował. Wybrałem ten przykład, ponieważ był prosty i zapewniał dobrą platformę do zaprezentowania sposobu działania wzorca Metoda szablonowa. Z drugiej strony, w rzeczywistości nie polecam budowania aplikacji ftoc w ten sposób. To jest dobry przykład nadużywania wzorca. Zastosowanie wzorca Metoda szablonowa w tej konkretnej aplikacji jest śmieszne. To jedynie komplikuje problem i niepotrzebnie rozbudowuje program. Idea hermetyzacji głównej pętli wszystkich aplikacji na świecie brzmiała cudownie na początku, ale jej praktyczne zastosowanie w tym przypadku jest bezowocne. Wzorce projektowe to doskonałe narzędzia. Mogą pomóc w rozwiązaniu wielu problemów projektowych. Ale fakt, że one istnieją, nie oznacza, że powinny być stosowane zawsze. W tym przypadku, chociaż zastosowanie wzorca Metoda szablonowa do rozwiązania opisywanego problemu było możliwe, to jego użycie nie było wskazane. Koszt zastosowania wzorca okazał się wyższy niż korzyści, jakie za jego pomocą uzyskaliśmy. Rozważmy teraz nieco bardziej przydatny przykład (patrz listing 14.4).
METODA SZABLONOWA
179
Sortowanie bąbelkowe3 Listing 14.4. BubbleSorter.java public class BubbleSorter { static int operations = 0; public static int sort(int [] array) { operations = 0; if (array.length <= 1) return operations; for (int nextToLast = array.length-2; nextToLast >= 0; nextToLast--) for (int index = 0; index <= nextToLast; index++) compareAndSwap(array, index); return operations;
} private static void swap(int[] array, int index) { int temp = array[index]; array[index] = array[index+1]; array[index+1] = temp; }
}
private static void compareAndSwap(int[] array, int index) { if (array[index] > array[index+1]) swap(array, index); operations++; }
Klasa BubbleSorter „wie”, jak sortować tablicę liczb całkowitych za pomocą algorytmu sortowania bąbelkowego. Metoda sort klasy BubbleSorter zawiera algorytm, który „wie”, jak zrealizować sortowanie bąbelkowe. Dwie metody pomocnicze: swap i compareAndSwap obsługują szczegóły operacji na liczbach całkowitych i tablicach oraz obsługują mechanizmy niezbędne do działania algorytmu sortowania. Stosując wzorzec Metoda szablonowa, możemy wydzielić algorytm sortowania bąbelkowego do abstrakcyjnej klasy bazowej o nazwie BubbleSorter. Klasa BubbleSorter zawiera implementację funkcji sort, która wywołuje abstrakcyjną metodę o nazwie outOfOrder oraz inną o nazwie swap. Metoda outOfOrder porównuje dwa sąsiednie elementy w tablicy i zwraca true, jeśli elementy nie są ustawione we właściwej kolejności. Metoda swap przestawia dwie sąsiednie komórki w tablicy. Metoda sort nic „nie wie” o tablicy ani „nie obchodzi” jej, jakie obiekty są w niej zapisane. Po prostu wywołuje metodę outOfOrder dla różnych indeksów w tablicy i określa, czy te indeksy powinny być przestawione, czy nie (patrz listing 14.5). Listing 14.5. BubbleSorter.java public abstract class BubbleSorter { private int operations = 0; protected int length = 0; protected int doSort() { operations = 0;
3
Algorytm sortowania bąbelkowego tak jak klasa Application jest łatwy do zrozumienia, dlatego jest użyteczną pomocą naukową. Jednak nikt przy zdrowych zmysłach nigdy faktycznie nie skorzystałby z sortowania bąbelkowego do realizacji poważnych zadań sortowania. Istnieją znacznie lepsze algorytmy.
180
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
if (length <= 1) return operations; for (int nextToLast = length-2; nextToLast >= 0; nextToLast--) for (int index = 0; index <= nextToLast; index++) { if (outOfOrder(index)) swap(index); operations++; } }
}
return operations;
protected abstract void swap(int index); protected abstract boolean outOfOrder(int index);
Na podstawie klasy BubbleSorter możemy stworzyć jej proste pochodne, za pomocą których można posortować dowolne rodzaje obiektów. Na przykład można stworzyć klasę IntBubbleSorter, która sortuje tablice liczb całkowitych, oraz DoubleBubbleSorter, która sortuje tablice liczb double (patrz rysunek 14.1, listing 14.6 oraz listing 14.7).
Rysunek 14.1. Struktura klasy BubbleSorter Listing 14.6. IntBubbleSorter.java public class IntBubbleSorter extends BubbleSorter { private int[] array = null; public int sort(int [] theArray) { array = theArray; length = array.length; return doSort(); } protected void swap(int index) { int temp = array[index]; array[index] = array[index+1]; array[index+1] = temp; }
}
protected boolean outOfOrder(int index) { return (array[index] > array[index+1]); }
STRATEGIA
181
Listing 14.7. DoubleBubbleSorter.java public class DoubleBubbleSorter extends BubbleSorter { private double[] array = null; public int sort(double [] theArray) { array = theArray; length = array.length; return doSort(); } protected void swap(int index) { double temp = array[index]; array[index] = array[index+1]; array[index+1] = temp; }
}
protected boolean outOfOrder(int index) { return (array[index] > array[index+1]); }
Wzorzec Metoda szablonowa prezentuje jedną z klasycznych form wielokrotnego wykorzystywania kodu w programowaniu obiektowym. Generyczne algorytmy są umieszczane w klasie bazowej i dziedziczone w różnych kontekstach szczegółowych. Jednak stosowanie tej techniki wiąże się z kosztami. Dziedziczenie jest bardzo silną relacją. Klasy pochodne są nierozerwalnie związane ze swoimi klasami bazowymi. Na przykład metody outOfOrder i swap interfejsu IntBubbleSorter to dokładnie to, czego potrzeba dla innych rodzajów algorytmów sortowania. Pomimo tego nie ma sposobu, aby skorzystać z metod outOfOrder i swap we wspomnianych innych algorytmach sortowania. Decydując się na dziedziczenie po klasie BubbleSorter, musimy liczyć się z tym, że klasa IntBubbleSorter będzie na stale związana z klasą BubbleSorter. Innym rozwiązaniem jest zastosowanie wzorca projektowego Strategia.
Strategia Wzorzec projektowy Strategia (ang. Strategy) rozwiązuje problem odwracania zależności uniwersalnego algorytmu i szczegółowej implementacji w zupełnie inny sposób. Rozważmy ponownie przykład nadużycia wzorca projektowego przez klasę Application. Zamiast umieszczać uniwersalny algorytm w abstrakcyjnej klasie bazowej, umieścimy go w konkretnej klasie o nazwie ApplicationRunner. W interfejsie o nazwie Application zdefiniowaliśmy metody abstrakcyjne, które musi wywołać uniwersalny algorytm. Następnie stworzyliśmy klasę ftocStrategy implementującą interfejs Application i przekazaliśmy ją do obiektu ApplicationRunner. Obiekt ApplicationRunner deleguje wywołania do interfejsu ftocStrategy (patrz rysunek 14.2 oraz listingi od 14.8 do 14.10).
Rysunek 14.2. Zastosowanie wzorca Strategia do algorytmu Application
182
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
Listing 14.8. ApplicationRunner.java public class ApplicationRunner { private Application itsApplication = null;
}
public ApplicationRunner(Application app) { itsApplication = app; } public void run() { itsApplication.init(); while (!itsApplication.done()) itsApplication.idle(); itsApplication.cleanup(); }
Listing 14.9. Application.java public interface Application { public void init(); public void idle(); public void cleanup(); public boolean done(); }
Listing 14.10. ftocStrategy.java import java.io.*; public class ftocStrategy implements Application { private InputStreamReader isr; private BufferedReader br; private boolean isDone = false; public static void main(String[] args) throws Exception { (new ApplicationRunner(new ftocStrategy())).run(); } public void init() { isr = new InputStreamReader(System.in); br = new BufferedReader(isr); } public void idle() { String fahrString = readLineAndReturnNullIfError(); if (fahrString == null || fahrString.length() == 0) isDone = true; else { double fahr = Double.parseDouble(fahrString); double celcius = 5.0/9.0*(fahr-32); System.out.println("F=" + fahr + ", C=" + celcius); } } public void cleanup() { System.out.println("Program ftoc zakończył pracę!"); }
STRATEGIA
183
public boolean done() { return isDone; }
}
private String readLineAndReturnNullIfError() { String s; try { s = br.readLine(); } catch(IOException e) { s = null; } return s; }
Powyższa struktura w zestawieniu z wzorcem Metoda szablonowa ma zarówno zalety, jak i wady. Wzorzec Strategia wymaga więcej klas oraz pośrednich konstrukcji niż wzorzec Metoda szablonowa. Wskaźnik na delegację w obiekcie ApplicationRunner jest nieco bardziej kosztowny pod względem czasu działania i miejsca na dane w porównaniu z dziedziczeniem. Z drugiej strony, jeśli mieliśmy uruchomić wiele różnych aplikacji, moglibyśmy wielokrotnie wykorzystać egzemplarz obiektu ApplicationRunner, a tym samym zminimalizować sprzężenia pomiędzy uniwersalnym algorytmem a zarządzanymi przez niego szczegółami. Żadne z tych kosztów i korzyści nie mają znaczenia decydującego. W większości przypadków te korzyści lub wady są pomijalnie małe. W typowym przypadku najbardziej niepokojącą wadą jest potrzeba dodatkowej klasy w przypadku wzorca projektowego Strategia. Jest jednak więcej cech, które należy wziąć pod uwagę.
Sortowanie jeszcze raz Rozważmy implementację algorytmu sortowania bąbelkowego z wykorzystaniem wzorca projektowego Strategia (patrz listingi od 14.11 do 14.13). Listing 14.11. BubbleSorter.java public class BubbleSorter { private int operations = 0; private int length = 0; private SortHandle itsSortHandle = null; public BubbleSorter(SortHandle handle) { itsSortHandle = handle; } public int sort(Object array) { itsSortHandle.setArray(array); length = itsSortHandle.length(); operations = 0; if (length <= 1) return operations; for (int nextToLast = length-2; nextToLast >= 0; nextToLast--) for (int index = 0; index <= nextToLast; index++) {
184
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
}
}
}
if (itsSortHandle.outOfOrder(index)) itsSortHandle.swap(index); operations++;
return operations;
Listing 14.12. SortHandle.java public interface SortHandle { public void swap(int index); public boolean outOfOrder(int index); public int length(); public void setArray(Object array); }
Listing 14.13. IntSortHandle.java public class IntSortHandle implements SortHandle { private int[] array = null; public void swap(int index) { int temp = array[index]; array[index] = array[index+1]; array[index+1] = temp; } public void setArray(Object array) { this.array = (int[])array; } public int length() { return array.length; }
}
public boolean outOfOrder(int index) { return (array[index] > array[index+1]); }
Zwróćmy uwagę, że klasa IntSortHandle nic „nie wie” o klasie BubbleSorter. W żaden sposób nie zależy od implementacji algorytmu sortowania bąbelkowego. Inaczej sprawa wygląda w przypadku wzorca projektowego Metoda szablonowa. Jeśli jeszcze raz spojrzymy na listing 14.6, zauważymy, że klasa IntBubbleSorter zależała bezpośrednio od klasy BubbleSorter — tej, która zawiera algorytm sortowania bąbelkowego. Sposób z wykorzystaniem wzorca Metoda szablonowa częściowo narusza zasadę odwracania zależności, ponieważ metody swap i outOfOrder zależą bezpośrednio od algorytmu sortowania bąbelkowego. Sposób bazujący na wzorcu Strategia nie jest obarczony taką zależnością. A zatem możemy skorzystać z interfejsu IntSortHandle z implementacją klasy Sorter inną niż BubbleSorter. Na przykład możemy stworzyć odmianę algorytmu sortowania bąbelkowego, która kończy działanie w przypadku, gdy okaże się, że kolejność elementów w przetwarzanej tablicy jest prawidłowa (patrz listing 14.14). Z interfejsu IntSortHandle może również skorzystać klasa QuickBubbleSorter oraz dowolna inna klasa pochodna klasy SortHandle.
BIBLIOGRAFIA
185
Listing 14.14. QuickBubbleSorter.java public class QuickBubbleSorter { private int operations = 0; private int length = 0; private SortHandle itsSortHandle = null; public QuickBubbleSorter(SortHandle handle) { itsSortHandle = handle; } public int sort(Object array) { itsSortHandle.setArray(array); length = itsSortHandle.length(); operations = 0; if (length <= 1) return operations; boolean thisPassInOrder = false; for (int nextToLast = length-2; nextToLast >= 0 && !thisPassInOrder; nextToLast--) { thisPassInOrder = true; //potencjalnie. for (int index = 0; index <= nextToLast; index++) { if (itsSortHandle.outOfOrder(index)) { itsSortHandle.swap(index); thisPassInOrder = false; } operations++; } }
}
}
return operations;
Tak więc wzorzec projektowy Strategia daje jedną dodatkową korzyść w porównaniu z wzorcem Metoda szablonowa. O ile wzorzec Metoda szablonowa pozwala na to, aby generyczny algorytm manipulował wieloma szczegółowymi implementacjami, o tyle wzorzec Strategia dzięki całkowitej zgodności z zasadą odwracania zależności pozwala na to, aby każda ze szczegółowych implementacji była zarządzana przez wiele różnych uniwersalnych algorytmów.
Wniosek Zarówno wzorzec projektowy Metoda szablonowa, jak i wzorzec Strategia umożliwiają rozdzielenie wysokopoziomowych algorytmów od niskopoziomowych detali. Oba pozwalają na używanie wysokopoziomowych algorytmów niezależnie od szczegółów. Kosztem nieco większej złożoności, ilości pamięci i czasu wykonania wzorzec projektowy Strategia pozwala także na wielokrotne wykorzystywanie szczegółowych implementacji od wysokopoziomowego algorytmu.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19954. 2. Robert C. Martin, et al., Pattern Languages of Program Design 3, Reading, MA: Addison-Wesley, 1998. 4
Wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum
186
ROZDZIAŁ 14. METODA SZABLONOWA I STRATEGIA: DZIEDZICZENIE A DELEGACJA
R OZDZIAŁ 15
Wzorce projektowe Fasada i Mediator
Symbole wznoszą fasadę szacunku, aby ukryć nieprzyzwoitość marzeń — Mason Cooley
Dwa wzorce omówione w tym rozdziale spełniają wspólny cel. Oba nakładają jakąś strategię na inną grupę obiektów. Wzorzec Fasada (ang. Facade) nakłada strategię od góry, natomiast wzorzec Mediator (ang. Mediator) nakłada strategię od dołu. Wykorzystanie wzorca Fasada jest widoczne i ogranicza zakres funkcji, natomiast użycie wzorca Mediator jest niewidoczne i nie ogranicza zbioru oferowanych działań.
Fasada Wzorzec projektowy Fasada jest wykorzystywany w przypadku, gdy chcemy zapewnić prosty i konkretny interfejs dla grupy obiektów, które mają złożony i ogólny interfejs. Dla przykładu rozważmy klasę DB.java z listingu 26.9. Klasa ta nakłada bardzo prosty interfejs, specyficzny dla klasy ProductData, na złożone i ogólne interfejsy klas w obrębie pakietu java.sql. Strukturę pokazano na rysunku 15.1. Zwróćmy uwagę, że klasa DB chroni klasę Application przed koniecznością znajomości wewnętrznych szczegółów pakietu java.sql. Ukrywa całą ogólność i złożoność pakietu java.sql za bardzo prostym i konkretnym interfejsem. Fasada podobna do klasy DB narzuca strategię wykorzystania pakietu java.sql. Klasa ta „wie”, w jaki sposób zainicjować i zamknąć połączenie z bazą danych. „Wie” również, w jaki sposób przetłumaczyć składowe klasy ProductData na pola bazy danych i z powrotem. „Wie”, jak zbudować odpowiednie zapytania i polecenia do operowania na bazie danych. Do tego ukrywa tę całą złożoność przed użytkownikami. Z punktu widzenia klasy Application pakiet java.sql nie istnieje. Jest ukryty za Fasadą.
188
ROZDZIAŁ 15. WZORCE PROJEKTOWE FASADA I MEDIATOR
Rysunek 15.1. Klasa fasady — DB
Zastosowanie wzorca projektowego Fasada oznacza, że programiści przyjęli zasadę, według której wszystkie wywołania do bazy danych przechodzą przez klasę DB. Wywołania kierowane do pakietu java.sql bez pośrednictwa Fasady w dowolnej części kodu aplikacji są naruszeniem konwencji. A zatem Fasada narzuca aplikacji swoją politykę. Według konwencji klasa DB stała się jedynym sprzedawcą usług pakietu java.sql.
Mediator Wzorzec projektowy Mediator także narzuca swoją politykę. Jednak o ile wzorzec Fasada nakłada swoją politykę w sposób widoczny i ograniczający funkcje, o tyle wzorzec Mediator robi to w sposób ukryty, bez ograniczania zbioru funkcji. Na przykład QuickEntryMediator z listingu 15.1 to klasa, która jest ukryta „za kulisami” i wiąże pole tekstowe z listą. Kiedy użytkownik wpisuje tekst w polu tekstowym, to pierwszy element, który odpowiada wpisanej wartości, jest podświetlany. Dzięki temu wystarczy wpisać skrót, aby wybrać z listy właściwą pozycję. Listing 15.1. QuickEntryMediator.java package utility; import javax.swing.*; import javax.swing.event.*;
/** QuickEntryMediator. Klasa otrzymuje na wejściu obiekty JTextField i JList. Zakłada, że użytkownik wpisuje do pola JTextField znaki, które są prefiksami elementów listy JList. Klasa automatycznie zaznacza na liście JList pierwszy element pasujący do bieżącego prefiksu w polu JTextField. Jeśli obiekt JTextField ma wartość null albo prefiks nie pasuje do żadnego elementu na liście JList, to zaznaczenie na liście JList jest zerowane. Ten obiekt nie pozwala na wywoływanie metod. Wystarczy stworzyć obiekt i można o nim zapomnieć. (Ale nie wolno dopuścić, by został zniszczony przez mechanizm „odśmiecania” ...)
MEDIATOR
189
Przykład: JTextField t = new JTextField(); JList l = new JList(); QuickEntryMediator qem = new QuickEntryMediator(t, l); // to by było na tyle. @author Robert C. Martin, Robert S. Koss @data 30 czerwca 1999 2113 (SLAC) */ public class QuickEntryMediator { public QuickEntryMediator(JTextField t, JList l) { itsTextField = t; itsList = l; itsTextField.getDocument().addDocumentListener( new DocumentListener() { public void changedUpdate(DocumentEvent e) { textFieldChanged(); } public void insertUpdate(DocumentEvent e) { textFieldChanged(); } public void removeUpdate(DocumentEvent e) { textFieldChanged(); }
} // new DocumentListener ); // addDocumentListener
} // QuickEntryMediator()
private void textFieldChanged() { String prefix = itsTextField.getText(); if (prefix.length() == 0) { itsList.clearSelection(); return; }
}
ListModel m = itsList.getModel(); boolean found = false; for (int i = 0; found == false && i < m.getSize(); i++) { Object o = m.getElementAt(i); String s = o.toString(); if (s.startsWith(prefix)) { itsList.setSelectedValue(o, true); found = true; }
if (!found) { itsList.clearSelection(); }
} // textFieldChanged private JTextField itsTextField; private JList itsList;
} // class QuickEntryMediator
Strukturę klasy QuickEntryMediator pokazano na rysunku 15.2. Stworzono egzemplarz klasy QuickEntryMediator z obiektami JList i JTextField. Obiekt QuickEntryMediator rejestruje dla obiektu JTextField anonimowy obiekt nasłuchujący DocumentListener. Ten obiekt wywołuje metodę textFieldChanged za każdym razem, gdy w tekście zostanie wprowadzona modyfikacja. Następnie metoda znajduje na liście JList element, którego prefiks jest zgodny z wpisanym tekstem, i go zaznacza.
190
ROZDZIAŁ 15. WZORCE PROJEKTOWE FASADA I MEDIATOR
Rysunek 15.2. Klasa QuickEntryMediator
Użytkownicy klas JList i JTextField nie mają pojęcia o istnieniu obiektu klasy Mediatora. Obiekt działa w ukryciu, nakładając swoją politykę na te obiekty bez ich zezwolenia i wiedzy.
Wniosek Nakładanie polityki, jeśli jest ona rozbudowana i ma być widoczna, można zrealizować od góry za pomocą wzorca Fasada. Z drugiej strony, jeśli wymagana jest dyskrecja i większa subtelność, to wybór wzorca projektowego Mediator może być bardziej odpowiedni. Fasady są zazwyczaj stosowane zgodnie z ustaloną konwencją. Wszyscy zgadzają się na używanie Fasady zamiast obiektów znajdujących się za nią. Z drugiej strony, wzorzec Mediator jest ukryty przed użytkownikami. Jego polityka jest raczej faktem dokonanym niż kwestią konwencji.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19951.
1
Wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku. Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum.
R OZDZIAŁ 16
Wzorce projektowe Singleton i Monostate
Nieskończone błogosławieństwo istnienia! Jest i nie ma nic oprócz Niego — Edwin A. Abbott, Flatland
Pomiędzy klasami a ich egzemplarzami zazwyczaj zachodzi relacja jeden do wielu. Większość klas pozwala na tworzenie wielu egzemplarzy. Egzemplarze są tworzone, kiedy są potrzebne, i niszczone, kiedy przestajemy ich potrzebować. Pojawiają się i znikają w strumieniu operacji rezerwowania i zwalniania pamięci. Istnieją jednak klasy, które powinny mieć tylko jeden egzemplarz. Ten egzemplarz powinien być powoływany do istnienia w momencie uruchomienia programu i powinien zostać zniszczony wraz z końcem działania aplikacji. Takie obiekty czasami stanowią rdzeń aplikacji. Za pośrednictwem tego rdzenia można uzyskać dostęp do wielu innych obiektów w systemie. Czasami są to fabryki używane do tworzenia innych obiektów w systemie. Czasami są to menedżery odpowiedzialne za zarządzanie innymi obiektami i ich funkcjonowanie. Niezależnie od tego, czym są takie obiekty, stworzenie ich w większej liczbie egzemplarzy niż jeden jest poważnym błędem logicznym. Jeśli utworzymy więcej niż jeden rdzeń aplikacji, to dostęp do obiektów w aplikacji może zależeć od wybranego rdzenia. Nie wiedząc o tym, że istnieje więcej niż jeden rdzeń, programiści mogą nieświadomie uzyskać dostęp tylko do podzbioru obiektów aplikacji. Jeśli istnieje więcej niż jedna fabryka, kontrola tworzonych obiektów może być utrudniona. Jeśli istnieje więcej niż jeden menedżer, to działania, które powinny następować po sobie kolejno, mogą być wykonywane równolegle. Może się wydawać, że tworzenie mechanizmów, które wymuszają istnienie takich obiektów w liczbie pojedynczej, jest nadużyciem. W końcu już po zainicjowaniu aplikacji można samodzielnie zadbać, aby utworzyć tylko jeden taki obiekt. W rzeczywistości takie rozwiązanie często jest najlepsze. Należy
192
ROZDZIAŁ 16. WZORCE PROJEKTOWE SINGLETON I MONOSTATE
unikać stosowania dodatkowych mechanizmów, jeśli nie są one potrzebne w danej chwili. Powinniśmy jednak dbać o to, aby kod komunikował nasze intencje. Jeśli mechanizm egzekwowania liczby pojedynczej jest prosty, korzyści komunikacyjne mogą przewyższyć koszty stworzenia tego mechanizmu. W tym rozdziale opisano dwa wzorce projektowe wymuszające występowanie obiektów w liczbie pojedynczej. Wzorce te różnią się pomiędzy sobą relacją kosztów do korzyści. W wielu sytuacjach ich koszt jest na tyle niski, że równoważy korzyści wynikające z większej ekspresji kodu.
Singleton1 Singleton to bardzo prosty wzorzec projektowy. Przypadek testowy z listingu 16.1 pokazuje, jak powinien on działać. Pierwsza funkcja testowa pokazuje, że do egzemplarza obiektu Singleton istnieje dostęp za pośrednictwem publicznej, statycznej metody Instance. Pokazuje również, że metoda Instance jest wywoływana wiele razy i za każdym razem zwraca referencję do dokładnie tego samego egzemplarza. Druga funkcja testowa pokazuje, że klasa Singleton nie ma publicznego konstruktora, zatem nikt nie może utworzyć egzemplarza obiektu Singleton bez użycia metody Instance. Listing 16.1. Przypadek testowy wzorca projektowego Singleton import junit.framework.*; import java.lang.reflect.Constructor; public class TestSimpleSingleton extends TestCase { public TestSimpleSingleton(String name) { super(name); } public void testCreateSingleton() { Singleton s = Singleton.Instance(); Singleton s2 = Singleton.Instance(); assertSame(s, s2); }
}
public void testNoPublicConstructors() throws Exception { Class singleton = Class.forName("Singleton"); Constructor[] constructors = singleton.getConstructors(); assertEquals("public constructors.", 0, constructors.length); }
Ten przypadek testowy jest specyfikacją dla wzorca projektowego Singleton. Zaprezentowany test prowadzi bezpośrednio do implementacji pokazanej na listingu 16.2. Z analizy tego kodu powinno być jasne, że nigdy nie może istnieć więcej niż jeden egzemplarz klasy Singleton wewnątrz zasięgu statycznej zmiennej Singleton.theInstance. Listing 16.2. Implementacja wzorca projektowego Singleton public class Singleton { private static Singleton theInstance = null; private Singleton() {} public static Singleton Instance()
1
[GOF 95], str. 127.
SINGLETON
{
}
}
193
if (theInstance == null) theInstance = new Singleton(); return theInstance;
Korzyści ze stosowania wzorca Singleton Przenośność pomiędzy wieloma platformami. Dzięki zastosowaniu odpowiedniego oprogramo-
wania middleware (np. RMI) wzorzec projektowy Singleton może być rozszerzony do pracy na wielu maszynach wirtualnych Javy (JVM) i wielu komputerach. Możliwość stosowania do dowolnej klasy. We wzorcu Singleton można przekształcić każdą klasę. Wystarczy zadeklarować konstruktory jako prywatne oraz dodać właściwe funkcje statyczne i zmienną. Możliwość tworzenia przez dziedziczenie. Na podstawie określonej klasy można stworzyć podklasę, która będzie singletonem. Leniwe konstruowanie. Jeśli Singleton nie zostanie użyty, nigdy nie będzie stworzony.
Koszty stosowania wzorca Singleton Niezdefiniowana destrukcja. Nie istnieje dobry sposób niszczenia lub „zwalniania” Singletonu.
Jeśli dodamy metodę decommision, która przypisuje wartość null do egzemplarza theInstance, to inne moduły w systemie nadal mogą zachować referencje do egzemplarza Singletonu. Kolejne wywołania metody Instance spowodują stworzenie innego egzemplarza. W efekcie równolegle będą istniały dwa egzemplarze. Problem ten jest szczególnie dotkliwy w C++, gdzie egzemplarz może być zniszczony. To prowadzi do ewentualnego odwoływania się do zniszczonego obiektu. Brak możliwości dziedziczenia. Klasa odziedziczona z Singletonu nie jest singletonem. Jeśli powinna być Singletonem, należy dodać do niej statyczną funkcję i zmienną. Wydajność. Każde wywołanie metody Instance wiąże się z wywołaniem instrukcji if. W przypadku większości tych wywołań instrukcja if jest bezużyteczna. Nieprzezroczystość. Użytkownicy Singletonu wiedzą, że używają go, ponieważ muszą wywoływać metodę Instance.
Wzorzec projektowy Singleton w praktyce Załóżmy, że mamy system webowy, który pozwala użytkownikom na logowanie się w zabezpieczonych obszarach serwera WWW. Taki system zawiera bazę danych z nazwami użytkowników, hasłami i innymi atrybutami użytkownika. Załóżmy dodatkowo, że dostęp do bazy danych jest realizowany za pomocą zewnętrznego API. Moglibyśmy uzyskać dostęp do bazy danych bezpośrednio w każdym module, który potrzebuje zapisywania i odczytu danych użytkownika. To jednak spowodowałoby rozproszenie wywołań zewnętrznego API po całym kodzie i nie pozwoliłoby na egzekwowanie konwencji dotyczących dostępu lub struktury. Lepszym rozwiązaniem jest zastosowanie wzorca projektowego Fasada i utworzenie klasy UserDatabase dostarczającej metod czytania i zapisywania obiektów User. Te metody uzyskują dostęp do zewnętrznego API bazy danych i tłumaczą obiekty User na tabele i wiersze bazy danych. W obrębie klasy UserDatabase możemy egzekwować konwencje dotyczące struktury i dostępu. Na przykład możemy zagwarantować, że żaden obiekt User nie zostanie zapisany, jeśli jego pole username będzie puste. Możemy także zapewnić sekwencyjny dostęp do rekordu User. W ten sposób można uzyskać pewność, że dwa moduły nie będą mogły jednocześnie czytać i zapisywać rekordu. Rozwiązanie z wykorzystaniem wzorca Singleton pokazano w kodzie na listingach 16.3 i 16.4. Klasa Singletonu nosi nazwę UserDatabaseSource. Klasa implementuje interfejs UserDatabase. Zwróćmy uwagę, że statyczna metoda instance() nie zawiera tradycyjnej instrukcji if, która zabezpieczałaby przed tworzeniem wielu egzemplarzy. Zamiast tego wykorzystano mechanizm inicjalizacji Javy.
194
ROZDZIAŁ 16. WZORCE PROJEKTOWE SINGLETON I MONOSTATE
Listing 16.3. Interfejs UserDatabase public interface UserDatabase { User readUser(String userName); void writeUser(User user); }
Listing 16.4. Singleton — klasa UserDatabaseSource public class UserDatabaseSource implements UserDatabase { private static UserDatabase theInstance = new UserDatabaseSource(); public static UserDatabase instance() { return theInstance; } private UserDatabaseSource() { } public User readUser(String userName) {
// Jakaś implementacja.
}
return null; // aby kod się skompilował.
public void writeUser(User user) { }
}
// Jakaś implementacja.
Pokazany przykład prezentuje bardzo popularne zastosowanie wzorca projektowego Singleton. Daje ono pewność, że dostęp do bazy danych będzie realizowany za pomocą pojedynczego egzemplarza klasy UserDatabaseSource. Dzięki temu można łatwo wprowadzić testy, liczniki i blokady w klasie UserData baseSource, które będą egzekwowały wspomniane wcześniej konwencje dotyczące dostępu i struktury.
Monostate2 Wzorzec projektowy Monostate to kolejny sposób na zapewnienie występowania obiektu w liczbie pojedynczej. Wzorzec ten działa z wykorzystaniem całkowicie innego mechanizmu. Ze sposobem działania tego mechanizmu można się zapoznać, studiując przypadek testowy wzorca Monostate zamieszczony na listingu 16.5. Listing 16.5. Przypadek testowy wzorca projektowego Monostate import junit.framework.*; public class TestMonostate extends TestCase { public TestMonostate(String name) { super(name); } public void testInstance() 2
[BALL 2000].
MONOSTATE
{
}
195
Monostate m = new Monostate(); for (int x = 0; x<10; x++) { m.setX(x); assertEquals(x, m.getX()); }
public void testInstancesBehaveAsOne() { Monostate m1 = new Monostate(); Monostate m2 = new Monostate();
}
}
for (int x = 0; x<10; x++) { m1.setX(x); assertEquals(x, m2.getX()); }
Pierwsza funkcja testowa opisuje obiekt, którego zmienną x można ustawić i odczytać. Natomiast drugi przypadek testowy pokazuje, że dwa egzemplarze tej samej klasy zachowują się tak, jakby to była jedna klasa. Jeśli ustawimy zmienną x w jednym egzemplarzu na określoną wartość, możemy odczytać tę wartość poprzez pobranie zmiennej x innego egzemplarza. Działa to tak, jakby dwa egzemplarze były w rzeczywistości różnymi nazwami tego samego obiektu. Gdybyśmy podłączyli klasę Singleton do tego przypadku testowego i zastąpili wszystkie instrukcje new Monostate wywołaniami metody Singleton.Instance, to ten test nadal powinien przechodzić. Zatem pokazany test opisuje zachowanie klasy Singleton bez narzucania ograniczenia pojedynczego egzemplarza! Jak to jest możliwe, aby dwa egzemplarze zachowywały się tak, jakby był to pojedynczy obiekt? To proste — oznacza to, że te dwa obiekty współdzielą te same zmienne. Można to łatwo osiągnąć poprzez zadeklarowanie wszystkich zmiennych jako statycznych. Na listingu 16.6 pokazano implementację wzorca projektowego Monostate, która przechodzi zamieszczony powyżej przypadek testowy. Zwróćmy uwagę, że zmienna itsX tego obiektu jest statyczna. Zwróćmy także uwagę na to, że żadna z metod nie jest statyczna. To bardzo ważne, o czym przekonasz się później. Listing 16.6. Implementacja wzorca Monostate public class Monostate { private static int itsX = 0; public Monostate() {} public void setX(int x) { itsX = x; }
}
public int getX() { return itsX; }
Uważam ten wzorzec za „uroczo zakręcony”. Niezależnie od tego, ile stworzymy egzemplarzy klasy Monostate, wszystkie one zachowują się tak, jakby był to jeden obiekt. Można nawet zniszczyć wszystkie aktualne egzemplarze klasy Monostate bez obawy o utratę danych.
Zwróćmy uwagę na fakt, że różnica pomiędzy dwoma pokazanymi wzorcami polega na tym, że jeden dotyczy zachowania, natomiast drugi — struktury. Wzorzec projektowy Singleton wymusza stosowanie struktury, która zapewnia występowanie obiektu w liczbie pojedynczej. Wzorzec nie dopuszcza
196
ROZDZIAŁ 16. WZORCE PROJEKTOWE SINGLETON I MONOSTATE
możliwości tworzenia więcej niż jednego egzemplarza obiektu. Z kolei wzorzec projektowy Monostate egzekwuje zachowanie bez nakładania strukturalnych ograniczeń. O wymienionych różnicach świadczy to, że klasa testowa Singleton przechodzi przypadek testowy dla wzorca projektowego Monostate, ale klasa Monostate nie przechodzi przypadku testowego dla wzorca Singleton.
Korzyści ze stosowania wzorca Monostate Przezroczystość. Klasy klienckie wzorca Monostate nie zachowują się inaczej niż klasy klienckie
standardowych obiektów. Nie muszą wiedzieć, że obiekt implementuje wzorzec Monostate. Możliwość dziedziczenia. Klasy pochodne klas implementujących wzorzec Monostate same także
implementują ten wzorzec. W istocie wszystkie pochodne klasy implementującej wzorzec Monostate są częścią tej samej konstrukcji. Wszystkie one korzystają z tych samych zmiennych statycznych. Polimorfizm. Ponieważ metody klas implementujących wzorzec Monostate nie są statyczne, mogą być przesłonięte w klasach pochodnych. W związku z tym różne pochodne mogą oferować różne zachowanie, wykorzystując ten sam zbiór zmiennych statycznych. Dobrze zdefiniowane operacje tworzenia i destrukcji. Zmienne klas implementujących wzorzec Monostate są statyczne, zatem czas tworzenia i destrukcji jest dla nich ściśle określony.
Koszty stosowania wzorca Monostate Brak możliwości konwersji. Nie można przekształcić zwykłej klasy na klasę zgodną ze wzorcem
projektowym Monostate poprzez dziedziczenie. Wydajność. Obiekt klasy zgodnej ze wzorcem Monostate może być wielokrotnie tworzony i nisz-
czony, ponieważ jest to zwykły obiekt. Tego rodzaju operacje zwykle są kosztowne. Zajmowana przestrzeń. Zmienne klas implementujących wzorzec Monostate zajmują miejsce nawet
wtedy, gdy obiekty tej klasy nigdy nie są wykorzystywane. Brak przenośności. Jeden obiekt klasy implementującej wzorzec Monostate nie może działać na
wielu egzemplarzach maszyny JVM lub na przestrzeni wielu platform.
Wzorzec projektowy Monostate w praktyce Rozważmy implementację prostej, skończonej maszyny stanów dla kołowrotu przy wejściu do metra. Projekt tej aplikacji zaprezentowano na rysunku 16.1. Początkowo kołowrót jest w stanie Locked. Po wrzuceniu monety następuje przejście do stanu Unlocked, odblokowanie bramki, wyzerowanie wszystkich ewentualnych stanów alarmowych i przekazanie monety do pojemnika. Jeśli w tym momencie użytkownik przejdzie przez bramkę, kołowrót przechodzi z powrotem do stanu Locked i blokuje bramkę.
Rysunek 16.1. Skończona maszyna stanów kołowrotu przy wejściu do metra
Istnieją dwie nadzwyczajne sytuacje. Jeżeli użytkownik wrzuci dwie lub więcej monet przed przejściem przez bramkę, monety zostaną zwrócone, a bramka pozostanie odblokowana. Jeżeli użytkownik przejdzie przez bramkę bez zapłaty, spowoduje to zainicjowanie alarmu dźwiękowego, a bramka pozostanie zablokowana.
MONOSTATE
197
Program testowy, który opisuje takie działanie, pokazano na listingu 16.7. Zwróćmy uwagę, że w metodach testowych założono, że klasa Turnstile implementuje wzorzec Monostate. Klasa oczekuje możliwości generowania zdarzeń i odpowiadania na zapytania od różnych egzemplarzy. To ma sens w przypadku, gdy nigdy nie będzie istniał więcej niż jeden egzemplarz klasy Turnstile. Listing 16.7. TestTurnstile import junit.framework.*; public class TestTurnstile extends TestCase { public TestTurnstile(String name) { super(name); } public void setUp() { Turnstile t = new Turnstile(); t.reset(); } public void testInit() { Turnstile t = new Turnstile(); assert(t.locked()); assert(!t.alarm()); } public void testCoin() { Turnstile t = new Turnstile(); t.coin(); Turnstile t1 = new Turnstile(); assert(!t1.locked()); assert(!t1.alarm()); assertEquals(1, t1.coins()); } public void testCoinAndPass() { Turnstile t = new Turnstile(); t.coin(); t.pass(); Turnstile t1 = new Turnstile(); assert(t1.locked()); assert(!t1.alarm()); assertEquals("coins", 1, t1.coins());
} public void testTwoCoins() { Turnstile t = new Turnstile(); t.coin(); t.coin();
}
Turnstile t1 = new Turnstile(); assert("unlocked", !t1.locked()); assertEquals("coins",1, t1.coins()); assertEquals("refunds", 1, t1.refunds()); assert(!t1.alarm());
public void testPass() { Turnstile t = new Turnstile(); t.pass();
198
ROZDZIAŁ 16. WZORCE PROJEKTOWE SINGLETON I MONOSTATE
}
Turnstile t1 = new Turnstile(); assert("alarm", t1.alarm()); assert("locked", t1.locked());
public void testCancelAlarm() { Turnstile t = new Turnstile(); t.pass(); t.coin(); Turnstile t1 = new Turnstile(); assert("alarm", !t1.alarm()); assert("locked", !t1.locked()); assertEquals("coin", 1, t1.coins()); assertEquals("refund", 0, t1.refunds()); }
}
public void testTwoOperations() { Turnstile t = new Turnstile(); t.coin(); t.pass(); t.coin(); assert("unlocked", !t.locked()); assertEquals("coins", 2, t.coins()); t.pass(); assert("locked", t.locked()); }
Implementację klasy Turnstile implementującej wzorzec Monostate pokazano na listingu 16.8. Klasa bazowa Turnstile deleguje dwie funkcje zdarzeń (coin i pass) do dwóch pochodnych klasy Turnstile (Locked i Unlocked) reprezentujących stany skończonej maszyny stanów. Listing 16.8. Turnstile public class Turnstile { private static boolean isLocked = true; private static boolean isAlarming = false; private static int itsCoins = 0; private static int itsRefunds = 0; protected final static Turnstile LOCKED = new Locked(); protected final static Turnstile UNLOCKED = new Unlocked(); protected static Turnstile itsState = LOCKED; public void reset() { lock(true); alarm(false); itsCoins = 0; itsRefunds = 0; itsState = LOCKED; } public boolean locked() { return isLocked; } public boolean alarm() { return isAlarming; } {
public void coin()
MONOSTATE
}
itsState.coin();
public void pass() { itsState.pass(); } protected void lock(boolean shouldLock) { isLocked = shouldLock; } protected void alarm(boolean shouldAlarm) { isAlarming = shouldAlarm; } { }
public int coins() return itsCoins;
public int refunds() { return itsRefunds; } public void deposit() { itsCoins++; }
}
public void refund() { itsRefunds++; }
class Locked extends Turnstile { public void coin() { itsState = UNLOCKED; lock(false); alarm(false); deposit(); } public void pass() { alarm(true); }
} class Unlocked extends Turnstile { public void coin() { refund(); }
}
public void pass() { lock(true); itsState = LOCKED; }
199
200
ROZDZIAŁ 16. WZORCE PROJEKTOWE SINGLETON I MONOSTATE
Ten przykład pokazuje kilka przydatnych własności wzorca projektowego Monostate. Wykorzystuje możliwości stosowania polimorfizmu w klasach pochodnych klasy implementującej wzorzec Monostate oraz fakt, że pochodne klasy zgodnej ze wzorcem Monostate same również są zgodne z tym wzorcem. Ten przykład pokazuje również, jak trudne może być czasami przekształcenie klasy zgodnej ze wzorcem Monostate w zwykłą klasę. Struktura tego rozwiązania w dużym stopniu zależy od charakteru Monostate klasy Turnstile. Gdyby trzeba było zarządzać więcej niż jednym kołowrotem za pomocą tej skończonej maszyny stanów, w kodzie trzeba by było przeprowadzić znaczącą refaktoryzację. Być może zastanawiasz się nad niekonwencjonalnym wykorzystaniem dziedziczenia w tym przykładzie. To, że klasy Unlocked i Locked są pochodnymi klasy Turnstile, wydaje się być naruszeniem standardowych zasad projektowania obiektowego. Ponieważ jednak klasa Turnstile jest zgodna ze wzorcem Monostate, nie istnieją jej odrębne egzemplarze. Tak więc Unlocked i Locked nie są w rzeczywistości osobnymi obiektami. Zamiast tego są one częścią abstrakcji Turnstile. Obiekty Unlocked i Locked mają dostęp do tych samych zmiennych i metod, do których ma dostęp obiekt Turnstile.
Wniosek Często konieczne jest wyegzekwowanie ograniczenia, zgodnie z którym określony obiekt ma tylko jeden egzemplarz. W niniejszym rozdziale zaprezentowano dwie różne techniki pozwalające na spełnienie tego warunku. Wzorzec projektowy Singleton wykorzystuje prywatne konstruktory, zmienne statyczne i funkcję statyczną do kontroli i ograniczania tworzenia egzemplarzy. Wzorzec Monostate sprawia, że wszystkie zmienne obiektu są statyczne. Wzorzec Singleton najlepiej stosuje się w przypadku, gdy mamy klasę, którą chcemy ograniczyć poprzez dziedziczenie, a nie mamy nic przeciwko temu, że każdy, aby uzyskać dostęp, będzie zmuszony do wywołania metody instance(). Wzorzec projektowy Monostate najlepiej stosować w przypadku, gdy chcemy, aby fakt występowania obiektu w liczbie pojedynczej był dla użytkowników przezroczysty, lub gdy chcemy zastosować polimorficzne pochodne jednego obiektu.
Bibliografia 1. Gamma, et al., Design Patterns, Reading, MA: Addison-Wesley, 19953. 2. Robert C. Martin, et al., Pattern Languages of Program Design 3, Reading, MA: Addison-Wesley, 1998. 3. Steve Ball i John Crawford, Monostate Classes: The Power of One. Opublikowano w magazynie „More C++ Gems”, zredagowane przez Roberta C. Martina. Cambridge, Wielka Brytania: Cambridge University Press, 2000, str. 223.
3
Wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, Wydawnictwa Naukowo-Techniczne, 2005 — przyp. tłum.
R OZDZIAŁ 17
Wzorzec projektowy Obiekt Null
Błędnie bezbłędny, lodowato regularny, wspaniale pusty, śmiertelnie perfekcyjny, nic więcej — Alfred Tennyson (1809 – 1892)
Przeanalizujmy następujący kod: Employee e = DB.getEmployee("Bogdan"); if (e != null && e.isTimeToPay(today)) e.pay();
Zadajemy do bazy danych pytanie o obiekt Employee odpowiadający pracownikowi o imieniu Bogdan. Obiekt klasy DB zwraca null, jeśli taki obiekt nie istnieje. W przeciwnym razie zwraca żądany egzemplarz klasy Employee. Jeśli pracownik istnieje i jest czas wypłaty dla tego pracownika, to wywołujemy metodę pay. Podobny kod wszyscy pisaliśmy już wcześniej. Idiom jest popularny, ponieważ w językach programowania wywodzących się z języka C pierwszy operand operatora && jest przetwarzany w pierwszej kolejności, natomiast drugi jest przetwarzany tylko wtedy, gdy pierwszy ma wartość true. Większość programistów zapomina również o sprawdzaniu, czy operand ma wartość null. Chociaż ten idiom jest popularny, jest nieelegancki i prowokuje do popełniania błędów.
202
ROZDZIAŁ 17. WZORZEC PROJEKTOWY OBIEKT NULL
Aby złagodzić podatność na błędy, można zaimplementować metodę DB.getEmployee w taki sposób, aby zgłaszała wyjątek, zamiast zwracać wartość null. Jednak stosowanie bloków try-catch jest jeszcze mniej eleganckie od testów sprawdzających wartość null. Co więcej, zastosowanie wyjątków zmusza do deklarowania ich w klauzulach throws. To sprawia, że trudno dostosować wyjątki do istniejącej aplikacji. Opisane problemy można rozwiązać, stosując wzorzec projektowy Obiekt null (ang. Null object)1. Zastosowanie tego wzorca często eliminuje potrzebę sprawdzania wartości null, a tym samym pomaga uprościć kod. Strukturę wzorca pokazano na rysunku 17.1. Employee jest interfejsem, który ma dwie implementacje. Klasa EmployeeImplementation to standardowa implementacja tego interfejsu. Zawiera wszystkie metody i zmienne, których istnienia oczekujemy od obiektu Employee. Kiedy metoda DB.getEmployee znajdzie pracownika w bazie danych, zwróci egzemplarz obiektu EmployeeImplementation. Obiekt klasy NullEmployee zostanie zwrócony tylko wówczas, kiedy metoda DB.getEmployee nie może znaleźć pracownika.
Rysunek 17.1. Wzorzec projektowy Obiekt null
Klasa NullEmployee implementuje wszystkie metody interfejsu Employee jako brak jakichkolwiek działań. Ten „brak działań” zależy od indywidualnych metod. Na przykład można by oczekiwać, że metoda isTimeToPay będzie zawsze zwracać false, ponieważ dla obiektu NullEmployee nigdy nie ma dnia wypłaty. Stosując wzorzec Obiekt null, można zastąpić pierwotny kod kodem o następującej postaci: Employee e = DB.getEmployee("Bogdan"); if (e.isTimeToPay(today)) e.pay();
Taki kod nie jest ani podatny na błędy, ani nieelegancki. Jest bardzo spójny. Metoda DB.getEmployee zawsze zwróci egzemplarz obiektu Employee. Ten egzemplarz będzie zachowywał się właściwie niezależnie od tego, czy pracownik został znaleziony, czy nie. Oczywiście można sobie wyobrazić wiele sytuacji, w których chcielibyśmy wiedzieć, że metodzie DB.getEmployee nie udało się znaleźć pracownika. W tym celu można utworzyć w interfejsie Employee zmienną z modyfikatorem static final, która będzie przechowywała jeden egzemplarz klasy Null Employee. Na listingu 17.1 pokazano przypadek testowy dla klasy NullEmployee. W tym przypadku „Bogdan” nie istnieje w bazie danych. Zwróćmy uwagę, że w przypadku testowym oczekujemy, aby metoda isTimeToPay zwróciła false. Zwróćmy także uwagę na to, że test oczekuje, aby metoda DB.getEmployee zwracała obiekt Employee.NULL.
1
[PLOPD3], str. 5. Ten wspaniały artykuł Bobby’ego Woolfa jest pełen dowcipu, ironii i praktycznych porad.
WNIOSEK
203
Listing 17.1. TestEmployee.java (fragment) public void testNull() throws Exception { Employee e = DB.getEmployee("Bogdan"); if (e.isTimeToPay(new Date())) fail(); assertEquals(Employee.NULL, e); }
Klasę DB pokazano na listingu 17.2. Zwróćmy uwagę, że dla celów testu metoda getEmployee po prostu zwraca Employee.NULL. Listing 17.2. DB.java public class DB { public static Employee getEmployee(String name) { return Employee.NULL; } }
Interfejs Employee pokazano na listingu 17.3. Zwróćmy uwagę, że zadeklarowano w nim zmienną statyczną o nazwie NULL, która zawiera anonimową implementację interfejsu Employee. Ta anonimowa implementacja jest jedynym egzemplarzem pracownika null. Implementacja metody isTimeToPay zawsze zwraca false, natomiast metoda pay nie wykonuje żadnych działań. Listing 17.3. Employee.java import java.util.Date; public interface Employee { public boolean isTimeToPay(Date payDate); public void pay(); public static final Employee NULL = new Employee() { public boolean isTimeToPay(Date payDate) { return false; }
}
};
public void pay() { }
Dzięki zaimplementowaniu pracownika null jako anonimowej, wewnętrznej klasy zyskaliśmy pewność istnienia tylko jednego egzemplarza takiego obiektu. Klasa NullEmployee samodzielnie nie istnieje. Nikt inny nie może stworzyć egzemplarza pracownika null. To korzystne rozwiązanie, ponieważ chcemy mieć możliwość posługiwania się wyrażeniem: if (e == Employee.NULL)
Takie wyrażenie byłoby niewiarygodne, gdyby było możliwe tworzenie wielu egzemplarzy pracownika null.
204
ROZDZIAŁ 17. WZORZEC PROJEKTOWY OBIEKT NULL
Wniosek Czytelnicy korzystający z języków programowania bazujących na języku C są przyzwyczajeni do funkcji, które zwracają null bądź 0 w przypadku wystąpienia różnych błędów. Zakładamy, że w takich przypadkach należy sprawdzać wartość zwracaną przez takie funkcje. Stosowanie wzorca projektowego Obiekt null zwalnia nas z tego obowiązku. Korzystając z tego wzorca, możemy zapewnić, że funkcje zawsze zwrócą prawidłowe obiekty, nawet wtedy, gdy zakończą się niepowodzeniem. Obiekty reprezentujące to niepowodzenie nie wykonują „żadnych działań”.
Bibliografia 1. Robert Martin, Dirk Riehle i Frank Buschmann, Pattern Languages of Program Design 3, Reading, MA: Addison-Wesley, 1998.
R OZDZIAŁ 18
Studium przypadku: system płacowy. Pierwsza iteracja
Wszystko, co jest w jakikolwiek sposób piękne, jest piękne samo w sobie, i przemija samo w sobie, nie osiągając chwały — Marek Aureliusz, około 170 r. n.e.
Wprowadzenie W tym rozdziale zamieszczono studium przypadku opisujące pierwszą iterację prostego systemu płacowego. Historyjki użytkownika w tym studium przypadku są uproszczone. Na przykład całkowicie zignorowano problem naliczania podatków. To typowe podejście dla wczesnych iteracji. Ich celem jest dostarczenie systemu, który realizuje tylko niewielki podzbiór potrzeb biznesowych klienta. W tym rozdziale przeprowadzimy szybkie sesje analizy i projektowania, które często wykonuje się na początku normalnej iteracji. Klient wybrał historyjki dla iteracji, a teraz trzeba ustalić, w jaki sposób zostaną one zaimplementowane. Takie sesje projektowe są krótkie i pobieżne — tak jak niniejszy rozdział. Diagramy UML, które zamieścimy w tym rozdziale, nie są niczym więcej niż pospiesznymi szkicami na tablicy. Prawdziwą pracę projektową przeprowadzimy w następnym rozdziale, gdy będziemy pracować nad testami jednostkowymi i implementacją.
206
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Specyfikacja Poniżej zamieszczono kilka notatek, które zrobiliśmy podczas rozmów z klientem dotyczących historyjek wybranych do realizacji w pierwszej iteracji. Niektórzy pracownicy pracują na godziny. Wypłaca się im wynagrodzenie według stawki godzino-
wej, która jest ustawiana w jednym z pól w rekordzie pracownika. Pracownicy dostarczają dzienne karty pracy, w których są zarejestrowane daty oraz liczba przepracowanych godzin. Jeśli pracują więcej niż 8 godzin dziennie, za te dodatkowe godziny są opłacani według stawki wynoszącej 1,5 raza więcej od ich normalnej stawki. Wynagrodzenia są im wypłacane w każdy piątek. Niektórzy pracownicy otrzymują „płaskie” wynagrodzenie. Wypłata następuje ostatniego roboczego dnia w miesiącu. Ich miesięczne wynagrodzenie jest jednym z pól w rekordzie pracownika. Niektórzy pracownicy otrzymują prowizję na podstawie zrealizowanej przez nich sprzedaży. Dostarczają dokumenty potwierdzające sprzedaż, zawierające datę sprzedaży oraz kwotę. Stawka prowizji jest zapisana w jednym z pól rekordu pracownika. Wynagrodzenia są im wypłacane w każdy piątek. Pracownicy mają możliwość wyboru metody wypłaty. Mogą otrzymywać czeki, które są wysyłane na wskazane adresy pocztowe. Mogą odebrać czeki osobiście od płatnika lub mogą zażądać przelania pieniędzy na wskazany rachunek bankowy. Niektórzy pracownicy należą do związku zawodowego. W rekordzie pracownika istnieje pole dotyczące wysokości należnych składek. Składki są potrącane z ich uposażenia. Ponadto związek zawodowy od czasu do czasu może pobierać dodatkowe opłaty od indywidualnych członków związku. Związek zawodowy dostarcza zleceń potrąceń co tydzień. Wskazane kwoty muszą być potrącone z kolejnej wypłaty wskazanego pracownika. Aplikacja płacowa jest uruchamiana tylko raz każdego dnia roboczego i dokonuje wypłat określonej grupie pracowników. System uzyska informacje dotyczące daty wypłaty. Na tej podstawie będzie mógł obliczyć wynagrodzenia od ostatniej wypłaty zrealizowanej dla pracownika do wskazanej daty.
Pracę moglibyśmy rozpocząć od wygenerowania schematu bazy danych. Wyraźnie widać, że do rozwiązania problemu może być potrzebny jakiś rodzaj relacyjnej bazy danych, a sformułowane wymagania dają nam dobre wyobrażenie, jakie tabele i pola powinny się znaleźć w takiej bazie danych. Z łatwością można by zaprojektować schemat, a następnie rozpocząć budowę potrzebnych kwerend. Jednak takie podejście prowadziłoby do wygenerowania aplikacji, w której centralnym punktem byłaby baza danych. Bazy danych są szczegółami implementacji! Wybór bazy danych powinien być odłożony w czasie tak długo, jak to możliwe. Zbyt wiele aplikacji jest nierozerwalnie związanych ze swoimi bazami danych tylko dlatego, że od początku zaprojektowano je z myślą o bazie danych. Pamiętajmy, jaka jest definicja abstrakcji: nacisk na rzeczy zasadnicze i eliminowanie rzeczy nieistotnych. Baza danych nie ma znaczenia na tym etapie projektu. Jest to jedynie technika wykorzystywana do przechowywania i dostępu do danych — nic więcej.
Analiza według przypadków użycia Zamiast zaczynać od danych występujących w systemie, spróbujmy zacząć od przeanalizowania zachowań systemu. Ostatecznie to za implementację tych zachowań otrzymamy zapłatę. Jednym ze sposobów uchwycenia i analizy zachowań systemu jest stworzenie przypadków użycia. Przypadki użycia, po raz pierwszy opisane przez Jacobsona, są bardzo podobne do pojęcia historyjek użytkowników w programowaniu EP. Przypadek użycia jest jak historyjka użytkownika, która została sformułowana trochę bardziej szczegółowo. Takie opracowanie jest właściwe wtedy, gdy historyjka użytkownika została wybrana do zaimplementowania w bieżącej iteracji.
ANALIZA WEDŁUG PRZYPADKÓW UŻYCIA
207
Podczas wykonywania analizy przypadków użycia zaglądamy do historyjek użytkowników i testów akceptacyjnych, aby zapoznać się z bodźcami, które generują użytkownicy tego systemu. Następnie staramy się ustalić, w jaki sposób system reaguje na te bodźce. Oto przykładowy zbiór historyjek użytkownika wybranych przez klienta do wykonania w kolejnej iteracji: 1. 2. 3. 4. 5. 6. 7.
Dodawanie nowego pracownika. Usuwanie pracownika. Dostarczenie karty pracy. Dostarczenie raportu sprzedaży. Dostarczenie informacji o opłacie na rzecz związku zawodowego. Zmiana szczegółowych danych pracownika (np. stawka godzinowa, należne składki). Wygenerowanie listy płac na dzień.
Spróbujmy przekształcić każdą z wymienionych historyjek użytkowników na bardziej rozbudowany przypadek użycia. Nie trzeba wchodzić w szczegóły. Opis powinien jedynie obejmować te elementy, które ułatwią nam projektowanie kodu implementującego wybraną historyjkę.
Dodawanie pracowników Przypadek użycia nr 1: Dodawanie nowego pracownika Nowy pracownik jest dodawany w wyniku transakcji AddEmp. Transakcja zawiera nazwisko pracownika, adres oraz numer przypisany do pracownika. Transakcja może przyjąć jedną z trzech form: AddEmp "" "" H AddEmp "" "" S AddEmp "" "" C
Następuje utworzenie rekordu pracownika i przypisanie wartości odpowiednich pól. Alternatywa 1: Błąd w strukturze transakcji. Jeśli struktura transakcji jest nieodpowiednia, to jest wyświetlany komunikat o błędzie i nie są podejmowane żadne działania. Przypadek użycia nr 1 sugeruje użycie pewnej abstrakcji. Istnieją trzy formy transakcji AddEmp, ale we wszystkich trzech formach występują pola , i . Możemy skorzystać ze wzorca projektowego Polecenie do stworzenia abstrakcyjnej klasy bazowej AddEmployeeTransaction. Klasa ta będzie miała trzy pochodne. AddHourlyEmployeeTransaction, AddSalariedEmployeeTransaction oraz AddCommissionedEmployeeTransaction (patrz rysunek 18.1). Ta struktura spełnia zasadę pojedynczej odpowiedzialności (SRP) — dla każdego zadania wyznaczono oddzielną klasę. Alternatywą byłoby umieszczenie wszystkich tych zadań w jednym module. Chociaż to mogłoby zmniejszyć liczbę klas w systemie, a tym samym uprościć go, to spowodowałoby skoncentrowanie całego kodu przetwarzania transakcji w jednym miejscu. W efekcie powstałby duży moduł, potencjalnie podatny na błędy.
208
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Rysunek 18.1. Hierarchia klas AddEmployeeTransaction
Przypadek użycia nr 1 jawnie mówi o rekordzie pracownika, co wskazuje na zastosowanie jakiejś bazy danych. Nasza skłonność do baz danych może skusić nas do myślenia o układach rekordów lub strukturze pól w tabeli relacyjnej bazy danych, ale musimy oprzeć się tej pokusie. Prawdziwym sednem tego przypadku użycia jest stworzenie pracownika. Jaki jest model obiektowy pracownika? Lepiej postawione pytanie powinno brzmieć: „co powinny tworzyć wymienione trzy transakcje?”. W mojej ocenie tworzą one trzy różne rodzaje obiektów pracownika odzwierciedlające trzy formy transakcji AddEmp. Możliwą strukturę pokazano na rysunku 18.2.
Rysunek 18.2. Możliwa hierarchia klas Employee
Usuwanie pracowników Przypadek użycia nr 2: Usuwanie pracownika Pracownicy są usuwani w wyniku transakcji DelEmp. Transakcja ta ma następujący format: DelEmp
Po otrzymaniu tej transakcji następuje usunięcie odpowiedniego rekordu pracownika. Alternatywa 1: Nieprawidłowy bądź nieznany identyfikator EmpID. Jeśli pole identyfikatora ma nieprawidłową strukturę lub jeśli nie odwołuje się do prawidłowego rekordu pracownika, to transakcja powoduje wyświetlenie komunikatu o błędzie i nie jest podejmowane żadne inne działanie. Ten przypadek użycia nie daje mi żadnych spostrzeżeń projektowych w tym momencie. W związku z tym przyjrzyjmy się następnemu.
ANALIZA WEDŁUG PRZYPADKÓW UŻYCIA
209
Dostarczenie karty pracy Przypadek użycia nr 3: Dostarczenie karty pracy Po otrzymaniu transakcji TimeCard system utworzy rekord karty czasu pracy i powiąże go z rekordem właściwego pracownika. TimeCard
Alternatywa 1: Wskazany pracownik nie pracuje według stawki godzinowej. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Alternatywa 2: Błąd w strukturze transakcji. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Ten przypadek użycia wskazuje, że niektóre transakcje dotyczą tylko określonych rodzajów pracowników. To wzmacnia przekonanie o tym, że różne rodzaje pracowników powinny być reprezentowane przez różne klasy. W tym przypadku istnieje również domniemanie istnienia relacji pomiędzy kartami czasu pracy a pracownikami pracującymi w systemie godzinowym. Możliwy statyczny model takiej relacji pokazano na rysunku 18.3.
Rysunek 18.3. Relacja pomiędzy obiektami HourlyEmployee i TimeCard
Dostarczenie raportów sprzedaży Przypadek użycia nr 4: Dostarczenie raportu sprzedaży Po otrzymaniu transakcji SalesReceipt system utworzy nowy rekord raportu sprzedaży i powiąże go z rekordem właściwego pracownika. SalesReceipt
Alternatywa 1: Wskazany pracownik nie jest uprawniony do prowizji od sprzedaży. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Alternatywa 2: Błąd w strukturze transakcji. System wyświetli odpowiedni komunikat o błędzie i nie podejmie żadnych innych działań. Ten przypadek użycia jest bardzo podobny do przypadku użycia nr 3. Sugeruje strukturę pokazaną na rysunku 18.4.
210
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Rysunek 18.4. Relacja pomiędzy pracownikami wynagradzanymi według prowizji a raportami sprzedaży
Dostarczenie informacji o opłacie na rzecz związku zawodowego Przypadek użycia nr 5: Dostarczenie informacji o opłacie na rzecz związku zawodowego Po otrzymaniu transakcji ServiceCharge system utworzy rekord opłaty na rzecz związku zawodowego i powiąże go z rekordem właściwego członka związku zawodowego. ServiceCharge
Alternatywa 1: Nieprawidłowy format transakcji. Jeśli transakcja ma nieprawidłowy format lub jeśli identyfikator nie odnosi się do członka związku zawodowego, to następuje wyświetlenie odpowiedniego komunikatu o błędzie. Ten przypadek użycia pokazuje, że dostępu do danych członków związków zawodowych nie uzyskujemy za pomocą identyfikatorów pracowników. Związek zawodowy utrzymuje własny system numerów identyfikacyjnych dla związkowców. Z tego powodu system musi zapewniać możliwość wiązania członków związku zawodowego z pracownikami. Istnieje wiele różnych sposobów zapewnienia tego rodzaju relacji. Zatem aby uniknąć przypadkowości, lepiej będzie odłożyć tę decyzję na później. Być może ograniczenia z innych części systemu zmuszą nas do podjęcia konkretnej decyzji. Jedno jest pewne. Istnieje bezpośrednia relacja pomiędzy członkami związku zawodowego a opłacanymi przez nich składkami. Możliwy statyczny model takiej relacji pokazano na rysunku 18.5.
Rysunek 18.5. Członkowie związku zawodowego i opłacane składki
Zmiana danych pracownika Przypadek użycia nr 6: Zmiana danych pracownika Po otrzymaniu transakcji ChgEmp system zmodyfikuje szczegółowe dane w rekordzie wskazanego pracownika. Transakcja ma kilka możliwych odmian. ChgEmp Name ChgEmp Address ChgEmp Hourly ChgEmp Pensja
Zmiana nazwiska pracownika Zmiana adresu pracownika Zmiana wynagrodzenia przy stawce godzinowej Zmiana sposobu wynagradzania przy pensji miesięcznej
ANALIZA WEDŁUG PRZYPADKÓW UŻYCIA
ChgEmp Commissioned ChgEmp Hold ChgEmp Direct ChgEmp Mail ChgEmp Member Dues ChgEmp NoMember
211
Zmiana sposobu wynagradzania przy prowizji od sprzedaży Osobisty odbiór czeku Przelew na rachunek bankowy Przesłanie czeku pocztą Ustawia opcję członkostwa pracownika w związku zawodowym Usuwa opcję członkostwa pracownika w związku zawodowym
Alternatywa 1: Błędy transakcji. Jeśli struktura transakcji jest nieprawidłowa lub jeśli identyfikator nie odnosi się do rzeczywistego pracownika albo identyfikator odnosi się do członka związku zawodowego, to następuje wyświetlenie odpowiedniego komunikatu o błędzie i system nie podejmuje żadnych innych działań. Ten przypadek użycia daje dużo informacji o projektowanym systemie. Informuje o wszystkich aspektach dotyczących pracownika, dla których musi istnieć możliwość modyfikacji. Fakt, że można zmienić sposób wynagradzania pracownika z systemu godzinowego na pensję, oznacza, że diagram przedstawiony na rysunku 18.2 jest nieprawidłowy. Zamiast takiego schematu do obliczenia płacy lepiej byłoby zastosować wzorzec Strategia. Klasa Employee mogłaby zawierać klasę strategii PaymentClassification podobną do tej, której diagram pokazano na rysunku 18.6. Takie rozwiązanie jest korzystne, ponieważ można zmodyfikować obiekt PaymentClassification bez konieczności modyfikowania którejkolwiek części obiektu Employee. Zmiana sposobu wynagradzania pracownika z godzinowego na ze stałą pensją miesięczną wymagałaby zastąpienia obiektu HourlyClassification odpowiedniego obiektu Employee obiektem SalariedClassification. Obiekt PaymentClassification ma trzy odmiany. Obiekt HourlyClassification zawiera stawkę godzinową oraz listę obiektów TimeCard. Obiekt SalariedClassification przechowuje informacje o wysokości miesięcznej pensji. Obiekt CommissionedClassification zawiera informacje o miesięcznej pensji, stawce prowizji oraz listę obiektów SalesReceipt. W tych przypadkach zastosowałem relacje kompozycji, ponieważ uważam, że obiekty TimeCard i SalesReceipt powinny być usuwane wraz z usuwaniem pracownika. System musi zapewniać również możliwość modyfikacji sposobu wypłaty. Na rysunku 18.6 zaimplementowano tę koncepcję poprzez zastosowanie wzorca Strategia i trzech różnych klas pochodnych klasy PaymentMethod. Jeśli obiekt Employee zawiera obiekt MailMethod, to pracownikowi reprezentującemu ten obiekt czeki będą wysyłane pocztą. W obiekcie MailMethod jest zarejestrowany adres, pod który będą wysyłane czeki. Jeśli obiekt Employee zawiera obiekt DirectMethod, to wynagrodzenie pracownika będzie bezpośrednio przelewane na rachunek bankowy zapisany w obiekcie DirectMethod. Jeśli obiekt Employee zawiera obiekt HoldMethod, to czeki wypłat będą przesyłane do płatnika, od którego pracownik reprezentujący ten obiekt będzie je mógł odebrać osobiście. Na koniec na rysunku 18.6 warto zwrócić uwagę na zastosowanie wzorca projektowego Obiekt null w celu określenia przynależności do związku zawodowego. Każdy obiekt Employee zawiera obiekt Affiliation, który występuje w dwóch postaciach. Jeśli obiekt Employee zawiera obiekt NoAffiliation, to jego wynagrodzenie nie jest modyfikowane przez żadną inną instytucję poza pracodawcą. Jeśli jednak obiekt Employee zawiera obiekt UnionAffiliation, to ten pracownik musi płacić składki i opłaty zarejestrowane w obiekcie UnionAffiliation.
212
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Rysunek 18.6. Poprawiony diagram klas systemu płacowego — model podstawowy
Dzięki zastosowaniu tych wzorców system dobrze spełnia zasadę otwarte-zamknięte (OCP). Klasa Employee jest zamknięta na zmiany w sposobie płatności, klasyfikacji płatności i przynależności do związ-
ków zawodowych. Do systemu można dodać nowe metody, klasyfikacje i przynależności do organizacji bez wpływu na obiekt Employee. Diagram z rysunku 18.6 staje się modelem podstawowym, czyli architekturą systemu. Znajduje się on w centralnym punkcie wszystkiego, co robi system płacowy. W aplikacji płacowej będzie wiele innych klas i projektów, ale będą one miały wtórne znaczenie dla tej podstawowej struktury. Oczywiście ta konstrukcja nie jest „wyrzeźbiona w skale”: będzie podlegać zmianom tak jak wszystko inne.
Wypłaty Przypadek użycia nr 7: Wygenerowanie listy płac na dzień Po otrzymaniu transakcji Payday system wyszukuje wszystkich pracowników, dla których tego dnia powinna być zrealizowana wypłata. Następnie system określa, jaką kwotę powinien wypłacić każdemu z nich, i realizuje wypłatę zgodnie z wybranym sposobem wypłaty. Payday
Chociaż przeznaczenie intencji tego przypadku użycia jest zrozumiałe, to nie jest proste określenie wpływu, jaki będzie on miał na statyczną strukturę z rysunku 18.6. Trzeba sobie odpowiedzieć na kilka pytań. Po pierwsze, skąd obiekt Employee „wie”, jak obliczyć wypłatę. Jest oczywiste, że jeśli pracownik jest wynagradzany według stawki godzinowej, to system musi zliczyć czas na podstawie kart pracy, a następnie pomnożyć go przez stawkę godzinową. Jeśli pracownik jest wynagradzany w systemie prowizji,
ANALIZA WEDŁUG PRZYPADKÓW UŻYCIA
213
to system musi zliczyć jego raporty sprzedaży, pomnożyć uzyskaną wartość przez stawkę prowizji i dodać zasadnicze uposażenie. Ale gdzie te obliczenia są wykonywane? Idealnym miejscem wydają się pochodne klasy PaymentClassification. Obiekty te utrzymują informacje niezbędne do obliczenia wynagrodzenia, więc powinny zawierać metody potrzebne do obliczenia wynagrodzenia. Na rysunku 18.7 przedstawiono diagram, który opisuje, jak to mogłoby działać.
Rysunek 18.7. Obliczanie wynagrodzenia pracownika
Kiedy skierujemy żądanie obliczenia wynagrodzenia do obiektu Employee, obiekt skieruje to żądanie do obiektu PaymentClassification. Właściwy algorytm, który będzie zastosowany, zależy do typu obiektu PaymentClassification zapisanego wewnątrz obiektu Employee. Trzy możliwe scenariusze przedstawiono na rysunkach od 18.8 do 18.10.
Rysunek 18.8. Obliczanie wynagrodzenia pracownika wynagradzanego według stawki godzinowej
Rysunek 18.9. Obliczanie wynagrodzenia pracownika otrzymującego prowizję
214
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Rysunek 18.10. Obliczanie wynagrodzenia pracownika otrzymującego stałą pensję
Refleksja: czego się nauczyliśmy? Dowiedzieliśmy się, że prosta analiza przypadku użycia może dostarczyć mnóstwo informacji na temat projektu systemu. Rysunki od 18.6 do 18.10 są efektem analizy przypadków użycia — tzn. myślenia o zachowaniu systemu.
Wyszukiwanie potrzebnych abstrakcji Aby skutecznie stosować zasadę OCP, trzeba poszukać abstrakcji i znaleźć te, które tworzą podstawy aplikacji. Często te abstrakcje nie są podane, a nawet wspomniane w wymaganiach aplikacji, a nawet przypadkach użycia. Wymagania i przypadki użycia mogą być zbyt przesiąknięte szczegółami, aby wyrażać uogólnienia w postaci bazowych abstrakcji. Jakie są bazowe abstrakcje aplikacji płacowej? Przyjrzyjmy się ponownie wymaganiom. Widzimy w nich takie zdania jak: „niektórzy pracownicy pracują według stawki godzinowej”, „niektórym pracownikom jest wypłacana stała pensja” oraz „niektórzy pracownicy otrzymują prowizję”. Można na tej podstawie sformułować następujące uogólnienie: „Wszyscy pracownicy otrzymują wynagrodzenie, ale jest ono wypłacane według różnych zasad”. Abstrakcją w tym przypadku jest sformułowanie „Wszyscy pracownicy otrzymują wynagrodzenie”. Tę abstrakcję dobrze wyraża model klasy Payment Classification pokazany na rysunkach od 18.7 do 18.10. Tak więc tę abstrakcję już znaleźliśmy wśród naszych historyjek użytkowników, wykonując bardzo prostą analizę przypadków użycia.
Abstrakcja harmonogramu Poszukując innych abstrakcji, znajdujemy: „Wypłaty są realizowane w każdy piątek”, „Wypłata następuje w ostatnim dniu roboczym miesiąca” oraz „Wypłata następuje co drugi piątek”. To prowadzi do innego uogólnienia: „Wszyscy pracownicy otrzymują wynagrodzenie zgodnie z określonym harmonogramem”. Abstrakcją w tym przypadku jest pojęcie harmonogramu. Powinna istnieć możliwość zapytania obiektu Employee o to, czy określony dzień jest datą wypłaty. W przypadkach użycia jest o tym zaledwie wzmianka. Wymagania zawierają powiązanie harmonogramu pracownika z jego klasyfikacją wynagrodzenia. Zatem pracownicy wynagradzani według stawki godzinowej otrzymują wynagrodzenie co tydzień, pracownicy ze stałą pensją otrzymują wypłatę co miesiąc, natomiast pracownicy otrzymujący prowizję otrzymują wypłatę co drugi tydzień. Jednak czy takie powiązanie jest istotne? Czy polityka nie może się zmienić pewnego dnia w taki sposób, aby pracownicy mogli wybrać konkretny harmonogram, albo żeby pracownicy należący do różnych działów lub filii mogli mieć różne harmonogramy? Czy harmonogramy nie mogą się zmieniać niezależnie od sposobu wynagradzania? To na pewno wydaje się prawdopodobne. Gdybyśmy zgodnie z wymaganiami oddelegowali problem tworzenia harmonogramu do klasy PaymentClassification, to nasza klasa nie byłaby zamknięta na problemy zmian w harmonogramie. Gdybyśmy zmienili sposób wynagradzania, musielibyśmy także testować harmonogram. Gdybyśmy
WYSZUKIWANIE POTRZEBNYCH ABSTRAKCJI
215
zmienili harmonogramy, musielibyśmy także testować strategię wynagradzania. Naruszylibyśmy zarówno zasady OCP, jak i SRP. Powiązanie pomiędzy harmonogramem a sposobem wynagradzania mogłoby doprowadzić do błędów, w wyniku których zmiana w konkretnym sposobie płatności mogłaby powodować generowanie nieprawidłowego harmonogramu dla niektórych pracowników. Takie błędy mogą wydawać się sensowne dla programistów, ale mogą też zasiać strach w sercach menedżerów i użytkowników. Obawiają się oni, i słusznie, że jeśli harmonogramy mogą ulec uszkodzeniu w wyniku zmian w sposobie wynagradzania, to wszelkie zmiany dokonane w dowolnym miejscu mogą powodować problemy w dowolnej innej niezwiązanej części systemu. Obawiają się, że nie będą mogli przewidzieć skutków wprowadzonych zmian. Kiedy nie można przewidzieć efektów, tracimy zaufanie do programu, a aplikacja w oczach menedżerów i użytkowników uzyskuje status „niebezpiecznej i niestabilnej”. Pomimo istotnego charakteru abstrakcji harmonogramu nasza analiza przypadków użycia nie dała nam żadnych bezpośrednich wskazówek na temat jej istnienia. Aby dostrzec tę abstrakcję, należy dokładnie przeanalizować wymagania i zastanowić się nad oczekiwaniami społeczności użytkowników. Nadmierne poleganie na narzędziach i procedurach oraz niedocenianie wiedzy i doświadczenia to przepis na katastrofę. Na rysunkach 18.11 i 18.12 pokazano statyczne i dynamiczne modele abstrakcji harmonogramu. Jak można zauważyć, ponownie zastosowaliśmy wzorzec Strategia. Klasa Employee zawiera abstrakcyjną klasę PaymentSchedule. Istnieją trzy odmiany klasy PaymentSchedule odpowiadające trzem harmonogramom, według których pracownicy są wynagradzani.
Rysunek 18.11. Statyczny model abstrakcji Schedule
Rysunek 18.12. Dynamiczny model abstrakcji Schedule
Sposoby wypłaty Innym uogólnieniem, które możemy wywnioskować na podstawie wymagań, jest sformułowanie: „wszyscy pracownicy otrzymują wynagrodzenie określonym sposobem”. Abstrakcją jest klasa PaymentMethod. Co ciekawe, ta abstrakcja została wyrażona wcześniej na rysunku 18.6.
216
ROZDZIAŁ 18. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. PIERWSZA ITERACJA
Przynależność do związków zawodowych Z wymagań wynika, że pracownicy mogą należeć do związków zawodowych. Trzeba jednak pamiętać, że związek zawodowy może nie być jedyną instytucją, która może zgłaszać roszczenia do części wynagrodzenia pracownika. Pracownicy mogą życzyć sobie realizowania automatycznych wpłat na rzecz wskazanych organizacji charytatywnych lub opłacania składek do stowarzyszeń zawodowych. Wynika stąd następujące uogólnienie: „Pracownik może należeć do wielu organizacji. Składki na rzecz tych organizacji powinny być automatycznie opłacane z wynagrodzenia pracownika”. Odpowiednią abstrakcją jest w tym przypadku klasa Affiliation, której diagram pokazano na rysunku 18.6. Na tym rysunku nie pokazano jednak obiektu Employee, który zawiera więcej niż jeden obiekt Affiliation. Zaprezentowano też istnienie klasy NoAffiliation. Ten projekt nie całkiem pasuje do abstrakcji, której — jak się wydaje — teraz potrzebujemy. Na rysunkach 18.13 i 18.14 pokazano statyczne i dynamiczne modele reprezentujące abstrakcję Affilliation.
Rysunek 18.13. Statyczna struktura abstrakcji Affiliation
Rysunek 18.14. Dynamiczna struktura abstrakcji Affiliation
Lista obiektów Affiliation pozwoliła na wyeliminowanie potrzeby korzystania ze wzorca projektowego Obiekt null dla pracowników niezrzeszonych w związku zawodowym. Jeśli pracownik nie jest zrzeszony w żadnej organizacji, to teraz jego lista obiektów Affiliation po prostu będzie pusta.
Wniosek Na początku iteracji często się zdarza, że zespół gromadzi się przy „białej tablicy”. Członkowie zespołu wspólnie omawiają historyjki użytkownika, które zostały wybrane do tej iteracji. Taka szybka sesja projektowa zwykle trwa mniej niż godzinę. Powstałe diagramy UML mogą pozostać na tablicy bądź też mogą być z niej zmazane. Zwykle nie są utrwalane w formie papierowej. Celem sesji jest rozpoczęcie procesu myślowego i zaprezentowanie deweloperom wspólnego modelu mentalnego, na podstawie którego będą realizowane prace. Celem nie jest stworzenie kompletnego projektu. Niniejszy rozdział jest opisowym odpowiednikiem takiej szybkiej sesji projektowej.
Bibliografia 1. Ivar Jacobson, Object-Oriented Software Engineering, A Use-Case-Driven Approach, Wokingham, Wielka Brytania: Addison-Wesley, 1992.
R OZDZIAŁ 19
Studium przypadku: system płacowy. Implementacja
Minęło sporo czasu od momentu, gdy zaczęliśmy pisać kod obsługujący i weryfikujący projekty, nad którymi pracujemy. Zamierzam tworzyć ten kod w bardzo małych, przyrostowych etapach, ale będę prezentował go czytelnikom tylko w dogodnych punktach w tekście. To, że czytelnikom są prezentowane w pełni sformatowane fragmenty kodu, nie oznacza, że napisałem go w takiej formie. W rzeczywistości pomiędzy każdą partią kodu, którą zaprezentowałem, były dziesiątki edycji, kompilacji i sprawdzania testów — każda taka operacja dotyczyła niewielkiej, ewolucyjnej zmiany w kodzie. Zaprezentujemy również sporo diagramów UML. Te diagramy UML należy traktować tak jak szkice na tablicy, których używałem, by przekazać czytelnikowi — mojemu partnerowi — to, co mam na myśli. Diagramy UML są dogodnym medium komunikacji pomiędzy deweloperami. Na rysunku 19.1 pokazano, że transakcje zaprezentowaliśmy w formie abstrakcyjnej klasy bazowej o nazwie Transaction z metodą o nazwie Execute(). To jest oczywiście wzorzec projektowy Polecenie. Implementację klasy Transaction zamieszczono na listingu 19.1.
Rysunek 19.1. Interfejs Transaction
218
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Listing 19.1. Transaction.h #ifndef TRANSACTION_H #define TRANSACTION_H class Transaction { public: virtual ~Transaction(); virtual void Execute() = 0; }; #endif
Dodawanie pracowników Na rysunku 19.2 pokazano możliwą strukturę transakcji służącej do dodawania pracowników. Należy pamiętać, że w ramach tych transakcji jest wiązany harmonogram wypłat pracowników z formą wypłaty. Jest to właściwe, ponieważ transakcje są raczej sposobem realizacji, a nie częścią podstawowego modelu. Tak więc podstawowy model „nie wie” o tym powiązaniu. Jest ono tylko częścią jednej z implementacji i może ulec zmianie w dowolnym czasie. Na przykład z łatwością moglibyśmy dodać transakcję, która pozwala zmienić harmonogram wypłat pracownika.
Rysunek 19.2. Statyczny model transakcji AddEmployeeTransaction
DODAWANIE PRACOWNIKÓW
219
Zwróćmy także uwagę na to, że domyślną metodą płatności jest odbieranie czeku od płatnika. Jeśli pracownik chce wybrać inną metodę realizacji wypłaty, zmiany muszą być wykonane za pomocą odpowiedniej transakcji ChgEmp. Tak jak zwykle kod zaczynamy pisać od napisania testu. Na listingu 19.2 pokazano przypadek testowy, który pokazuje, że transakcja AddSalariedTransaction działa prawidłowo. Kod, który zamieszczono poniżej, spowoduje, że zaprezentowany przypadek testowy przejdzie. Listing 19.2. PayrollTest::TestAddSalariedEmployee void PayrollTest::TestAddSalariedEmployee() { int empId = 1; AddSalariedEmployee t(empId, "Bogdan", "Dom", 2500.00); t.Execute(); Employee* e = GpayrollDatabase.GetEmployee(empId); assert("Bob" == e->GetName()); PaymentClassification* pc = e->GetClassification(); SalariedClassification* sc = dynamic_cast(pc); assert(sc);
}
assertEquals(2500.00, sc->GetSalary(), .001); PaymentSchedule* ps = e->GetSchedule(); MonthlySchedule* ms = dynamic_cast(ps); assert(ms); PaymentMethod* pm = e->GetMethod(); HoldMethod* hm = dynamic_cast(pm); assert(hm);
Baza danych systemu płacowego Klasa AddEmployeeTransaction korzysta z klasy o nazwie PayrollDatabase. Klasa ta przechowuje wszystkie istniejące obiekty Employee w obiekcie Dictionary, w której kluczem jest identyfikator empID. Klasa zawiera także obiekt Dictionary zawierający odwzorowanie identyfikatorów memberIDs członków związków zawodowych na identyfikatory empID. Strukturę tej klasy pokazano na rysunku 19.3. PayrollDatabase jest przykładem zastosowania wzorca projektowego Fasada (rozdział 15.).
Rysunek 19.3. Statyczna struktura klasy PayrollDatabase
Uproszczoną implementację klasy PayrollDatabase zamieszczono na listingach 19.3 i 19.4. Pokazana implementacja ma ułatwić nam zrealizowanie początkowych przypadków testowych. Nie zawiera jeszcze słownika, który odwzorowuje identyfikatory członków związków zawodowych na egzemplarze obiektów Employee.
220
ROZDZIAŁ 19. STUDIUM PRZYPADKU: SYSTEM PŁACOWY. IMPLEMENTACJA
Listing 19.3. PayrollDatabase.h #ifndef PAYROLLDATABASE_H #define PAYROLLDATABASE_H #include