Spis treści Słowo wstępne Przedmowa O autorach
13 14 20
CZĘŚĆ I
21
ZWINNE WYTWARZANIE OPROGRAMOWANIA
Rozdział 1 Praktyki agile
23
Agile Alliance
24
Manifest Agile Alliance
Zasady Wniosek Bibliografia
24
27 29 29
Rozdział 2 Przegląd informacji o programowaniu ekstremalnym
31
Praktyki programowania ekstremalnego
31
Klient jest członkiem zespołu Historyjki użytkowników Krótkie cykle Testy akceptacyjne Programowanie parami Programowanie sterowane testami Wspólna własność Ciągła integracja Równomierne tempo Otwarta przestrzeń robocza Gra w planowanie Prosty projekt Refaktoryzacja Metafora
32 32 32 33 33 34 34 34 35 35 35 36 37 37
Wniosek Bibliografia
38 38
Rozdział 3 Planowanie
39
Początkowa eksploracja Tworzenie prototypów, dzielenie i szybkość
Planowanie wersji dystrybucyjnych Planowanie iteracji Planowanie zadań
40 40
41 41 41
Półmetek
42
Przebieg iteracji Wniosek Bibliografia
42 43 43
4
SPIS TREŚCI
Rozdział 4 Testowanie Programowanie sterowane testami Przykład projektu w stylu „najpierw test” Izolacja testu Nieoczekiwane wyeliminowanie sprzężeń
Testy akceptacyjne Przykład testów akceptacyjnych Architektura „przy okazji”
Wniosek Bibliografia
Rozdział 5 Refaktoryzacja Generowanie liczb pierwszych — prosty przykład refaktoryzacji Ostateczny przegląd
45 45 46 47 48
49 50 51
51 52
53 54 59
Wniosek Bibliografia
62 63
Rozdział 6 Epizod programowania
65
Gra w kręgle Wniosek
CZĘŚĆ II
66 98
PROJEKT AGILE
101
Symptomy złego projektu Zasady Zapachy a zasady Bibliografia
101 101 102 102
Rozdział 7 Co to jest projekt agile?
103
Co złego dzieje się z oprogramowaniem? Zapachy projektu — woń psującego się oprogramowania
103 104
Co stymuluje oprogramowanie do psucia się? Zespoły agile nie pozwalają psuć się oprogramowaniu
106 106
Program Copy Przykład programu Copy wykonanego zgodnie z metodyką agile Skąd deweloperzy agile wiedzieli, co należy zrobić?
Utrzymywanie projektu w jak najlepszej postaci Wniosek Bibliografia
106 109 110
110 111 111
Rozdział 8 SRP — zasada pojedynczej odpowiedzialności
113
SRP — zasada pojedynczej odpowiedzialności
113
Czym jest odpowiedzialność? Rozdzielanie sprzężonych odpowiedzialności Trwałość
115 115 116
Wniosek Bibliografia
116 116
Rozdział 9 OCP — zasada otwarte-zamknięte
117
OCP — zasada otwarte-zamknięte Opis Kluczem jest abstrakcja
117 118 118
SPIS TREŚCI
Aplikacja Shape Naruszenie zasady OCP Zachowanie zgodności z zasadą OCP Przyznaję się. Kłamałem Przewidywanie i „naturalna” struktura Umieszczanie „haczyków” Stosowanie abstrakcji w celu uzyskania jawnego domknięcia Zastosowanie podejścia „sterowania danymi” w celu uzyskania domknięcia
Wniosek Bibliografia
Rozdział 10 LSP — zasada podstawiania Liskov LSP — zasada podstawiania Liskov Prosty przykład naruszenia zasady LSP Kwadraty i prostokąty — bardziej subtelne naruszenie zasady LSP Prawdziwy problem Poprawność nie jest wrodzona Relacja IS-A dotyczy zachowania Projektowanie według kontraktu Specyfikowanie kontraktów w testach jednostkowych
Realny przykład Motywacja Problem Rozwiązanie niezgodne z zasadą LSP Rozwiązanie zgodne z zasadą LSP
Wydzielanie zamiast dziedziczenia Heurystyki i konwencje Zdegenerowane funkcje w klasach pochodnych Zgłaszanie wyjątków z klas pochodnych
Wniosek Bibliografia
5
119 120 121 122 122 123 124 125
126 126
127 127 128 129 131 132 132 132 133
133 133 135 136 136
137 139 140 140
140 140
Rozdział 11 DIP — zasada odwracania zależności
141
DIP — zasada odwracania zależności Podział na warstwy
141 142
Odwrócenie własności Zależność od abstrakcji
Prosty przykład Wyszukiwanie potrzebnych abstrakcji
Przykład programu Furnace Polimorfizm dynamiczny i statyczny
Wniosek Bibliografia
Rozdział 12 ISP — zasada segregacji interfejsów Zaśmiecanie interfejsów Odrębne klienty oznaczają odrębne interfejsy Siła oddziaływania klientów na interfejsy
ISP — zasada segregacji interfejsów Interfejsy klas a interfejsy obiektów Separacja przez delegację Separacja przez wielokrotne dziedziczenie
142 143
144 145
146 147
148 148
149 149 150 151
151 152 152 153
6
SPIS TREŚCI
Przykład interfejsu użytkownika bankomatu Poliady i monady
CZĘŚĆ III
153 158
Wniosek Bibliografia
159 159
STUDIUM PRZYPADKU: SYSTEM PŁACOWY
161
Szczątkowa specyfikacja systemu płacowego
162
Ćwiczenie Przypadek użycia nr 1: dodawanie nowego pracownika Przypadek użycia nr 2: usuwanie pracownika Przypadek użycia nr 3: dostarczenie karty pracy Przypadek użycia nr 4: dostarczenie raportu sprzedaży Przypadek użycia nr 5: dostarczenie informacji o opłacie na rzecz związku zawodowego Przypadek użycia nr 6: zmiana danych pracownika Przypadek użycia nr 7: wygenerowanie listy płac na dzień
Rozdział 13 Wzorce projektowe Polecenie i Aktywny obiekt Proste polecenia Transakcje Fizyczny i czasowy podział kodu Czasowy podział kodu
Metoda Undo Aktywny obiekt Wniosek Bibliografia
Rozdział 14 Metoda szablonowa i Strategia: dziedziczenie a delegacja Metoda szablonowa Nadużywanie wzorca Sortowanie bąbelkowe
Strategia Sortowanie jeszcze raz
Wniosek Bibliografia
Rozdział 15 Wzorce projektowe Fasada i Mediator Fasada Mediator Wniosek Bibliografia
Rozdział 16 Wzorce projektowe Singleton i Monostate Singleton Korzyści ze stosowania wzorca Singleton Koszty stosowania wzorca Singleton Wzorzec projektowy Singleton w praktyce
Monostate Korzyści ze stosowania wzorca Monostate Koszty stosowania wzorca Monostate Wzorzec projektowy Monostate w praktyce
Wniosek Bibliografia
162 162 163 163 163 164 164 164
165 166 167 168 168
169 169 173 173
175 176 178 179
181 183
185 185
187 187 188 190 190
191 192 193 193 193
194 196 196 196
200 200
SPIS TREŚCI
Rozdział 17 Wzorzec projektowy Obiekt Null Wniosek Bibliografia
Rozdział 18 Studium przypadku: system płacowy. Pierwsza iteracja Wprowadzenie Specyfikacja
Analiza według przypadków użycia Dodawanie pracowników Usuwanie pracowników Dostarczenie karty pracy Dostarczenie raportów sprzedaży Dostarczenie informacji o opłacie na rzecz związku zawodowego Zmiana danych pracownika Wypłaty
Refleksja: czego się nauczyliśmy? Wyszukiwanie potrzebnych abstrakcji Abstrakcja harmonogramu Sposoby wypłaty Przynależność do związków zawodowych
Wniosek Bibliografia
Rozdział 19 Studium przypadku: system płacowy. Implementacja Dodawanie pracowników Baza danych systemu płacowego Zastosowanie wzorca Metoda szablonowa do dodawania pracowników
Usuwanie pracowników Zmienne globalne
Karty pracy, raporty sprzedaży i składki Zmiana danych pracowników Zmiana klasyfikacji Co ja paliłem?
Realizacja wypłat Czy chcemy, aby deweloperzy podejmowali decyzje biznesowe? Realizacja wypłat dla pracowników ze stałą pensją Realizacja wypłat dla pracowników zatrudnionych w systemie godzinowym Okresy rozliczeniowe: problem projektowy
Program główny Baza danych Podsumowanie projektu systemu płacowego
CZĘŚĆ IV
7
201 204 204
205 205 206
206 207 208 209 209 210 210 212
214 214 214 215 216
216 216
217 218 219 220
223 225
225 231 235 240
244 246 246 248 251
257 257 258
Historia Zasoby
259 259
Bibliografia
259
PODZIAŁ SYSTEMU PŁACOWEGO NA PAKIETY
261
Rozdział 20 Zasady projektowania pakietów Projektowanie z wykorzystaniem pakietów? Ziarnistość: zasady spójności pakietów Zasada równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Zasada zbiorowego wielokrotnego użytku (CRP)
263 263 264 264 265
8
SPIS TREŚCI
Zasada zbiorowego zamykania (CCP) Podsumowanie tematyki spójności pakietów
Stabilność: zasady sprzęgania pakietów Zasada acyklicznych zależności (ADP) Cotygodniowe kompilacje Eliminowanie cykli zależności Skutki istnienia cykli w grafie zależności między pakietami Przerywanie cykli Odchylenia
Projekt góra-dół Zasada stabilnych zależności (SDP) Stabilność Metryki stabilności Nie wszystkie pakiety muszą być stabilne Gdzie powinna się znaleźć implementacja projektu wysokiego poziomu?
267 267 267 268 269 270 270
271 272 272 273 274 276
Zasada stabilnych abstrakcji (SAP)
276
Mierzenie abstrakcji Ciąg główny Odległość od ciągu głównego
276 277 278
Wniosek
Rozdział 21 Wzorzec projektowy Fabryka Cykl zależności Fabryki wymienne Wykorzystanie wzorca Fabryka do tworzenia zestawów testowych Znaczenie korzystania z fabryk Wniosek Bibliografia
Rozdział 22 Studium przypadku: system płacowy (część 2.) Struktura pakietów i notacja Zastosowanie zasady zbiorowego domykania (CCP) Zastosowanie zasady równoważności wielokrotnego wykorzystania kodu i dystrybucji (REP) Sprzężenia i hermetyzacja Metryki Zastosowanie wskaźników do aplikacji płacowej Fabryki obiektów Fabryka obiektów dla pakietu TransactionImplementation Inicjowanie fabryk Przebudowa granic spójności
CZĘŚĆ V
266 266
280
281 283 284 284 286 287 287
289 290 291 292 294 296 297 300 300 301 301
Ostateczna struktura pakietów Wniosek Bibliografia
302 304 304
STUDIUM PRZYPADKU: STACJA POGODOWA
305
Rozdział 23 Wzorzec projektowy Kompozyt Przykład: polecenia kompozytowe Wielokrotność czy brak wielokrotności
307 308 309
SPIS TREŚCI
Rozdział 24 Obserwator — ewolucja kodu do wzorca Zegar cyfrowy Wniosek Wykorzystanie diagramów w tym rozdziale
Wzorzec projektowy Obserwator Zarządzanie zasadami projektu obiektowego dla wzorca projektowego Obserwator
Bibliografia
Rozdział 25 Wzorce projektowe Serwer abstrakcyjny i Most Wzorzec projektowy Serwer abstrakcyjny Kto jest właścicielem interfejsu?
Wzorzec projektowy Adapter Wzorzec projektowy Adapter w formie klasy Problem modemu. Adaptery i zasada LSP
Wzorzec projektowy Most Wniosek Bibliografia
Rozdział 26 Wzorce projektowe Pełnomocnik i Schody do nieba — zarządzanie zewnętrznymi interfejsami API Wzorzec projektowy Pełnomocnik Implementacja wzorca projektowego Pełnomocnik w aplikacji koszyka na zakupy Podsumowanie wiadomości o wzorcu projektowym Pełnomocnik Obsługa baz danych, oprogramowania middleware oraz zewnętrznych interfejsów API
Schody do nieba Przykład zastosowania wzorca Schody do nieba
Wniosek Inne wzorce projektowe, które można wykorzystywać z bazami danych Wniosek Bibliografia
Rozdział 27 Analiza przypadku: stacja pogodowa Firma Chmura Oprogramowanie WMS-LC Wybór języka
Projekt oprogramowania systemu Nimbus-LC Historia 24-godzinna i utrwalanie Implementacja algorytmów HiLo
Wniosek Bibliografia Przegląd wymagań dla oprogramowania Nimbus-LC Wymagania użytkowe Historia 24-godzinna Konfiguracja użytkownika Wymagania administracyjne
Przypadki użycia systemu Nimbus-LC Aktorzy Przypadki użycia Historia pomiarów Konfiguracja Administracja
9
311 311 326 327
327 328
329
331 332 333
333 334 334
338 339 340
341 342 345 356 357
359 360
365 365 366 366
367 367 369 369
369 382 384
391 391 391 391 392 392 392
393 393 393 393 393 393
10
SPIS TREŚCI
Plan publikacji wersji dystrybucyjnych systemu Nimbus-LC Wprowadzenie Wydanie I Zagrożenia Produkty projektu Wydanie II Zaimplementowane przypadki użycia Zagrożenia Produkty projektu Wydanie III Zaimplementowane przypadki użycia Zagrożenia Produkty projektu
CZĘŚĆ VI
STUDIUM PRZYPADKU: ETS
Rozdział 28 Wzorzec projektowy Wizytator Rodzina wzorców projektowych Wizytator Wizytator Wzorzec projektowy Wizytator działa jak macierz
Wzorzec projektowy Acykliczny wizytator Wzorzec projektowy Wizytator działa jak macierz rzadka Wykorzystanie wzorca projektowego Wizytator w generatorach raportów Inne zastosowania wzorca projektowego Wizytator
Wzorzec projektowy Dekorator Wiele dekoratorów
Wzorzec projektowy Obiekt rozszerzenia Wniosek Przypomnienie
394 394 394 394 395 395 395 395 395 396 396 396 396
397 399 400 400 403
403 407 407 412
413 416
418 426 426
Bibliografia
426
Rozdział 29 Wzorzec projektowy Stan
427
Przegląd informacji o automatach stanów skończonych Techniki implementacji Zagnieżdżone instrukcje Switch/Case Interpretacja tabeli przejść
Wzorzec projektowy Stan
427 429 429 432
433
SMC — kompilator maszyny stanów
436
Kiedy należy korzystać z maszyn stanów?
439
Wysokopoziomowe strategie obsługi GUI Kontrolery interakcji z GUI Przetwarzanie rozproszone
Wniosek Listingi Implementacja klasy Turnstile.java z wykorzystaniem interpretacji tabeli przejść Klasa Turnstile.java wygenerowana przez kompilator SMC oraz inne pliki pomocnicze
Bibliografia
439 440 441
441 441 441 443
447
Rozdział 30 Framework ETS
449
Wprowadzenie
449
Przegląd informacji o projekcie Wczesny okres 1993 – 1994 Framework?
449 451 451
SPIS TREŚCI
Framework Zespół z roku 1994 Termin Strategia Wyniki
Projekt frameworka
11
452 452 452 452 453
454
Wspólne wymagania dla aplikacji oceniających Projekt frameworka do wyznaczania ocen
454 456
Przypadek zastosowania wzorca Metoda szablonowa
459
Napisać pętlę raz Wspólne wymagania dla aplikacji zdawania Projekt frameworka do zdawania Architektura menedżera zadań
Wniosek Bibliografia
Dodatek A Notacja UML. Część I: Przykład CGI System rejestrowania kursów: opis problemu Aktorzy Przypadki użycia Model dziedziny Architektura Klasy abstrakcyjne i interfejsy na diagramach sekwencji
Podsumowanie Bibliografia
Dodatek B Notacja UML. Część II: STATMUX
460 463 463 469
472 472
473 474 475 475 478 482 492
494 494
495
Definicja statystycznego multipleksera
495
Środowisko oprogramowania Ograniczenia czasu rzeczywistego Procedury obsługi przerwań wejścia Procedury obsługi przerwań wyjścia Protokoły komunikacji
496 496 497 501 502
Wniosek Bibliografia
512 512
Dodatek C Satyra na dwa przedsiębiorstwa
513
Rufus! Inc. Project Kickoff Rupert Industries Projekt Alpha
513 513
Dodatek D Kod źródłowy jest projektem Czym jest projekt oprogramowania?
Skorowidz
525 525
535
12
SPIS TREŚCI
Lista wzorców projektowych ACYKLICZNY WIZYTATOR ADAPTER AKTYWNY OBIEKT DEKORATOR FABRYKA FASADA KOMPOZYT MEDIATOR MENEDŻER ZADAŃ METODA SZABLONOWA MONOSTATE MOST OBIEKT NULL OBIEKT ROZSZERZENIA OBSERWATOR PEŁNOMOCNIK POLECENIE SCHODY DO NIEBA SERWER ABSTRAKCYJNY SINGLETON STAN STRATEGIA WIZYTATOR
403 333 169 413 281 187 307 188 469 176 194 338 201 418 327 341 165 359 332 192 433 181 400
Słowo wstępne Piszę te słowa tuż po opublikowaniu głównego wydania projektu open source Eclipse. Nadal działam „w trybie awaryjnym”, a mój umysł jest zmęczony. Ale jedna rzecz jest dla mnie jaśniejsza niż kiedykolwiek: to ludzie, a nie procesy, są kluczem do produktów. Recepta na sukces jest prosta: praca z osobami mającymi obsesję na punkcie tworzenia oprogramowania, rozwój z wykorzystaniem lekkich procesów, które są dostosowane do każdego zespołu i stale się dostosowują. „Podwójne kliknięcie” na programistach z naszych zespołów ujawnia osoby, które uznają programowanie za sedno rozwoju. Oni nie tylko piszą kod. Oni stale go „trawią”, aby coraz bardziej wgłębiać się w system. Walidacja projektów za pomocą kodu dostarcza informacji zwrotnych. Mają one kluczowe znaczenie dla uzyskania zaufania do projektu. Jednocześnie programiści rozumieją znaczenie wzorców, refaktoryzacji, testów, przyrostowych dostaw, częstego budowania i innych najlepszych praktyk programowania ekstremalnego (ang. Extreme Programming — EP), które zmieniły sposób, w jaki dziś postrzegamy metodyki. Umiejętności w tym stylu programowania są warunkiem sukcesu w projektach o wysokim ryzyku technicznym i zmieniających się wymaganiach. Zwinne wytwarzanie oprogramowania nie jest sednem ceremonii i dokumentacją projektową, ale jest intensywne, jeśli chodzi o codzienne praktyki rozwoju. Niniejsza książka koncentruje się na zastosowaniu tych technik w praktyce. Robert Martin jest długoletnim aktywistą w środowisku programistów obiektowych. Ma swój wkład w praktyki programowania w C++, wzorce projektowe i ogólne zasady projektowania obiektowego. Był wczesnym i bardzo aktywnym zwolennikiem technik EP oraz metod agile. Ta książka bazuje na tym wkładzie. Opisuje pełne spektrum praktyk zwinnego wytwarzania oprogramowania. To ambitne wyzwanie. Robert zwiększa jego trudność, demonstrując wszystko za pomocą analizy przypadków i mnóstwa kodu, tak jak przystało na praktyki agile. Wyjaśnia programowanie i projektowanie, praktycznie to robiąc. W tej książce jest mnóstwo dobrych rad dotyczących rozwoju oprogramowania. Są one dobre zarówno dla osób, które chcą zostać zwinnymi deweloperami, jak i dla tych, którzy pragną doskonalić już posiadane umiejętności. Nie mogłem się doczekać tej książki i nie jestem rozczarowany. Erich Gamma Object Technology International
Dla Ann Marie, Angeli, Micaha, Giny, Justina, Angelique, Matta i Alexis... Nie ma większego skarbu ani bogatszego znaleziska niż towarzystwo mojej rodziny i komfort ich miłości
14
PRZEDMOWA
Przedmowa
Bob, ale ty powiedziałeś, że skończysz tę książkę w zeszłym roku — Claudia Frers, UML World, 1999
Zwinne wytwarzanie oprogramowania to możliwość szybkiego tworzenia oprogramowania w obliczu szybko zmieniających się wymagań. W celu osiągnięcia tej zwinności trzeba stosować praktyki zapewniające niezbędną dyscyplinę i informacje zwrotne. Trzeba przestrzegać zasad projektowania, dzięki którym oprogramowanie stanie się elastyczne i łatwe w utrzymaniu. Trzeba znać wzorce projektowe stworzone w celu wykorzystania tych zasad w konkretnych problemach. Ta książka jest próbą połączenia tych trzech pojęć w funkcjonującą całość. Książka opisuje zasady, wzorce i praktyki. Następnie demonstruje, jak są one stosowane, poprzez dziesiątki różnych analiz przypadków. Co ważniejsze, te analizy przypadków nie zostały przedstawione jako ukończone dzieła. Są to raczej projekty w toku. Możemy zaobserwować, jak projektanci popełniają błędy, jak je identyfikują i na koniec korygują. Zobaczymy ich w roli osób rozwiązujących zagadki, rozważających niejasności i kompromisy. Będziemy obserwować akt projektowania.
PRZEDMOWA
15
Diabeł tkwi w szczegółach Ta książka zawiera mnóstwo kodu Javy i C++. Mam nadzieję, że czytelnicy uważnie przeczytają ten kod, ponieważ w dużym stopniu to kod jest sednem tej książki. Kod jest urzeczywistnieniem tego, co w tej książce chcę przekazać. Książka została napisana zgodnie z powtarzającym się wzorem. Składa się z szeregu analiz przypadków o różnych rozmiarach. Niektóre z nich są bardzo małe, a opisanie innych wymaga kilku rozdziałów. Każde studium przypadku jest poprzedzone materiałem, który ma na celu przygotowanie się do jego analizy. Na przykład studium przypadku systemu płacowego jest poprzedzone rozdziałem opisującym zasady projektowania obiektowego i wzorce stosowane w studium przypadku. Książka rozpoczyna się od omówienia praktyk i procesów stosowanych podczas wytwarzania oprogramowania. Omówienie to jest przerywane wieloma analizami przypadków i przykładami. Następnie przechodzimy do zagadnień projektowych i zasad projektowania, a potem do niektórych wzorców projektowych, dodatkowych zasad projektowania rządzących tworzeniem pakietów oraz kolejnych wzorców. Wszystkim tym tematom towarzyszą analizy przypadków. Trzeba się przygotować na czytanie kodu i analizowanie diagramów UML. Książka, którą masz zamiar przeczytać, ma bardzo techniczny charakter. Zrozumienie jej treści, podobnie jak diabeł, tkwi w szczegółach.
Trochę historii Ponad sześć lat temu napisałem książkę zatytułowaną Designing Object-Oriented C++ Applications using the Booch Method. To było dla mnie coś jak opus magnum. Jestem bardzo zadowolony z wyniku i ze sprzedaży. Niniejszą książkę rozpocząłem z zamiarem napisania drugiego wydania tamtej książki, ale okazało się, że nie przyjęła ona takiej formy. Na stronach tej książki pozostało bardzo niewiele z poprzedniej pozycji. Wykorzystałem nieco ponad trzy rozdziały, a i one zostały znacząco zmienione. Intencja, duch i wiele lekcji z tamtej książki są takie same. Ale nauczyłem się bardzo wiele o projektowaniu i wytwarzaniu oprogramowania w ciągu sześciu lat od ukazania się książki Designing Object-Oriented C++ Applications using the Booch Method. Ta książka odzwierciedla tę wiedzę. Cóż za pół dekady! Designing została wydana tuż przed tym, jak Internet skonsolidował planetę. Od tamtego czasu podwoiła się liczba skrótów, którymi posługujemy się na co dzień. Mamy wzorce projektowe, Javę, EJB, RMI, J2EE, XML, XSLT, HTML, ASP, JSP, serwlety, aplikacje serwerowe, ZOPE, SOAP, C#, .NET itd., itd. Wyznam, że było ciężko utrzymać zgodność treści rozdziałów tej książki z aktualnym stanem wiedzy w branży programowania.
Związki z Boochem W 1997 r. Grady Booch poprosił mnie o pomoc w napisaniu trzeciego wydania jego niezwykle udanej książki Object-Oriented Analysis and Design with Applications. Wcześniej pracowałem z Gradym przy okazji kilku projektów. Byłem też zapalonym czytelnikiem i kontrybutorem w jego różnych dziełach, w tym w notacji UML. Przyjąłem tę propozycję z radością. O pomoc w realizacji tego projektu poprosiłem mojego dobrego przyjaciela Jima Newkirka. W ciągu następnych dwóch lat Jim i ja napisaliśmy kilka rozdziałów do książki Boocha. Oczywiście z powodu wykonywania tamtej pracy nie mogłem poświęcić się niniejszej książce tak bardzo, jak bym chciał, ale czułem, że książka Boocha jest warta tego, by uczestniczyć w jej tworzeniu. Poza tym niniejsza książka była naprawdę po prostu drugim wydaniem Designing, a ja nie byłem do tego przekonany. Jeśli miałbym coś powiedzieć, chciałbym powiedzieć coś nowego i innego.
16
PRZEDMOWA
Niestety, tamta wersja książki Boocha nie była właściwym miejscem, by to zrobić. Trudno znaleźć czas na napisanie książki w normalnych czasach. Podczas burzliwych dni boomu dotcomów było to prawie niemożliwe. Grady był jeszcze bardziej zajęty projektem Rational oraz nowymi przedsięwzięciami, takimi jak Catapulse. Z tego powodu projekt przestał się rozwijać. W końcu zapytałem Grady’ego i wydawnictwo Addison-Wesley, czy mogę zamieścić rozdziały, które napisałem wraz z Jimem, w tej książce. Łaskawie zgodzili się. Z tego powodu kilka analiz przypadków i rozdziałów o UML pochodzi z tego źródła.
Wpływ programowania ekstremalnego Pod koniec 1998 roku pojawiła się koncepcja programowania ekstremalnego (ang. extreme programming — EP), która podała w wątpliwość słuszność naszych przekonań na temat rozwoju oprogramowania. Czy powinniśmy tworzyć wiele diagramów UML przed pisaniem kodu, czy też powinniśmy unikać wszelkiego rodzaju diagramów i po prostu pisać dużo kodu? Czy powinniśmy pisać wiele dokumentów narracyjnych, które opisują nasz projekt, czy też powinniśmy starać się tworzyć opisowy i ekspresywny kod, tak aby dokumenty pomocnicze nie były konieczne? Czy powinniśmy programować w parach? Czy powinniśmy pisać testy przed pisaniem kodu produkcyjnego? Co powinniśmy robić? Ta rewolucja przyszła w dogodnym dla mnie momencie. W drugiej połowie lat 90. Object Mentor pomógł kilku firmom rozwiązać problemy z projektami obiektowymi (ang. object-oriented — OO) i zarządzaniem projektami. Pomagaliśmy firmom realizować ich projekty. W ramach tej pomocy zaszczepialiśmy w zespołach nasze własne poglądy i stosowane praktyki. Niestety, te poglądy i praktyki nie zostały spisane. Należały one raczej do tradycji, która została ustnie przekazana naszym klientom. W 1998 roku zdałem sobie sprawę, że musimy spisać nasze procesy i praktyki, tak aby można było lepiej artykułować je klientom. W tym celu napisałem wiele artykułów na temat procesu wytwarzania oprogramowania w „C++ Report”1. Wspomnianym artykułom brakowało marki. Były one pouczające, a w niektórych przypadkach zabawne, ale zamiast kodyfikować praktyki i poglądy, które faktycznie wykorzystywaliśmy w naszych projektach, były nieświadomym kompromisem z wartościami, które zostały na nas nałożone przez dziesięciolecia. Uzmysłowił mi to Kent Beck.
Związki z Beckiem Pod koniec 1998 roku, kiedy pracowałem nad kodyfikacją procesu rekomendowanego przez witrynę Object Mentor, natknąłem się na pracę Kenta na temat programowania ekstremalnego. Informacje były rozproszone na stronie wiki Warda Cunninghama2 i pomieszane z pracami wielu innych autorów. Mimo to dzięki odrobinie pracy i cierpliwości udało mi się odczytać istotę tego, co pisał Kent. Byłem zaintrygowany, ale sceptyczny. Niektóre z kwestii ujętych w EP były dokładnie „na celu” mojej koncepcji procesu rozwoju oprogramowania. Jednak inne elementy, jak na przykład brak wyodrębnionego etapu projektowania, dziwiły mnie. Kent i ja wywodziliśmy się z bardzo odmiennych środowisk programowania. On był uznanym konsultantem Smalltalka, a ja byłem uznanym konsultantem C++. Tym dwóm światom trudno było się ze sobą komunikować. Pomiędzy nimi była przepaść paradygmatu Kuhna3.
1
Artykuły te są dostępne w sekcji „publications” na stronie http://www.objectmentor.com. Są cztery artykuły. Pierwsze trzy noszą tytuł Iterative and Incremental Development (I, II, III). Ostatni jest zatytułowany C.O.D.E. Culled Object development procEss.
2
http://c2.com/cgi/wiki. Ta strona internetowa zawiera ogromną liczbę artykułów na wiele różnych tematów. Autorami są setki, a może nawet tysiące osób. Mówi się, że tylko Ward Cunningham mógł wszcząć rewolucję społeczną za pomocą kilku linijek w Perlu.
3
W każdej wiarygodnej pracy intelektualnej napisanej w latach 1995 – 2001 musiała pojawić się wzmianka na temat „paradygmatu Kuhna”. Odnosi się on do książki Thomasa S. Kuhna The Structure of Scientific Revolutions, University of Chicago Press, 1962.
PRZEDMOWA
17
W innych okolicznościach nigdy bym nie poprosił Kenta o napisanie artykułu do czasopisma „C++ Report”. Ale zbieżność naszego myślenia o procesie wytwarzania oprogramowania mogła przełamać przepaść językową. W lutym 1999 roku spotkałem się z Kentem w Monachium na konferencji OOP. Wygłaszał wykład na temat EP w pokoju, naprzeciwko którego ja wygłaszałem wykład o zasadach projektowania obiektowego. Ponieważ nie byłem w stanie słyszeć tej wykładu, poszukałem Kenta podczas lunchu. Rozmawialiśmy o EP. Wtedy poprosiłem go, aby napisał artykuł do „C++ Report”. To był świetny artykuł o przypadku, w którym Kentowi wraz ze współpracownikiem udało się wprowadzić gruntowną zmianę projektu w żywym systemie w ciągu niewiele ponad godziny. W ciągu najbliższych kilku miesięcy przeszedłem przez powolny proces porządkowania własnych obaw na temat EP. Najbardziej obawiałem się zaakceptowania procesu, w którym nie ma wyraźnego etapu projektu „z góry”. Uświadomiłem sobie, że wzdragam się przed tym. Czyż nie miałem obowiązku wobec moich klientów oraz wobec branży jako całości, aby uczyć, że projekt jest na tyle ważny, aby poświęcić na niego czas? W końcu zdałem sobie sprawę, że tak naprawdę sam nie praktykowałem tego etapu. Nawet we wszystkich artykułach i książkach, które napisałem na temat projektowania, diagramów Boocha i diagramów UML, zawsze używałem kodu jako sposobu na sprawdzenie, czy diagramy miały sens. W całej mojej praktyce doradzania klientom poświęcałem zaledwie godzinę lub dwie na pomoc w rysowaniu diagramów, a następnie zachęcałem do badania stworzonych diagramów za pomocą kodu. Zrozumiałem, że chociaż terminy związane z technikami EP były mi obce (w znaczeniu „kuhnowskim”4), praktyki kryjące się za tymi terminami były mi znane. Moje inne obawy dotyczące EP były łatwiejsze do pokonania. Zawsze byłem zakamuflowanym zwolennikiem programowania w parach. Techniki EP pozwoliły mi wyjść z ukrycia i cieszyć się ziszczeniem mojego pragnienia programowania z partnerem. „Refaktoryzacja”, „ciągła integracja” i „klient na miejscu” były dla mnie pojęciami bardzo łatwymi do zaakceptowania. Były one bardzo blisko tego sposobu pracy, jaki polecałem moim klientom. Jedna z praktyk EP była dla mnie objawieniem. Projektowanie zgodne ze stylem „najpierw test” (ang. test-first) brzmi niewinnie, kiedy słyszy się o nim po raz pierwszy. Głosi zasadę, aby przed napisaniem kodu produkcyjnego najpierw napisać test. Cały kod produkcyjny pisze się po to, aby testy, które nie przechodzą, zaczęły przechodzić. Nie byłem przygotowany na głębokie konsekwencje, jakie mogło mieć pisanie kodu w ten sposób. Praktyka ta całkowicie zmieniła sposób, w jaki piszę oprogramowanie, i przekształciła go na lepsze. Tę transformację można zaobserwować w tej książce. Pewna część zamieszczonego kodu została napisana przed rokiem 1999. Dla tego kodu nie ma przypadków testowych. Z drugiej strony, cały kod napisany po 1999 roku jest zaprezentowany razem z testami i zazwyczaj testy są umieszczone jako pierwsze. Jestem pewien, że zauważysz różnicę. Jesienią 1999 r. byłem przekonany, że witryna Object Mentor powinna przyjąć techniki EP jako swój proces i że powinienem porzucić moją chęć opisania własnego procesu. Kent zrobił świetną robotę, artykułując praktyki i proces EP. Moje wątłe próby zbladły w porównaniu z jego pracą.
Organizacja książki Książka została podzielona na sześć głównych części, które uzupełniono kilkoma dodatkami. Część I „Zwinne wytwarzanie oprogramowania”
W tej części opisano koncepcję produkcji agile. Zaczyna się od manifestu stowarzyszenia Agile Alliance, zawiera przegląd technik programowania ekstremalnego (EP). Następnie przechodzimy do wielu niewielkich studiów przypadków, które naświetlają niektóre, indywidualne praktyki EP — szczególnie te, które mają wpływ na sposób projektowania i pisania kodu.
4
Jeśli wspomnisz Kuhna w artykule dwa razy, zasługujesz na podwójne uznanie.
18
PRZEDMOWA
Część II „Projekt agile”
Rozdziały w tej części mówią o obiektowym projekcie oprogramowania. Rozdział 1. zawiera pytanie: Co to jest projekt? Omówiono w nim problem i techniki zarządzania złożonością. Rozdział kończy się omówieniem zasad obiektowego projektu klasy. Część III „Studium przypadku: system płacowy” To największe i najbardziej kompletne studium przypadku w tej książce. Opisuje obiektowy projekt i implementację w C++ prostego systemu płacowego. W pierwszych kilku rozdziałach w tej części opisano wzorce projektowe, które wykorzystano w studium przypadku. Ostatnie dwa rozdziały zawierają pełne studium przypadku. Część IV „Podział systemu płacowego na pakiety” Ta część rozpoczyna się od opisania zasad obiektowego projektu pakietu. Następnie przechodzimy do zilustrowania tych zasad poprzez stopniowe pakowanie klas z poprzedniej części. Część V „Studium przypadku: stacja pogodowa” Ta część zawiera jedno ze studiów przypadków, które były pierwotnie planowane do książki Boocha. Studium przypadku Stacja pogodowa opisuje firmę, która podjęła znaczącą decyzję biznesową, i wyjaśnia, w jaki sposób zespół programistów Javy na to zareagował. Tak jak zwykle rozdział rozpoczyna się opisem wzorców projektowych, które będą stosowane, a kończy opisem projektu i implementacją. Część VI „Studium przypadku: ETS” Ta część zawiera opis rzeczywistego projektu, w którym uczestniczył autor. Projekt ten był prowadzony od 1999 roku. Jest to zautomatyzowany system testowy realizowany dla National Council of Architectural Registration Boards, używany do dostarczania i klasyfikowania rejestru egzaminów. Dodatki opisujące notację UML Pierwsze dwa dodatki zawierają kilka niewielkich studiów przypadków, które zostały użyte do opisania notacji UML. Dodatki różne
Jak korzystać z tej książki? Jeśli jesteś programistą... Przeczytaj tę książkę od deski do deski. Ta książka została napisana przede wszystkim dla programistów. Zawiera informacje potrzebne do tworzenia oprogramowania w zwinny sposób. Przeczytanie książki od deski do deski pozwala na zapoznanie się z praktykami, poznanie zasad, wzorców i, na koniec, studiów przypadków, które wiążą je wszystkie razem. Zintegrowanie całej tej wiedzy pomoże programistom realizować własne projekty.
Jeśli jesteś menedżerem lub analitykiem biznesowym... Przeczytaj część I „Zwinne wytwarzanie oprogramowania”. Rozdziały w tej części zawierają wyczerpujący opis zasad i praktyk wytwarzania oprogramowania zgodnie z duchem agile. Przechodzą od wymagań poprzez planowanie do testowania, refaktoryzacji i programowania. W ten sposób uzyskujesz wskazówki dotyczące tworzenia projektów i zarządzania nimi. Wiedza zdobyta w tej części pomoże Ci realizować własne projekty.
Jeśli chcesz się nauczyć UML... Najpierw przeczytaj dodatek A „Notacja UML. Część I: Przykład CGI”. Następnie przeczytaj dodatek B „Notacja UML. Część II: STATMUX”. Potem przeczytaj wszystkie rozdziały z części III „Studium przypadku: system płacowy”. Lektura w tej kolejności da Ci dobre podstawy zarówno w zakresie składni, jak i stosowania UML. Pomoże także w tłumaczeniu UML na takie języki programowania jak Java lub C++.
PRZEDMOWA
19
Jeśli chcesz nauczyć się wzorców projektowych... Aby znaleźć konkretny wzorzec, należy skorzystać z „Listy wzorców projektowych” na stronie 12. Aby ogólnie zapoznać się ze wzorcami, należy przeczytać część II „Projekt agile”. Tam zapoznasz się z zasadami projektowania. Następnie należy przeczytać część III „Studium przypadku: system płacowy”, część IV „Podział systemu płacowego na pakiety”, część V „Studium przypadku: stacja pogodowa” i część VI „Studium przypadku: ETS”. W tych częściach zdefiniowano wszystkie wzorce i pokazano, jak z nich korzystać w typowych sytuacjach.
Jeśli chcesz zapoznać się z zasadami projektowania obiektowego... Przeczytaj rozdział 2., „Projekt agile”, część III „Studium przypadku: system płacowy” oraz część IV „Podział systemu płacowego na pakiety”. W tych rozdziałach opisano zasady projektowania obiektowego i pokazano, jak je stosować.
Jeśli chcesz dowiedzieć się o metodach stosowanych w produkcji agile... Przeczytaj część I „Zwinne wytwarzanie oprogramowania”. W tej części opisano produkcję agile, począwszy od wymagań, poprzez planowanie, testowanie, refaktoryzację i programowanie.
Jeśli chcesz się trochę pośmiać... Przeczytaj dodatek C „Satyra na dwa przedsiębiorstwa”.
Podziękowania Serdeczne podziękowania kieruję do: Lowella Lindstroma, Briana Buttona, Erika Meade, Mike’a Hilla, Michaela Feathersa, Jima Newkirka, Micaha Martina, Angelique Thouvenin Martin, Susan Rosso, Talisha Jefferson, Rona Jeffriesa, Kenta Becka, Jeffa Langra, Davida Farbera, Boba Kossa, Jamesa Grenning Lance S. Lahmana, Dave’a Harrisa, Jamesa Kanze’a, Marka Webstera, Chrisa Biegaya, Alana Francisa, Fran Daniele, Patricka Lindnera, Jake’a Warde’a, Amy’ego Todda, Laury Steele, Williama Pietra, Camille Trentacoste, Vince’a O’Briena, Gregory’ego Dullesa, Lynda Castillo, Craiga Larmana, Tima Ottingera, Chrisa Lopeza, Phila Goodwina, Charlesa Tolanda, Roberta Evansa, Johna Rotha, Debbie Utley, Johna Brewera, Russ Ruter, Davida Vydry, Iana Smitha, Erica Evansa, wszystkich członków grupy Silicon Valley Patterns, Pete’a Brittinghama, Grahama Perkinsa, Philipa i Richarda MacDonald. Korektorzy książki: Pete McBreen (McBreen Consulting) Stephen J. Mellor (Projtech.com) Brian Button (Object Mentor Inc.)
Bjarne Stroustrup (AT & T Research) Micah Martin (Object Mentor Inc) James Grenning (Object Mentor Inc.)
Wielkie podziękowania należą się Grady’emu Boochowi i Paulowi Beckerowi za umożliwienie mi wykorzystania rozdziałów, które były pierwotnie planowane do umieszczenia w trzecim wydaniu książki Grady’ego Object-Oriented Analysis and Design with Applications. Specjalne podziękowania kieruję również do Jacka Reevesa za zezwolenie na wykorzystanie jego artykułu Czym jest projekt?. Kolejne specjalne podziękowania należą się Erichowi Gamma za napisanie słowa wstępnego do tej książki — mam nadzieję, że tym razem czcionki są lepiej dobrane. Wspaniałe, a niekiedy olśniewające ilustracje na początku każdego rozdziału zostały wykonane przez Jennifer Kohnke. Dekoracyjne ilustracje rozrzucone w rozdziałach to piękne produkty pracy Angeli Dawn Martin Brooks, mojej córki — jednej z radości mojego życia.
Zasoby Wszystkie kody źródłowe zamieszczone w tej książce można pobrać ze strony: http://www.helion.pl/ksiazki/zwiwyo.htm.
20
O AUTORACH
O autorach Robert C. Martin Robert C. Martin (wujek Bob) jest profesjonalnym programistą od 1970 roku oraz międzynarodowym konsultantem oprogramowania od 1990 roku. Jest założycielem Object Mentor Inc., zespołu doświadczonych konsultantów, którzy doradzają swoim klientom na całym świecie w dziedzinie C++, Javy, .NET, OO, wzorców projektowych, UML, metodologii agile i programowania ekstremalnego. W 1995 roku Robert napisał bestsellerową książkę Designing Object Oriented C++ Applications using the Booch Method, opublikowaną przez Prentice Hall. Od 1996 do 1999 roku był redaktorem naczelnym czasopisma „C++ Report”. W 1997 roku był redaktorem naczelnym książki Pattern Languages of Program Design 3, opublikowanej przez Addison-Wesley. W 1999 roku był redaktorem książki More C++ Gems, opublikowanej przez Cambridge Press. Wraz z Jamesem Newkirkiem jest współautorem książki XP in Practice, wydanej w 2001 roku przez Addison-Wesley. W 2002 roku napisał długo oczekiwaną książkę Agile Software Development: Principles, Patterns, and Practices, Prentice Hall, 2002. Opublikował dziesiątki artykułów w różnych czasopismach branżowych. Jest częstym prelegentem na konferencjach i targach międzynarodowych. Jest wesoły jak skowronek.
James W. Newkirk James Newkirk jest menedżerem i architektem oprogramowania. Podczas swojej długiej kariery zajmował się różnymi zagadnieniami — począwszy od programowania mikrokontrolerów w czasie rzeczywistym, do usług sieciowych. Był współautorem książki Extreme Programming in Practice, opublikowanej przez Addison-Wesley w 2001 roku. Od sierpnia 2000 roku pracuje z platformą .NET Framework. Współuczestniczył w rozwoju NUnit — narzędzia do testów jednostkowych dla platformy .NET.
Robert S. Koss Dr Robert S. Koss pisze oprogramowanie od 41 lat. Stosował zasady projektowania obiektowego w wielu projektach. Pełnił w nich różne role — począwszy od programisty, do starszego architekta. Dr Koss był nauczycielem w setkach kursów poświęconych technikom programowania obiektowego i językom programowania dla tysięcy studentów z całego świata. Obecnie jest zatrudniony jako starszy konsultant w Object Mentor, Inc.
CZĘŚĆ I Zwinne wytwarzanie oprogramowania
Interakcje między ludźmi są skomplikowane. Ich efekty nigdy nie są bardzo klarowne i ostre, ale znaczenie tych interakcji jest większe od jakiegokolwiek innego aspektu pracy — Tom DeMarco i Timothy Lister Peopleware1, str. 5
Zasady, wzorce i praktyki są ważne, ale to dzięki ludziom mają one jakiekolwiek znaczenie. Jak powiedział Alistair Cockburn2: „Procesy i technologie wywierają drugorzędny wpływ na wyniki projektu. Pierwszorzędny wpływ wywierają ludzie”. Nie możemy zarządzać zespołami programistów tak, jakby były to systemy składające się z elementów napędzanych przez proces. Ludzie nie są „jednostkami oprogramowania obsługującymi wtyczki3”. Jeżeli nasze projekty mają odnieść sukces, to musimy zbudować zespoły współpracujące ze sobą i samoorganizujące się. Te firmy, które zachęcają do tworzenia takich zespołów, zyskują ogromną przewagę konkurencyjną nad tymi, które są zdania, że zespół zajmujący się rozwojem oprogramowania nie jest niczym więcej niż zbiorem małych człowiekopodobnych istot. Zgrany zespół deweloperów jest najpotężniejszą siłą rozwoju.
1
Wydanie polskie: Czynnik ludzki. Skuteczne przedsięwzięcia i wydajne zespoły, WNT, 2002 — przyp. tłum.
2
Prywatna rozmowa.
3
Termin wymyślony przez Kenta Becka.
22
ROZDZIAŁ 1. PRAKTYKI AGILE
AGILE ALLIANCE
23
R OZDZIAŁ 1
Praktyki agile
Kogut na wieży kościoła, choć wykuty z żelaza, wkrótce zostałby zniszczony przez wichurę, gdyby nie pojął szlachetnej sztuki zwracania się w stronę kierunku każdego wiatru — Heinrich Heine
Wielu z nas przeżyło koszmar projektu, który był prowadzony bez wiodącej praktyki. Brak skutecznych praktyk prowadzi do nieprzewidywalności, powtarzania błędów i zmarnowanego wysiłku. Klienci są rozczarowani przesuwaniem harmonogramów, wzrastającymi kosztami i złą jakością. Programiści są zniechęceni, ponieważ pracują coraz dłużej, by produkować coraz gorsze oprogramowanie. Kiedy raz doświadczymy takiego fiaska, zaczynamy się bać, że to doświadczenie może się powtórzyć. Te obawy motywują do stworzenia procesu, który nakłada ograniczenia na nasze działania. Wymaga określonych wyników i artefaktów. Wymyślamy te ograniczenia i wyniki, bazując na dotychczasowych doświadczeniach. Wybieramy to, co dobrze sprawdziło się w poprzednich projektach. Mamy nadzieję, że sprawdzi się ponownie, i przestajemy się bać. Jednak projekty nie są na tyle proste, aby kilka ograniczeń i artefaktów mogło niezawodnie zapobiec błędom. W miarę popełniania kolejnych błędów diagnozujemy je. W rezultacie dodajemy nowe ograniczenia i artefakty po to, aby zapobiec tym błędom w przyszłości. Po wielu projektach proces staje się ogromny i przeciążony, przez co znacznie utrudnia zrobienie czegokolwiek. Wielki, kłopotliwy proces może stwarzać te same problemy, którym powinien zapobiegać. Może spowolnić zespół do tego stopnia, że harmonogramy zaczną się przesuwać, a koszty nadmiernie rosnąć. Może zmniejszyć responsywność zespołu tak mocno, że zawsze będzie on tworzył zły produkt. Niestety,
24
ROZDZIAŁ 1. PRAKTYKI AGILE
w wielu przypadkach członkowie zespołów zaczynają wtedy uważać, że ich proces jest niedostatecznie rozwinięty. Z tego powodu popadają w rodzaj proceduralnej inflacji, jeszcze bardziej rozbudowując proces. Proceduralna inflacja to dobre określenie stanu rzeczy w wielu firmach zajmujących się produkcją oprogramowania około roku 2000. Choć było jeszcze wiele zespołów, które działały kompletnie bez procedur, coraz powszechniejsze — szczególnie w dużych korporacjach — stawało się przyjmowanie bardzo rozbudowanych, ciężkich procesów (patrz dodatek C).
Agile Alliance Na początku 2001 r. spotkała się grupa ekspertów z branży, aby przedstawić wartości i zasady, które pozwoliłyby zespołom programistycznym na sprawną pracę i szybkie reagowanie na zmiany. Motywacją była obserwacja pracy zespołów programistycznych, które w wielu korporacjach ugrzęzły w bagnie coraz bardziej rozbudowanych procesów. Grupa przyjęła nazwę Agile Alliance4. W ciągu najbliższych kilku miesięcy członkowie grupy stworzyli listę nowych wartości: tzw. Manifest Agile Alliance.
Manifest Agile Alliance Manifest zwinnego wytwarzania oprogramowania. Rozwijając oprogramowanie i pomagając robić to innym, odkrywamy, jak można robić to lepiej. Dzięki tej pracy doszliśmy do następujących wartości:
Ludzie i interakcje ważniejsze niż procesy i narzędzia. Działające oprogramowanie ważniejsze niż kompleksowa dokumentacja. Współpraca z klientem ważniejsza niż negocjacje kontraktu. Reagowanie na zmiany ważniejsze niż przestrzeganie planu.
Chociaż doceniamy to, co podano po prawej stronie, bardziej cenimy to, co znajduje się po lewej. Kent Beck Ward Cunningham Andrew Hunt Robert C. Martin Dave Thomas
Mike Beedle Martin Fowler Ron Jeffries Steve Mellor
Arie van Bennekum James Grenning Jon Kern Ken Schwaber
Alistair Cockburn Jim Highsmith Brian Marick Jeff Sutherland
Ludzie i interakcje ważniejsze niż procesy i narzędzia. Najważniejszym składnikiem sukcesu są ludzie. Dobry proces nie ustrzeże projektu przed niepowodzeniem, jeśli w zespole nie ma silnych graczy, ale zły proces może spowodować, że nawet najsilniejsi gracze będą nieskuteczni. Nawet grupa silnych graczy może przegrać z kretesem, jeśli nie będą działać zespołowo. Silny gracz niekoniecznie musi być doskonałym programistą. Może to być przeciętny programista, ale taki, który dobrze pracuje z innymi. Skuteczna praca z innymi, komunikacja i interakcje są ważniejsze niż surowy talent do programowania. Zespół przeciętnych programistów, którzy dobrze się ze sobą komunikują, ma większe szanse powodzenia niż grupa supergwiazd niepotrafiących współpracować jako zespół. Odpowiednie narzędzia mogą być bardzo ważne dla osiągnięcia sukcesu. Kompilatory, środowiska IDE, systemy kontroli wersji kodu źródłowego itd. Wszystko to jest niezbędne dla prawidłowego funkcjonowania zespołu programistów. Jednak narzędzia łatwo można przecenić. Nadmiar rozbudowanych, nieporęcznych narzędzi jest tak samo zły jak ich brak.
4
agilealliance.org
AGILE ALLIANCE
25
Radzę, aby zaczynać od prostych narzędzi. Nie należy zakładać, że narzędzie nie nadaje się do użytku, dopóki go nie wypróbujemy i nie przekonamy się, że nie da się go używać. Zamiast kupować najlepszy, superdrogi system kontroli kodu źródłowego, lepiej znaleźć darmowy i używać go tak długo, aż uda się nam wykazać, że „z niego wyrośliśmy”. Przed zakupem wielostanowiskowej licencji najlepszego na rynku narzędzia CASE lepiej używać zwykłych białych tablic i papieru milimetrowego tak długo, aż będzie można racjonalnie wykazać, że potrzeba czegoś więcej. Przed wdrożeniem superzaawansowanego systemu baz danych warto spróbować używać kartotekowej bazy danych bazującej na „zwykłych plikach”. Nie należy zakładać, że większe i lepsze narzędzia automatycznie sprawią, że staniemy się lepsi w danej dziedzinie. Często bardziej utrudnią pracę, niż pomogą. Należy pamiętać, że budowanie zespołu jest ważniejsze niż budowanie środowiska. Wiele zespołów i wielu menedżerów popełnia błąd, budując najpierw środowisko i spodziewając się, że zespół stworzy się sam. Zamiast tego lepiej popracować nad stworzeniem zespołu, a potem pozwolić zespołowi skonfigurować środowisko w oparciu o swoje potrzeby. Działające oprogramowanie ważniejsze niż kompleksowa dokumentacja. Oprogramowanie bez dokumentacji to katastrofa. Kod nie jest idealnym medium do wyrażania decyzji projektowych i struktury systemu. Zespół powinien stworzyć dokumenty czytelne dla ludzi, które opisują system i uzasadniają podjęte decyzje. Jednak zbyt obszerna dokumentacja jest gorsza niż zbyt uboga. Wytwarzanie rozbudowanych dokumentów dotyczących oprogramowania zajmuje dużo czasu. Jeszcze więcej czasu pochłania utrzymanie synchronizacji tych dokumentów z kodem. Jeśli dokumentacja nie jest zsynchronizowana z kodem, zamienia się w obszerny, skomplikowany stek kłamstw i staje się znaczącym źródłem dezorientacji. Stworzenie przez zespół i utrzymywanie dokumentu uzasadniającego decyzje projektowe i strukturę projektu to dobry pomysł, ale dokument ten musi być krótki i wyrazisty. Przez „krótki” rozumiem co najwyżej 20 – 30 stron. „Wyrazisty”, czyli taki, który podaje ogólne uzasadnienie decyzji projektowych i tylko struktury najwyższego poziomu w systemie. W jaki sposób szkolić nowych członków zespołu, jeśli dysponujemy tylko dokumentem, który prezentuje krótkie uzasadnienie decyzji projektowych oraz strukturę? Poprzez ścisłą współpracę z nimi. Przekazujemy im wiedzę, siedząc obok nich i pomagając im. Czynimy z nich część zespołu poprzez szkolenia i interakcje. Kod i zespół to dwa elementy, które najlepiej sprawdzają się w roli środka przekazywania informacji nowym członkom zespołu. Kod nie kłamie na temat tego, co robi. Wyodrębnienie z kodu uzasadnienia decyzji projektowych i intencji może być trudne, ale kod jest jedynym jednoznacznym źródłem informacji. Członkowie zespołu posiadają stale zmieniającą się mapę drogową systemu w swoich głowach. Nie ma szybszego i bardziej wydajnego sposobu przekazywania tej mapy drogowej innym niż interakcje międzyludzkie. Wiele zespołów poniosło porażkę, dążąc bardziej do tworzenia dokumentacji niż do rozwoju oprogramowania. Często jest to fatalny błąd. Istnieje prosta zasada — nazywam ją pierwszym prawem dokumentacji Martina — która temu zapobiega: Nie twórz żadnych dokumentów, jeżeli nie są potrzebne natychmiast i nie są znaczące. Współpraca z klientem ważniejsza niż negocjacje kontraktu. Oprogramowania nie można zamówić tak jak innych towarów. Nie można przekazać krótkiego opisu oprogramowania, które nas interesuje, a następnie zlecić, aby ktoś je wykonał, stosując się do ustalonego harmonogramu i za określoną cenę. Próby traktowania projektów programistycznych w taki sposób wiele razy kończyły się niepowodzeniem. Czasami niepowodzenia były spektakularne. Często menedżerowie firm chcieliby przedstawić zespołowi programistów swoje potrzeby, a potem oczekują, że zespół zniknie na jakiś czas i wróci z systemem, który spełnia te potrzeby. Jednak ten tryb pracy prowadzi do słabej jakości i niepowodzeń.
26
ROZDZIAŁ 1. PRAKTYKI AGILE
Udane projekty uwzględniają opinie klientów stale i często. Zamiast zawierać kontrakt lub zlecać pracę, klient zamawiający oprogramowanie ściśle współpracuje z deweloperami, dostarczając im częstych uwag na temat ich pracy. Sporządzenie kontraktu, który określa wymagania, harmonogram i koszt projektu, jest zasadniczym błędem. W większości przypadków warunki określone w takim kontrakcie tracą sens na długo przed ukończeniem projektu5. Najlepsze kontrakty to takie, które regulują sposób współpracy pomiędzy zespołem deweloperskim a klientem. Jako przykład udanej umowy podam kontrakt, który wynegocjowałem w 1994 r. Kontrakt dotyczył dużego, wielowarstwowego projektu obejmującego pół miliona linii kodu. Zespołowi programistów były wypłacane stosunkowo niskie stawki miesięczne. Duże wypłaty były realizowane po dostarczeniu określonych dużych bloków funkcjonalności. Bloki te nie były szczegółowo określone w kontrakcie. Przeciwnie, kontrakt określał, że wypłata za blok nastąpi wówczas, gdy ten blok przejdzie test akceptacyjny klienta. Szczegóły testów akceptacyjnych nie były określone w kontrakcie. W trakcie realizacji tego projektu bardzo ściśle współpracowaliśmy z klientem. Wersje dystrybucyjne oprogramowania były dostarczane niemal w każdy piątek. Do poniedziałku lub wtorku następnego tygodnia klient wręczał nam listę zmian, które mieliśmy uwzględnić w oprogramowaniu. Wspólnie nadawaliśmy tym zmianom priorytety i planowaliśmy realizację w kolejnych tygodniach. Klient współpracował z nami tak ściśle, że testy akceptacyjne nigdy nie były problemem. Wiedział, kiedy blok funkcjonalności spełnia jego potrzeby, bo obserwował, jak on się rozwija z tygodnia na tydzień. Wymagania w tym projekcie stale się zmieniały. Poważne zmiany nie należały do rzadkości. Usuwane były całe bloki funkcjonalności, a na ich miejsce były wstawiane inne. Pomimo tego kontrakt i projekt przetrwały i zakończyły się sukcesem. Kluczem do tego sukcesu była intensywna współpraca z klientem oraz kontrakt, który regulował tę współpracę, choć nie określał ani szczegółów zakresu, ani harmonogramu, ani ceny. Reagowanie na zmiany ważniejsze niż podążanie za planem. To zdolność do reagowania na zmiany często decyduje o sukcesie lub porażce projektu oprogramowania. Kiedy tworzymy plany, musimy mieć pewność, że są one elastyczne i gotowe do uwzględnienia zmian w biznesie i technologii. Przebieg projektu oprogramowania nie może być planowany zbyt daleko w przyszłość. Po pierwsze, może się zmienić otoczenie biznesowe, a to może spowodować zmianę wymagań. Po drugie, klienci mogą zmienić wymagania, gdy zobaczą, jak system zaczyna funkcjonować. I wreszcie nawet gdy znamy wymagania i jesteśmy pewni, że się nie zmienią, nie ma dobrego sposobu oszacowania, ile czasu zajmie ich spełnienie. Początkujący menedżerowie często ulegają pokusie, aby stworzyć piękny wykres PERT lub wykres Gantta dla całego projektu i przykleić go na ścianie. Mają wrażenie, że ten wykres daje im kontrolę nad projektem. Mogą śledzić poszczególne zadania i skreślać je na wykresie, gdy zostaną zrealizowane. Mogą porównywać rzeczywiste daty z datami planowanymi na wykresie i reagować na wszelkie rozbieżności. W praktyce struktura tego wykresu degraduje się. W miarę jak zespół zdobywa wiedzę na temat systemu oraz w miarę jak klienci uświadamiają sobie swoje potrzeby, niektóre zadania na wykresie stają się niepotrzebne. Odkrywane są nowe zadania, które trzeba dodać. Krótko mówiąc, zmienia się kształt planu, a nie tylko terminy. Lepszą strategią planowania jest wykonywanie szczegółowych planów na najbliższe dwa tygodnie, mniej dokładnych planów na najbliższe trzy miesiące i bardzo ogólnych planów na dłuższy okres. Powinniśmy znać zadania, nad którymi będziemy pracować przez najbliższe dwa tygodnie. Powinniśmy z grubsza znać wymagania, nad którymi będziemy pracować przez najbliższe trzy miesiące. I powinniśmy mieć tylko mgliste pojęcie na temat tego, co system będzie robić za rok. To zmniejszenie rozdzielczości planu oznacza, że inwestujemy w szczegółowy plan tylko dla tych zadań, które są przeznaczone do natychmiastowej realizacji. Po wykonaniu szczegółowego planu trudno go zmienić, ponieważ zespół nabiera rozmachu i angażuje się w projekt. Ponieważ jednak plan obejmuje tylko kilka tygodni, pozostała jego część pozostaje elastyczna. 5
Czasami na długo przed podpisaniem kontraktu.
ZASADY
27
Zasady Powyższe wartości zainspirowały opracowanie wymienionych poniżej 12 zasad. Są to cechy, które odróżniają zbiór praktyk agile od ciężkiego procesu: Najwyższym priorytetem jest zadowolenie klienta. Zapewniamy je poprzez ciągłe dostarczanie warto-
6
ściowego oprogramowania. W czasopiśmie „MIT Sloan Management Review” opublikowano analizę praktyk tworzenia oprogramowania, które pomagają firmom budować produkty wysokiej jakości6. W artykule wymieniono wiele praktyk, które miały istotny wpływ na jakość końcowego systemu. Jedną z nich była silna korelacja między jakością a wczesnym dostarczaniem częściowo działającego systemu. W artykule stwierdzono, że „im mniej funkcji w początkowej wersji dystrybucyjnej, tym wyższa jakość wersji końcowej”. Kolejnym wnioskiem płynącym z tego artykułu jest silna korelacja pomiędzy końcową jakością a częstymi dostawami coraz obszerniejszej funkcjonalności. „Im częstsze dostawy, tym wyższa końcowa jakość”. Zgodnie z praktyką agile dostawy powinny być realizowane wcześnie i często. Staramy się dostarczyć podstawowy system w ciągu kilku pierwszych tygodni od rozpoczęcia projektu. Następnie co dwa tygodnie dostarczamy systemy o rosnącej funkcjonalności. Klienci mogą zdecydować o przekazaniu tych systemów do eksploatacji, jeśli uważają, że mają one wystarczającą funkcjonalność. Mogą również dokonać przeglądu istniejących funkcji i przedstawić informacje o zmianach, które chcieliby wprowadzić. Zmieniające się wymagania są mile widziane nawet w późnej fazie rozwoju. Procesy agile uwzględniają zmiany uzasadnione dążeniem klienta do uzyskania przewagi nad konkurentami na rynku. To deklaracja przyjętej postawy. Uczestnicy procesu agile nie boją się zmian. Zmiany w wymaganiach postrzegają jako coś dobrego, ponieważ te zmiany oznaczają, że zespół nauczył się więcej o tym, co trzeba zrobić, aby zaspokoić rynek. Zwinny zespół ciężko pracuje nad tym, aby utrzymać elastyczność struktury tworzonego oprogramowania, dlatego kiedy zmienią się wymagania, ma to minimalny wpływ na system. W dalszej części tej książki poznasz zasady i wzorce projektowania obiektowego, które pomagają w utrzymaniu tego rodzaju elastyczności. Dostarczamy działające oprogramowanie często — od kilku tygodni do kilku miesięcy — z preferencją do krótszej skali czasowej. Dostarczamy działające oprogramowanie. Dostarczamy je wcześnie (po kilku pierwszych tygodniach) i często (co kilka tygodni). Nie czerpiemy satysfakcji z dostarczania zbioru dokumentów i planów. To nie są prawdziwe dostawy. Kładziemy nacisk na dostarczanie oprogramowania, które spełnia potrzeby klienta. Ludzie biznesu i deweloperzy muszą współpracować codziennie w trakcie trwania całego projektu. Aby projekt był zwinny, muszą istnieć znaczące i częste interakcje pomiędzy klientami, deweloperami i interesariuszami. Projekt oprogramowania nie jest bronią typu „wystrzel i zapomnij”. Projekt oprogramowania musi być stale zarządzany. Projekty należy budować wokół zmotywowanych osób. Należy zapewnić im środowisko i wsparcie, którego potrzebują, oraz zaufać, że wykonają zadanie. Zwinny projekt to taki, w którym ludzie są najważniejszym czynnikiem sukcesu. Wszystkie inne elementy — procesy, środowiska, zarządzanie itd. — są uważane za drugorzędne i mogą być zmienione, jeśli wywierają negatywny wpływ na ludzi. Na przykład jeśli przeszkodą dla zespołu jest środowisko biurowe, to należy je zmienić. Jeśli przeszkadzają jakieś etapy procesu, to trzeba wyeliminować te etapy.
Product-Development Practices That Work: How Internet Companies Build Software, „MIT Sloan Management Review”, zima 2001, numer przedruku 4226.
28
ROZDZIAŁ 1. PRAKTYKI AGILE
Najbardziej wydajnym i skutecznym sposobem przekazywania informacji zespołowi projektowemu
oraz wewnątrz zespołu są bezpośrednie rozmowy. W projekcie agile ludzie ze sobą rozmawiają. Rozmowa jest podstawową formą komunikacji. Można tworzyć dokumenty, ale nie dąży się do tego, aby wszystkie informacje o projekcie były przedstawione w formie pisemnej. Zwinny zespół projektowy nie wymaga specyfikacji, planów lub projektów w formie pisemnej. Członkowie zespołu mogą je tworzyć, jeśli postrzegają natychmiastową i znaczącą potrzebę ich istnienia, ale nie są one domyślnym środkiem komunikacji. Jest nim rozmowa. Podstawowym miernikiem postępu jest działające oprogramowanie. W projektach agile postępy mierzy się ilością oprogramowania, które w danym momencie spełnia oczekiwania klienta. Postępów nie mierzymy fazą, którą w danym momencie realizujemy, ani objętością dokumentacji, która została wytworzona, ani ilością napisanego kodu infrastrukturalnego. Projekt jest zrealizowany w 30%, jeśli działa 30% jego koniecznych funkcji. Procesy agile promują równomierny rozwój. Sponsorzy, deweloperzy i użytkownicy powinni mieć możliwość utrzymania stałego tempa przez czas nieokreślony. Zwinny projekt nie jest 100-metrowym sprintem. To raczej maraton. Zespół nie startuje z pełną szybkością, którą stara się utrzymać przez cały czas. Zamiast tego biegnie w dość szybkim, ale równomiernym tempie. Zbyt szybkie tempo prowadzi do wypalenia, „chodzenia na skróty” i końcowej klęski. Zespół agile sam określa swoje tempo. Nie wolno dopuścić do zbyt wielkiego zmęczenia. Nie da się pożyczyć energii z jutra, aby zrobić nieco więcej dziś. Zespół pracuje w tempie, które pozwala zachować najwyższe standardy jakości przez cały czas trwania projektu. Ciągła dbałość o doskonałość techniczną i dobry projekt zwiększa zwinność. Wysoka jakość jest kluczem do wysokiego tempa. Drogą do zapewnienia szybkiego rozwoju jest utrzymanie oprogramowania w maksymalnie czystej i solidnej formie. W związku z tym wszyscy członkowie zespołu agile są zobowiązani do tworzenia kodu o najwyższej jakości, jaką potrafią zapewnić. Nie robią bałaganu, który posprzątają, gdy będą mieli więcej czasu. Jeśli zrobią bałagan, to sprzątają go przed końcem dnia. Prostota — sztuka maksymalizacji ilości wykonanej pracy — ma znaczenie kluczowe. Zespoły agile nie próbują budować wielkich systemów, bujając w obłokach. Zawsze starają się przyjąć najprostszą ścieżkę, która jest zgodna z ich celami. Nie przykładają zbyt dużej wagi do przewidywania przyszłych problemów ani nie starają się bronić przed wszystkimi problemami naraz. Wykonują dziś swoją pracę jak najprościej, dbając o jak najwyższą jakość. Dzięki temu zespoły są przekonane, że z łatwością dokonają zmian, kiedy jutro pojawią się problemy. Najlepsze architektury, wymagania i projekty powstają w samoorganizujących się zespołach. Zwinny zespół to zespół samoorganizujący się. Obowiązki nie są przekazywane z zewnątrz poszczególnym członkom zespołu. Są one przekazywane zespołowi jako całości i to zespół określa najlepszy sposób wykonania zadań. Członkowie zwinnego zespołu wspólnie pracują nad wszystkimi aspektami projektu. Każdy może mieć wkład w całość. Żaden pojedynczy członek zespołu nie jest odpowiedzialny za architekturę, wymagania czy testy. Zespół współdzieli te obowiązki, a każdy członek zespołu może mieć wpływ na sposób ich wypełniania. W regularnych odstępach czasu zespół zastanawia się, jak stać się bardziej wydajnym, a następnie odpowiednio dostosowuje swoje działania. Zwinny zespół nieustannie dostosowuje swoją organizację, zasady, konwencje, relacje itp. Wie, że jego środowisko stale się zmienia, i ma świadomość, że sam również musi się zmieniać w tym środowisku, aby pozostać zwinnym.
BIBLIOGRAFIA
29
Wniosek Zawodowym celem każdego programisty i każdego zespołu projektowego jest dostarczanie możliwie najwyższej wartości swoim pracodawcom i klientom. A jednak projekty programistyczne kończą się niepowodzeniem — i to w przerażającej liczbie przypadków. Pomimo dobrych intencji za co najmniej część z tych niepowodzeń jest odpowiedzialna rosnąca spirala proceduralnej inflacji. Zasady i wartości zwinnego wytwarzania oprogramowania powstały jako sposób na to, by pomóc zespołom przerwać cykl proceduralnej inflacji i skupić się na prostych technikach osiągania celów. W chwili pisania tej książki do wyboru było wiele odmian metodologii zwinnego wytwarzania oprogramowania. Należą do nich SCRUM7, Crystal8, Feature Driven Development9, Adaptive Software Development (ADP)10 i, co najważniejsze, programowanie ekstremalne11.
Bibliografia 1. Kent Beck, Extreme Programming Explained: Embracing Change, Reading, MA: Addison-Wesley, 1999. 2. James Newkirk, Robert Martin, Extreme Programming in Practice, Upper Saddle River, NJ: Addison-Wesley, 2001. 3. James Highsmith, Adaptive Software Development: A Collaborative Approach to Managing Complex Systems, New York, NY: Dorset House, 2000.
7
www.controlchaos.com
8
crystalmethodologies.org
9
Java Modeling In Color With UML: Enterprise Components and Process, Peter Coad, Eric Lefebvre i Jeff De Luca, Prentice Hall, 1999.
10
[Highsmith2000].
11
[Beck1999], [Newkirk2001].
30
ROZDZIAŁ 1. PRAKTYKI AGILE
R OZDZIAŁ 2
Przegląd informacji o programowaniu ekstremalnym
My deweloperzy musimy pamiętać, że świat nie kończy się na technikach EP — Pete McBreen
W poprzednim rozdziale zaprezentowano zarys tego, co nazywamy zwinnym wytwarzaniem oprogramowania. Nie udzielono jednak dokładnej odpowiedzi, co mamy robić. Przedstawiono kilka ogólników i celów, ale to niezbyt daleko posunęło nas w prawidłowym kierunku. W niniejszym rozdziale skorygowano ten problem.
Praktyki programowania ekstremalnego Programowanie ekstremalne jest najbardziej znaną spośród zwinnych metod wytwarzania oprogramowania. Składa się z zestawu prostych, ale współzależnych od siebie praktyk. Praktyki te współpracują ze sobą, tworząc całość, która jest większa od pojedynczych części. Tę całość krótko przeanalizujemy w tym rozdziale, natomiast niektóre części omówimy w kolejnych rozdziałach.
32
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Klient jest członkiem zespołu Chcemy, aby klient i deweloperzy ściśle ze sobą współpracowali. Powinni wzajemnie rozumieć swoje problemy i razem pracować nad ich rozwiązaniem. Kim jest klient? Klient zespołu EP to osoba lub grupa osób, która określa priorytety i funkcje. Czasami klientem jest grupa analityków biznesowych lub specjalistów od marketingu pracujących w tej samej firmie co deweloperzy. Czasami klient jest przedstawicielem użytkownika wykonującym jego polecenia. Innym razem klient jest tą osobą, która płaci za produkt. Ale w projekcie EP niezależnie od tego, kim są klienci, są oni członkami zespołu i pozostają do dyspozycji zespołu. Najlepiej, jeśli klient pracuje w tym samym pomieszczeniu co deweloperzy. Trochę gorzej, jeśli klient działa w odległości kilkudziesięciu metrów od deweloperów. Im większa odległość, tym trudniej klientowi stać się prawdziwym członkiem zespołu. Jeśli klient jest w innym budynku lub w innym państwie, to zintegrowanie go z zespołem jest bardzo trudne. Co zrobić, jeśli klienta po prostu nie może być w pobliżu? Moja rada jest taka, aby znaleźć kogoś, kto może być blisko i kto chce i jest w stanie spełniać rolę prawdziwego klienta.
Historyjki użytkowników Aby zaplanować projekt, musimy wiedzieć coś na temat wymagań, ale nie musimy wiedzieć o tym zbyt dużo. Dla celów planowania musimy wiedzieć tylko tyle, aby móc oszacować wymagania. Można by sądzić, że w celu oszacowania wymagań trzeba znać wszystkie szczegóły, ale to nie do końca jest prawdą. Trzeba wiedzieć, że istnieją szczegóły, i trzeba wiedzieć mniej więcej, jakie rodzaje informacji są wykorzystywane, ale nie trzeba znać wszystkich detali. Konkretne detale dotyczące wymagania mogą z czasem się zmieniać, zwłaszcza gdy klient zaczyna widzieć całość systemu. Nic nie skupia uwagi na wymaganiach lepiej niż obserwacja systemu rodzącego się do życia. Dlatego uchwycenie wszystkich szczegółów wymagania na długo, zanim zostanie ono zaimplementowane, niesie ryzyko straty czasu i przedwczesnego skupienia uwagi. W przypadku korzystania z technik EP poczucie szczegółów wymagania uzyskujemy dzięki omawianiu ich z klientem, ale nie koncentrujemy się na detalach. Klient pisze kilka słów na kartce papieru. Tych kilka słów przypomni nam o rozmowie. Deweloperzy piszą oszacowania na kartce papieru mniej więcej w tym samym czasie, kiedy klient zapisuje swoją historyjkę. Oszacowania bazują na takim sposobie postrzegania detali, jaki uzyskali podczas rozmów z klientem. Historyjka użytkownika jest mnemonikiem stale trwającej rozmowy o wymaganiach. Jest to narzędzie planowania, z którego korzysta klient w celu zaplanowania implementacji wymagania zgodnie z jego priorytetem i szacunkowymi kosztami.
Krótkie cykle W projekcie EP działające oprogramowanie jest dostarczane co dwa tygodnie. W każdej z tych dwutygodniowych iteracji jest tworzone działające oprogramowanie, które dotyczy niektórych potrzeb interesariuszy. Na koniec każdej iteracji system jest prezentowany interesariuszom w celu uzyskania ich opinii. Plan iteracji. Iteracja zazwyczaj trwa dwa tygodnie. Reprezentuje pomocnicze wydanie, które może zostać wdrożone do produkcji, lecz nie musi. Jest to zbiór historyjek użytkowników wybranych przez klienta według budżetu ustalonego przez deweloperów. Deweloperzy ustalają budżet iteracji poprzez zmierzenie kosztu zadań zrealizowanych w poprzedniej iteracji. Klient może wybierać dowolną liczbę historyjek w iteracji, dopóki całkowita wartość szacowanego kosztu nie przekracza budżetu. Po rozpoczęciu iteracji klient zobowiązuje się nie zmieniać definicji ani priorytetu historyjek w tej iteracji. W tym czasie deweloperzy mogą podzielić historyjki na zadania i realizować je w kolejności, która ma największy sens z technicznego i biznesowego punktu widzenia.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
33
Plan wersji dystrybucyjnej. Zespoły EP często tworzą plany wersji dystrybucyjnych, które pokrywają sześć kolejnych iteracji. Jest to tzw. plan wersji dystrybucyjnej (ang. release plan). Opracowanie wersji dystrybucyjnej zazwyczaj zajmuje trzy miesiące. Wersja dystrybucyjna reprezentuje główne wydanie, które zazwyczaj może być wprowadzone do produkcji. Plan wersji dystrybucyjnej składa się z uszeregowanej według priorytetów kolekcji historyjek użytkowników, które zostały wybrane przez klienta zgodnie z budżetem ustalonym przez deweloperów. Deweloperzy ustalają budżet wersji dystrybucyjnej na podstawie kosztu zadań zrealizowanych w poprzedniej wersji. Klient może wybierać dowolną liczbę historyjek w wersji dystrybucyjnej, dopóki łączna wartość szacunkowego kosztu nie przekracza budżetu. Klient decyduje również o kolejności, w jakiej te historyjki będą implementowane w wersji dystrybucyjnej. Jeśli zespół sobie tego życzy, może określić kilka pierwszych iteracji wersji dystrybucyjnej, pokazując, które historyjki będą zrealizowane w każdej z iteracji. Wersje dystrybucyjne nie są rzeźbą w kamieniu. Klient może zmienić zawartość wersji w dowolnym momencie. Może anulować niektóre historyjki, napisać nowe historyjki lub zmienić priorytet historyjek.
Testy akceptacyjne Szczegóły dotyczące historyjek użytkowników są odzwierciedlone w formie testów akceptacyjnych określonych przez klienta. Testy akceptacyjne dla historyjki są pisane bezpośrednio przed lub nawet jednocześnie z implementacją tej historyjki. Są pisane w języku skryptowym, który pozwala na uruchomienie ich w sposób automatyczny i wielokrotny. Ich ogólnym celem jest zweryfikowanie, czy system zachowuje się tak, jak określił klient. Język testów akceptacyjnych rośnie i ewoluuje wraz z systemem. Klienci mogą zatrudnić programistów do stworzenia prostego systemu skryptów lub mogą korzystać z odrębnego działu zapewnienia jakości (ang. quality assurance — QA), który je opracuje. Wielu klientów korzysta z pomocy działów QA do opracowania narzędzia do testów akceptacyjnych oraz do samego pisania testów akceptacyjnych. Kiedy test akceptacyjny przejdzie, jest on dodawany do zbioru testów akceptacyjnych i nigdy nie wolno dopuścić, aby test ponownie nie przechodził. Ten rosnący zbiór testów akceptacyjnych jest uruchamiany kilka razy dziennie, za każdym razem, gdy system jest budowany. Jeśli testy akceptacyjne nie przechodzą, kompilacja jest uznawana za nieudaną. Zatem gdy wymaganie zostanie zaimplementowane, to nigdy nie przestanie być spełnione. System migruje z jednego stanu działania do drugiego i nigdy nie może przestać działać przez dłuższy czas niż kilka godzin.
Programowanie parami Cały kod produkcyjny jest pisany przez pary programistów pracujących razem przy tej samej stacji roboczej. Jedna osoba z każdej pary steruje klawiaturą i wpisuje kod. Druga osoba obserwuje wpisywany kod, zwracając uwagę na błędy i szukając ulepszeń1. Te dwie osoby intensywnie współpracują. Obie są w pełni zaangażowane w akt pisania oprogramowania. Role często się zmieniają. Osoba sterująca klawiaturą może się zmęczyć lub poczuć, że utknęła w martwym punkcie. Wtedy partner przejmuje klawiaturę i zaczyna „prowadzić”. Klawiatura jest przekazywana pomiędzy nimi kilka razy w ciągu godziny. Projektantami i autorami otrzymanego w ten sposób kodu są obaj programiści. Żaden z nich nie wnosi więcej niż połowę. Członkowie par zmieniają się co najmniej raz dziennie. Dzięki temu każdy programista pracuje w dwóch różnych parach każdego dnia. W trakcie iteracji każdy członek zespołu powinien pracować z każdym innym członkiem zespołu. Powinni oni pracować prawie nad wszystkimi funkcjami, które są realizowane w tej iteracji. 1
Widziałem pary, w których jedna osoba sterowała klawiaturą, a druga myszą.
34
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Taki sposób działania znacząco zwiększa rozpowszechnianie wiedzy w zespole. Chociaż specjalności pozostają, a zadania, które wymagają specyficznych umiejętności, zazwyczaj są przydzielane odpowiednim specjalistom, ci specjaliści będą pracować w parach z niemal wszystkimi członkami w zespole. To powoduje rozprzestrzenianie się specjalności w zespole, dzięki czemu członkowie zespołu mogą bez trudu przejmować obowiązki innych. Z badań przeprowadzonych przez Laurie Williamsa2 i Noska3 wynika, że praca parami nie zmniejsza wydajności programistów, a znacznie obniża wskaźnik awaryjności.
Programowanie sterowane testami W rozdziale 4., który poświęcono testowaniu, szczegółowo omówiono programowanie sterowane testami (ang. test-driven development — TDD). W poniższych akapitach zamieszczono szybki przegląd informacji na ten temat. Cały kod produkcyjny pisze się po to, aby testy jednostkowe, które nie przechodzą, zaczęły przechodzić. Najpierw pisze się test jednostkowy, który nie przechodzi, ponieważ funkcja, dla której jest on pisany, nie istnieje. Następnie piszemy kod, który sprawia, że określony test zaczyna przechodzić. Iteracja pomiędzy pisaniem przypadków testowych i kodu jest bardzo szybka — rzędu minut. Przypadki testowe i kod ewoluują wspólnie. Przypadki testowe wpływają na kod w bardzo niewielkim stopniu (przykład można znaleźć w rozdziale 6. „Epizod programowania”). W rezultacie wraz z kodem powstaje kompletny zbiór testów. Te testy pozwalają programistom sprawdzać, czy program działa. Jeśli para wprowadzi niewielkie zmiany, może uruchomić testy, aby upewnić się, czy niczego nie zepsuła. To znacznie ułatwia refaktoryzację (omówioną później). Kiedy piszemy kod po to, aby test zaczął przechodzić, ten kod z definicji staje się sprawdzalny. Dodatkowo istnieje silna motywacja do oddzielenia od siebie modułów tak, aby każdy z nich mógł być niezależnie testowany. Zatem projekt kodu, który jest pisany w ten sposób, ma tendencję do znacznie mniejszych sprzężeń. Istotną rolę w rozbijaniu sprzężeń odgrywają zasady projektowania obiektowego 4.
Wspólna własność Para ma prawo do sprawdzenia i poprawienia dowolnego modułu. Żaden z programistów nie jest indywidualnie odpowiedzialny za żaden konkretny moduł lub technologię. Wszyscy pracują nad interfejsem użytkownika (GUI)5. Wszyscy pracują nad middleware. Wszyscy pracują nad bazą danych. Nikt nie ma więcej władzy nad modułem lub technologią niż pozostałe osoby. To nie oznacza, że techniki EP zabraniają specjalizacji. Programista, który specjalizuje się w GUI, najczęściej będzie zajmował się zadaniami dotyczącymi GUI, ale także będzie pracował w parach mających zadania związane z middleware i bazami danych. Jeśli ktoś zechce nauczyć się drugiej specjalności, może zgłosić się do zadań ze specjalistami, którzy jej go nauczą. Nikt nie jest ograniczony do jednej specjalności.
Ciągła integracja Programiści sprawdzają swój kod i integrują go kilka razy dziennie. Zasada jest prosta. Pierwszy programista, który pobierze kod, wygrywa, pozostali muszą kod scalać. W zespołach EP stosowane są nieblokujące systemy kontroli kodu źródłowego. Oznacza to, że programiści mogą pobrać dowolny moduł w każdej chwili, niezależnie od tego, kto jeszcze mógł go pobrać. 2
[Williams2000], [Cockburn2001].
3
[Nosek].
4
Patrz część II.
5
Nie próbuję tutaj zalecać architektury trójwarstwowej. Po prostu wybrałem trzy popularne części technologii oprogramowania.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
35
Gdy programista pobierze moduł ponownie po modyfikacji, musi być przygotowany do scalenia go z wszelkimi zmianami wprowadzonymi przez wszystkie osoby, które pobrały ten moduł przed nim. Aby uniknąć długich sesji scalania, członkowie zespołu oddają swoje moduły bardzo często. Para pracuje nad zadaniem przez godzinę lub dwie. Tworzy przypadki testowe i kod produkcyjny. W pewnym dogodnym punkcie krytycznym, prawdopodobnie na długo przed ukończeniem zadania, para postanawia oddać kod ponownie. Najpierw sprawdza, czy wszystkie testy przechodzą. Integruje swój nowy kod z istniejącą bazą kodu. Jeśli trzeba wykonać scalenie, wykonuje je. Jeśli to konieczne, para konsultuje się z programistami, którzy wcześniej oddali swoje zmiany. Po zintegrowaniu zmian buduje nowy system. Uruchamia wszystkie testy w systemie, ze wszystkimi aktualnymi testami akceptacyjnymi włącznie. Jeśli para zepsuła coś, co wcześniej działało, naprawia to. Po uruchomieniu wszystkich testów kończy operację oddania kodu. Zatem zespoły EP budują system wiele razy każdego dnia. Budują cały system od początku do końca6. Jeżeli wynikiem końcowym systemu jest płyta CD, to wypalają płytę. Jeżeli wynikiem końcowy systemu jest aktywna witryna WWW, instalują tę witrynę WWW — najczęściej na serwerze testowym.
Równomierne tempo Projekt oprogramowania nie jest sprintem — to maraton. Zespół, który po przekroczeniu linii startu zacznie się ścigać tak szybko, jak może, wypali się na długo przed tym, nim zbliży się do ukończenia. Aby szybko finiszować, zespół musi pracować w równomiernym tempie. Zespół musi zachować swoją energię i czujność. Musi celowo działać w stałym, umiarkowanym tempie. W programowaniu EP obowiązuje zasada, że zespół nie może pracować w godzinach nadliczbowych. Jedynym wyjątkiem od tej reguły jest ostatni tydzień przed opublikowaniem wersji dystrybucyjnej. Jeśli zespół jest bliski celu — czyli wersji dystrybucyjnej — i może sobie pozwolić na sprint do mety, to godziny nadliczbowe są dopuszczalne.
Otwarta przestrzeń robocza Zespół pracuje razem w otartym pokoju. Są w nim stoły, na których są ustawione stacje robocze. Na każdym stole są dwie lub trzy takie stacje robocze. Przed każdą stają roboczą stoją dwa krzesła, na których może usiąść para współpracujących programistów. Ściany są pokryte wykresami stanu, schematami podziału na zadania, diagramami UML itp. W tym pokoju słychać szum rozmów. Każda para jest w zasięgu słuchu z każdą inną parą. Każdy bez trudu może usłyszeć, gdy ktoś inny ma kłopoty. Każdy wie, w jakim stanie są inni. Programiści mogą się intensywnie ze sobą komunikować. Można by pomyśleć, że takie środowisko będzie rozpraszać, że w takich warunkach nigdy nie będzie można nic zrobić z powodu ciągłego hałasu i braku skupienia. Jak pokazała rzeczywistość, nic takiego się nie dzieje. Co więcej, nie tylko nie następuje obniżenie produktywności, ale jak wynika z badań przeprowadzonych na Uniwersytecie w Michigan, praca w środowisku „pokoju narad wojennych” może zwiększyć wydajność dwukrotnie7.
Gra w planowanie W kolejnym rozdziale, „Planowanie”, zamieszczono szczegółowy opis gry planistycznej w programowaniu ekstremalnym. W tym punkcie pokrótce ją opiszę. Istotą gry w planowanie jest podział odpowiedzialności pomiędzy przedstawicieli biznesu i deweloperów. Ludzie biznesu (nazywani również klientami) decydują o ważności poszczególnych funkcji, natomiast deweloperzy określają, jak wysokie będą koszty implementacji określonej funkcji. 6
Ron Jeffries mawia: „Od końca do końca to dalej, niż myślisz”.
7
http://www.sciencedaily.com/releases/2000/12/001206144705.htm
36
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Na początku prac nad każdą wersją dystrybucyjną i na początku każdej iteracji deweloperzy określają budżet na podstawie tego, ile byli w stanie zrobić w ostatniej iteracji lub w ostatniej wersji dystrybucyjnej. Klienci wybierają historyjki, których łączne koszty nie przekraczają tego budżetu. Dzięki zastosowaniu tych prostych zasad oraz dzięki krótkim iteracjom i częstym publikacjom wersji dystrybucyjnych klienci i deweloperzy przyzwyczajają się do rytmu projektu. Klienci mają poczucie tempa, w jakim pracują deweloperzy. Bazując na tym poczuciu, klienci będą w stanie określić, jak długo potrwa ich projekt i ile będzie kosztować.
Prosty projekt Zespół EP dąży do tego, aby tworzone przez niego projekty były w maksymalnym stopniu proste i ekspresywne. Co więcej, członkowie zespołu koncentrują się wyłącznie na tych historyjkach, które są zaplanowane w bieżącej iteracji. Nie martwią się historyjkami, które będą realizowane w przyszłości. Zamiast tego starają się przekształcać projekt systemu — od iteracji do iteracji — w taki sposób, aby mieć jak najlepszy projekt dla historyjek, które system implementuje w określonym momencie. To oznacza, że zespół EP zazwyczaj nie rozpoczyna od infrastruktury. Prawdopodobnie nie wybierze w pierwszej kolejności bazy danych. Nie będzie się także zajmować warstwą middleware. Celem pierwszego etapu pracy zespołu będzie doprowadzenie do działania pierwszej partii historyjek w możliwie najprostszy sposób. Zespół doda infrastrukturę dopiero wtedy, gdy przyjdzie czas na opracowanie historyjki, która go do tego zmusi. Programiści ekstremalni kierują się następującymi trzema mantrami: Weź pod uwagę najprostsze rozwiązanie, które może zadziałać. Zespoły EP zawsze starają się znaleźć możliwie najprostszą opcję projektu dla bieżącej partii historyjek. Jeśli implementacja bieżących historyjek może działać z wykorzystaniem zwykłych plików, możemy zrezygnować z bazy danych lub EJB. Jeśli możemy zaimplementować bieżące historyjki za pomocą zwykłego połączenia przez gniazdo, nie musimy korzystać z ORB lub RMI. Jeśli da się zrealizować bieżące historyjki bez wielowątkowości, nie musimy uwzględniać obsługi wielu wątków. Staramy się rozważyć najprostszy sposób implementacji bieżących historyjek. Następnie wybieramy rozwiązanie, które jest tak blisko tej prostoty, jak tylko można się do niej zbliżyć w praktyce. Nie przejmuj się tym, że to będzie później potrzebne. Tak, ale wiemy, że kiedyś baza danych będzie potrzebna. Wiemy, że kiedyś będzie trzeba zastosować ORB. Wiemy, że pewnego dnia trzeba będzie zapewnić obsługę wielu użytkowników. Musimy więc umieścić haki do tych elementów teraz. Zgadza się? Zespół EP poważnie rozważa skutki podjęcia decyzji o dodaniu infrastruktury, zanim stanie się ona bezwzględnie konieczna. Zaczynają od założenia, że ta infrastruktura nie będzie potrzebna. Zespół opracowuje infrastrukturę tylko wtedy, jeśli ma dowód lub przynajmniej bardzo przekonujące przesłanki, że wprowadzenie infrastruktury w określonym momencie będzie bardziej wydajne pod względem kosztów od czekania. Raz i tylko raz. Programiści EP nie tolerują powielania kodu. Wszędzie tam, gdzie znajdą duplikaty, eliminują je. Istnieje wiele źródeł powielania kodu. Najbardziej oczywiste są te fragmenty kodu, które zostały zaznaczone za pomocą myszy i wklejone w wielu miejscach. Kiedy znajdziemy takie fragmenty, eliminujemy je poprzez tworzenie funkcji lub klasy bazowej. Czasem dwa lub więcej algorytmów jest do siebie bardzo podobnych, a jednak różnią się między sobą w subtelny sposób. Przekształcamy je na funkcje lub wykorzystujemy wzorzec Metoda szablonowa8. Niezależnie od źródła dublowania, kiedy zostanie ono odkryte, nie będziemy go tolerować. 8
Patrz rozdział 14., „Metoda szablonowa i Strategia: dziedziczenie a delegacja”.
PRAKTYKI PROGRAMOWANIA EKSTREMALNEGO
37
Najlepszym sposobem na wyeliminowanie nadmiarowości jest tworzenie abstrakcji. W końcu jeśli dwie rzeczy są do siebie podobne, musi istnieć jakaś abstrakcja, która je unifikuje. Tak więc akt eliminacji redundancji zmusza zespół do tworzenia wielu abstrakcji i dalszego zmniejszenia sprzężeń.
Refaktoryzacja9 Zagadnienie refaktoryzacji zostanie opisane bardziej szczegółowo w rozdziale 5. W tym punkcie zamieszczę zwięzły przegląd najważniejszych informacji na ten temat. Kod ma tendencję do „psucia się”. W miarę dodawania kolejnych funkcji i poprawiania kolejnych błędów struktura kodu się degraduje. Jeśli to zlekceważymy, wskutek degradacji kod będzie poplątany i trudny do zarządzania. Zespół EP stara się odwrócić tę degradację poprzez częstą refaktoryzację. Refaktoryzacja jest praktyką polegającą na wprowadzaniu serii drobnych transformacji, które poprawiają strukturę systemu bez wpływu na jego zachowanie. Każda transformacja jest trywialna — sprawia wrażenie, że nie warto jej wprowadzać. Jednak razem łączą się one w znaczne transformacje projektu i architektury systemu. Po każdej maleńkiej transformacji uruchamiamy testy jednostkowe, aby upewnić się, czy nic się nie zepsuło. Następnie wprowadzamy następną transformację, następną i następną. Po każdej z nich uruchamiamy testy. W ten sposób utrzymujemy działający system, jednocześnie przekształcając jego projekt. Refaktoryzację przeprowadza się w sposób ciągły, a nie na końcu projektu, przed opublikowaniem wersji dystrybucyjnej, czy nawet na koniec dnia. Refaktoryzacja jest operacją, którą wykonujemy co godzinę lub nawet co pół godziny. Dzięki refaktoryzacji przez cały czas zachowujemy kod tak czysty, jak to możliwe, i tak ekspresywny, jak to możliwe.
Metafora Metafora jest najmniej rozumianą ze wszystkich praktyk EP. Programiści ekstremalni są praktykami — brak konkretnej definicji sprawia, że czujemy dyskomfort. Rzeczywiście, zwolennicy EP często proponowali usunięcie metafory jako praktyki. A jednak w pewnym sensie metafora jest jedną z najważniejszych praktyk. Weźmy za przykład puzzle. Skąd wiadomo, jak do siebie pasują poszczególne fragmenty? Oczywiście każdy fragment przylega do innego, a jego kształt musi perfekcyjnie uzupełniać elementy, z którymi się styka. Gdybyście byli niewidomi i mieli bardzo wrażliwy zmysł dotyku, moglibyście ułożyć puzzle dzięki uważnemu dotykaniu każdego fragmentu i próbowaniu go we wszystkich pozycjach. Ale jest coś ważniejszego od kształtu elementów, co wiąże puzzle ze sobą. To obraz. Obraz jest prawdziwym przewodnikiem. Obraz jest tak potężny, że jeżeli dwie sąsiednie części obrazu nie mają uzupełniających się kształtów, to wiemy, że producent puzzli popełnił błąd. To jest metafora. To duży obraz, który łączy ze sobą cały system. To wizja systemu, która sprawia, że położenie i kształt wszystkich indywidualnych modułów stają się oczywiste. Jeśli kształt modułu jest niezgodny z metaforą, to wiemy, że ten moduł jest niewłaściwy. Metafora często sprowadza się do systemu nazewnictwa. Nazwy zapewniają słownictwo dla elementów w systemie i pomagają zdefiniować relacje pomiędzy nimi. Na przykład kiedyś pracowałem nad systemem, w którym tekst był przesyłany na ekran z szybkością 60 znaków na sekundę. W tym tempie wypełnienie ekranu zajmuje trochę czasu. W związku z tym pozwoliliśmy programowi, który generował tekst, na wypełnianie bufora. Gdy bufor był pełny, zatrzymywaliśmy program. Gdy bufor się opróżniał, uruchamialiśmy program ponownie. Rozmawialiśmy o tym systemie, porównując go do wywrotek przewożących śmieci na wysypisko. Bufory były małymi ciężarówkami. Ekran był wysypiskiem. Program był generatorem śmieci. Wszystkie nazwy pasowały do siebie i pomagały nam myśleć o systemie jako o całości. 9
[Fowler99].
38
ROZDZIAŁ 2. PRZEGLĄD INFORMACJI O PROGRAMOWANIU EKSTREMALNYM
Oto inny przykład. Kiedyś pracowałem nad systemem analizującym ruch w sieci. Co pół godziny system odpytywał dziesiątki kart sieciowych i pobierał dane monitorowania. Każda karta sieciowa przekazywała niewielki blok danych składający się z kilku pojedynczych zmiennych. Nazwaliśmy te bloki „plastrami”. Plastry były surowymi danymi, które trzeba było analizować. Program analizujący „gotował” plastry, dlatego został nazwany „Tosterem”. Pojedyncze zmienne w obrębie plastrów nazwaliśmy „okruchami”. W sumie była to bardzo przydatna i zabawna metafora.
Wniosek Programowanie ekstremalne to zestaw prostych i konkretnych praktyk, które łączą się w zwinny proces wytwarzania oprogramowania. Proces ten jest wykorzystywany przez wiele zespołów z dobrymi wynikami. EP to dobra, uniwersalna metoda tworzenia oprogramowania. Wiele zespołów projektowych może ją przyjąć bez żadnych zmian. Wiele innych może ją dostosować poprzez dodanie lub modyfikację niektórych praktyk.
Bibliografia 1. Dahl Dijkstra, Structured Programming, Nowy Jork, Hoare, Academic Press, 1972. 2. Daryl Conner, Leading at the Edge of Chaos, Wiley, 1998. 3. Alistair Cockburn, The Methodology Space, Raport techniczny czasopisma „Humans and Technology” HaT TR.97.03 (z dnia 03.10.97), http://members.aol.com/acockburn/papers/methyspace/methyspace.htm. 4. Kent Beck, Programowanie ekstremalne Explained: Embracing Change, Reading, MA: Addison-Wesley, 1999. 5. James Newkirk i Robert Martin, Extreme programming in Practice, Upper Saddle River, NJ: Addison-Wesley, 2001. 6. Laurie Williams, Robert Kessler, Ward Cunningham, Ron Jeffries, Strengthening the Case for Pair Programming, „IEEE Software”, lipiec – sierpień 2000. 7. Alistair Cockburn i Laurie Williams, The Costs and Benefits of Pair Programming, Konferencja na Sardynii XP 2000, odtworzone w książce Extreme Programming Examined, Giancarlo SUCCI, Michele Marchesi, Addison-Wesley, 2001. 8. Nosek J.T, The Case for Collaborative Programming, „Communications of the ACM” (1998): 105 – 108. 9. Martin Fowler, Refactoring: Improving the Design of Existing Code, Reading, MA: Addison-Wesley, 1999.
R OZDZIAŁ 3
Planowanie
Kiedy możesz zmierzyć to, o czym mówisz, i wyrazić to za pomocą liczb, to wiesz
coś o tym, ale kiedy nie możesz tego zmierzyć i przedstawić w liczbach, Twoja wiedza jest skromna i niezadowalająca — Lord Kelvin, 1883
W tym rozdziale zamieszczono opis gry planistycznej w programowaniu ekstremalnym (EP) 1. Jest ona podobna do sposobu planowania w kilku innych metodykach agile2, takich jak SCRUM3, Crystal4, Feature Driven Development5 oraz Adaptive Software Development (ADP)6. Jednak żadna z tych metodyk nie charakteryzuje się taką szczegółowością i dyscypliną.
1
[Beck 99], [Newkirk 2001].
2
www.AgileAlliance.org
3
www.controlchaos.com
4
crystalmethodologies.org
5
Peter Coad, Eric Lefebvre i Jeff De Luca, Java Modeling In Color With UML: Enterprise Components and Process, Prentice Hall, 1999.
6
[Higsmith 2000].
40
ROZDZIAŁ 3. PLANOWANIE
Początkowa eksploracja Na początku projektu deweloperzy i klienci starają się zidentyfikować tyle istotnych historyjek użytkowników, ile potrafią. Nie starają się jednak zidentyfikować wszystkich historyjek użytkownika. W miarę postępów projektu klienci kontynuują pisanie nowych historyjek użytkowników. Napływ historyjek użytkowników nie kończy się do chwili zakończenia projektu. Podczas szacowania historyjek deweloperzy pracują razem z klientami. Szacunki są względne, a nie bezwzględne. Zapisujemy kilka punktów na karcie historyjki w celu zaprezentowania jej względnego kosztu. Możemy nie mieć pewności co do tego, ile czasu reprezentuje określony punkt historyjki, ale wiemy, że historyjka składająca się z ośmiu punktów będzie realizowana dwa razy dłużej niż historyjka składająca się z czterech punktów.
Tworzenie prototypów, dzielenie i szybkość Opowieści, które są zbyt duże lub zbyt małe, są trudne do oszacowania. Deweloperzy mają tendencję do niedoceniania dużych historyjek i przeceniania małych. Każdą historyjkę, która jest zbyt duża, należy rozbić na kilka części, które nie są zbyt duże. Wszystkie historyjki, które są zbyt małe, należy połączyć z innymi małymi historyjkami. Na przykład rozważmy historyjkę „użytkownicy mogą bezpiecznie przesyłać pieniądze na swój rachunek oraz pomiędzy swoimi rachunkami”. To jest duża historyjka. Oszacowanie jej będzie trudne i prawdopodobnie niedokładne. Możemy jednak podzielić ją tak, jak pokazano poniżej. Po dokonaniu tego podziału oszacowanie staje się znacznie łatwiejsze:
Użytkownicy mogą się zalogować. Użytkownicy mogą się wylogować. Użytkownicy mogą wpłacić pieniądze na swój rachunek. Użytkownicy mogą wypłacić pieniądze ze swojego rachunku. Użytkownicy mogą przesyłać pieniądze ze swojego rachunku na inny rachunek.
Kiedy historyjka zostanie podzielona lub połączona, należy ponownie ją oszacować. Proste dodanie lub odjęcie szacowanej wartości nie jest mądre. Głównym powodem podziału lub połączenia historyjki jest doprowadzenie jej do takiego rozmiaru, aby oszacowanie było dokładne. Nic ma niczego dziwnego w tym, że po rozbiciu historyjki ocenionej na pięć punktów suma ocen uzyskanych historyjek wynosi dziesięć. Dziesięć to bardziej dokładne oszacowanie. Względne szacunki nie poinformują nas o dokładnym rozmiarze historyjki, więc nie pomogą nam w określeniu, czy należy ją podzielić, czy też scalić. W celu poznania rzeczywistego rozmiaru historyjki potrzebny jest czynnik, który nazywamy prędkością (ang. velocity). Jeśli znamy wartość prędkości, możemy pomnożyć oszacowanie dowolnej historyjki przez prędkość, aby uzyskać rzeczywiste oszacowanie czasu potrzebnego do zaimplementowania tej historyjki. Na przykład jeżeli prędkość wynosi „2 dni na punkt historyjki”, a mamy historyjkę o względnym oszacowaniu czterech punktów, to realizacja historyjki powinna zająć osiem dni. W miarę postępów projektu pomiar prędkości stanie się jeszcze dokładniejszy, ponieważ będziemy mogli zmierzyć liczbę zrealizowanych punktów historyjek na jedną iterację. Na początku projektu deweloperzy prawdopodobnie nie będą mieli zbyt dobrego wyczucia prędkości. Muszą stworzyć pierwsze przybliżenie, wykorzystując dowolny sposób, który ich zdaniem da najlepsze rezultaty. Potrzeba dokładności w tym momencie nie jest szczególnie istotna, więc nie trzeba poświęcać na to zbyt dużo czasu. Często wystarczy poświęcić kilka dni na stworzenie prototypu jednej lub dwóch historyjek, aby uzyskać poczucie prędkości zespołu. Sesja tworzenia prototypu to tzw. spike.
PLANOWANIE ZADAŃ
41
Planowanie wersji dystrybucyjnych Znając prędkość, klienci mogą ocenić koszt każdej z historyjek. Znają też wartość biznesową i priorytet każdej historyjki. To pozwala im wybrać historyjki, które mają być realizowane w pierwszej kolejności. Ten wybór nie jest wyłącznie kwestią priorytetu. Coś, co jest ważne, ale jednocześnie drogie, może być przełożone na okres po zrealizowaniu historyjki, która jest mniej ważna, ale znacznie tańsza. Tego rodzaju wybory to decyzje biznesowe. Przedstawiciele biznesu decydują, które historyjki przyniosą im największy zwrot z każdej zainwestowanej złotówki. Deweloperzy i klienci uzgadniają datę publikacji pierwszej wersji dystrybucyjnej projektu. Zwykle jest to termin oddalony o 2 – 4 miesiące od daty planowania. Klienci wybierają historyjki, które chcą zaimplementować w tej wersji dystrybucyjnej, oraz określają zgrubną kolejność, w jakiej chcą, aby były one zaimplementowane. Klienci nie mogą wybrać więcej historyjek, niż uda się zmieścić w zależności od aktualnej prędkości. Ponieważ prędkość jest początkowo niedokładna, wybór daty jest przybliżony. Jednak dokładność nie jest w tym momencie zbyt istotna. Plan publikowania wersji dystrybucyjnych może być uściślany, w miarę jak oszacowanie prędkości staje się bardziej dokładne.
Planowanie iteracji Następnie deweloperzy i klienci wybierają rozmiar iteracji. Zazwyczaj trwa to dwa tygodnie. Jak już wspomniano, klienci wybierają historyjki, które chcą, by były zaimplementowane w pierwszej iteracji. Nie mogą wybrać więcej historyjek, niż może się zmieścić w zależności od aktualnej prędkości. Kolejność realizacji historyjek w ramach iteracji to decyzja techniczna. Deweloperzy implementują historyjki w kolejności, która ma największy sens z technicznego punktu widzenia. Mogą pracować nad historyjkami seryjnie, zaczynając kolejną po zakończeniu poprzedniej, lub mogą pracować nad wszystkimi historyjkami jednocześnie. To zależy wyłącznie od nich. Klienci nie mogą zmieniać historyjek w iteracji po jej rozpoczęciu. Mogą zmodyfikować lub uporządkować dowolną historyjkę w projekcie, ale nie te, nad którymi deweloperzy aktualnie pracują. Iteracja kończy się w określonym terminie, nawet jeżeli wszystkie historyjki nie są zrealizowane. Szacunki dla wszystkich zakończonych historyjek są sumowane i jest obliczana prędkość dla tej iteracji. Ta miara prędkości jest następnie wykorzystywana do planowania kolejnej iteracji. Zasada jest bardzo prosta. Planowana prędkość dla każdej iteracji jest zmierzoną prędkością poprzedniej iteracji. Jeśli zespół zrealizował w ostatniej iteracji 31 punktów historyjkowych, to powinien zaplanować zrealizowanie 31 punktów historyjkowym w kolejnej iteracji. Prędkość zespołu wynosi 31 punktów na iterację. To sprzężenie zwrotne prędkości pomaga utrzymać synchronizacją planowania z zespołem. W miarę jak zespół zyskuje wiedzę i umiejętności, proporcjonalnie wzrasta prędkość. Jeśli zespół kogoś straci, jego prędkość spadnie. Jeśli architektura ewoluuje w sposób, który ułatwia rozwój, prędkość wzrasta.
Planowanie zadań Na początku nowej iteracji deweloperzy i klienci spotykają się w celu przeprowadzenia planowania. Deweloperzy dzielą historyjki na zadania programistyczne. Zadaniem jest coś, co jeden programista może zaimplementować w 4 – 16 godzin. Historyjki są analizowane przy pomocy klientów, a zadania są wyliczane tak dokładnie, jak to możliwe. Lista zadań jest tworzona na tablicy typu flipchart, białej tablicy lub innym dogodnym medium. Następnie deweloperzy, jeden po drugim, zgłaszają się do zadań, które chcą implementować. Kiedy deweloper zgłasza się do zadania, ocenia to zadanie w dowolnych punktach zadaniowych7.
7
Wielu deweloperów wykorzystuje „idealne godziny programowania” w roli swoich punktów zadaniowych.
42
ROZDZIAŁ 3. PLANOWANIE
Deweloperzy mogą zgłaszać się do każdego rodzaju zadania. Specjaliści od baz danych nie muszą zgłaszać się wyłącznie do zadań dotyczących baz danych. Specjaliści od interfejsu GUI mogą zgłosić się do zadań dotyczących bazy danych, jeśli mają ochotę. Może się to wydawać niewydajne, ale jak się przekonamy, istnieje mechanizm, który tym zarządza. Korzyści są oczywiste. Im więcej deweloperzy wiedzą o całym projekcie, tym projekt jest lepszy, a zespół projektowy jest bardziej poinformowany. Chcemy, aby wiedza na temat projektu rozprzestrzeniała się w zespole niezależnie od specjalizacji. Każdy programista wie, ile punktów zadaniowych udało mu się zaimplementować w ostatniej iteracji. Ta liczba to jego osobisty budżet. Żaden programista nie powinien zgłaszać się do zadań, które przekraczają jego budżet. Wybór zadań trwa do czasu, aż zostaną przydzielone wszystkie zadania albo wszyscy deweloperzy wykorzystają swoje budżety. Jeśli pozostaną jakieś zadania, to deweloperzy negocjują ze sobą, przydzielając zadania na podstawie swoich różnych umiejętności. Jeśli nie ma wystarczająco dużo miejsca, aby przydzielić wszystkie zadania, to deweloperzy proszą klientów o usunięcie zadań lub historyjek z iteracji. Jeśli wszystkie zadania zostaną przydzielone, a deweloperzy nadal mają miejsce w swoich budżetach na więcej pracy, proszą klientów o więcej historyjek.
Półmetek W połowie iteracji zespół przeprowadza spotkanie. W tym momencie połowa historyjek zaplanowanych na iterację powinna być zrealizowana. Jeśli połowa historyjek nie została zrealizowana, to zespół próbuje zmienić przydział zadań i obowiązków tak, aby zapewnić zakończenie wszystkich historyjek do końca iteracji. Jeśli deweloperzy nie potrafią znaleźć takiego nowego podziału, to powinni poinformować o tym klientów. Klienci mogą zdecydować o usunięciu zadania lub historyjki z iteracji. Plan minimum to wyszczególnienie zadań i historyjek o najniższym priorytecie, tak aby deweloperzy unikali pracy nad nimi. Załóżmy na przykład, że klienci wybrali w iteracji osiem historyjek o łącznej wartości 24 punktów historyjkowych. Załóżmy także, że zostały one podzielone na 42 zadania. Spodziewamy się, że na półmetku iteracji będziemy mieć zrealizowanych 21 zadań i 12 punktów historyjkowych. Te 12 punktów historyjkowych musi pochodzić z w pełni zaimplementowanych historyjek. Celem jest zrealizowanie historyjek, a nie tylko zadań. Bardzo niepożądanym scenariuszem jest sytuacja, gdy w chwili dotarcia do końca iteracji zrealizowano 90% zadań, ale nie zrealizowano żadnej historyjki. Na półmetku interesują nas zakończone historyjki, które reprezentują połowę punktów historyjkowych dla iteracji.
Przebieg iteracji Co dwa tygodnie bieżąca iteracja się kończy i rozpoczyna się następna. Na koniec każdej iteracji działająca aplikacja jest demonstrowana klientom. Klienci są proszeni o ocenę wyglądu, stylu i wydajności projektu. Dostarczają opinii w zakresie nowych historyjek użytkowników. Klienci często oglądają postępy. Mogą zmierzyć prędkość. Mogą przewidzieć, jak szybko pracuje zespół, i mogą odpowiednio wcześnie zaplanować historyjki o wysokim priorytecie. Krótko mówiąc, mają kontrolę i wszystkie potrzebne dane do zarządzania projektem zgodnie ze swoimi potrzebami.
BIBLIOGRAFIA
43
Wniosek Z iteracji na iterację i z wersji dystrybucyjnej na wersję dystrybucyjną projekt wchodzi w przewidywalny i wygodny rytm. Każdy wie, czego się spodziewać i kiedy może się tego spodziewać. Interesariusze widzą postępy często i są to postępy znaczące. Zamiast notatników pełnych schematów i planów demonstrowane jest działające oprogramowanie, które można wypróbować, poczuć i wyrazić na jego temat opinię. Deweloperzy widzą rozsądny plan bazujący na własnych szacunkach i kontrolowany przez zmierzoną przez siebie prędkość. Wybierają zadania, z którymi czują się komfortowo, i utrzymują wysoką jakość wykonania. Menedżerowie otrzymują dane w każdej iteracji. Wykorzystują te dane do sterowania i zarządzania projektem. Nie muszą uciekać się do nacisków, gróźb lub odwoływać się do lojalności tylko po to, aby uzyskać dowolny i nierealistyczny termin. Jeśli brzmi to jak przysłowiowa „sielanka”, to w praktyce tak nie jest. Interesariusze nie zawsze są zadowoleni z danych, które uzyskują w trakcie procesu, zwłaszcza na początku. Stosowanie metody agile nie oznacza, że interesariusze otrzymają to, czego chcą. To po prostu oznacza, że będą oni mieli możliwość kontroli nad zespołem w celu uzyskania największej wartości biznesowej jak najmniejszym kosztem.
Bibliografia 1. Kent Beck, Programowanie ekstremalne Explained: Embrace Change, Reading, MA, Addison-Wesley, 1999. 2. James Newkirk i Robert Martin, Extreme programming in Practice, Upper Saddle River, NJ, Addison-Wesley, 2001. 3. James Highsmith, Adaptive Software Development: A Collaborative Approach to Managing Complex Systems, Nowy Jork, Dorset House, 2000.
44
ROZDZIAŁ 3. PLANOWANIE
R OZDZIAŁ 4
Testowanie
Ogień jest próbą dla złota, przeciwności — dla silnych mężczyzn — Seneka (c. 3. 65 r. p.n.e.)
Akt pisania testów jednostkowych jest bardziej aktem projektowania niż weryfikacji. Jest to również bardziej akt dokumentacji niż weryfikacja. Pisanie testów jednostkowych zamyka niezwykłą liczbę pętli sprzężenia zwrotnego, a jedną z nich jest ta, która dotyczy weryfikacji funkcji.
Programowanie sterowane testami A gdyby tak pisać testy przed pisaniem programów? Gdyby tak odmówić implementacji funkcji w programach, dopóki nie będzie testu, który nie przechodzi dlatego, że tej funkcji nie ma? Gdyby tak odmówić dodania nawet jednej linijki kodu do programów, dopóki nie będzie testu, który nie przechodzi z powodu jej braku? Co by się stało, gdybyśmy stopniowo dodawali funkcjonalności do naszych programów przez pisanie najpierw testów, które zawodzą, ponieważ zakładały istnienie tej funkcjonalności, a następnie dążylibyśmy do tego, by te testy się powiodły? Jaki wpływ miałoby to na oprogramowanie, które piszemy? Jakie korzyści czerpalibyśmy z istnienia takich kompleksowych testów? Pierwszą i najbardziej oczywistą korzyścią byłoby to, że dla każdej funkcji programu istniałby test, który weryfikuje jej działanie. Ten zbiór testów spełnia rolę jednokierunkowego sprzęgła dla dalszego rozwoju. Poinformuje nas, kiedy przypadkowo doprowadzimy do awarii pewnych istniejących funkcji. Możemy dodawać funkcje do programu lub zmieniać strukturę programu bez obawy, że w tym procesie zepsujemy jakąś ważną funkcję. Testy poinformują nas, że program nadal zachowuje się poprawnie. W takim przypadku mamy znacznie większą swobodę wprowadzania zmian i poprawek do programu.
46
ROZDZIAŁ 4. TESTOWANIE
Ważniejszym, ale mniej oczywistym skutkiem jest to, że akt pisania testu w pierwszej kolejności zmusza nas do innego punktu widzenia. Musimy postrzegać program, który mamy zamiar napisać, z punktu widzenia programu wywołującego. Z tego względu w tym samym czasie zwracamy uwagę zarówno na interfejs programu, jak i na jego działanie. Dzięki pisaniu testu w pierwszej kolejności projektujemy oprogramowanie zapewniające wygodę wywoływania. Co więcej, pisząc najpierw test, zmuszamy się do projektowania programu, który jest sprawdzalny. Projektowanie programu tak, aby był wywoływalny i sprawdzalny, ma niezwykle istotne znaczenie. Aby oprogramowanie było wywoływalne i sprawdzalne, musi być oddzielone od otoczenia. Tak więc akt pisania testów najpierw zmusza nas do eliminowania sprzężeń w oprogramowaniu. Testy są również nieocenioną formą dokumentacji. Jeśli chcemy się dowiedzieć, jak wywołać funkcję lub stworzyć obiekt, możemy wykorzystać test, który to pokazuje. Testy działają jako zestaw przykładów, które pomagają innym programistom nauczyć się pracy z kodem. Ta dokumentacja jest kompilowalna i wykonywalna. Jest zawsze aktualna. Nie może kłamać.
Przykład projektu w stylu „najpierw test” Niedawno napisałem dla zabawy wersję gry Hunt the Wumpus. Program ten jest prostą grą przygodową, w której gracz chodzi po jaskini, próbując zabić Wumpusa, zanim Wumpus go zje. Jaskinia jest zbiorem pomieszczeń, które są ze sobą połączone za pomocą korytarzy. Każdy pokój może mieć przejścia na północ, południe, wschód lub zachód. Gracz porusza się, informując komputer o tym, w którym kierunku chce się udać. Jednym z pierwszych testów, które napisałem do tego programu, była funkcja testMove z listingu 4.1. Funkcja ta tworzy nowy obiekt WumpusGame, łączy pokój 4. z pokojem 5. za pomocą przejścia od strony wschodniej, umieszcza gracza w pokoju 4., wydaje polecenie do przejścia na wschód, a następnie stwierdza, że gracz powinien być w pokoju 5. Listing 4.1. Funkcja testMove public void testMove() { WumpusGame g = new WumpusGame(); g.connect(4,5,"E"); g.setPlayerRoom(4); g.east(); assertEquals(5, g.getPlayerRoom()); }
Cały ten kod został napisany przed napisaniem jakiejkolwiek części klasy WumpusGame. Posłuchałem rady Warda Cunninghama i napisałem test w taki sposób, w jaki chciałbym go przeczytać. Zaufałem, że mogę doprowadzić do tego, że test przejdzie, poprzez napisanie kodu, który będzie zgodny z wzorcem narzuconym przez test. Jest to tak zwane programowanie intencyjne (ang. intentional programming). Formułujemy zamiar w teście przed jego zaimplementowaniem. Dzięki temu zamiar staje się tak prosty i czytelny, jak to możliwe. Ufamy, że ta prostota i czytelność doprowadzą do dobrej struktury programu. Programowanie intencyjne natychmiast doprowadziło mnie do ciekawych decyzji projektowych. W teście nie wykorzystano klasy Room. Mój zamiar komunikuje działanie połączenia jednego pomieszczenia z innym. Nie wydaje się, abym potrzebował klasy Room dla ułatwienia tej komunikacji. Do reprezentowania pokojów wystarczą liczby całkowite. Czytelnikowi może się to wydawać sprzeczne z intuicją. Przecież można odnieść wrażenie, że ten program dotyczy przede wszystkim pokojów: przemieszczania się pomiędzy pokojami, odkrywania, co znajduje się w pokoju, itp. Czy ten projekt jest wadliwy z powodu braku klasy Room? Mógłbym argumentować, że pojęcie połączeń ma o wiele większe znaczenie w grze Wumpus od pojęcia pomieszczenia. Można by również wykazać, że ten pierwszy test wskazał dobry sposób na rozwiązanie problemu. Rzeczywiście myślę, że tak jest w tym przypadku, ale nie staram się sformułować takiego
PROGRAMOWANIE STEROWANE TESTAMI
47
twierdzenia. Najważniejsze jest to, że test zaprezentował kluczową kwestię projektu na bardzo wczesnym etapie. Akt pisania testów przed kodem jest aktem rozpoznawania decyzji projektowych. Zwróćmy uwagę, że test mówi nam, w jaki sposób działa program. Na podstawie tej prostej specyfikacji większość z nas z łatwością napisałaby cztery metody klasy WumpusGame. Bez większych problemów potrafilibyśmy także wymienić i napisać trzy inne polecenia kierunku. Gdybyśmy później chcieli się dowiedzieć, jak połączyć dwa pomieszczenia lub jak poruszać się w określonym kierunku, to ten test pokaże nam, jak to zrobić w sposób nie budzący wątpliwości. Test działa jak kompilowalny i wykonywalny dokument opisujący program.
Izolacja testu Akt pisania testów przed kodem produkcyjnym często ujawnia obszary w programie, w których trzeba wyeliminować sprzężenia. Na przykład na rysunku 4.1 pokazano prosty schemat UML1 aplikacji płacowej. Klasa Payroll wykorzystuje klasę EmployeeDatabase do pobrania obiektu Employee. Wysyła do obiektu Employee żądanie obliczenia płacy. Następnie przekazuje informację o płacy do obiektu CheckWriter w celu wygenerowania czeku. Na koniec księguje wypłatę do obiektu Employee i zapisuje obiekt z powrotem do bazy danych.
Rysunek 4.1. Model aplikacji płacowej ze sprzężeniami
Zakładamy, że na razie nie napisaliśmy jeszcze kodu tej aplikacji. Jak do tej pory ten schemat po prostu został narysowany na tablicy podczas trwania szybkiej sesji projektowej2. Teraz trzeba napisać testy, które specyfikują zachowanie obiektu Payroll. Istnieje szereg problemów związanych z pisaniem takich testów. Po pierwsze, jakiej bazy danych używamy? Obiekt Payroll musi czytać informacje z jakiejś bazy danych. Czy trzeba napisać w pełni działającą bazę danych, zanim będziemy mogli przetestować klasę Payroll? Jakie dane do niej załadujemy? Po drugie, w jaki sposób sprawdzimy, że został wydrukowany właściwy czek? Nie możemy napisać automatycznego testu, który bierze czek z drukarki i sprawdza kwotę, która na nim figuruje. Rozwiązaniem tych problemów jest zastosowanie wzorca Atrapa obiektu (ang. Mock object)3. Możemy wstawić interfejsy pomiędzy wszystkimi współpracownikami obiektu Payroll i utworzyć namiastki testowe (ang. test stubs), które implementują te interfejsy. Właściwą strukturę pokazano na rysunku 4.2. Klasa Payroll wykorzystuje teraz interfejsy do komunikowania się z obiektami EmployeeDatabase, CheckWriter i Employee. Zostały utworzone trzy obiekty-atrapy, które implementują te interfejsy. Obiekt PayrollTest odpytuje te obiekty-atrapy w celu zweryfikowania, czy obiekt Payroll prawidłowo nimi zarządza.
1
Czytelnikom nie znającym UML polecam zapoznanie się z dwoma dodatkami, w których szczegółowo opisano tę notację. Warto zapoznać się z dodatkami A i B.
2
[Jeffries 2001].
3
[Mackinnon 2000].
48
ROZDZIAŁ 4. TESTOWANIE
Rysunek 4.2. Aplikacja Payroll pozbawiona sprzężeń i korzystająca z obiektów-atrap do testowania
Na listingu 4.2 zaprezentowano intencję testu. Kod tworzy właściwe obiekty-atrapy, przekazuje je do obiektu Payroll, żąda od obiektu Payroll zrealizowania wypłaty dla wszystkich pracowników, a następnie żąda od obiektów-atrap, aby sprawdziły, czy wszystkie czeki zostały poprawnie wydrukowane i czy wszystkie wypłaty zostały prawidłowo zaksięgowane. Listing 4.2. TestPayroll public void testPayroll() { MockEmployeeDatabase db = new MockEmployeeDatabase(); MockCheckWriter w = new MockCheckWriter(); Payroll p = new Payroll(db, w); p.payEmployees(); assert(w.checksWereWrittenCorrectly()); assert(db.paymentsWerePostedCorrectly()); }
Oczywiście ten test sprawdza jedynie to, czy obiekt Payroll wywołał właściwe funkcje z właściwymi danymi. Test faktycznie nie sprawdza, czy czeki zostały wydrukowane. Nie sprawdza też, czy została odpowiednio zaktualizowana rzeczywista baza danych. Zamiast tego sprawdza, czy klasa Payroll — w odosobnieniu — zachowuje się tak, jak powinna. Można by się zastanawiać, do czego służy obiekt MockEmployee. Wydaje się możliwe, aby zamiast atrapy została użyta prawdziwa klasa Employee. Gdyby tak było, to nie miałbym skrupułów, aby jej użyć. W tym przypadku założyłem, że klasa Employee jest bardziej skomplikowana, niż jest to potrzebne do sprawdzenia działania klasy Payroll.
Nieoczekiwane wyeliminowanie sprzężeń Wyeliminowanie sprzężeń z klasy Payroll to dobra rzecz. Pozwala nam używać różnych baz danych i klas drukowania czeków zarówno w celach testowania, jak i rozszerzenia aplikacji. Myślę, że interesujące jest to, że wyeliminowanie sprzężeń było spowodowane potrzebą testowania. Najwyraźniej potrzeba
TESTY AKCEPTACYJNE
49
izolacji testowanego modułu zmusza nas do eliminowania z niego sprzężeń w sposób, który przynosi korzyści dla ogólnej struktury programu. Pisanie testów przed kodem poprawia projekt systemu. Duża część tej książki dotyczy zasad projektowych stosowanych w celu zarządzania zależnościami. Te zasady oferują czytelnikom pewne wskazówki i techniki eliminowania sprzężeń z klas i pakietów. Stosowanie tych zasad jest najbardziej korzystne, jeśli wykorzystuje się je w ramach strategii testów jednostkowych. To testy jednostkowe zapewniają większość bodźców i wskazówek do eliminacji sprzężeń.
Testy akceptacyjne Testy jednostkowe są koniecznymi, ale niewystarczającymi narzędziami weryfikacji. Testy jednostkowe sprawdzają, czy małe elementy systemu działają zgodnie z oczekiwaniami, ale nie weryfikują, czy system działa prawidłowo jako całość. Testy jednostkowe są testami białej skrzynki4, które weryfikują poszczególne mechanizmy systemu. Testy akceptacyjne są testami czarnej skrzynki5, które sprawdzają, czy zostały spełnione wymagania klienta. Testy akceptacyjne są pisane przez osoby, które nie znają wewnętrznych mechanizmów systemu. Mogą one być pisane bezpośrednio przez klienta lub przez pewne osoby związane z klientem, na przykład pracowników działu jakości. Testy akceptacyjne są programami, dlatego są wykonywalne. Jednakże zwykle są pisane za pomocą specjalnych języków skryptowych stworzonych dla klientów aplikacji. Testy akceptacyjne są ostateczną dokumentacją funkcji. Kiedy klient napisze testy akceptacyjne, które sprawdzają, czy funkcja działa poprawnie, programiści mogą odczytać te testy akceptacyjne, aby w pełni zrozumieć funkcję. Tak więc o ile testy jednostkowe służą jako kompilowalna i wykonywalna dokumentacja dla wewnętrznych mechanizmów systemu, o tyle testy akceptacyjne spełniają rolę kompilowalnej i wykonywalnej dokumentacji funkcji systemu. Ponadto fakt pisania testów akceptacyjnych przed kodem ma głęboki wpływ na architekturę systemu. Aby system był sprawdzalny, należy pozbawić go sprzężeń na wysokim poziomie architektury. Na przykład interfejs użytkownika (ang. user interface — UI) musi być oddzielony od reguł biznesowych w taki sposób, aby testy akceptacyjne mogły uzyskać dostęp do tych reguł biznesowych bez konieczności korzystania z UI. We wczesnych iteracjach projektu powstaje pokusa, aby testy akceptacyjne wykonywać ręcznie. Nie jest to wskazane, ponieważ pozbawia te wczesne iteracje dążenia do eliminowania sprzężeń. Źródłem tego dążenia jest bowiem potrzeba automatyzacji testów akceptacyjnych. Świadomość konieczności zautomatyzowania testów akceptacyjnych po uruchomieniu pierwszej iteracji prowadzi do przyjmowania bardzo ważnych kompromisów architektonicznych. Podobnie jak testy jednostkowe kierują nas w stronę podejmowania właściwych decyzji projektowych na niskim poziomie, tak testy akceptacyjne skłaniają do podejmowania lepszych decyzji architektonicznych na wysokim poziomie. Stworzenie frameworka testów akceptacyjnych może wydawać się trudnym zadaniem. Jeśli jednak weźmiemy funkcje tylko z jednej iteracji i utworzymy tylko część frameworka niezbędnego do wykonania tych kilku testów akceptacyjnych, dojdziemy do przekonania, że napisanie tego frameworka nie jest takie trudne. Przekonamy się również, że wysiłek jest wart kosztów.
4
Test, który zna wewnętrzną strukturę testowanego modułu i zależy od niej.
5
Test, który nie zna wewnętrznej struktury testowanego modułu i od niej nie zależy.
50
ROZDZIAŁ 4. TESTOWANIE
Przykład testów akceptacyjnych Rozważmy ponownie aplikację płacową. W pierwszej iteracji musimy mieć możliwość dodawania i usuwania pracowników do i z bazy danych. Musimy także być w stanie tworzyć czeki dla pracowników obecnie zapisanych w bazie danych. Na szczęście musimy brać pod uwagę tylko pracowników etatowych. Pracownikami innego rodzaju zajmiemy się w jednej z późniejszych iteracji. Na razie nie napisaliśmy jeszcze żadnego kodu i jeszcze nie zainwestowaliśmy niczego w projekt. To najlepszy czas, aby zacząć myśleć o testach akceptacyjnych. Tak jak wcześniej użytecznym narzędziem jest programowanie intencyjne. Powinniśmy napisać testy akceptacyjne tak, jak naszym zdaniem powinny one wyglądać, a następnie opracować wokół takiego testu strukturę języka skryptowego i systemu płacowego. Chcemy, aby testy akceptacyjne były wygodne do pisania i łatwe do zmiany. Chcemy, aby były umieszczone w narzędziu do zarządzania konfiguracją i zapisane tak, aby można je było uruchomić w każdej chwili, gdy będziemy mieli na to ochotę. W związku z tym ma sens, aby testy akceptacyjne były napisane w prostych plikach tekstowych. Oto przykład skryptu testu akceptacyjnego: AddEmp 1429 "Robert Martin" 3215.88 Payday Verify Paycheck EmpId 1429 GrossPay 3215.88
W pokazanym przykładzie dodajemy do bazy danych pracownika o identyfikatorze 1429. Nazywa się Robert Martin, a jego miesięczna pensja wynosi 3215,88 dolara. Następnie informujemy system, że jest dzień wypłaty i trzeba zrealizować wypłatę dla wszystkich pracowników. Na koniec sprawdzamy, czy została wygenerowana wypłata dla pracownika o identyfikatorze 1429 z polem GrossPay o wartości $3215.88. Wyraźnie widać, że napisanie takiego skryptu przez klientów będzie bardzo łatwe. Do takiego skryptu można również łatwo dodawać nowe funkcjonalności. Zastanówmy się jednak, co to oznacza dla struktury systemu. Pierwsze dwie linijki skryptu są funkcjami aplikacji płacowej. Możemy nazwać te linijki transakcjami płac. Są to funkcje, których oczekują użytkownicy aplikacji płacowej. Jednak linijka z funkcją Verify nie jest transakcją, której oczekują użytkownicy aplikacji płacowej. Ta linijka to dyrektywa, która jest specyficzna dla testu akceptacyjnego. Tak więc nasz framework testów akceptacyjnych będzie musiał analizować ten plik tekstowy i oddzielać transakcje płacowe od dyrektyw testu akceptacyjnego. Musi wysłać transakcje płacowe do aplikacji płacowej, a następnie użyć dyrektyw testu akceptacyjnego, aby odpytać aplikację płacową w celu weryfikacji danych. To wywiera nacisk architektoniczny na aplikację płacową. Program płacowy będzie musiał akceptować wejście bezpośrednio od użytkowników, a także z frameworka testów akceptacyjnych. Chcemy jak najszybciej scalić te dwie ścieżki wejścia. Wygląda na to, że program płacowy będzie potrzebował procesora transakcji, który będzie w stanie obsługiwać transakcje w formie AddEmp i Payday pochodzące z więcej niż jednego źródła. Trzeba znaleźć jakąś wspólną formę dla tych transakcji, tak aby ilość specjalistycznego kodu była ograniczona do minimum. Jednym z rozwiązań mogłoby być wprowadzanie do aplikacji płacowej transakcji w formacie XML. Oczywiście framework testów akceptacyjnych może generować XML. Wydaje się też prawdopodobne, że interfejs użytkownika systemu płacowego także może generować XML. Możemy więc oczekiwać transakcji, które wyglądają tak:
1429 Robert Martin 3215.88
WNIOSEK
51
Te transakcje mogą trafić do aplikacji płacowej poprzez wywołanie procedury, gniazdo, a nawet wejściowy plik wsadowy. Rzeczywiście, zmiana jednego źródła na inne w trakcie prac projektowych jest trywialnym zadaniem. Dlatego na początku iteracji możemy zdecydować, że będziemy czytać transakcje z pliku, a następnie migrować do API lub gniazda w późniejszej fazie projektu. W jaki sposób framework testów akceptacyjnych wywołuje dyrektywę Verify? Jest oczywiste, że musi istnieć pewien sposób dostępu do danych generowanych przez aplikację płacową. Jak wspominaliśmy wcześniej, nie chcemy, aby framework testów akceptacyjnych potrafił czytać wydrukowane czeki, ale możemy zrealizować następne dobre rozwiązanie. Możemy zlecić aplikacji płacowej generowanie czeków w XML. Framework testów akceptacyjnych może następnie przechwycić ten XML i odpytywać o właściwe dane. Ostatni etap drukowania czeku z XML może być na tyle banalny, aby można go było obsłużyć za pomocą ręcznych testów akceptacyjnych. Dlatego aplikacja płacowa może stworzyć dokument XML, który zawiera wszystkie czeki. Dokument ten może mieć następującą zawartość:
1429 Robert Martin 3215.88
Oczywiście framework testów akceptacyjnych po otrzymaniu takiego dokumentu XML może uruchomić dyrektywę Verify. Jak wspominaliśmy wcześniej, możemy przesłać taki dokument XML przez gniazdo, API bądź plik. W początkowych iteracjach plik wydaje się najprostszym sposobem. Z tego powodu aplikacja płacowa rozpocznie swoje życie od czytania transakcji XML z pliku i wyprowadzania czeków do pliku XML. Framework testów akceptacyjnych będzie czytał transakcje w postaci tekstowej, tłumaczył na XML i zapisywał do pliku. Następnie wywoła aplikację płacową. Na koniec przeczyta wyjściowy XML z programu płacowego i wywoła dyrektywy Verify.
Architektura „przy okazji” Zwróćmy uwagę na wpływ, jaki wywierają testy akceptacyjne na architekturę systemu płacowego. Sam fakt, że zaczęliśmy brać pod uwagę pisanie testów w pierwszej kolejności, bardzo szybko doprowadził nas do zastosowania wejścia i wyjścia w formacie XML. Taka architektura umożliwiła oddzielenie źródeł transakcji od aplikacji płacowej. Spowodowała również oddzielenie mechanizmu drukującego czeki od aplikacji płacowej. To są dobre decyzje architektoniczne.
Wniosek Im prostsze jest uruchomienie zestawu testów, tym częściej będą one uruchamiane. Im częściej będą uruchamiane testy, tym szybciej wykryjemy wszelkie odchylenia. Gdyby udało nam się uruchomić wszystkie testy kilka razy dziennie, to nieprawidłowe działanie systemu nigdy nie trwałoby dłużej niż kilka minut. To rozsądny cel. Po prostu nie pozwalamy na to, aby system wymknął nam się spod kontroli. Kiedy system raz zacznie działać na określonym poziomie, nigdy nie „zsunie się” na niższy poziom. Jednak weryfikacja to tylko jedna z korzyści płynących z pisania testów. Zarówno testy jednostkowe, jak i testy akceptacyjne są formą dokumentacji. Ta dokumentacja jest kompilowalna i wykonywalna, dlatego jest dokładna i wiarygodna. Co więcej, testy są napisane w jednoznacznych językach, które są czytelne dla ich odbiorców. Programiści potrafią czytać testy jednostkowe, ponieważ są one napisane w ich języku programowania. Klienci potrafią czytać testy akceptacyjne, ponieważ są one napisane w języku, który oni sami zaprojektowali.
52
ROZDZIAŁ 4. TESTOWANIE
Prawdopodobnie najważniejszą korzyścią z testowania jest jego wpływ na architekturę i projekt. Aby moduł lub aplikacja były sprawdzalne, muszą być również pozbawione sprzężeń. Im bardziej aplikacja jest sprawdzalna, tym w większym stopniu są z niej wyeliminowane sprzężenia. Akt pisania kompleksowych testów akceptacyjnych i testów jednostkowych ma głęboki wpływ na strukturę oprogramowania.
Bibliografia 1. Tim Mackinnon, Steve Freeman, Philip Craig, Endo-Testing: Unit Testing with Mock Objects. Extreme Programming Examined, Addison-Wesley, 2001. 2. Ron Jeffries et al., Extreme Programming Installed, Upper Saddle River, NJ: Addison-Wesley, 2001.
R OZDZIAŁ 5
Refaktoryzacja
Jedynym czynnikiem, który staje się rzadkością w świecie obfitości, jest ludzka uwaga — Kevin Kelly w czasopiśmie „Wired”
Tematem tego rozdziału jest ludzka uwaga. Chodzi o zwracanie uwagi na to, co robimy, i dbanie o to, abyśmy robili to jak najlepiej. Chodzi o różnicę pomiędzy zrobieniem czegoś tak, żeby działało, a zrobieniem tego dobrze. Chodzi o wagę, jaką przywiązujemy do struktury naszego kodu. Martin Fowler w swojej klasycznej książce Refactoring definiuje refaktoryzację jako „... proces modyfikacji systemu oprogramowania w sposób, który nie wywiera szkodliwego wpływu na działanie kodu na zewnątrz, ale poprawia jego strukturę wewnętrzną1”. Ale po co mielibyśmy poprawiać strukturę działającego kodu? Co ze starym powiedzeniem, „jeśli coś nie jest zepsute, nie należy tego naprawiać”? Każdy moduł oprogramowania spełnia trzy funkcje. Pierwszą jest funkcja wypełniania przez system podczas działania programu. Ta funkcja jest powodem, dla którego moduł istnieje. Drugą funkcją modułu jest możliwość wprowadzania zmian. Prawie wszystkie moduły zmieniają się w trakcie swojego życia, a deweloperzy są odpowiedzialni za to, aby wprowadzanie tych zmian było jak najprostsze. Moduł, w którym wprowadzanie zmian jest trudne, jest uszkodzony i wymaga naprawy, nawet jeśli działa. Trzecią funkcją modułu jest komunikacja z jego czytelnikami. Deweloperzy, którzy nie znają modułu, powinni być w stanie przeczytać i zrozumieć go bez zbędnej gimnastyki umysłowej. Moduł, który niezbyt dobrze komunikuje swoje działanie, jest uszkodzony i wymaga naprawy.
1
[Fowler 99], str. xvi.
54
ROZDZIAŁ 5. REFAKTORYZACJA
Co sprawia, że moduł staje się łatwy do czytania i wprowadzania zmian? Znaczna część tej książki jest poświęcona zasadom i wzorcom, których głównym celem jest pomoc w tworzeniu elastycznych i adaptowalnych modułów. Jednak stworzenie modułu, który jest łatwy do czytania i wprowadzania zmian, wymaga czegoś więcej niż tylko zasad i wzorców. Wymaga uwagi, dyscypliny i pasji do tworzenia piękna.
Generowanie liczb pierwszych — prosty przykład refaktoryzacji2 Rozważmy kod z listingu 5.1. Ten program generuje liczby pierwsze. Jest to jedna wielka funkcja z wieloma zmiennymi w postaci pojedynczych liter i komentarzami, które mają pomóc nam ją przeczytać. Listing 5.1. GeneratePrimes.java, wersja pierwsza /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. *
* Eratostenes z Cyreny, ur. 276 p.n.e. w Cyrenie, Libia — zm. 194 p.n.e. w Aleksandrii. * Pierwszy człowiek, który obliczył obwód Ziemi. * Znany także z prac nad kalendarzem z latami przestępnymi. Prowadził bibliotekę w Aleksandrii. *
* Algorytm jest stosunkowo prosty. W tablicy liczb całkowitych zaczynających się od liczby 2 * skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż przekroczysz pierwiastek kwadratowy z wartości maksymalnej. * * @author Robert C. Martin * @version 9 12 grudnia 1999 */
import java.util.*;
public class GeneratePrimes {
/**
* @param maxValue to ograniczenie generowanych wartości. */
public static int[] generatePrimes(int maxValue) { if (maxValue >= 2) // jedyny prawidłowy przypadek {
// deklaracje
int s = maxValue + 1; // rozmiar tablicy boolean[] f = new boolean[s]; int i;
// zainicjowanie tablicy wartością true
for (i = 0; i < s; i++) f[i] = true;
// pozbycie się znanych wartości, które nie są liczbami pierwszymi
f[0] = f[1] = false;
// sito
int j; for (i = 2; i < Math.sqrt(s) + 1; i++) { if (f[i]) // jeśli i jest nieskreślone, skreśl jego wielokrotności. { for (j = 2 * i; j < s; j += i) f[j] = false; // wielokrotność nie jest liczbą pierwszą
2
Początkowo napisałem ten program na potrzeby kursu XP Immersion I, stosując testy napisane przez Jima Newkirka. Kent Beck i Jim Newkirk zrefaktoryzowali ten kod w obecności studentów. Tutaj spróbowałem odtworzyć tę refaktoryzację.
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
}
55
}
// ile liczb pierwszych jest w tablicy?
int count = 0; for (i = 0; i < s; i++) { if (f[i]) count++; // zwiększenie licznika. } int[] primes = new int[count];
// przeniesienie liczb pierwszych do wyniku
for (i = 0, j = 0; i < s; i++) { if (f[i]) // jeżeli to liczba pierwsza primes[j++] = i; }
}
}
return primes; // zwrócenie liczb pierwszych } else // maxValue < 2 return new int[0]; // zwrócenie tablicy null w przypadku błędnych danych wejściowych.
Test jednostkowy dla funkcji GeneratePrimes zamieszczono na listingu 5.2. Test przyjmuje podejście statystyczne, sprawdzając, czy generator może generować liczby pierwsze dla 0, 2, 3 i 100 elementów. W pierwszym przypadku algorytm nie powinien zwrócić żadnych liczb pierwszych. W drugim przypadku powinna być jedna liczba pierwsza — 2. W trzecim powinny być dwie liczby pierwsze — 2 i 3. W ostatnim przypadku powinno być 25 liczb pierwszych, z których ostatnia to 97. Jeśli wszystkie testy przejdą, to można przyjąć założenie, że generator pracuje. Wątpię, aby ten test pokrywał wszystkie przypadki, ale nie potrafię znaleźć sensownego scenariusza, w którym test przechodzi, a pomimo tego funkcja nie działa. Listing 5.2. TestGeneratePrimes.java import junit.framework.*; import java.util.*; public class TestGeneratePrimes extends TestCase { public static void main(String args[]) { junit.swingui.TestRunner.main( new String[] {"TestGeneratePrimes"}); } public TestGeneratePrimes(String name) { super(name); } public void testPrimes() { int[] nullArray = GeneratePrimes.generatePrimes(0); assertEquals(nullArray.length, 0); int[] minArray = GeneratePrimes.generatePrimes(2); assertEquals(minArray.length, 1); assertEquals(minArray[0], 2); int[] threeArray = GeneratePrimes.generatePrimes(3); assertEquals(threeArray.length, 2); assertEquals(threeArray[0], 2);
56
ROZDZIAŁ 5. REFAKTORYZACJA
assertEquals(threeArray[1], 3);
}
}
int[] centArray = GeneratePrimes.generatePrimes(100); assertEquals(centArray.length, 25); assertEquals(centArray[24], 97);
Do wykonania procesu refaktoryzacji tego program użyłem przeglądarki refaktoryzacji Idea firmy IntelliJ. Za pomocą tego narzędzie można w trywialny sposób wyodrębniać metody oraz zmieniać nazwy zmiennych i klas. Wydaje się dość oczywiste, że główną funkcję należałoby rozbić na trzy oddzielne funkcje. Pierwsza inicjuje wszystkie zmienne i ustawia sito. Druga faktycznie wykonuje algorytm sita, natomiast trzecia ładuje przesiane wyniki do tablicy liczb całkowitych. Aby lepiej pokazać tę strukturę, na listingu 5.3 wyodrębniłem te funkcje do trzech osobnych metod. Usunąłem również kilka niepotrzebnych komentarzy i zmieniłem nazwę klasy na PrimeGenerator. Wszystkie testy nadal przechodziły. Wyodrębnienie tych trzech funkcji zmusiło mnie do wypromowania niektórych zmiennych funkcji do statycznych pól klasy. Sądzę, że to pozwoliło jednoznacznie pokazać, które zmienne są lokalne, a które mają szerszy zasięg. Listing 5.3. PrimeGenerator.java, wersja druga /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. * W tablicy liczb całkowitych zaczynających się od liczby 2 * Skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż przekroczysz pierwiastek kwadratowy z wartości maksymalnej. */
import java.util.*; public class PrimeGenerator { private static int s; private static boolean[] f; private static int[] primes;
public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeSieve(maxValue); sieve(); loadPrimes(); return primes; // zwrócenie liczb pierwszych } } private static void loadPrimes() { int i; int j;
// ile liczb pierwszych jest w tablicy?
int count = 0; for (i = 0; i < s; i++) { if (f[i]) count++; // zwiększenie licznika. } primes = new int[count];
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
57
// przeniesienie liczb pierwszych do wyniku
for (i = 0, j = 0; i < s; i++) { if (f[i]) // jeżeli to liczba pierwsza primes[j++] = i; } } private static void sieve() { int i; int j; for (i = 2; i < Math.sqrt(s) + 1; i++) { if (f[i]) // jeśli i jest nieskreślone, skreśl jego wielokrotności. { for (j = 2 * i; j < s; j += i) f[j] = false; // wielokrotność nie jest liczbą pierwszą } } } private static void initializeSieve(int maxValue) {
// deklaracje
s = maxValue + 1; // rozmiar tablicy f = new boolean[s]; int i;
// zainicjowanie tablicy wartościami true
for (i = 0; i < s; i++) f[i] = true;
// pozbycie się znanych wartości, które nie są liczbami pierwszymi
}
}
f[0] = f[1] = false;
W funkcji initializeSieve jest trochę chaosu, dlatego na listingu 5.4 znacznie ją uporządkowałem. Po pierwsze, zastąpiłem wszystkie wystąpienia zmiennej s wywołaniem f.length. Następnie zmieniłem nazwy trzech funkcji na bardziej ekspresywne. Na koniec zreorganizowałem zawartość funkcji initializeArrayOfIntegers (która wcześniej nazywała się initializeSieve), aby łatwiej się ją czytało. Wszystkie testy nadal przechodziły. Listing 5.4. PrimeGenerator.java, wersja trzecia (fragment) public class PrimeGenerator { private static boolean[] f; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeArrayOfIntegers(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void initializeArrayOfIntegers(int maxValue) { f = new boolean[maxValue + 1];
58
}
ROZDZIAŁ 5. REFAKTORYZACJA
f[0] = f[1] = false; // ani liczba pierwsza, ani wielokrotność. for (int i = 2; i < f.length ; i++) f[i] = true;
Następnie przyjrzałem się metodzie crossOutMultiples. W tej funkcji, a także w innych, było wiele instrukcji postaci if(f[i]==true). Intencją było sprawdzenie, czy i jest skreślone, dlatego zmieniłem nazwę f na unCrossed. To jednak doprowadziło do brzydkich instrukcji w postaci unCrossed[i] = false. Uznałem, że podwójna negacja jest myląca. Dlatego zmieniłem nazwę tablicy na isCrossed, a następnie zmieniłem sens wszystkich wartości Boolean. Wszystkie testy nadal przechodziły. Pozbyłem się inicjalizacji, która ustawiała wartości tablicy isCrossed[0] i isCrossed[1] na true i po prostu sprawdziłem, czy któraś z części tej funkcji nie używa tablicy isCrossed dla indeksów mniejszych od 2. Wyodrębniłem wewnętrzną pętlę funkcji crossOutMultiples i nazwałem ją crossOutMultiplesOf. Uznałem także, że zapis if(isCrossed[i]== false) był mylący, dlatego stworzyłem funkcję notCrossed i zmieniłem instrukcję if na if(notCrossed(i)). Wszystkie testy nadal przechodziły. Poświęciłem trochę czasu na pisanie komentarza, który próbował wyjaśnić, dlaczego musimy iterować tylko do wartości pierwiastka kwadratowego z rozmiaru tablicy. Doprowadziło mnie to do wyodrębnienia obliczeń do funkcji, w której mogłem umieścić komentarz wyjaśniający. Pisząc komentarz, zauważyłem, że pierwiastek kwadratowy jest maksymalnym czynnikiem pierwszym dowolnej liczby całkowitej w tablicy. Z tego względu wybrałem tę nazwę dla zmiennych oraz funkcji, które je przetwarzały. Wynik wszystkich przekształceń refaktoryzacyjnych pokazano na listingu 5.5. Wszystkie testy nadal przechodziły. Listing 5.5. PrimeGenerator.java, wersja czwarta (fragment) public class PrimeGenerator { private static boolean[] isCrossed; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { initializeArrayOfIntegers(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void initializeArrayOfIntegers(int maxValue) { isCrossed = new boolean[maxValue + 1]; for (int i = 2; i < isCrossed.length; i++) isCrossed[i] = false; } private static void crossOutMultiples() { int maxPrimeFactor = calcMaxPrimeFactor(); for (int i = 2; i <= maxPrimeFactor; i++) if (notCrossed(i)) crossOutMultiplesOf(i); } private static int calcMaxPrimeFactor() { // Skreślamy wszystkie wielokrotności liczby p, jeśli p jest liczbą pierwszą. // Zatem czynnikami wszystkich skreślonych wielokrotności są p i q. // Jeśli p > pierwiastek kwadratowy z rozmiaru tablicy,
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
59
// to q nigdy nie będzie większe niż 1. // Zatem p jest największym czynnikiem pierwszym w tablicy. // Jednocześnie jest to limit iteracji.
}
double maxPrimeFactor = Math.sqrt(isCrossed.length) + 1; return (int) maxPrimeFactor;
private static void crossOutMultiplesOf(int i) { for (int multiple = 2*i; multiple < isCrossed.length; multiple += i) isCrossed[multiple] = true; } private static boolean notCrossed(int i) { return isCrossed[i] == false; }
Ostatnią funkcją do zrefaktoryzowania jest putUncrossedIntegersIntoResult. Metoda ta składa się z dwóch części. W pierwszej części zlicza nieskreślone liczby całkowite w tablicy i tworzy tablicę wynikową o tym rozmiarze. W drugiej przenosi nieskreślone liczby całkowite do tablicy wynikowej. Wyodrębniłem pierwszą część do osobnej funkcji i zrobiłem trochę porządku (patrz listing 5.6). Wszystkie testy nadal przechodziły. Listing 5.6. PrimeGenerator.java, wersja piąta (fragment) private static void putUncrossedIntegersIntoResult() { result = new int[numberOfUncrossedIntegers()]; for (int j = 0, i = 2; i < isCrossed.length; i++) if (notCrossed(i)) result[j++] = i; } private static int numberOfUncrossedIntegers() { int count = 0; for (int i = 2; i < isCrossed.length; i++) if (notCrossed(i)) count++; return count; }
Ostateczny przegląd Następnie jeszcze raz przejrzałem cały program, czytając go od początku do końca — jak ktoś, kto chce przeczytać dowód geometryczny. To bardzo ważny krok. Dotąd refaktoryzowałem fragmenty. Teraz chcę zobaczyć, czy cały program tworzy czytelną całość. Najpierw zdałem sobie sprawę, że nie podoba mi się nazwa initializeArrayOfIntegers. To, co inicjalizujemy, w rzeczywistości nie jest tablicą liczb całkowitych, ale tablicą wartości Boolean. Jednak zastosowanie nazwy initializeArrayOfBooleans nie poprawia sytuacji. W rzeczywistości w tej metodzie anulujemy skreślenie odpowiednich liczb całkowitych, tak aby następnie móc skreślić wielokrotności. Z tego powodu zmieniłem nazwę metody na uncrossIntegersUpTo. Zdałem sobie również sprawę, że nie podoba mi się nazwa isCrossed dla tablicy wartości Boolean. Z tego powodu zmieniłem tę nazwę na crossedOut. Wszystkie testy nadal przechodziły.
60
ROZDZIAŁ 5. REFAKTORYZACJA
Można by pomyśleć, że dość lekko podchodziłem do zmiany nazw, ale dzięki edytorowi refaktoryzacji można sobie pozwolić na tego rodzaju poprawki — ich koszt jest praktycznie zerowy. Nawet bez specjalistycznego narzędzia proste wyszukiwanie z zamianą jest dość tanie. A testy zdecydowanie łagodzą wszelkie szanse na to, aby nieświadomie coś zepsuć. Nie wiem, co ja paliłem, pisząc to wszystko o wartości maxPrimeFactor. Ups! Pierwiastek kwadratowy z rozmiaru tablicy niekoniecznie musi być liczbą pierwszą. Ta metoda nie obliczała maksymalnego czynnika pierwszego. Wyjaśniający komentarz był po prostu błędny. Z tego powodu przepisałem komentarz, aby lepiej wyjaśnić uzasadnienie pierwiastka kwadratowego, i odpowiednio zmieniłem nazwę wszystkich zmiennych3. Wszystkie testy nadal przechodziły. Do czego, u licha, jest to +1? Myślę, że to musiała być paranoja. Obawiałem się, że ułamkowy pierwiastek kwadratowy po konwersji na liczbę całkowitą przyjmie wartość, która będzie zbyt mała, aby mogła służyć jako granica iteracji. Ale to było głupie. Prawdziwą granicą iteracji jest największa liczba pierwsza, która jest mniejsza lub równa pierwiastkowi kwadratowemu z rozmiaru tablicy. W związku z tym pozbyłem się fragmentu +1. Wszystkie testy nadal przechodziły, ale ta ostatnia zmiana wywołała u mnie pewną nerwowość. Rozumiem uzasadnienie zastosowania wartości pierwiastka kwadratowego, ale mam wrażenie, że mogą występować pewne graniczne przypadki, które nie zostały pokryte. Z tego powodu napisałem kolejny test, który sprawdza, czy nie ma wielokrotności w żadnej z list liczb pierwszych od 2 do 500 (patrz funkcja testExhaustive na listingu 5.8). Nowy test przeszedł, a moje obawy zostały rozwiane. Pozostałą część kodu czyta się dość przyjemnie. Myślę więc, że praca jest skończona. Ostateczną wersję zamieszczono na listingach 5.7 i 5.8. Listing 5.7. PrimeGenerator.java, wersja ostateczna /**
* Ta klasa generuje liczby pierwsze do wartości maksymalnej określonej przez użytkownika. * Zastosowano algorytm sita Eratostenesa. * W tablicy liczb całkowitych zaczynających się od liczby 2 * Skreśl wszystkie wielokrotności 2. Znajdź kolejną nieskreśloną liczbę i skreśl jej wszystkie wielokrotności. * Powtarzaj, aż nie będzie wielokrotności w tablicy. */
public class PrimeGenerator { private static boolean[] crossedOut; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) return new int[0]; else { uncrossIntegersUpTo(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void uncrossIntegersUpTo(int maxValue) { crossedOut = new boolean[maxValue + 1]; 3
Kiedy Kent Beck i Jim Newkirk zrefaktoryzowali ten program, obyli się bez pierwiastka kwadratowego. Kent uznał, że zastosowanie pierwiastka kwadratowego było niezrozumiałe i nie było testu, który by nie przeszedł, gdyby iteracje były wykonywane do wartości rozmiaru tablicy. Nie mogę zmusić się do rezygnacji z wydajności. Myślę, że to zdradza moje korzenie — programowanie w języku assemblera.
GENEROWANIE LICZB PIERWSZYCH — PROSTY PRZYKŁAD REFAKTORYZACJI
}
for (int i = 2; i < crossedOut.length; i++) crossedOut[i] = false;
private static void crossOutMultiples() { int limit = determineIterationLimit(); for (int i = 2; i <= limit; i++) if (notCrossed(i)) crossOutMultiplesOf(i); } private static int determineIterationLimit() {
// Każda wielokrotność w tablicy posiada czynnik pierwszy, który // jest mniejszy lub równy pierwiastkowi kwadratowemu z rozmiaru tablicy // Dlatego nie trzeba skreślać wielokrotności liczb // większych od tego pierwiastka.
}
double iterationLimit = Math.sqrt(crossedOut.length); return (int) iterationLimit;
private static void crossOutMultiplesOf(int i) { for (int multiple = 2*i; multiple < crossedOut.length; multiple += i) crossedOut[multiple] = true; } private static boolean notCrossed(int i) { return crossedOut[i] == false; } private static void putUncrossedIntegersIntoResult() { result = new int[numberOfUncrossedIntegers()]; for (int j = 0, i = 2; i < crossedOut.length; i++) if (notCrossed(i)) result[j++] = i; }
}
private static int numberOfUncrossedIntegers() { int count = 0; for (int i = 2; i < crossedOut.length; i++) if (notCrossed(i)) count++; return count; }
Listing 5.8. TestGeneratePrimes.java, wersja ostateczna import junit.framework.*; public class TestGeneratePrimes extends TestCase { public static void main(String args[]) { junit.swingui.TestRunner.main( new String[] {"TestGeneratePrimes"}); } public TestGeneratePrimes(String name) { super(name);
61
62
ROZDZIAŁ 5. REFAKTORYZACJA
} public void testPrimes() { int[] nullArray = PrimeGenerator.generatePrimes(0); assertEquals(nullArray.length, 0); int[] minArray = PrimeGenerator.generatePrimes(2); assertEquals(minArray.length, 1); assertEquals(minArray[0], 2); int[] threeArray = PrimeGenerator.generatePrimes(3); assertEquals(threeArray.length, 2); assertEquals(threeArray[0], 2); assertEquals(threeArray[1], 3);
}
int[] centArray = PrimeGenerator.generatePrimes(100); assertEquals(centArray.length, 25); assertEquals(centArray[24], 97);
public void testExhaustive() { for (int i = 2; i<500; i++) verifyPrimeList(PrimeGenerator.generatePrimes(i)); } private void verifyPrimeList(int[] list) { for (int i=0; i
}
private void verifyPrime(int n) { for (int factor=2; factor
Wniosek Końcową wersję tego programu czyta się znacznie lepiej od wersji początkowej. Program działa również nieco lepiej. Jestem bardzo zadowolony z efektów. Program jest znacznie bardziej zrozumiały, a przez to wprowadzanie w nim zmian jest znacznie łatwiejsze. Ponadto w strukturze programu poszczególne części zostały od siebie odizolowane. To także sprawia, że wprowadzanie zmian w programie jest znacznie łatwiejsze. Można by się obawiać, że wyodrębnianie funkcji, które są wywoływane tylko raz, może negatywnie wpłynąć na wydajność. Myślę, że w większości przypadków poprawiona czytelność jest warta kilku dodatkowych nanosekund. Jednak w programie mogłyby występować głęboko zagnieżdżone wewnętrzne pętle — wtedy te kilka nanosekund mogłoby być kosztowne. Radzę założyć, że koszt będzie znikomy, i czekać, czy później nie okaże się, że założenie było błędne. Czy warto było poświęcić czas, który w to zainwestowaliśmy? Ostatecznie funkcja działała, gdy zaczęliśmy przekształcenia. Zdecydowanie polecam, aby zawsze wykonywać tego rodzaju refaktoryzację dla każdego modułu, który piszemy, oraz dla każdego modułu, który utrzymujemy. Zainwestowany czas jest bardzo krótki w porównaniu z wysiłkiem, jakiego zaoszczędzimy sobie i innym w najbliższej przyszłości. Refaktoryzację można porównać do posprzątania kuchni po kolacji. Kiedy pominiemy tę czynność za pierwszym razem, szybciej uporamy się z kolacją. Ale brak czystych naczyń i wolnej przestrzeni do pracy sprawi, że przygotowanie posiłku następnego dnia zajmie więcej czasu. To sprawi, że znów nie będzie nam się chciało sprzątać. Rzeczywiście, zawsze można szybciej skończyć obiad dziś, jeśli pominiemy
BIBLIOGRAFIA
63
sprzątanie, ale bałagan będzie narastał. W końcu będziemy spędzać mnóstwo czasu na szukaniu naczyń oraz skrobaniu z nich wyschniętych resztek jedzenia, aby nadawały się do przygotowania w nich posiłku. Przygotowanie kolacji będzie trwało wieczność. Pominięcie sprzątania wcale nie przyspieszyło przygotowania kolacji. Celem refaktoryzacji, jak przedstawiono w niniejszym rozdziale, jest sprzątanie swojego kodu każdego dnia. Nie chcemy, aby bałagan skumulował się. Nie chcemy, aby w przyszłości zaszła konieczność szorowania bitów, które z czasem się nagromadziły. Chcemy mieć możliwość rozszerzania i modyfikowania naszego systemu jak najmniejszym wysiłkiem. Najważniejszym czynnikiem, który na to pozwala, jest czystość kodu. Nie potrafię podkreślić tego dobitniej. Wszystkie zasady i wzorce zamieszczone w tej książce na nic się nie przydadzą, jeśli spróbujemy zastosować je do kodu, w którym panuje bałagan. Przed zainwestowaniem czasu w reguły i wzorce należy zainwestować czas w czysty kod.
Bibliografia 1. Martin Fowler, Refactoring: Improving the Design of Existing Code, Reading, MA: Addison-Wesley, 1999.
64
ROZDZIAŁ 5. REFAKTORYZACJA
R OZDZIAŁ 6
Epizod programowania
Projektowanie i programowanie to działania człowieka. Jeśli o tym zapomnisz, wszystko stracone — Bjarne Stroustrup, 1991
W celu zademonstrowania praktyk programowania EP Bob Koss (RSK) i Bob Martin (RCM) będą programować w parze prostą aplikację, a czytelnik będzie to obserwował jako niemy obserwator. W celu stworzenia naszej aplikacji zastosujemy techniki TDD oraz przeprowadzimy głęboką refaktoryzację. W tym rozdziale zamieszczono dość wierną rekonstrukcję epizodu programowania, który faktycznie się odbył. Dwóch Bobów uczestniczyło w nim w pokoju hotelowym pod koniec 2000 roku. W czasie realizacji tego epizodu popełniono wiele błędów. Niektóre były w kodzie, niektóre w logice, niektóre w projekcie, a jeszcze inne w wymaganiach. Podczas lektury tego rozdziału zobaczysz, jak poruszamy się we wszystkich tych obszarach — identyfikując i likwidując błędy i nieporozumienia. Proces jest chaotyczny — tak jak wszystkie działania realizowane przez człowieka. Rezultat... cóż — to niesamowite, że może powstać taki porządek z tak chaotycznego procesu. Program oblicza wyniki gry w kręgle, dlatego przyda Ci się znajomość reguł tej gry. Jeśli ich nie znasz, zapoznaj się z zawartością ramki towarzyszącej temu rozdziałowi.
66
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
Gra w kręgle RCM: Pomożesz mi napisać niewielką aplikację, która oblicza punkty w grze w kręgle? RSK: (w myślach: Zgodnie z praktyką EP programowania w parach nie mogę powiedzieć nie, kiedy ktoś poprosi mnie o pomoc. Sądzę, że ma to zastosowanie zwłaszcza wtedy, gdy prosi szef). Jasne, Bob. Z przyjemnością ci pomogę. RCM: Doskonale! Chciałbym napisać aplikację, która zarządza ligą kręglową. Musi rejestrować wszystkie gry, wyznaczać tabelę zespołów, ustalać zwycięzców i przegranych każdego cotygodniowego meczu i dokładnie liczyć punkty w każdej grze. RSK: Fajnie. Kiedyś byłem dość dobry w kręgle. To będzie fajna zabawa. Wyrecytowałeś kilka historyjek użytkowników. Od której z nich chcesz zacząć? RCM: Zacznijmy od punktowania pojedynczej gry. RSK: W porządku. Co to znaczy? Jakie są wejścia i wyjścia dla tej historyjki? RCM: Wydaje mi się, że wejścia to po prostu sekwencja rzutów. Rzut to zwykła liczba całkowita, która mówi, ile kręgli przewróciła kula. Wyjściem jest liczba uzyskanych punktów w każdej serii. RSK: Zakładam, że w tym ćwiczeniu pełnisz rolę klienta. Zatem w jakiej formie chciałbyś zaprezentować wejścia i wyjścia? RCM: Tak. Jestem klientem. Potrzebujemy funkcji, której wywołanie dodaje rzuty, oraz innej funkcji, która oblicza wynik. Coś w stylu: throwBall(6); throwBall(3); assertEquals(9, getScore());
RSK: OK. Będziemy potrzebowali trochę danych testowych. Spróbuję naszkicować kartę wyników (patrz rysunek 6.1).
Rysunek 6.1. Typowa karta wyników w grze w kręgle
RCM: Ten facet jest dość niekonsekwentny. RSK: Albo pijany, ale będzie służyć jako dobry test akceptacyjny.
RCM: Będziemy potrzebować innych, ale zajmiemy się tym później. Od czego powinniśmy zacząć? Czy powinniśmy wymyślić projekt tego systemu?
GRA W KRĘGLE
67
RSK: Nie miałbym nic przeciwko diagramowi UML przedstawiającemu pojęcia należące do dziedziny problemu, które można zaobserwować na karcie wyników. W ten sposób można wyodrębnić kilka obiektów-kandydatów, które można dokładniej przedstawić w kodzie. RCM: (wkładając swój kapelusz świetnego projektanta obiektów) OK. Jest oczywiste, że obiekt Game składa się z sekwencji dziesięciu rund (obiektów Frame). Każdy obiekt Frame zawiera jeden, dwa lub trzy rzuty (obiekty Throw). RSK: Doskonały pomysł. Dokładnie w ten sposób myślałem. Spróbujmy to szybko naszkicować. (Patrz rysunek 6.2).
Rysunek 6.2. Diagram UML karty wyników gry w kręgle
RSK: Wybierz klasę... dowolną klasę. Czy zaczniemy od końca łańcucha zależności i będziemy implementować klasy wstecz? Dzięki temu testowanie będzie łatwiejsze. RCM: Pewnie. Dlaczego nie. Spróbujmy stworzyć przypadek testowy dla klasy Throw. RSK: (zaczyna pisanie) // TestThrow.java--------------------------------import junit.framework.*;
public class TestThrow extends TestCase { public TestThrow(String name) { super(name); }
// public void test???? }
RSK: Czy masz pomysł na to, jakie powinno być zachowanie obiektu Throw? RCM: On przechowuje liczbę kręgli strąconych przez gracza. RSK: OK. Właśnie powiedziałeś, ni mniej, ni więcej, że on właściwie nic nie robi. Może powinniśmy wrócić do struktury i skupić się na obiekcie, który ma jakieś zachowanie, a nie na takim, który tylko przechowuje dane? RCM: Hm... Czy sądzisz, że istnienie klasy Throw nie ma uzasadnienia? RSK: Cóż. Jeśli nie ma żadnego działania, to jaka może być jej rola? Nie wiem jeszcze, czy ona istnieje, czy nie. Po prostu czułbym się wydajniejszy, gdybyśmy pracowali nad obiektem, który zawiera jakieś inne metody niż tylko settery i gettery. Ale skoro chcesz prowadzić... (przekazuje klawiaturę RCM). RCM: Dobrze. Przejdźmy w górę łańcucha zależności do klasy Frame i zobaczmy, czy możemy napisać jakieś przypadki testowe, które zmuszą nas do dokończenia klasy Throw (przesuwa klawiaturę z powrotem do RSK). RSK: (w myślach: Zastanawiam się, czy RCM prowadzi mnie w ślepy zaułek, żeby mnie czegoś nauczyć, czy rzeczywiście się ze mną zgadza). OK. Nowy plik i nowy przypadek testowy. // TestFrame.java-----------------------------------import junit.framework.*; public class TestFrame extends TestCase { public TestFrame( String name )
68
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
{ } }
super( name );
// public void test???
RCM: Dobrze. Napisaliśmy to po raz drugi. Czy potrafisz teraz wymyślić jakieś interesujące przypadki testowe dla klasy Frame? RSK: Klasa Frame może dostarczać wynik, liczbę kręgli w każdym rzucie, informację, czy miało miejsce strącenie wszystkich kręgli w jednym rzucie (ang. strike), czy też strącenie wszystkich kręgli w rundzie (ang. spare)… RCM: Pokaż mi kod. RSK: (pisze) // TestFrame.java--------------------------------import junit.framework.*;
public class TestFrame extends TestCase { public TestFrame( String name ) { super( name ); } public void testScoreNoThrows() { Frame f = new Frame(); assertEquals( 0, f.getScore() ); } }
// Frame.java--------------------------------------public class Frame { public int getScore() { return 0; } }
RCM: Przypadki testowe przechodzą, ale getScore to naprawdę głupia funkcja. Jeśli dodamy rzut do obiektu Frame, to funkcja nie powiedzie się. Spróbujmy napisać przypadek testowy, który dodaje kilka rzutów, a następnie sprawdza wynik. // TestFrame.java--------------------------------public void testAddOneThrow() { Frame f = new Frame(); f.add(5); assertEquals(5, f.getScore()); }
RCM: To się nie skompiluje. W klasie Frame nie ma metody add. RSK: Założę się, że jeśli zdefiniujesz metodę, to się skompiluje. RCM: // Frame.java--------------------------------------public class Frame { public int getScore() {
GRA W KRĘGLE
}
}
69
return 0;
public void add(Throw t) { }
RCM: (głośno myśląc) To się nie skompiluje, bo nie napisaliśmy klasy Throw. RSK: Posłuchaj, Bob. Test przekazuje liczbę integer, a metoda oczekuje obiektu Throw. Nie możemy mieć jednego i drugiego. Zanim znów pójdziemy ścieżką klasy Throw, czy możesz opisać jej zachowanie? RCM: O! Nawet nie zauważyłem, że napisałem f.add(5). Powinienem był napisać f.add(new Throw(5)), ale to strasznie brzydko wygląda. Naprawdę chcę napisać f.add(5). RSK: Brzydko czy nie, na razie odłóżmy estetykę na bok. Czy potrafisz opisać jakiekolwiek zachowanie obiektu Throw, Bob? Interesuje mnie binarna odpowiedź. RCM: 101101011010100101. Nie mam pojęcia, czy jest jakieś zachowanie klasy Throw. Zaczynam myśleć, że Throw to po prostu int. Nie musimy jednak jeszcze tego rozważać, ponieważ możemy napisać metodę Frame.add, która pobiera argument int. RSK: W takim razie sądzę, że powinniśmy tak zrobić choćby dlatego, że to rozwiązanie jest proste. Kiedy zauważymy jakieś problemy, możemy napisać coś bardziej zaawansowanego. RCM: Zgoda. // Frame.java--------------------------------------public class Frame { public int getScore() { return 0; }
}
public void add(int pins) { }
RCM: OK. To się kompiluje i powoduje, że test nie przechodzi. Spróbujmy teraz doprowadzić do tego, aby test zaczął przechodzić. // Frame.java--------------------------------------public class Frame { public int getScore() { return itsScore; } public void add(int pins) { itsScore += pins; } private int itsScore = 0; }
RCM: To się kompiluje i testy przechodzą, ale jest ewidentnie uproszczone. Jaki będzie następny przypadek testowy? RSK: Czy możemy najpierw zrobić przerwę? ------------------------------Przerwa----------------------------
70
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: Teraz lepiej. Frame.add to krucha funkcja. Co się stanie, jeśli wywołamy ją z argumentem 11?
RSK: Może zgłosić wyjątek, jeśli tak się zdarzy. Ale co ją wywołuje? Czy to będzie framework aplikacji, z którego będą korzystały tysiące osób, i trzeba zabezpieczyć się przed takimi ewentualnościami, czy też będzie wywoływana przez ciebie i tylko przez ciebie? W tym drugim przypadku po prostu nie wywołuj tej funkcji z argumentem 11 (śmiech). RCM: Dobra uwaga. Testy w pozostałej części systemu przechwycą nieprawidłowy argument. Jeśli będą kłopoty, możemy później wprowadzić sprawdzenie wartości argumentu. Funkcja add na razie nie obsługuje punktacji strike lub spare. Napiszmy test, który to wyraża. RSK: Hm... jeśli wywołamy add(10) w celu zaprezentowania strącenia strike, to co wtedy powinna zwracać metoda getScore()? Nie wiem, jak napisać asercję, więc być może zadajemy niewłaściwe pytanie. Albo kierujemy właściwe pytanie do niewłaściwego obiektu. RCM: Kiedy wywołamy add(10) lub add(3), a za nim add(7), to wywołanie getScore na obiekcie Frame jest bez znaczenia. Obiekt Frame musiałby odczytać późniejsze egzemplarze Frame, aby obliczyć wynik. Jeśli te późniejsze egzemplarze Frame nie istnieją, to musiałby zwrócić coś brzydkiego, na przykład –1. Nie chcę zwracać wartości –1. RSK: Pomysł z –1 też mi się nie podoba. Wspomniałeś sytuację, w której obiekty Frame „wiedzą” o istnieniu innych obiektów Frame. Gdzie są przechowywane te inne obiekty Frame? RCM: W obiekcie Game. RSK: Zatem Game zależy od Frame, a Frame zależy od Game. Nie podoba mi się to. RCM: Obiekty Frame nie muszą zależeć od Game. Możemy je zorganizować w formie listy. Każdy obiekt Frame mógłby zawierać wskaźnik do następnego i poprzedniego obiektu frame na liście. Aby uzyskać punktację, obiekt Frame zaglądałby do obiektów Frame znajdujących się za nim i przed nim. Dzięki temu posiadałby informacje o potrzebnych trafieniach spare lub strike. RSK: OK. Trochę głupio się czuję, bo nie mogę sobie tego wyobrazić. Pokaż mi kod. RCM: W porządku. A zatem najpierw potrzebujemy przypadku testowego. RSK: Dla obiektu Game czy też kolejnego testu dla Frame? RCM: Myślę, że potrzebny jest test dla Game, ponieważ to obiekt Game będzie budował obiekty Frame i łączył je ze sobą. RSK: Chcesz, żebyśmy przerwali to, co robimy z klasą Frame, i wykonali mentalny longjump do klasy Game, czy też chcesz stworzyć obiekt MockGame, który robi to, co jest potrzebne do tego, aby klasa Frame zaczęła działać? RCM: Nie. Przerwijmy pracę nad Frame i zacznijmy pracować nad klasą Game. Przypadki testowe dla klasy Game powinny dowieść, że potrzebujemy powiązanej listy obiektów Frame. RSK: Nie jestem pewien, w jaki sposób pokażą one potrzebę istnienia listy. Potrzebuję kodu. RCM: (pisze) // TestGame.java-----------------------------------------import junit.framework.*;
public class TestGame extends TestCase { public TestGame(String name) { super(name); } public void testOneThrow()
GRA W KRĘGLE
{
}
71
Game g = new Game(); g.add(5); assertEquals(5, g.score());
}
RCM: Czy to wygląda sensownie? RSK: Jasne. Ale ja ciągle szukam tego uzasadnienia dla listy obiektów Frame. RCM: Ja też. Spróbujmy pójść ścieżką tych przypadków testowych. Zobaczymy, gdzie nas to zaprowadzi. // Game.java---------------------------------public class Game { public int score() { return 0; }
}
public void add(int pins) { }
RCM: OK. To się kompiluje i powoduje, że test nie przechodzi. Teraz zróbmy tak, aby zaczął przechodzić. // Game.java---------------------------------public class Game { public int score() { return itsScore; } public void add(int pins) { itsScore += pins; } private int itsScore = 0; }
RCM: To przechodzi. Świetnie. RSK: Nie mogę się nie zgodzić, ale ja ciągle szukam tego uzasadnienia dla powiązanej listy obiektów Frame. Właśnie to doprowadziło nas do koncepcji klasy Game. RCM: Zgadza się. Ja też tego szukam. Spodziewam się, że kiedy zaczniemy wstrzykiwanie przypadków testowych dla punktacji spare i strike, to będziemy zmuszeni do zbudowania obiektów Frame i scalenia ich na powiązanej liście. Ale nie chcę budować tej listy, dopóki kod nas do tego nie zmusi. RSK: Świetna uwaga. Kontynuujmy małymi krokami prace nad klasą Game. A może by tak stworzyć inny test, który sprawdza dwa rzuty, ale bez trafienia spare? RCM: OK. Taki test powinien już przechodzić. Spróbujmy. // TestGame.java-----------------------------------------public void testTwoThrowsNoMark() { Game g = new Game(); g.add(5); g.add(4); assertEquals(9, g.score()); }
72
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: Tak, ten test przechodzi. Teraz spróbujmy czterech rzutów, bez trafień premiowanych. RSK: Taki test także przejdzie. Nie spodziewałem się tego. Możemy dodawać kolejne rzuty i ciągle nie potrzebujemy obiektu Frame. Ale jak dotąd nie rozpatrywaliśmy trafień spare ani strike. Może wtedy będą nam potrzebne obiekty Frame? RCM: Na to właśnie liczę. Spróbujmy rozważyć przypadek testowy: // TestGame.java------------------------------------------
public void testFourThrowsNoMark() { Game g = new Game(); g.add(5); g.add(4); g.add(7); g.add(2); assertEquals(18, g.score()); assertEquals(9, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); }
RCM: Czy to wygląda sensownie? RSK: Oczywiście, że tak. Zapomniałem, że musimy mieć możliwość wyświetlenia punktacji w każdej rundzie. Ach. Szkic karty wyników posłużył mi jako podstawka pod kubek z dietetyczną kolą. Dlatego właśnie o tym zapomniałem. RCM: (wzdycha) OK. Spowodujmy najpierw, by test przestał przechodzić, dodając metodę scoreForFrame do klasy Game. // Game.java---------------------------------public int scoreForFrame(int frame) { return 0; }
RCM: Świetnie. To się kompiluje i powoduje, że test nie przechodzi. Jak zrobić, żeby zaczął przechodzić? RSK: Możemy zacząć tworzyć obiekty Frame, ale czy to jest najprostszy sposób, aby testy zaczęły przechodzić? RCM: Właściwie nie. Moglibyśmy po prostu stworzyć tablicę liczb całkowitych w obiekcie Game. Każde wywołanie metody add spowodowałoby dodanie nowej wartości integer do tablicy. Każde wywołanie metody scoreForFrame powodowałoby iterowanie po tablicy i obliczanie wyniku. // Game.java---------------------------------public class Game { public int score() { return itsScore; }
public void add(int pins) { itsThrows[itsCurrentThrow++]=pins; itsScore += pins; } public int scoreForFrame(int frame) { int score = 0; for ( int ball = 0; frame > 0 && (ball < itsCurrentThrow);
GRA W KRĘGLE
{
73
ball+=2, frame--)
score += itsThrows[ball] + itsThrows[ball+1]; } return score;
}
} private int itsScore = 0; private int[] itsThrows = new int[21]; private int itsCurrentThrow = 0;
RCM: (bardzo zadowolony z siebie) To działa. RSK: Skąd magiczna liczba 21? RCM: To jest maksymalna możliwa liczba rzutów w jednej grze. RSK: Super! Niech zgadnę. W młodości byłeś hakerem uniksowym, dumnym z tego, że potrafiłeś napisać całą aplikację za pomocą jednej instrukcji, której nikt poza tobą nie potrafił odszyfrować. Metodę scoreForFrame() trzeba zrefaktoryzować, aby była bardziej komunikatywna. Ale zanim zaczniemy rozważać refaktoryzację, pozwól mi zadać inne pytanie. Czy klasa Game jest najlepszym miejscem na tę metodę? Moim zdaniem klasa Game narusza zasadę pojedynczej odpowiedzialności (ang. Single Responsibility Principle — SRP)1. Pobiera informacje o rzutach i „wie”, jak liczyć punkty w każdej rundzie. Co sądzisz o obiekcie Scorer? RCM: (robi niegrzeczny gest ręką) Nie wiem, gdzie są te funkcje. Na razie interesuje mnie, aby liczenie punktacji zaczęło działać. Kiedy wszystko będzie na miejscu, to będziemy mogli dyskutować nad zasadą SRP. Mimo wszystko uwaga o uniksowym hakerze jest poniekąd słuszna. Spróbujmy uprościć tę pętlę. public int scoreForFrame(int theFrame) { int ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { score += itsThrows[ball++] + itsThrows[ball++]; } return score; }
RCM: Tak jest trochę lepiej, ale są skutki uboczne z zastosowania wyrażenia score+=. Tutaj nie mają znaczenia, bo nie jest ważne, w jakiej kolejności są obliczane wartości dwóch dodawanych wyrażeń (a może jest inaczej? Czy jest możliwe, aby te dwie inkrementacje były wykonane przed dowolnym działaniem na tablicach?). RSK: Myślę, że możemy przeprowadzić eksperyment, aby sprawdzić, czy nie ma żadnych skutków ubocznych, ale ta funkcja nie będzie działać dla trafień spare lub strike. Czy powinniśmy próbować poprawić jej czytelność, czy raczej powinniśmy się zająć kontynuowaniem implementowania jej funkcjonalności? RCM: Eksperyment może mieć znaczenie tylko w przypadku niektórych kompilatorów. W innych kompilatorach może być stosowana inna kolejność obliczania wartości. Nie wiem, czy to jest problem, czy nie, ale spróbujmy najpierw pozbyć się potencjalnej zależności od kolejności, a następnie kontynuujmy z kolejnymi przypadkami testowymi.
1
Patrz rozdział 8., „SRP zasada pojedynczej odpowiedzialności”.
74
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
public int scoreForFrame(int theFrame) { int ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { int firstThrow = itsThrows[ball++]; int secondThrow = itsThrows[ball++]; score += firstThrow + secondThrow; } }
return score;
RCM: Dobrze. Następny przypadek testowy. Spróbujmy zająć się punktacją spare. public void testSimpleSpare() { Game g = new Game(); }
RCM: Mam dość pisania tej instrukcji. Spróbujmy zrefaktoryzować test i umieścić instrukcję tworzenia obiektu gry w funkcji setUp. // TestGame.java-----------------------------------------import junit.framework.*;
public class TestGame extends TestCase { public TestGame(String name) { super(name); } private Game g; public void setUp() { g = new Game(); } public void testOneThrow() { g.add(5); assertEquals(5, g.score()); } public void testTwoThrowsNoMark() { g.add(5); g.add(4); assertEquals(9, g.score()); }
}
public void testFourThrowsNoMark() { g.add(5); g.add(4); g.add(7); g.add(2); assertEquals(18, g.score()); assertEquals(9, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); } public void testSimpleSpare() { }
GRA W KRĘGLE
75
RCM: Tak lepiej. Spróbujmy teraz napisać test dla punktacji spare. public void testSimpleSpare() { g.add(3); g.add(7); g.add(3); assertEquals(13, g.scoreForFrame(1)); }
RCM: OK. Ten test nie przechodzi. Teraz trzeba zrobić tak, żeby zaczął przechodzić? RSK: Ja poprowadzę. public int scoreForFrame(int theFrame) { int ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { int firstThrow = itsThrows[ball++]; int secondThrow = itsThrows[ball++]; int frameScore = firstThrow + secondThrow;
// obliczenie premii spare wymaga wyniku pierwszego rzutu następnej rundy
} }
if ( frameScore == 10 ) score += frameScore + itsThrows[ball++]; else score += frameScore;
return score;
RSK: Hura! To działa! RCM: (przejmując klawiaturę) OK. Ale myślę, że inkrementacja piłki w przypadku frameScore==10 nie powinna tam być. Oto przypadek testowy, który potwierdza mój pogląd: public void testSimpleFrameAfterSpare() { g.add(3); g.add(7); g.add(3); g.add(2); assertEquals(13, g.scoreForFrame(1)); assertEquals(18, g.score()); }
RCM: Ha! Zobacz. Test nie przechodzi. A jeśli tylko pozbędziemy się tej brzydkiej, nadmiarowej inkrementacji... if ( frameScore == 10 ) score += frameScore + itsThrows[ball];
76
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: Oj!... Test nadal nie przechodzi... Być może powodem jest metoda obliczająca punkty? Spróbuję ją przetestować, zmieniając przypadek testowy w taki sposób, by korzystał z wywołania scoreForFrame(2). public void testSimpleFrameAfterSpare() { g.add(3); g.add(7); g.add(3); g.add(2); assertEquals(13, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); }
RCM: Hm... To przechodzi. Metoda score musi być zepsuta. Przyjrzyjmy się jej. public int score() { return itsScore; } public void add(int pins) { itsThrows[itsCurrentThrow++]=pins; itsScore += pins; }
RCM: Tak, tu jest błąd. Metoda score zwraca jedynie sumę trafionych kręgli zamiast prawidłowego wyniku. Chcemy, aby metoda score wywoływała metodę scoreForFrame() z wartością bieżącej rundy. RSK: Nie wiemy, jaka jest bieżąca runda. Spróbujmy dodać ten komunikat do każdego z naszych aktualnych testów — oczywiście pojedynczo. RCM: W porządku. // TestGame.java------------------------------------------
public void testOneThrow() { g.add(5); assertEquals(5, g.score()); assertEquals(1, g.getCurrentFrame()); }
// Game.java---------------------------------public int getCurrentFrame() { return 1; }
RCM: To działa. Ale to głupie. Napiszmy następny przypadek testowy. public void testTwoThrowsNoMark() { g.add(5); g.add(4); assertEquals(9, g.score()); assertEquals(1, g.getCurrentFrame()); }
RCM: Ten jest nieciekawy. Spróbujmy czegoś innego. public void testFourThrowsNoMark() { g.add(5); g.add(4); g.add(7); g.add(2);
GRA W KRĘGLE
}
77
assertEquals(18, g.score()); assertEquals(9, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); assertEquals(2, g.getCurrentFrame());
RCM: Ten test nie przechodzi. Zróbmy tak, aby zaczął przechodzić. RSK: Sądzę, że algorytm jest trywialny. Wystarczy podzielić liczbę rzutów przez dwa, ponieważ w rundzie są dwa rzuty. Chyba że mamy strike... ale jeszcze się nimi nie zajmujemy, zatem tutaj także możemy je zignorować. RCM: (kombinuje, dodając i odejmując, aż test zacznie działać2) public int getCurrentFrame() { return 1 + (itsCurrentThrow-1)/2; }
RCM: To nie jest zbyt zadowalające. RSK: A gdyby tak zrezygnować z obliczeń za każdym razem? Może by tak zmodyfikować składową currentFrame po każdym rzucie? RCM: Spróbujmy. // Game.java----------------------------------
public int getCurrentFrame() { return itsCurrentFrame; } public void add(int pins) { itsThrows[itsCurrentThrow++]=pins; itsScore += pins; if (firstThrow == true) { firstThrow = false; itsCurrentFrame++; } else { firstThrow=true;; } } private int itsCurrentFrame = 0; private boolean firstThrow = true;
RCM: OK. To działa. Ale oznacza również, że bieżąca runda to runda, w której była rzucona ostatnia kula, a nie runda, w której będzie rzucona następna kula. Jeśli będziemy o tym pamiętać, powinno być dobrze. RSK: Nie mam takiej dobrej pamięci, dlatego proponuję popracować nad czytelnością tego kodu. Ale zanim zajmiemy się tym dokładniej, wyciągnijmy ten kod z metody add() i umieśćmy w prywatnej funkcji składowej o nazwie adjustCurrentFrame() lub podobnej. RCM: OK. To brzmi dobrze. public void add(int pins) { itsThrows[itsCurrentThrow++]=pins; itsScore += pins;
2
Dave Thomas i Andy Hunt nazywają to „programowaniem przez przypadek”.
78
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
}
adjustCurrentFrame();
private void adjustCurrentFrame() { if (firstThrow == true) { firstThrow = false; itsCurrentFrame++; } else { firstThrow=true;; } }
RCM: Teraz zmienimy nazwy zmiennej i funkcji, aby były bardziej czytelne. Jak powinniśmy nazwać zmienną itsCurrentFrame? RSK: W zasadzie podoba mi się ta nazwa. Uważam jednak, że inkrementacja jest wykonywana w niewłaściwym miejscu. Dla mnie bieżąca runda to runda, w której rzucam. Zatem nie należy jej inkrementować natychmiast po ostatnim rzucie w rundzie. RCM: Zgadzam się. Spróbujmy zmienić przypadki testowe, aby to uwzględniały, a następnie poprawimy metodę adjustCurrentFrame. // TestGame.java------------------------------------------
public void testTwoThrowsNoMark() { g.add(5); g.add(4); assertEquals(9, g.score()); assertEquals(2, g.getCurrentFrame()); } public void testFourThrowsNoMark() { g.add(5); g.add(4); g.add(7); g.add(2); assertEquals(18, g.score()); assertEquals(9, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); assertEquals(3, g.getCurrentFrame()); }
// Game.java-----------------------------------------private void adjustCurrentFrame() { if (firstThrow == true) { firstThrow = false; } else { firstThrow=true; itsCurrentFrame++; } }
}
private int itsCurrentFrame = 1;
GRA W KRĘGLE
79
RCM: OK. To działa. Spróbujmy teraz przetestować metodę getCurrentFrame w dwóch przypadkach z trafieniami spare. public void testSimpleSpare() { g.add(3); g.add(7); g.add(3); assertEquals(13, g.scoreForFrame(1)); assertEquals(2, g.getCurrentFrame()); } public void testSimpleFrameAfterSpare() { g.add(3); g.add(7); g.add(3); g.add(2); assertEquals(13, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); assertEquals(3, g.getCurrentFrame()); }
RCM: Działa. Wróćmy teraz do pierwotnego problemu. Musimy zmodyfikować metodę score. Możemy teraz napisać metodę score tak, by wywoływała scoreForFrame(getCurrentFrame()-1). public void testSimpleFrameAfterSpare() { g.add(3); g.add(7); g.add(3); g.add(2); assertEquals(13, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); assertEquals(18, g.score()); assertEquals(3, g.getCurrentFrame()); }
// Game.java----------------------------------
public int score() { return scoreForFrame(getCurrentFrame()-1); }
RCM: To powoduje, że nie przechodzi przypadek testowy TestOneThrow. Przyjrzyjmy się mu. public void testOneThrow() { g.add(5); assertEquals(5, g.score()); assertEquals(1, g.getCurrentFrame()); }
RCM: W przypadku tylko jednego rzutu pierwsza runda jest niepełna. Metoda score wywołuje scoreForFrame(0). To jest paskudne. RSK: Może tak, a może nie. Dla kogo piszemy ten program? Kto będzie wywoływał metodę score()? Czy nie można rozsądnie założyć, że nie będzie wywoływana z niepełną ramką? RCM: Tak, ale to mnie martwi. Aby znaleźć wyjście, musimy pozbyć się metody score z przypadku testowego testOneThrow. Czy właśnie to chcemy zrobić? RSK: Moglibyśmy. Moglibyśmy nawet usunąć cały przypadek testowy testOneThrow. Był używany w celu wprowadzenia do interesujących przypadków testowych. Czy teraz służy jakiemuś użytecznemu celowi? W dalszym ciągu mamy pokrycie we wszystkich pozostałych przypadkach testowych.
80
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: Tak. Rozumiem twój punkt widzenia. Zatem usuwamy go (edytuje kod, uruchamia testy, uzyskuje wszystkie zielone słupki). Ach. Teraz lepiej. Zajmijmy się teraz przypadkiem testowym trafienia strike. W końcu chcemy zobaczyć wszystkie obiekty Frame połączone w powiązaną listę. Czy nie tak? (chichocze). public void testSimpleStrike() { g.add(10); g.add(3); g.add(6); assertEquals(19, g.scoreForFrame(1)); assertEquals(28, g.score()); assertEquals(3, g.getCurrentFrame()); }
RCM: OK. To się kompiluje i nie przechodzi tak, jak przewidywaliśmy. Teraz trzeba zrobić tak, żeby test zaczął przechodzić? // Game.java----------------------------------
public class Game { public void add(int pins) { itsThrows[itsCurrentThrow++]=pins; itsScore += pins; adjustCurrentFrame(pins); } private void adjustCurrentFrame(int pins) { if (firstThrow == true) { if( pins == 10 ) // strike itsCurrentFrame++; else firstThrow = false; } else { firstThrow=true; itsCurrentFrame++; } } public int scoreForFrame(int theFrame) { int ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { int firstThrow = itsThrows[ball++]; if (firstThrow == 10) { score += 10 + itsThrows[ball] + itsThrows[ball+1]; } else {
GRA W KRĘGLE
81
int secondThrow = itsThrows[ball++]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( frameScore == 10 ) score += frameScore + itsThrows[ball]; else score += frameScore;
}
}
return score; } private int itsScore = 0; private int[] itsThrows = new int[21]; private int itsCurrentThrow = 0; private int itsCurrentFrame = 1; private boolean firstThrow = true;
RCM: OK. To nie było zbyt trudne. Zobaczmy, czy uda się obliczyć właściwą punktację doskonałej gry. public void testPerfectGame() { for (int i=0; i<12; i++) { g.add(10); } assertEquals(300, g.score()); assertEquals(10, g.getCurrentFrame()); }
RCM: Niedobrze. Wyszło, że zdobyliśmy 330 punktów. Skąd to może wynikać? RSK: Ponieważ bieżąca runda jest inkrementowana aż do wartości 12. RCM: Ach tak! Trzeba ograniczyć ją do 10. private void adjustCurrentFrame(int pins) { if (firstThrow == true) { if( pins == 10 ) // strike itsCurrentFrame++; else firstThrow = false; } else { firstThrow=true; itsCurrentFrame++; } itsCurrentFrame = Math.min(10, itsCurrentFrame); }
RCM: Do licha! Teraz wychodzi 270 punktów. O co chodzi? RSK: Bob, funkcja score odejmuje jeden od wartości getCurrentFrame, zatem zwraca punktację z ramki numer 9, a nie 10. RCM: Co? Sugerujesz, że powinienem ograniczyć wartość bieżącej rundy do 11 zamiast do 10? Spróbuję. itsCurrentFrame = Math.min(11, itsCurrentFrame);
82
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: OK. Zatem teraz punktacja jest prawidłowa, ale test nie przechodzi, bo numer bieżącej rundy to 11, a nie 10. Fatalnie! Ten problem z bieżącą rundą przyprawia mnie o ból głowy. Chcemy, żeby bieżąca runda była tą, w której gracz rzuca, ale co to oznacza na końcu gry? RSK: Może powinniśmy wrócić do koncepcji, że bieżąca runda jest rundą ostatnio rzuconej kuli. RCM: Albo może powinniśmy wprowadzić pojęcie ostatniej zakończonej rundy? W końcu wynik gry w dowolnym momencie to wynik ostatnio zakończonej rundy. RSK: Zakończona runda to runda, do której nie można wpisać punktacji. Zgadza się? RCM: Tak. Runda, w której nastąpiło trafienie spare, kończy się po następnej kuli. Runda, w której nastąpiło trafienie strike, kończy się po następnych dwóch kulach. Runda, w której nie było trafień premiowanych, kończy się po drugiej kuli w rundzie. Chwileczkę!... Staramy się doprowadzić do tego, aby metoda score() zaczęła działać. Zgadza się? W tym celu trzeba tylko wymusić, aby metoda score() wywołała scoreForFrame(10) w przypadku, gdy gra jest skończona. RSK: Skąd będziemy wiedzieć, że gra jest skończona? RCM: Gra jest skończona, jeśli metoda adjustCurrentFrame kiedykolwiek spróbuje inkrementacji właściwości itsCurrentFrame po dziesiątej rundzie. RSK: Zaczekaj. Mówisz po prostu, że gra jest skończona, kiedy metoda getCurrentFrame zwróci 11. Tak właśnie teraz działa kod! RCM: Hm. Sugerujesz, że powinniśmy zmienić przypadek testowy tak, by był zgodny z kodem? public void testPerfectGame() { for (int i=0; i<12; i++) { g.add(10); } assertEquals(300, g.score()); assertEquals(11, g.getCurrentFrame()); }
RCM: Cóż. To działa. Przypuszczam, że nie jest gorsze niż funkcja getMonth, która zwraca 0 dla stycznia. Ale wciąż czuję się z tym nieswojo. RSK: Być może coś przyjdzie nam do głowy później. Na razie wydaje mi się, że widzę błąd. Mogę? (przejmuje klawiaturę). public void testEndOfArray() { for (int i=0; i<9; i++) { g.add(0); g.add(0); } g.add(2); g.add(8); // 10-ta runda — spare g.add(10); // Strike w ostatniej pozycji tablicy. assertEquals(20, g.score()); }
RSK: Hm. Test przechodzi. Myślałem, że skoro 21. pozycją tablicy było trafienie strike, to obiekt Scorer będzie próbował dodać pozycje 22. i 23. do wyniku. Ale jak sądzę, tego nie robi. RCM: Hm, widzę, że ciągle myślisz o obiekcie Scorer. W każdym razie widzę, do czego zmierzasz, ale skoro metoda score nigdy nie wywołuje metody scoreForFrame z argumentem większym niż 10, to ostatnie trafienie strike w rzeczywistości nie jest liczone jako strike. Jest po prostu liczone jako 10 w celu obliczenia ostatniego trafienia spare. Nigdy nie przejdziemy poza koniec tablicy.
GRA W KRĘGLE
83
RSK: OK. Spróbujmy wstawić do programu oryginalną kartę wyników. public void testSampleGame() { g.add(1); g.add(4); g.add(4); g.add(5); g.add(6); g.add(4); g.add(5); g.add(5); g.add(10); g.add(0); g.add(1); g.add(7); g.add(3); g.add(6); g.add(4); g.add(10); g.add(2); g.add(8); g.add(6); assertEquals(133, g.score()); }
RSK: Dobrze. To działa. Czy potrafisz wymyślić jakieś inne przypadki testowe? RCM: Tak. Spróbujmy przetestować kilka dodatkowych warunków brzegowych — co sądzisz o sytuacji gracza, który ma 11 trafień strike, a w ostatniej rundzie trafia 9? public void testHeartBreak() { for (int i=0; i<11; i++) g.add(10); g.add(9); assertEquals(299, g.score()); }
RCM: To działa! A co w przypadku, gdy w dziesiątej rundzie zdarzy się spare? public void testTenthFrameSpare() { for (int i=0; i<9; i++) g.add(10); g.add(9); g.add(1); g.add(1); assertEquals(270, g.score()); }
RCM: (gapiąc się z zadowoleniem na zielone słupki) To także działa. Nie przychodzi mi na myśl żaden inny przypadek. A tobie? RSK: Nie. Myślę, że pokryliśmy wszystkie. Poza tym naprawdę chcę zrefaktoryzować ten bałagan. Ciągle w tym wszystkim widzę miejsce dla obiektu scorer. RCM: OK. Zgadzam się, że w funkcji scoreForFrame panuje bałagan. Przyjrzyjmy się jej. public int scoreForFrame(int theFrame) { int ball = 0; int score=0;
84
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { int firstThrow = itsThrows[ball++]; if (firstThrow == 10) { score += 10 + itsThrows[ball] + itsThrows[ball+1]; } else { int secondThrow = itsThrows[ball++]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( frameScore == 10 ) score += frameScore + itsThrows[ball]; else score += frameScore;
} }
return score;
RCM: Chciałbym wyodrębnić treść klauzuli else do osobnej funkcji o nazwie handleSecondThrow, ale nie mogę, bo używa zmiennych lokalnych ball, firstThrow i secondThrow. RSK: Moglibyśmy przekształcić te zmienne lokalne na składowe. RCM: Tak, takie przekształcenie wzmacnia pogląd, że uda nam się wydzielić punktowanie do osobnego obiektu scorer. Spróbujmy. RSK: (przejmuje klawiaturę) private void adjustCurrentFrame(int pins) { if (firstThrowInFrame == true) { if( pins == 10 ) // strike itsCurrentFrame++; else firstThrowInFrame = false; } else { firstThrowInFrame=true; itsCurrentFrame++; } itsCurrentFrame = Math.min(11, itsCurrentFrame); } public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { firstThrow = itsThrows[ball++]; if (firstThrow == 10) { score += 10 + itsThrows[ball] + itsThrows[ball+1]; } else {
GRA W KRĘGLE
85
secondThrow = itsThrows[ball++]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
if ( frameScore == 10 ) score += frameScore + itsThrows[ball]; else score += frameScore;
} } return score;
} private int ball; private int firstThrow; private int secondThrow; private private private private private
int itsScore = 0; int[] itsThrows = new int[21]; int itsCurrentThrow = 0; int itsCurrentFrame = 1; boolean firstThrowInFrame = true;
RSK: Nie spodziewałem się kolizji nazw. Mieliśmy już zmienną egzemplarza o nazwie firstThrow. Ale lepiej by było, żeby miała nazwę firstThrowInFrame. Tak czy owak teraz działa. Możemy zatem wydzielić tę klauzulę else do jej własnej funkcji. public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { firstThrow = itsThrows[ball++]; if (firstThrow == 10) { score += 10 + itsThrows[ball] + itsThrows[ball+1]; } else { score += handleSecondThrow(); } } }
return score;
private int handleSecondThrow() { int score = 0; secondThrow = itsThrows[ball++]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( frameScore == 10 ) score += frameScore + itsThrows[ball]; else score += frameScore; return score;
RCM: Spójrz na strukturę metody scoreForFrame! W pseudokodzie wygląda to mniej więcej tak: if strike score += 10 + nextTwoBalls(); else handleSecondThrow.
86
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: A gdyby tak zmienić to do takiej postaci: if strike score += 10 + nextTwoBalls(); else if spare score += 10 + nextBall(); else score += twoBallsInFrame()
RSK: Rety! Toż to nic innego, jak reguły punktacji w kręglach. Zgadza się? OK. Zobaczmy, czy uda nam się przenieść tę strukturę do prawdziwej funkcji. Najpierw zmienimy sposób inkrementacji zmiennej ball tak, aby trzy przypadki manipulowały nią w niezależny sposób. public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { firstThrow = itsThrows[ball]; if (firstThrow == 10) { ball++; score += 10 + itsThrows[ball] + itsThrows[ball+1]; } else { score += handleSecondThrow(); } } }
return score;
private int handleSecondThrow() { int score = 0; secondThrow = itsThrows[ball+1]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( frameScore == 10 ) { ball+=2; score += frameScore + itsThrows[ball]; } else { ball+=2; score += frameScore; } return score;
RCM: (przejmuje klawiaturę) OK. Teraz pozbędziemy się zmiennych firstThrow i secondThrow i zastąpimy je odpowiednimi funkcjami. public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++)
GRA W KRĘGLE
{
}
firstThrow = itsThrows[ball]; if (strike()) { ball++; score += 10 + nextTwoBalls(); } else { score += handleSecondThrow(); }
} return score;
private boolean strike() { return itsThrows[ball] == 10; } private int nextTwoBalls() { return itsThrows[ball] + itsThrows[ball+1]; }
RCM: To jest zrobione. Idźmy dalej. private int handleSecondThrow() { int score = 0; secondThrow = itsThrows[ball+1]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( spare() ) { ball+=2; score += 10 + nextBall(); } else { ball+=2; score += frameScore; } return score;
private boolean spare() { return (itsThrows[ball] + itsThrows[ball+1]) == 10; } private int nextBall() { return itsThrows[ball]; }
RCM: OK. To też działa. Teraz zajmijmy się frameScore. private int handleSecondThrow() { int score = 0; secondThrow = itsThrows[ball+1]; int frameScore = firstThrow + secondThrow;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
if ( spare() ) {
87
88
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
ball+=2; score += 10 + nextBall();
}
} else { score += twoBallsInFrame(); ball+=2; } return score;
private int twoBallsInFrame() { return itsThrows[ball] + itsThrows[ball+1]; }
RSK: Bob, ty nie inkrementujesz zmiennej ball w spójny sposób. W przypadku premii spare i strike inkrementujesz przed obliczeniem punktacji. W przypadku metody twoBallsInFrame inkrementujesz po obliczeniu punktów. A kod zależy od tej kolejności! O co chodzi? RCM: Przepraszam. Powinienem był wyjaśnić. Planuję przenieść inkrementacje do metod strike, spare i twoBallsInFrame. Dzięki temu znikną one z funkcji scoreForFrame i funkcja będzie wyglądała dokładnie tak jak pseudokod. RSK: OK. Masz moje zaufanie na następnych kilka kroków, ale pamiętaj, że cię obserwuję. RCM: OK. Ponieważ nikt już nie używa zmiennych firstThrow, secondThrow i frameScore, możemy się ich pozbyć. public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { if (strike()) { ball++; score += 10 + nextTwoBalls(); } else { score += handleSecondThrow(); } } }
return score;
private int handleSecondThrow() { int score = 0;
// obliczenie punktacji spare wymaga pierwszego rzutu z następnej rundy
}
if ( spare() ) { ball+=2; score += 10 + nextBall(); } else { score += twoBallsInFrame(); ball+=2; } return score;
GRA W KRĘGLE
89
RCM: (iskry w jego oczach są odbiciem zielonego słupka) Ponieważ jedyną zmienną, która łączy teraz te trzy przypadki, jest ball i ponieważ ball jest w każdym przypadku obsługiwana niezależnie, możemy połączyć te trzy przypadki ze sobą. public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { if (strike()) { ball++; score += 10 + nextTwoBalls(); } else if ( spare() ) { ball+=2; score += 10 + nextBall(); } else { score += twoBallsInFrame(); ball+=2; } } return score; }
RSK: OK. Teraz możemy doprowadzić do tego, aby inkrementacje były spójne, i zmienić nazwę funkcji, aby poprawić czytelność (przejmuje klawiaturę). public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { if (strike()) { score += 10 + nextTwoBallsForStrike(); ball++; } else if ( spare() ) { score += 10 + nextBallForSpare(); ball+=2; } else { score += twoBallsInFrame(); ball+=2; } } }
return score;
private int nextTwoBallsForStrike() { return itsThrows[ball+1] + itsThrows[ball+2];
90
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
} private int nextBallForSpare() { return itsThrows[ball+2]; }
RCM: Spójrz na funkcję scoreForFrame! To zasady kręgli sformułowane przez nas tak zwięźle, jak to możliwe. RSK: Ale Bob! Co się stało z powiązaną listą obiektów Frame? (chichocze). RCM: (wzdycha) Opętały nas demony zbyt obszernego projektu z diagramu. Mój Boże. Trzy niewielkie ramki narysowane na serwetce Game, Frame i Throw, a w dalszym ciągu było to zbyt skomplikowane i po prostu błędne. RSK: Popełniliśmy błąd, zaczynając od klasy Throw. Powinniśmy byli zacząć od klasy Game. RCM: Dokładnie tak! Zatem następnym razem spróbujmy zacząć od najwyższego poziomu i schodzić w dół. RSK: (sapie) Projekt góra-dół? RCM: Raczej projekt góra-dół w stylu „najpierw test”. Szczerze mówiąc, nie wiem, czy to jest dobra reguła, czy nie. To jest po prostu to, co w tym przypadku by nam pomogło. A zatem następnym razem spróbuję tego podejścia i zobaczę, co się stanie. RSK: W porządku. Tak czy owak w dalszym ciągu jest do zrobienia trochę refaktoryzacji. Zmienna ball jest po prostu prywatnym iteratorem dla metody scoreForFrame i funkcji, które z niej korzystają. Wszystkie należy przenieść do innego obiektu. RCM: Ach tak. Twój obiekt Scorer. Jednak miałeś rację. Zróbmy to. RSK: (bierze klawiaturę i wykonuje kilka małych kroków, przerywając je testami...) // Game.java----------------------------------
public class Game { public int score() { return scoreForFrame(getCurrentFrame()-1); } public int getCurrentFrame() { return itsCurrentFrame; } public void add(int pins) { itsScorer.addThrow(pins); itsScore += pins; adjustCurrentFrame(pins); } private void adjustCurrentFrame(int pins) { if (firstThrowInFrame == true) { if( pins == 10 ) // strike itsCurrentFrame++; else firstThrowInFrame = false; } else { firstThrowInFrame=true; itsCurrentFrame++;
GRA W KRĘGLE
} }
itsCurrentFrame = Math.min(11, itsCurrentFrame);
public int scoreForFrame(int theFrame) { return itsScorer.scoreForFrame(theFrame); } private private private private }
int itsScore = 0; int itsCurrentFrame = 1; boolean firstThrowInFrame = true; Scorer itsScorer = new Scorer();
// Scorer.java-----------------------------------
public class Scorer { public void addThrow(int pins) { itsThrows[itsCurrentThrow++] = pins; } public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { if (strike()) { score += 10 + nextTwoBallsForStrike(); ball++; } else if ( spare() ) { score += 10 + nextBallForSpare(); ball+=2; } else { score += twoBallsInFrame(); ball+=2; } } return score; } private boolean strike() { return itsThrows[ball] == 10; } private boolean spare() { return (itsThrows[ball] + itsThrows[ball+1]) == 10; } private int nextTwoBallsForStrike() { return itsThrows[ball+1] + itsThrows[ball+2]; } private int nextBallForSpare() { return itsThrows[ball+2];
91
92
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
} private int twoBallsInFrame() { return itsThrows[ball] + itsThrows[ball+1]; }
}
private int ball; private int[] itsThrows = new int[21]; private int itsCurrentThrow = 0;
RSK: Teraz znacznie lepiej. W tej postaci obiekt Game po prostu zarządza rundami, a obiekt Scorer po prostu oblicza punkty. Zasada pojedynczej odpowiedzialności triumfuje! RCM: Wszystko jedno. Ale tak jest lepiej. Zauważyłeś, że zmienna itsScore nie jest już używana? RSK: Ha! Masz rację! Pozbądźmy się jej (zaczyna radośnie usuwać kod). public void add(int pins) { itsScorer.addThrow(pins); adjustCurrentFrame(pins); }
RSK: Nieźle. Czy powinniśmy teraz uporządkować funkcję adjustCurrentFrame? RCM: Tak. Przyjrzyjmy się temu. private void adjustCurrentFrame(int pins) { if (firstThrowInFrame == true) { if( pins == 10 ) // strike itsCurrentFrame++; else firstThrowInFrame = false; } else { firstThrowInFrame=true; itsCurrentFrame++; } itsCurrentFrame = Math.min(11, itsCurrentFrame); }
RCM: Najpierw spróbujmy wydzielić inkrementacje do pojedynczej funkcji, która jednocześnie ogranicza numer rundy do 11 (Brr. W dalszym ciągu nie podoba mi się to 11). RSK: Bob. 11 oznacza koniec gry. RCM: Tak. Brr (bierze klawiaturę i wykonuje kilka zmian, przerywając je testami). private void adjustCurrentFrame(int pins) { if (firstThrowInFrame == true) { if( pins == 10 ) // strike advanceFrame(); else firstThrowInFrame = false; } else { firstThrowInFrame=true; advanceFrame(); } }
GRA W KRĘGLE
93
private void advanceFrame() { itsCurrentFrame = Math.min(11, itsCurrentFrame + 1); }
RCM: Tak jest nieco lepiej. Teraz spróbujmy wydzielić przypadek premii strike do oddzielnej funkcji (wykonuje kilka niewielkich zmian i uruchamia testy po wprowadzeniu każdej z nich). private void adjustCurrentFrame(int pins) { if (firstThrowInFrame == true) { if (adjustFrameForStrike(pins) == false) firstThrowInFrame = false; } else { firstThrowInFrame=true; advanceFrame(); } } private boolean adjustFrameForStrike(int pins) { if (pins == 10) { advanceFrame(); return true; } return false; }
RCM: Tak jest dość dobrze. Teraz z tą 11. RSK: Widzę, że naprawdę ci się to nie podoba. RCM: Tak. Przyjrzyjmy się funkcji score(). public int score() { return scoreForFrame(getCurrentFrame()-1); }
RCM: To -1 wygląda dziwnie. To jedyne miejsce, gdzie używamy getCurrentFrame i jeszcze musimy dostrajać to, co ta funkcja zwraca. RSK: U licha! Masz rację! Ile razy zmienialiśmy zdanie na ten temat? RCM: Zbyt wiele razy. Ale tak to wygląda. Kod oczekuje, aby zmienna itsCurrentFrame reprezentowała rundę ostatnio rzuconej kuli, a nie rundę, w której mamy zamiar rzucać kulę. RSK: Ojej. To naruszy wiele przypadków testowych. RCM: W zasadzie myślę, że powinniśmy usunąć wywołanie metody getCurrentFrame z wszystkich przypadków testowych oraz usunąć samą funkcję getCurrentFrame. Nikt w zasadzie z niej nie korzysta. RSK: Rozumiem twoje zamiary. Spróbuję to zrobić. To będzie jak pozbycie się kulawego konia (przejmuje klawiaturę). // Game.java----------------------------------
public int score() { return scoreForFrame(itsCurrentFrame); } private void advanceFrame() { itsCurrentFrame = Math.min(10, itsCurrentFrame + 1); }
94
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
RCM: Och! Na litość boską! Chcesz mi powiedzieć, że cierpieliśmy z takiego powodu? Wszystko, co zrobiliśmy, to zmieniliśmy limit z 11 do 10 i usunęliśmy -1. Jasny gwint! RSK: Zgadza się, wujku Bob. To naprawdę nie było warte tych wszystkich frustracji. RCM: Nie podoba mi się efekt uboczny metody adjustFrameForStrike(). Chciałbym się go pozbyć. Co o tym sądzisz? private void adjustCurrentFrame(int pins) { if ((firstThrowInFrame && pins == 10) || (!firstThrowInFrame)) advanceFrame(); else firstThrowInFrame = false; }
RSK: Koncepcja jest OK i testy przechodzą, ale nie cierpię długich instrukcji if. A gdyby przekształcić to tak: private void adjustCurrentFrame(int pins) { if (strike(pins) || !firstThrowInFrame) advanceFrame(); else firstThrowInFrame = false; } private boolean strike(int pins) { return (firstThrowInFrame && pins == 10); }
RCM: Tak. To wygląda ładnie. Moglibyśmy nawet pójść o jeden krok dalej. private void adjustCurrentFrame(int pins) { if (lastBallInFrame(pins)) advanceFrame(); else firstThrowInFrame = false; } private boolean lastBallInFrame(int pins) { return strike(pins) || !firstThrowInFrame; }
RSK: Ładnie! RCM: OK. Wygląda na to, że wszystko zrobiliśmy. Przeczytajmy cały program i sprawdźmy, czy jest maksymalnie prosty i komunikatywny. // Game.java----------------------------------
public class Game { public int score() { return scoreForFrame(itsCurrentFrame); } public void add(int pins) { itsScorer.addThrow(pins); adjustCurrentFrame(pins); }
GRA W KRĘGLE
private void adjustCurrentFrame(int pins) { if (lastBallInFrame(pins)) advanceFrame(); else firstThrowInFrame = false; } private boolean lastBallInFrame(int pins) { return strike(pins) || !firstThrowInFrame; } private boolean strike(int pins) { return (firstThrowInFrame && pins == 10); } private void advanceFrame() { itsCurrentFrame = Math.min(10, itsCurrentFrame + 1); }
}
public int scoreForFrame(int theFrame) { return itsScorer.scoreForFrame(theFrame); } private int itsCurrentFrame = 0; private boolean firstThrowInFrame = true; private Scorer itsScorer = new Scorer();
// Scorer.java----------------------------------
public class Scorer { public void addThrow(int pins) { itsThrows[itsCurrentThrow++] = pins; } public int scoreForFrame(int theFrame) { ball = 0; int score=0; for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) { if (strike()) { score += 10 + nextTwoBallsForStrike(); ball++; } else if ( spare() ) { score += 10 + nextBallForSpare(); ball+=2; } else { score += twoBallsInFrame(); ball+=2; } } return score; } private boolean strike() { return itsThrows[ball] == 10;
95
96
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
} private boolean spare() { return (itsThrows[ball] + itsThrows[ball+1]) == 10; } private int nextTwoBallsForStrike() { return itsThrows[ball+1] + itsThrows[ball+2]; } private int nextBallForSpare() { return itsThrows[ball+2]; }
}
private int twoBallsInFrame() { return itsThrows[ball] + itsThrows[ball+1]; } private int ball; private int[] itsThrows = new int[21]; private int itsCurrentThrow = 0;
RCM: OK. Wygląda dość dobrze. Nie przychodzi mi na myśl nic, co jeszcze można by zrobić. RSK: Tak. Wygląda dobrze. Przyjrzyjmy się jeszcze testom. // TestGame.java-----------------------------------------import junit.framework.*;
public class TestGame extends TestCase { public TestGame(String name) { super(name); } private Game g; public void setUp() { g = new Game(); } public void testTwoThrowsNoMark() { g.add(5); g.add(4); assertEquals(9, g.score()); } public void testFourThrowsNoMark() { g.add(5); g.add(4); g.add(7); g.add(2); assertEquals(18, g.score()); assertEquals(9, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); } public void testSimpleSpare() { g.add(3); g.add(7); g.add(3); assertEquals(13, g.scoreForFrame(1)); } public void testSimpleFrameAfterSpare() {
GRA W KRĘGLE
}
g.add(3); g.add(7); g.add(3); g.add(2); assertEquals(13, g.scoreForFrame(1)); assertEquals(18, g.scoreForFrame(2)); assertEquals(18, g.score());
public void testSimpleStrike() { g.add(10); g.add(3); g.add(6); assertEquals(19, g.scoreForFrame(1)); assertEquals(28, g.score()); } public void testPerfectGame() { for (int i=0; i<12; i++) { g.add(10); } assertEquals(300, g.score()); } public void testEndOfArray() { for (int i=0; i<9; i++) { g.add(0); g.add(0); } g.add(2); g.add(8); // Premia spare w 10. rundzie g.add(10); // Strike w ostatniej pozycji tablicy assertEquals(20, g.score()); } public void testSampleGame() { g.add(1); g.add(4); g.add(4); g.add(5); g.add(6); g.add(4); g.add(5); g.add(5); g.add(10); g.add(0); g.add(1); g.add(7); g.add(3); g.add(6); g.add(4); g.add(10); g.add(2); g.add(8); g.add(6); assertEquals(133, g.score()); } public void testHeartBreak() { for (int i=0; i<11; i++) g.add(10);
97
98
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
}
}
g.add(9); assertEquals(299, g.score());
public void testTenthFrameSpare() { for (int i=0; i<9; i++) g.add(10); g.add(9); g.add(1); g.add(1); assertEquals(270, g.score()); }
RSK: Myślę, że testy pokrywają wszystkie przypadki. Czy przychodzą ci na myśl bardziej sensowne przypadki testowe? RCM: Nie. Myślę, że to dobry zestaw. Nie widzę też żadnych, które chciałbym w tym momencie usunąć. RSK: W takim razie mamy to gotowe! RCM: Tak sądzę. Wielkie dzięki za pomoc. RSK: Nie ma za co. To była przyjemność.
Wniosek Po napisaniu tego rozdziału opublikowałem go w witrynie WWW Object Mentor3. Wiele osób czytało go i przekazywało swoje uwagi. Niektórym przeszkadzało to, że praca prawie nie obejmowała projektu obiektowego. Ta reakcja wydała mi się interesująca. Czy projekt obiektowy musi występować w każdej aplikacji i w każdym programie? W tym rozdziale zaprezentowaliśmy przypadek, gdzie nie było takiej potrzeby. Klasa Scorer była naprawdę jedynym objawem obiektowości, a nawet ona w większym stopniu była przejawem zwykłego podziału kodu niż prawdziwego projektu obiektowego. Niektórzy uważali, że właściwie powinniśmy byli zaimplementować klasę Frame. Jeden z czytelników posunął się tak daleko, że utworzył wersję programu, która zawierała klasę Frame. Była o wiele bardziej rozbudowana i bardziej złożona niż to, co widzieliśmy powyżej. Niektórzy uważali, że nie zachowujemy się fair w stosunku do UML. W końcu nie wykonaliśmy kompletnego projektu przed przystąpieniem do projektowania. Zabawny, niewielki diagram UML nakreślony na odwrocie serwetki (rysunek 6.2) nie był kompletnym projektem. Nie uwzględniłem diagramów sekwencji. Ten argument wydał mi się dość dziwny. Nie wydało mi się prawdopodobne, aby dodanie diagramów sekwencji do rysunku 6.2 skłoniło nas do zrezygnowania z klas Throw i Frame. W rzeczywistości myślę, że to utwierdziłoby nas w przekonaniu, że klasy te były konieczne. Czy próbuję powiedzieć, że diagramy są niewłaściwe? Oczywiście, że nie. A jednak jeśli dokładniej się zastanowimy, wydaje się, że w pewien sposób są one niewłaściwe. W przypadku tego programu diagramy nie pomogły wcale. Właściwie tylko przeszkadzały. Gdybyśmy je wykorzystali, doprowadziłyby nas do programu, który byłby o wiele bardziej skomplikowany, niż to konieczne. Można by się spierać, że gdybyśmy skorzystali z diagramów, powstałby program, który byłby łatwiejszy w utrzymaniu, ale ja się z tym nie zgadzam. Program, który zaprezentowaliśmy, jest zrozumiały, a przez to łatwy w utrzymaniu. Nie ma w nim źle zarządzanych zależności, które sprawiłyby, że stał się sztywny lub kruchy. A zatem tak, diagramy czasami mogą być niewłaściwe. Kiedy są niewłaściwe? Kiedy są tworzone bez kodu, który je waliduje, oraz bez zamiaru ich przestrzegania. Nie ma niczego złego w narysowaniu diagramu, który wyjaśnia koncepcję. Jednak po stworzeniu diagramu nie należy zakładać, że jest to najlepszy projekt dla zadania. Może się okazać, że najlepszy projekt będzie ewoluował w miarę wykonywania niewielkich, małych kroków, gdy piszemy program, najpierw tworząc testy. 3
http://www.objectmentor.com
WNIOSEK
Przegląd zasad gry w kręgle Gra w kręgle (ang. Bowling) to gra, która polega na rzucaniu kuli wielkości dużego melona w wąską alejkę, w kierunku dziesięciu drewnianych kołków — kręgli. Celem tej gry jest strącenie w jednym rzucie jak największej liczby kręgli. Gra toczy się w dziesięciu rundach. Na początku każdej rundy ustawia są wszystkie dziesięć kręgli. Następnie gracz podejmuje dwie próby strącenia ich. Jeśli gracz strąci wszystkie kręgle w pierwszej próbie, to takie trafienie określa się jako strike i runda na nim się kończy. Jeśli graczowi nie uda się strącić wszystkich kręgli w pierwszej próbie, ale powiedzie mu się w drugiej, to takie trafienie określa się jako spare. Po rzuceniu drugiej kuli runda kończy się nawet wtedy, gdy wszystkie kręgle nadal stoją. Runda z trafieniem strike jest premiowana dodaniem do wyniku poprzedniej rundy dziesięciu punktów plus liczba kręgli strąconych w następnych dwóch rzutach. Runda z trafieniem spare jest premiowana dodaniem do wyniku poprzedniej rundy dziesięciu punktów plus liczba kręgli strąconych w następnym rzucie. W przeciwnym razie runda jest punktowana dodaniem do wyniku poprzedniej rundy liczby kręgli strąconych w dwóch rzutach. Jeśli gracz uzyska trafienie strike w dziesiątej rundzie, to jest uprawniony do rzucenia dwóch dodatkowych kul, aby skompletować punktację premii strike. Na podobnej zasadzie, jeśli gracz uzyska trafienie spare w dziesiątej rundzie, to jest uprawniony do rzutu dodatkowej kuli, aby skompletować punktację premii spare.
Zgodnie z tym w dziesiątej rundzie mogą być trzy kule zamiast dwóch.
Karta wyników zamieszczona powyżej przedstawia typową punktację dość słabej gry. W pierwszej rundzie gracz strącił 1 kręgiel za pomocą pierwszej kuli i cztery kolejne za pomocą drugiej. Zatem wynik tej rundy to pięć punktów. W drugiej rundzie gracz strącił 4 kręgle za pomocą pierwszej kuli i pięć kolejnych za pomocą drugiej. Łącznie strącił dziewięć kręgli. Po dodaniu tych punktów do wyniku poprzedniej rundy otrzymujemy czternaście punktów. W trzeciej rundzie gracz strącił sześć kręgli za pomocą pierwszej kuli, natomiast za pomocą drugiej strącił pozostałe, uzyskując premię spare. Dla tej rundy nie można obliczyć wyniku do czasu rzucenia następnej kuli. W czwartej rundzie gracz strącił pięć kręgli za pomocą pierwszej kuli. To pozwala nam zakończyć liczenie punktów za premię spare w rundzie trzeciej. Wynik trzeciej rundy wynosi dziesięć plus punktacja ramki drugiej (14), plus pierwsza kula ramki czwartej (5), czyli ostatnia kula rundy 4 to trafienie spare. W rundzie piątej gracz uzyskał trafienie strike. To pozwala nam zakończyć punktację rundy czwartej, która wynosi 29 + 10 + 10 = 49. Runda numer sześć jest ponura. W pierwszym rzucie kula trafiła do rynsztoku i nie strąciła żadnego kręgla. W drugim rzucie został strącony tylko jeden kręgiel. Punktacja za premię strike w piątej rundzie wynosi 49 + 10 + 0 + 1 = 60. Resztę z pewnością zdołasz wywnioskować samodzielnie.
99
100
ROZDZIAŁ 6. EPIZOD PROGRAMOWANIA
CZĘŚĆ II Projekt agile Jeśli zwinność (ang. agility) polega na budowaniu oprogramowania niewielkimi krokami, to jak w ogóle można mówić o projektowaniu oprogramowania? Kiedy należy poświęcić czas na to, by sprawdzić, czy oprogramowanie ma dobrą strukturę, która jest elastyczna, łatwa w utrzymaniu i pozwala na wielokrotne użycie? Jeśli budujemy oprogramowanie małymi krokami, to czy tak naprawdę nie ustawiamy sceny dla wielu przeróbek i cięć wykonywanych w imię refaktoryzacji? Czy z tego powodu nie stracimy obrazu całości? W zespole agile obraz całości ewoluuje wraz z oprogramowaniem. Z każdą iteracją zespół poprawia projekt systemu, aby był tak dobry, jak to możliwe w systemie w bieżącej postaci. Zespół nie poświęca zbyt wiele czasu na rozpatrywanie przyszłych wymagań i potrzeb. Nie stara się także budować dzisiaj infrastruktury do obsługi funkcji, które być może będą potrzebne jutro. Zamiast tego koncentruje się na bieżącej strukturze systemu, starając się, aby była tak dobra, jak to możliwe.
Symptomy złego projektu Skąd wiemy, że projekt oprogramowania jest dobry? Pierwszy rozdział tej części książki wyszczególnia i opisuje symptomy złego projektu. Rozdział ten pokazuje, jak te symptomy kumulują się w projekcie oprogramowania i jak ich unikać. Symptomy złego projektu są następujące: 1. 2. 3. 4. 5. 6. 7.
Sztywność (ang. rigidity) — projekt jest trudny do zmiany. Kruchość (ang. fragility) — projekt jest łatwy do zepsucia. Brak mobilności (ang. immobility) — projekt jest trudny do wielokrotnego użytku. Lepkość (ang. viscosity) — trudno jest zrealizować funkcje we właściwy sposób. Zbytnia złożoność — nadmiernie rozbudowany projekt (ang. overdesign). Niepotrzebne powielenia — nadużywanie myszy. Nieprzezroczystość (ang. opacity) — chaotyczna ekspresja.
Objawy te są podobne w charakterze do „brzydkich zapachów kodu” (ang. code smells)1, ale występują na wyższym poziomie. Są to zapachy, które przenikają ogólną strukturę programu, zamiast niewielkiej części kodu.
Zasady Pozostałe rozdziały w tej części opisują zasady projektowania obiektowego, które pomogą deweloperom wyeliminować zapachy projektu i zbudować jak najlepszy projekt dla bieżącego zestawu funkcji. Są to następujące zasady: 1. Zasada pojedynczej odpowiedzialności (Single Responsibility Principle — SRP). 2. Zasada otwarte-zamknięte (ang. Open-Closed Principle — OCP). 1
[Fowler 99].
102
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
3. Zasada podstawiania Liskov (Liskov Substitution Principle — LSP). 4. Zasada odwracania zależności (Dependency Inversion Principle — DIP). 5. Zasada segregacji interfejsu (Interface Segregation Principle — ISP). Zasady te są z trudem wypracowanym produktem wielu dziesięcioleci doświadczeń w inżynierii oprogramowania. Nie są one wytworem jednego umysłu, ale stanowią integrację myśli i publikacji wielu programistów i naukowców. Mimo że tutaj zostały przedstawione jako zasady projektowania obiektowego, są w istocie szczególnymi przypadkami długotrwałych zasad obowiązujących w inżynierii oprogramowania.
Zapachy a zasady Zapach projektu jest objawem, który można zmierzyć — w sposób subiektywny albo obiektywny. Często zapach jest spowodowany naruszeniem jednej lub wielu zasad. Na przykład zapach sztywności często jest wynikiem niedostatecznego przestrzegania zasady otwarte-zamknięte (OCP). Zespoły agile stosują zasady w celu eliminowania zapachów. Nie stosują zasad, kiedy nie ma zapachów. Błędem jest bezwarunkowe dążenie do stosowania zasady tylko dlatego, że to jest zasada. Zasady nie są perfumami, które mogą być obficie rozpylone po całym systemie. Nadmierna zgodność z zasadami prowadzi do zapachu nadmiernej złożoności.
Bibliografia 1. Martin Fowler, Refactoring, Addison-Wesley, 1999.
CO ZŁEGO DZIEJE SIĘ Z OPROGRAMOWANIEM?
103
R OZDZIAŁ 7
Co to jest projekt agile?
Po zapoznaniu się z cyklem rozwoju oprogramowania doszedłem do wniosku, że jedyną dokumentacją oprogramowania, która rzeczywiście wydaje się spełniać kryteria projektu technicznego, są listingi z kodem źródłowym — Jack Reeves
W 1992 roku Jack Reeves napisał słynny artykuł w magazynie „C++ Journal” zatytułowany What is Software Design?2. W tym artykule Reeves twierdzi, że projekt systemu oprogramowania jest udokumentowany głównie za pomocą jego kodu źródłowego. Diagramy reprezentujące kod źródłowy są uzupełniające w stosunku do projektu i same w sobie nie są projektem. Jak się okazało, artykuł Jacka stał się zwiastunem rozwoju agile. Na kolejnych stronach tej książki często będziemy mówili o projekcie. Nie należy zakładać, że oznacza to zestaw diagramów UML oddzielonych od kodu. Zbiór diagramów UML może reprezentować części projektu, ale to nie jest projekt. Projekt oprogramowania jest pojęciem abstrakcyjnym. Wiąże się zarówno z ogólnym kształtem i strukturą programu, jak również ze szczegółowym kształtem i strukturą każdego modułu, klasy i metody. Może być reprezentowany za pomocą wielu różnych mediów, ale jego ostateczną postacią jest kod źródłowy. Ostatecznie projektem jest kod źródłowy.
Co złego dzieje się z oprogramowaniem? Jeśli ktoś ma szczęście, to rozpoczyna projekt, mając jasny obraz tego, jaką postać ma system przyjąć. Projekt systemu jest żywym obrazem w naszym umyśle. Jeśli ktoś ma jeszcze więcej szczęścia, to klarowność towarzyszy projektowi do pierwszej wersji dystrybucyjnej. 2
[Reeves 92] To doskonały artykuł. Gorąco zachęcam do jego przeczytania. Dołączyłem go do tej książki jako dodatek D.
104
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
Wtedy coś zaczyna iść nie tak. Oprogramowanie zaczyna się psuć jak kawałek starego mięsa. W miarę upływu czasu zepsucie rozprzestrzenia się i rośnie. W kodzie gromadzą się brzydkie, ropiejące wrzody i czyraki, sprawiając, że jego utrzymanie staje się coraz trudniejsze. Ostatecznie wysiłek, jaki trzeba włożyć, aby wprowadzić nawet najprostsze zmiany, staje się tak uciążliwy, że deweloperzy i menedżerowie zaczynają nalegać na zaprojektowanie systemu od początku. Takie przedsięwzięcia rzadko kończą się sukcesem. Chociaż projektanci mają dobre intencje, to okazuje się, że strzelają do ruchomego celu. Stary system ewoluuje i zmienia się, a odzwierciedlenie tych zmian musi znaleźć miejsce w nowym projekcie. Brodawki i wrzody gromadzą się w nowym projekcie, zanim powstanie jego pierwsza wersja dystrybucyjna.
Zapachy projektu — woń psującego się oprogramowania Psujące się oprogramowanie można rozpoznać po dowolnym z następujących zapachów: 1. Sztywność — system jest trudny do zmiany, ponieważ każda zmiana wymusza wiele innych zmian w innych częściach systemu. 2. Kruchość — zmiany powodują psucie się systemu w miejscach, które na pozór nie mają związku z tą częścią, która została zmieniona. 3. Brak mobilności — trudno rozdzielić system na komponenty, które mogą być ponownie użyte w innych systemach. 4. Lepkość — stosowanie dobrych praktyk jest trudniejsze niż stosowanie złych praktyk. 5. Zbędna złożoność — projekt zawiera infrastrukturę, z której nie ma bezpośredniego pożytku. 6. Zbędne powtórzenia — projekt zawiera powtarzające się struktury, które mogłyby być ujednolicone w ramach pojedynczej abstrakcji. 7. Nieczytelność — system jest trudny do czytania i zrozumienia. Kod nie komunikuje dobrze swojej intencji. Sztywność. Sztywność to cecha oprogramowania polegająca na trudnym wprowadzaniu nawet najprostszych zmian. Projekt jest sztywny, jeśli pojedyncza zmiana powoduje kaskadę kolejnych zmian w modułach zależnych. Im więcej modułów, które muszą być zmienione, tym bardziej sztywny projekt. Większość programistów wcześniej czy później spotkała się z taką sytuacją. Ktoś prosi nas o wprowadzenie zmiany, która wydaje się być prosta. Zapoznajemy się z problemem i dokonujemy rozsądnego oszacowania nakładów wymaganej pracy. Ale później, podczas pracy nad wprowadzeniem zmiany, okazuje się, że istnieją reperkusje zmiany, których nie przewidzieliśmy. Musimy przebijać się przez ogromne porcje kodu i modyfikować znacznie więcej modułów, niż początkowo ocenialiśmy. Ostatecznie zmiany zajmują znacznie więcej czasu, niż szacowaliśmy. Na pytanie, dlaczego oszacowanie było takie niedokładne, powtarzamy tradycyjny lament programisty: „To było o wiele bardziej skomplikowane, niż sądziłem!”. Kruchość. Kruchość jest tendencją oprogramowania do psucia się w wielu miejscach po wprowadzeniu pojedynczej zmiany. Często nowe problemy pojawiają się w obszarach, które nie mają koncepcyjnego związku z obszarem, który został zmieniony. Naprawienie tych problemów prowadzi do jeszcze większej liczby problemów, a zespół deweloperski zaczyna przypominać psa, który goni własny ogon. W miarę wzrostu kruchości modułu prawdopodobieństwo, że wprowadzenie zmiany spowoduje nieoczekiwane problemy, jest bliskie pewności. Wydaje się to absurdalne, ale takie moduły wcale nie należą do rzadkości. Są to moduły, które stale wymagają naprawy. Nigdy nie znikają z listy błędów. Programiści wiedzą, że trzeba je zaprojektować od nowa (ale nikt nie chce się zmierzyć z zadaniem ich przebudowy). Im więcej poprawek w nich wprowadzamy, tym stają się gorsze. Brak mobilności. Projekt jest niemobilny, gdy zawiera części, które mogłyby być użyteczne w innych systemach, ale wysiłek i ryzyko związane z oddzieleniem tych części od pierwotnego systemu są zbyt duże. Jest to sytuacja niefortunna, ale niestety bardzo częsta.
ZAPACHY PROJEKTU — WOŃ PSUJĄCEGO SIĘ OPROGRAMOWANIA
105
Lepkość. Lepkość może mieć dwie formy: lepkość oprogramowania oraz lepkość środowiska. W obliczu konieczności wprowadzenia zmiany programiści zwykle wymyślają więcej niż jeden sposób na wprowadzenie tej zmiany. Niektóre ze sposobów zachowują projekt, inne nie (są to tzw. hacki — ang. hacks). Gdy sposoby zachowujące projekt są trudniejsze do wprowadzenia niż hacki, to lepkość projektu jest wysoka. Łatwo wprowadza się zmiany w niewłaściwy sposób, natomiast trudno zrobić to właściwie. Należy dążyć do zaprojektowania oprogramowania w taki sposób, aby wprowadzanie zmian, które zachowują projekt, było łatwe do wykonania. Lepkość środowiska powstaje w sytuacji, gdy środowisko programistyczne jest powolne i niewydajne. Na przykład jeśli czasy kompilacji są bardzo długie, deweloperzy mają pokusę, aby dokonywać zmian, które nie wymuszają dużych rekompilacji, choć zmiany te nie zachowują projektu. Jeśli pobranie zaledwie kilku plików z systemu kontroli kodu źródłowego wymaga godzin, to deweloperzy będą się starać wprowadzać zmiany, które wymagają jak najmniej pobrań z systemu kontroli wersji, bez względu na to, czy projekt jest zachowany, czy nie. W obu przypadkach lepki projekt to taki, w którym trudno utrzymać strukturę oprogramowania. Należy stworzyć systemy i środowiska projektowe, które ułatwiają zachowywanie projektu. Zbytnia złożoność. Projekt zawiera niepotrzebną złożoność, gdy zawiera elementy, które w danej chwili nie są użyteczne. To często się dzieje, gdy deweloperzy przewidują zmiany w wymaganiach i umieszczają w oprogramowaniu mechanizmy mające na celu obsługę tych potencjalnych zmian. Początkowo może się wydawać, że to jest dobre. W końcu przygotowywanie się do przyszłych zmian powinno utrzymać elastyczność kodu i zapobiec koszmarnym zmianom w późniejszym okresie. Niestety, efekt często jest dokładnie odwrotny. Poprzez przygotowywanie się do zbyt wielu zmian projekt jest zaśmiecony konstrukcjami, które nigdy nie są używane. Niektóre spośród tych przygotowań mogą się opłacać, ale wiele innych nie. W międzyczasie projekt znosi ciężar tych niewykorzystanych elementów konstrukcyjnych. W ten sposób oprogramowanie staje się skomplikowane i trudne do zrozumienia. Niepotrzebne powtórzenia. Operacje wytnij i wklej mogą być przydatne do edycji tekstu, ale w przypadku edycji kodu ich stosowanie może mieć katastrofalne skutki. Bardzo często systemy oprogramowania są budowane na dziesiątkach lub setkach powtarzających się elementów kodu. Dochodzi do tego w następujący sposób: Rafał musi napisać kod, który realizuje funkcjonalność A. Przegląda inne części kodu, w których podejrzewa występowanie podobnych operacji, i znajduje odpowiedni fragment kodu. Wycina ten kod i wkleja do swojego modułu, a następnie wprowadza odpowiednie modyfikacje. Rafał nie wie, że kod, który wyciął za pomocą myszy, został tam wprowadzony przez Tadka, który wyciął go z modułu napisanego przez Lidię. Lidia była pierwszą osobą, która zrealizowała funkcjonalność A, ale zdała sobie sprawę, że ta funkcjonalność była bardzo podobna do funkcjonalności B. Znalazła implementację tej funkcjonalności, wycięła i wkleiła ją do swojego modułu, a następnie wprowadziła niezbędne modyfikacje. Kiedy ten sam kod pojawia się w kółko w wielu miejscach, choć w nieco innej formie, deweloperom brakuje abstrakcji. Wyszukiwanie wszystkich powtórzeń i eliminowanie ich z wykorzystaniem odpowiedniej abstrakcji może nie znajdować się zbyt wysoko na ich liście priorytetów, ale wykonanie tej czynności znacząco przyczynia się do tworzenia systemu, który jest łatwiejszych do zrozumienia i utrzymania. Gdy w systemie jest zbędny kod, zadanie modyfikowania systemu może stać się uciążliwe. Błędy znalezione w takiej powtarzającej się jednostce muszą być poprawiane we wszystkich powtórzeniach. Ponieważ jednak każde powtórzenie nieco różni się od pozostałych, poprawka nie zawsze jest taka sama. Nieczytelność. Nieczytelność jest cechą modułu polegającą na trudności w jego zrozumieniu. Kod może być napisany w jasny i czytelny sposób lub może być napisany w sposób nieprzejrzysty i zawiły. Kod, który ewoluuje, z biegiem czasu wykazuje tendencję do stawania się coraz bardziej nieprzezroczystym. Utrzymanie nieprzezroczystości na minimalnym poziomie wymaga stałego wysiłku. Trzeba ciągle dbać o to, by kod był czytelny i ekspresywny.
106
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
Kiedy deweloperzy piszą moduł po raz pierwszy, kod może im się wydawać jasny. To dlatego, że wgłębili się w kod i doskonale go rozumieją. Po jakimś czasie, gdy wracają do tego modułu, często zastanawiają się, jak mogli napisać coś tak okropnego. Aby temu zapobiec, deweloperzy powinni starać się stawiać w pozycji czytelników swojego kodu i podejmować wysiłki zmierzające do refaktoryzacji kodu w taki sposób, aby czytelnicy kodu mogli go zrozumieć. Kod powinien być także przeglądany przez inne osoby.
Co stymuluje oprogramowanie do psucia się? W środowiskach, które nie stosują metodyki agile, projekty pogarszają się z powodu zmian wymagań w sposób, którego pierwotny projekt nie przewidywał. Często zmiany te muszą być wykonane szybko i mogą być wykonywane przez deweloperów, którzy nie znają pierwotnej filozofii projektu. Tak więc choć zmiana w projekcie działa, to w jakiś sposób narusza oryginalny projekt. Kawałek po kawałku, w miarę wprowadzania kolejnych zmian, naruszenia te kumulują się i projekt zaczyna „pachnieć”. Nie możemy jednak winić dryfowania wymagań za degradację projektu. Deweloperzy powinni doskonale zdawać sobie sprawę, że wymagania się zmieniają. Rzeczywiście, większość z nas zdaje sobie sprawę, że wymagania są najbardziej lotnymi elementami projektu. Jeżeli projekty nie udają się ze względu na ciągły deszcz zmieniających się wymagań, to winę za to ponoszą stosowane przez nas praktyki projektowe. Trzeba znaleźć jakiś sposób, aby projekty były odporne na zmiany, i zastosować praktyki, które zabezpieczają kod przed psuciem się.
Zespoły agile nie pozwalają psuć się oprogramowaniu Zespół agile kwitnie w obliczu zmian. Zespół niewiele inwestuje w początkowe prace, dlatego nie jest przywiązany do wyjściowego projektu. Zamiast tego utrzymuje konstrukcję systemu w sposób maksymalnie czysty i prosty i uzasadnia to za pomocą wielu testów jednostkowych i testów akceptacyjnych. Dzięki temu projekt jest elastyczny, a wprowadzanie w nim zmian jest łatwe. Zespół korzysta z tej elastyczności w celu ciągłego poprawiania projektu. Dzięki temu każda iteracja kończy się powstaniem systemu, którego projekt jest jak najlepszy w stosunku do wymagań w tej iteracji.
Program Copy Aby zilustrować powyższe punkty, warto przyjrzeć się procesowi psucia się oprogramowania. Powiedzmy, że szef przyszedł do Ciebie w poniedziałek rano i poprosił o napisanie programu, który kopiuje znaki z klawiatury na drukarkę. Po wykonaniu w myślach kilku szybkich ćwiczeń umysłowych doszedłeś do wniosku, że taki program nie powinien zająć więcej niż dziesięć linijek kodu. Projektowanie i kodowanie nie powinny zająć więcej niż godzinę. Razem ze spotkaniami grup interdyscyplinarnych, szkoleniami jakości, codziennymi spotkaniami grup badających postępy prac i trzema bieżącymi kryzysami w branży zakończenie tego programu powinno zająć około tygodnia — jeśli zostaniesz po godzinach. Zawsze jednak nasze szacunki mnożymy przez trzy. „Trzy tygodnie” — takiej odpowiedzi udzielasz szefowi. Szef mruczy coś pod nosem i zgadza się, pozostawiając Cię z zadaniem. Wstępny projekt. Masz teraz trochę czasu, zanim rozpocznie się spotkanie poświęcone przeglądowi procesu, więc decydujesz, że narysujesz projekt programu. Przy zastosowaniu projektu strukturalnego doszedłeś do diagramu struktury pokazanego na rysunku 7.1. Aplikacja składa się z trzech modułów lub podprogramów. Moduł Copy wywołuje dwa pozostałe. Program kopiujący pobiera znaki z modułu Read Keyboard i kieruje je do modułu Write Printer. Patrzysz na swój projekt i oceniasz, że jest dobry. Uśmiechasz się i wychodzisz z biura, by pójść na spotkanie. Przynajmniej będzie można tam trochę pospać.
PROGRAM COPY
107
Rysunek 7.1. Diagram struktury programu Copy
We wtorek przyszedłeś nieco wcześniej, dzięki czemu możesz zakończyć pracę nad programem Copy. Niestety, jeden z kryzysów w branży dał o sobie znać w nocy. W związku z tym musisz iść do laboratorium i pomóc w debugowaniu problemu. W przerwie na obiad, na którą w końcu wyszedłeś o 15.00, udało Ci się napisać kod programu Copy. Efekt pokazano na listingu 7.1. Listing 7.1. Program Copy void Copy() { int c; while ((c=RdKbd()) != EOF) WrtPrt(c); }
Ledwo udało Ci się zapisać edytowany kod, kiedy zdałeś sobie sprawę, że jesteś już spóźniony na spotkanie jakości. Wiesz, że to spotkanie jest ważne. Będzie poświęcone znaczeniu produkcji bezusterkowej. Zatem chowasz swoje krakersy i colę i udajesz się na spotkanie. W środę znów przyszedłeś wcześniej. Tym razem masz nadzieję, że nic nie stanie Ci na przeszkodzie. Otwierasz kod źródłowy programu Copy i zaczynasz go kompilować. I oto program kompiluje się za pierwszym razem — bez błędów! Dobre i to, bo Twój szef woła Cię na nieplanowane spotkanie na temat konieczności oszczędzania tonerów drukarek laserowych. W czwartek, po czterech godzinach spędzonych na rozmowie przez telefon z serwisantem w Rocky Mount w Karolinie Północnej, udzieleniu mu zdalnej pomocy w debugowaniu i rejestrowaniu błędów w jednym z bardziej nieczytelnych elementów systemu, otrzymałeś podziękowania i zaczynasz testować program Copy. Działa! Za pierwszym razem! To dobrze, bo Twój nowy uczeń właśnie usunął główny katalog z kodem źródłowym z serwera. Musisz więc znaleźć taśmy z najnowszą kopią zapasową i odtworzyć go. Oczywiście ostatnia pełna kopia zapasowa została zrobiona trzy miesiące temu. Dlatego musisz odtworzyć jeszcze dziewięćdziesiąt cztery przyrostowe kopie zapasowe. Piątek wydaje się zupełnie wolny od dodatkowych zajęć. To dobrze, ponieważ cały dzień zajmuje Ci pomyślne załadowanie programu Copy do systemu kontroli kodu źródłowego. Oczywiście program odnosi niezwykły sukces i jest wdrażany w całej firmie. Twoja reputacja jako programistycznego asa ponownie została potwierdzona. Możesz teraz wygrzać się w chwale swoich osiągnięć. Przy odrobinie szczęścia może faktycznie uda Ci się napisać w tym roku trzydzieści linijek kodu! Wymagania. One się zmieniają! Kilka miesięcy później szef przychodzi do Ciebie i mówi, że chciałby, aby program Copy mógł czytać z czytnika taśmy papierowej. Zgrzytasz zębami i przewracasz oczami. Zastanawiasz się, dlaczego ludzie zawsze zmieniają wymagania. Twój program nie był projektowany z myślą o obsłudze czytnika taśmy papierowej! Ostrzegasz swojego szefa, że wprowadzenie tego rodzaju zmian może naruszyć elegancję projektu. Niemniej jednak szef jest nieugięty. Mówi, że użytkownicy naprawdę potrzebują od czasu do czasu czytać znaki z czytnika taśmy papierowej. Zatem wzdychasz i planujesz wprowadzenie modyfikacji. Chcesz dodać do funkcji Copy argument typu Boolean. Jeśli będzie miał wartość true, będziesz czytać z czytnika taśmy papierowej; jeśli będzie miał wartość false, będziesz czytać z klawiatury, tak jak przedtem. Niestety, teraz już tak wiele innych
108
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
programów korzysta z programu Copy, że nie można zmieniać interfejsu. Zmiana interfejsu spowodowałaby, że ponowna kompilacja zajęłaby całe tygodnie. Sami inżynierowie testów zlinczowaliby Cię, nie wspominając nawet o siedmiu gościach z grupy zarządzania konfiguracją. Trzeba by również poświęcić wiele czasu na przeglądanie kodu każdego modułu, który wywołuje program Copy. Nie. Zmiana interfejsu nie wchodzi w rachubę. Ale w takim razie w jaki sposób poinformować program Copy o tym, że powinien czytać z czytnika taśmy papierowej? Oczywiście użyjesz zmiennej globalnej. Użyjesz także najlepszej i najwartościowszej funkcji grupy języków C-podobnych — operatora ?:. Efekt pokazano na listingu 7.2. Listing 7.2. Pierwsza modyfikacja programu Copy bool ptFlag = false;
// pamiętaj, żeby zresetować tę flagę
void Copy() { int c; while ((c=(ptflag ? RdPt() : RdKbd())) != EOF) WrtPrt(c); }
Programy wywołujące funkcję Copy, które chcą czytać z czytnika taśmy papierowej, muszą najpierw ustawić flagę ptFlag na true. Później mogą wywołać funkcję Copy, a ta będzie szczęśliwie czytać z czytnika taśmy papierowej. Kiedy funkcja Copy zwróci sterowanie, proces wywołujący musi zresetować flagę ptFlag. Gdyby tego nie zrobił, to następny wywołujący mógłby przez pomyłkę czytać z czytnika taśmy papierowej zamiast z klawiatury. Aby przypomnieć programistom o obowiązku zresetowania tej flagi, dodałeś odpowiedni komentarz. Po raz kolejny publikujesz wersję dystrybucyjną. Odnosi nawet większy sukces niż wcześniej, a hordy chętnych programistów czekają na okazję, aby z niej skorzystać. Życie jest piękne. Dać im palec... Kilka tygodni później Twój szef (który nadal jest Twoim szefem pomimo trzech reorganizacji w całej korporacji na przestrzeni wielu miesięcy) przychodzi do Ciebie i mówi, że klienci chcieliby, aby program Copy mógł pisać na dziurkarce taśmy papierowej. Ach ci klienci! Zawsze rujnują nasze projekty. Pisanie oprogramowania byłoby dużo łatwiejsze, gdyby nie to, że istnieją klienci. Mówisz szefowi, że te nieustanne zmiany mają głęboko negatywny wpływ na elegancję Twojego projektu. Ostrzegasz go, że jeśli zmiany w dalszym ciągu będą wprowadzane w tym strasznym tempie, to oprogramowanie do końca roku będzie niemożliwe do utrzymania. Twój szef kiwa głową ze zrozumieniem, a potem mówi, aby i tak dokonać zmian. Ta zmiana w projekcie jest podobna do poprzedniej. Wystarczy kolejna zmienna globalna i kolejny operator ?:. Efekt wysiłków pokazano na listingu 7.3. Listing 7.3. Druga modyfikacja programu Copy bool ptFlag = false; bool punchFlag = false;
// pamiętaj, żeby zresetować te flagi
void Copy() { int c; while ((c=(ptflag ? RdPt() : RdKbd())) != EOF) punchFlag ? WrtPunch(c) : WrtPrt(c); }
Szczególnie dumny jesteś z faktu, że pamiętałeś o tym, aby zmienić komentarz. Mimo to martwisz się, że struktura programu zaczyna się przewracać. Każda dodatkowa zmiana urządzenia wejściowego z pewnością zmusi Cię do całkowitej przebudowy pętli while. Być może nadszedł czas, aby odkurzyć swoje CV...
PROGRAM COPY
109
Oczekiwanie zmian. Ocenie czytelnika pozostawiam ustalenie, jak wiele z powyższego opisu było satyryczną przesadą. Sednem tej historii było pokazanie, że w obliczu zmian projekt programu może ulec szybkiej degradacji. Wyjściowy projekt programu Copy był prosty i elegancki. Wystarczyły jednak dwie zmiany, aby zaczął wykazywać oznaki sztywności, kruchości, braku mobilności, złożoności, redundancji i nieczytelności. Ten trend z pewnością będzie trwał, a program przekształci się w śmietnik. Możemy usiąść i zrzucać winę za ten stan rzeczy na zmiany. Możemy narzekać, że program został dobrze zaprojektowany zgodnie z oryginalną specyfikacją, a późniejsze zmiany w specyfikacji spowodowały degradację projektu. Jednak takie podejście ignoruje jeden z najbardziej znanych faktów w produkcji oprogramowania: wymagania zawsze się zmieniają! Należy pamiętać, że najbardziej ulotną rzeczą w większości projektów wytwarzania oprogramowania są wymagania. Wymagania przez cały czas są w stanie płynnym. To jest fakt, który my deweloperzy musimy zaakceptować! Żyjemy w świecie zmieniających się wymagań, a naszym zadaniem jest zapewnienie, aby nasze oprogramowanie przetrwało te zmiany. Jeżeli projekt naszego oprogramowania degraduje się, ponieważ zmieniły się wymagania, to nie jesteśmy agile.
Przykład programu Copy wykonanego zgodnie z metodyką agile Produkcja agile programu Copy mogła rozpocząć się dokładnie tak jak wcześniej — od kodu z listingu 7.13. Kiedy szef poprosił deweloperów agile, aby przystosowali program do czytania z czytnika taśmy papierowej, mogliby zareagować zmodyfikowaniem projektu w taki sposób, aby był odporny na tego rodzaju zmiany. Efekt mógłby wyglądać podobnie do kodu z listingu 7.4. Listing 7.4. Wersja numer 2 programu Copy wykonanego zgodnie z metodyką agile class Reader { public: virtual int read() = 0; }; class KeyboardReader : public Reader { public: virtual int read() {return RdKbd ();} }; KeyboardReader GdefaultReader; void Copy(Reader& reader = GdefaultReader) { int c; while ((c=reader.read()) != EOF) WrtPrt(c); }
Zamiast próbować łatać projekt, aby sprostać nowemu wymaganiu, zespół wykorzystał okazję do poprawienia projektu w taki sposób, aby w przyszłości był odporny na tego rodzaju zmiany. Od tej pory, gdy szef poprosi o obsługę nowego rodzaju urządzenia wejściowego, zespół będzie w stanie zareagować w sposób, który nie powoduje degradacji programu Copy. Zespół zastosował zasadę otwarte-zamknięte (ang. Open-Closed Principle — OCP), o której można przeczytać w rozdziale 9. Według tej zasady moduły powinny być projektowane w taki sposób, by można je było rozszerzać bez modyfikowania. To jest dokładnie to, co zrobił zespół. Można dostarczyć dowolne nowe urządzenie wejściowe, o które szef poprosi, bez modyfikowania programu Copy. 3
W rzeczywistości jest bardzo prawdopodobne, że stosowanie praktyki produkcji sterowanej zmusiłoby do stworzenia projektu na tyle elastycznego, że udałoby mu się przetrwać bez zmian żądania szefa. Jednak w tym przykładzie zignorujemy to.
110
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
Zwróćmy jednak uwagę, że zespół nie próbował przewidywać, jak program będzie się zmieniał, gdy projektował moduł po raz pierwszy. Zamiast tego program został napisany w najprostszy możliwy sposób. Dopiero gdy zmieniły się wymagania, zespół zmienił projekt modułu w taki sposób, aby był odporny na tego rodzaju zmiany. Można by się spierać, że członkowie zespołu wykonali tylko połowę pracy. Gdy zabezpieczali się przed różnymi urządzeniami wejściowymi, mogli również zabezpieczyć się przed różnymi urządzeniami wyjściowymi. Jednak zespół w rzeczywistości nie miał pojęcia, czy urządzenia wyjściowe kiedykolwiek się zmienią. Wprowadzenie dodatkowej ochrony w tym momencie byłoby pracą, która nie służyłaby żadnemu bieżącemu celowi. Jest oczywiste, że jeśli będzie potrzebne takie zabezpieczenie, łatwo będzie je można dodać później. W związku z tym w rzeczywistości nie ma powodu, aby dodawać je teraz.
Skąd deweloperzy agile wiedzieli, co należy zrobić? Deweloperzy agile w przykładzie zamieszczonym powyżej stworzyli klasę abstrakcyjną, aby zabezpieczyć się przed zmianami urządzenia wejściowego. Skąd wiedzieli, jak należy to zrobić? Jest to związane z jednym z podstawowych założeń projektu obiektowego. Początkowy projekt programu Copy jest nieelastyczny ze względu na kierunek jego zależności. Spójrzmy ponownie na rysunek 7.1. Zwróćmy uwagę, że moduł Copy zależy bezpośrednio od modułów Read Keyboard oraz Write Printer. W tej aplikacji Copy jest modułem wysokiego poziomu. Ten moduł określa strategię aplikacji. „Wie”, jak kopiować znaki. Niestety, został również uzależniony od niskopoziomowych szczegółów klawiatury i drukarki. Z tego powodu zmiana niskopoziomowych szczegółów będzie miała wpływ na wysokopoziomową strategię. Kiedy ta nieelastyczność uwidoczniła się, deweloperzy agile wiedzieli, że zależność modułu Copy od urządzenia wejściowego będzie trzeba odwrócić4 tak, aby moduł Copy przestał zależeć od urządzenia wejściowego. Wtedy zastosowano wzorzec Strategia5 w celu utworzenia żądanej inwersji. A zatem w skrócie — deweloperzy agile wiedzieli, co należy zrobić, ponieważ: 1. Wykryli problem, ponieważ postępowali zgodnie z praktykami agile. 2. Zdiagnozowali problem poprzez zastosowanie zasad projektowania. 3. Rozwiązali problem poprzez zastosowanie odpowiedniego wzorca projektowego. Współdziałanie tych trzech aspektów wytwarzania oprogramowania jest aktem projektowania.
Utrzymywanie projektu w jak najlepszej postaci Deweloperzy agile starają się utrzymywać projekt w taki sposób, aby był jak najwłaściwszy i jak najczystszy. To zobowiązanie nie jest ani przypadkowe, ani niepewne. Deweloperzy agile nie „porządkują” projektu co kilka tygodni. Zamiast tego starają się utrzymywać oprogramowanie w maksymalnie czystej i ekspresywnej postaci w każdym dniu, o każdej godzinie i nawet w każdej minucie. Nigdy nie mówią „wrócimy do tego i naprawimy później”. Nigdy nie pozwalają na to, aby oprogramowanie zaczęło się psuć. Postawa, jaką deweloperzy agile stosują w odniesieniu do projektu oprogramowania, jest taka sama jak postawa, którą stosują chirurdzy w odniesieniu do zachowania sterylności. To właśnie zachowanie procedur sterylności sprawia, że chirurgia jest możliwa. Bez niej ryzyko infekcji byłoby zbyt wysokie. Deweloperzy agile stosują takie samo podejście do swoich projektów. Ryzyko pozwolenia sobie na nawet najmniejsze objawy zepsucia jest zbyt wysokie, aby można je było tolerować. Projekt musi być utrzymywany w czystości, a ponieważ kod źródłowy jest najważniejszym wyrazem projektu, on także musi pozostać czysty. Profesjonalizm podpowiada, że programiści nie mogą tolerować psucia się kodu. 4
Zobacz zasadę odwracania zależności (Dependency Inversion Principle — DIP) w rozdziale 11.
5
Wzorzec Strategia omówimy bardziej szczegółowo w rozdziale 14.
BIBLIOGRAFIA
111
Wniosek A zatem co to jest projekt agile? Projekt agile jest procesem, a nie zdarzeniem. Polega na ciągłym stosowaniu zasad, wzorców i praktyk w celu poprawy struktury i czytelności oprogramowania. To zaangażowanie w ciągłe utrzymywanie projektu systemu w maksymalnie prostej, czystej i ekspresywnej postaci. W następnych rozdziałach będziemy omawiali zasady i wzorce projektowania oprogramowania. Podczas lektury należy pamiętać, że deweloper agile nie stosuje tych zasad i wzorców do wysokopoziomowego projektu „z góry”. Zamiast tego praktyki te są stosowane od iteracji do iteracji, jako próba utrzymania kodu i projektu, który on obejmuje, w jak najczystszej postaci.
Bibliografia 1. Jack Reeves, What Is Software Design?, „C++ Journal”, wolumin 2, nr 2. 1992. Dostępny pod adresem http://www.bleading-edge.com/Publications/C++Journal/Cpjour2.htm.
112
ROZDZIAŁ 7. CO TO JEST PROJEKT AGILE?
R OZDZIAŁ 8
SRP — zasada pojedynczej odpowiedzialności
Nikt inny, tylko sam Budda powinien wziąć odpowiedzialność za rozpowszechnianie okultystycznych tajemnic... — E. Cobham Brewer, 1810 – 1897 Dictionary of Phrase and Fable, 1898
Zasada pojedynczej odpowiedzialności została opisana w pracach Toma DeMarco1 i Meilira Page-Jonesa2. Stosowanie jej nazwali spójnością (ang. cohesion). Spójność definiowali jako funkcjonalną łączność elementów modułu. W tym rozdziale trochę zmienimy to znaczenie i zajmiemy się relacją spójności z siłami, które powodują, że moduł bądź klasa zmieniają się.
SRP — zasada pojedynczej odpowiedzialności Powód modyfikacji klasy powinien być tylko jeden. Rozważmy aplikację do obliczania punktacji gry w kręgle z rozdziału 6. Przez większość czasu podczas pracy nad aplikacją klasa Game spełniała dwie oddzielne funkcje. Śledziła bieżącą rundę oraz obliczała wynik. Ostatecznie RCM i RSK rozdzielili te dwie odpowiedzialności do dwóch klas. Klasa Game zachowała odpowiedzialność za śledzenie rund, natomiast klasa Scorer wzięła odpowiedzialność za obliczanie punktów (patrz rozdział 6.). 1
[DeMarco 79], str. 310.
2
[Page-Jones 88], rozdział 6., str. 82.
114
ROZDZIAŁ 8. SRP — ZASADA POJEDYNCZEJ ODPOWIEDZIALNOŚCI
Dlaczego rozdzielenie tych dwóch obowiązków do oddzielnych klas było ważne? Ponieważ każda odpowiedzialność jest osią zmian. Gdy zmienią się wymagania, zmiany te będą manifestowane poprzez zmianę odpowiedzialności pomiędzy klasami. Jeśli klasa przyjmuje więcej niż jedną odpowiedzialność, to istnieje więcej niż jeden powód tego, by się zmieniła. Jeśli klasa ma więcej niż jedną odpowiedzialność, to dochodzi do sprzężeń. Zmiany w jednej odpowiedzialności mogą zaburzać lub hamować zdolność klasy do sprawowania pozostałych. Ten rodzaj sprzężenia prowadzi do kruchych projektów, które w przypadku wprowadzenia zmian psują się w nieoczekiwany sposób. Dla przykładu rozważmy projekt pokazany na rysunku 8.1. Klasa Rectangle ma dwie metody. Jedna rysuje prostokąt na ekranie, natomiast druga oblicza pole prostokąta.
Rysunek 8.1. Więcej niż jedna odpowiedzialność
Z klasy Rectangle korzystają dwie różne aplikacje. Jedna aplikacja wykonuje obliczenia geometryczne. Wykorzystuje klasę Rectangle do obliczeń dotyczących figur geometrycznych. Ta aplikacja nigdy nie rysuje prostokąta na ekranie. Druga aplikacja ma charakter graficzny. Również może wykonywać pewne obliczenia geometryczne, ale ewidentnie rysuje prostokąt na ekranie. Ten projekt narusza zasadę pojedynczej odpowiedzialności. Klasa Rectangle ma dwie odpowiedzialności. Pierwsza odpowiedzialność polega na dostarczaniu matematycznego modelu geometrii prostokąta. Druga polega na renderowaniu prostokąta w graficznym interfejsie użytkownika. To naruszenie zasady SRP powoduje kilka uciążliwych problemów. Po pierwsze, musimy dołączyć moduł GUI do aplikacji realizującej obliczenia geometryczne. Gdyby była to aplikacja w C++, moduł GUI musiałby być skonsolidowany, co zajmuje czas — na kompilację i linkowanie — oraz powoduje zużycie pamięci. W aplikacji w Javie pliki .class modułu GUI musiałyby być zainstalowane na platformie docelowej. Po drugie, jeśli z jakiegoś powodu zmiana w aplikacji graficznej spowoduje konieczność zmiany klasy Rectangle, to zmiana ta może zmusić do ponownej kompilacji, testowania i instalacji aplikacji obliczeń geometrycznych. Jeśli o tym zapomnimy, to aplikacja może się zepsuć w nieprzewidywalny sposób. Lepszym projektem jest rozdzielenie tych dwóch odpowiedzialności do dwóch zupełnie różnych klas, tak jak pokazano na rysunku 8.2. W tym projekcie przeniesiono części obliczeniowe klasy Rectangle do klasy GeometricRectangle. Teraz zmiany w sposobie renderowania prostokątów nie mogą mieć wpływu na działanie aplikacji obliczeń geometrycznych.
Rysunek 8.2. Rozdzielone odpowiedzialności
SRP — ZASADA POJEDYNCZEJ ODPOWIEDZIALNOŚCI
115
Czym jest odpowiedzialność? W kontekście SRP odpowiedzialność definiujemy jako „powód zmiany”. Jeśli możemy znaleźć więcej niż jeden powód zmiany dla klasy, to klasa ta ma więcej niż jedną odpowiedzialność. Czasami trudno to dostrzec. Jesteśmy przyzwyczajeni do myślenia o grupach obowiązków. Dla przykładu przeanalizujmy interfejs Modem z listingu 8.1. Większość z nas zgodzi się, że ten interfejs wygląda rozsądnie. Cztery funkcje, które deklaruje, z pewnością należą do modemu. Listing 8.1. Modem.java — naruszenie zasady pojedynczej odpowiedzialności interface Modem { public void public void public void public char }
dial(String pno); hangup(); send(char c); recv();
Można tu jednak zauważyć dwie odpowiedzialności. Pierwsza odpowiedzialność to zarządzanie połączeniami. Druga to transmisja danych. Funkcje dial i hangup zarządzają połączeniem modemowym, natomiast funkcje send i recv służą do obsługi transmisji danych. Czy należy rozdzielić te dwie odpowiedzialności? To zależy od tego, w jaki sposób aplikacja będzie się zmieniać. Jeśli aplikacja będzie się zmieniać w sposób, który wpływa na sygnaturę funkcji połączeń, to projekt będzie wykazywał zapach sztywności, ponieważ trzeba będzie często kompilować i instalować klasy wywołujące funkcje send i recv. W tym przypadku te dwie odpowiedzialności należy rozdzielić tak, jak pokazano na rysunku 8.3. Dzięki temu aplikacje klienckie nie będą sprzęgały ze sobą dwóch zadań.
Rysunek 8.3. Rozdzielony interfejs klasy ModemImplementation
Jeśli natomiast aplikacja nie będzie się zmieniać w sposób, który powoduje zmienianie się tych dwóch odpowiedzialności w różnym czasie, to nie ma potrzeby, aby je rozdzielać. W rzeczywistości ich rozdzielenie spowodowałoby wydzielanie przez aplikację woni niepotrzebnej złożoności. Płynie stąd następujący wniosek: oś zmian jest osią zmiany tylko wówczas, gdy zmiany rzeczywiście występują. Nie jest rozsądne stosowanie zasady SRP lub jakiejkolwiek innej zasady, jeśli nie ma ku temu wyraźnych powodów.
Rozdzielanie sprzężonych odpowiedzialności Zwróćmy uwagę, że na rysunku 8.3 obie odpowiedzialności zostały sprzężone w klasie ModemImplementation. Nie jest to pożądane, ale może być konieczne. Często z powodów mających związek ze szczegółami sprzętu komputerowego lub systemu operacyjnego jesteśmy zmuszeni do sprzęgania odpowiedzialności, których nie powinno się ze sobą sprzęgać. Jednak dzięki rozdzieleniu interfejsów oba pojęcia zostały rozdzielone z punktu widzenia pozostałej części aplikacji.
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; iitsType) { 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 o1 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 wysokie2. 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