Sprawdzone rozwiązania T w o ic h
p r o b le m ó w !
^ «»«¡on -w esiey
W zo rce IMPLEMENTACYJNE
Spis treści
Wstęp ..........................................................................................................................................................11 Podziękowania........................................................................................................................... 12 Rozdział 1. Wprowadzenie.................................................................................................................... 13 Przewodnik ................................................................................................................................ 15 A teraz...........................................................................................................................................16 Rozdział 2. W zo rce..................................................................................................................................17 Rozdział 3. Teoria programowania.....................................................................................................21 Wartości ..................................................................................................................................... 22 Komunikatywność............................................................................................................. 22 Prostota ................................................................................................................................23 Elastyczność.........................................................................................................................24 Zasady......................................................................................................................................... 25 Lokalne konsekwencje ......................................................................................................26 Minimalizacja powtórzeń.................................................................................................26 Połączenie logiki i danych................................................................................................ 27 Symetria ...............................................................................................................................27 Przekaz deklaratywny ....................................................................................................... 28 Tempo zmian ..................................................................................................................... 29 Wnioski ...................................................................................................................................... 30 Rozdział 4. M otywacja............................................................................................................................31 Rozdział 5. Klasy...................................................................................................................................... 33 K lasa............................................................................................................................................ 34 Prosta nazwa klasy bazowej .................................................................................................... 35 Kwalifikowana nazwa klasy pochodnej ................................................................................36 Interfejs abstrakcyjny................................................................................................................37 Interfejs....................................................................................................................................... 38 Klasa abstrakcyjna.....................................................................................................................39
7
8
Spis treści Interfejs wersjonowany ........................................................................................................... 40 Obiekt wartościowy ................................................................................................................. 41 Specjalizacja ...............................................................................................................................43 Klasa pochodna ........................................................................................................................ 44 Implementator...........................................................................................................................46 Klasa wewnętrzna..................................................................................................................... 47 Zachowanie zależne od instancji............................................................................................48 Konstrukcja warunkowa ......................................................................................................... 48 Delegacja..................................................................................................................................... 50 Selektor dołączany ....................................................................................................................52 Anonimowa klasa wewnętrzna ..............................................................................................53 Klasa biblioteczna......................................................................................................................53 W niosek...................................................................................................................................... 54 Rozdział 6. Stan ....................................................................................................................................... 55 S ta n .............................................................................................................................................. 56 D ostęp......................................................................................................................................... 57 Dostęp bezpośredni ................................................................................................................. 58 Dostęp pośredni ........................................................................................................................59 Wspólny stan .............................................................................................................................60 Stan zmienny .............................................................................................................................60 Stan zewnętrzny........................................................................................................................ 62 Zmienna .....................................................................................................................................62 Zmienna lokalna........................................................................................................................63 P o le.............................................................................................................................................. 65 Parametr .....................................................................................................................................66 Parametr zbierający ................................................................................................................. 67 Parametr opcjonalny ............................................................................................................... 68 Zmienna lista argumentów..................................................................................................... 68 Obiekt parametrów.................................................................................................................. 69 Stałe ............................................................................................................................................. 70 Nazwa sugerująca znaczenie...................................................................................................71 Zadeklarowany ty p ................................................................................................................... 72 Inicjalizacja.................................................................................................................................73 Inicjalizacja wczesna................................................................................................................ 73 Inicjalizacja leniw a................................................................................................................... 74 W niosek...................................................................................................................................... 75 Rozdział 7. Zachowanie......................................................................................................................... 77 Przepływ sterowania................................................................................................................. 78 Przepływ główny........................................................................................................................78 Komunikat..................................................................................................................................79 Komunikat wybierający............................................................................................................ 80 Dwukrotne przydzielanie......................................................................................................... 80 Komunikat dekomponujący (sekwencjonujący)................................................................. 81 Komunikat odwracający.......................................................................................................... 82 Komunikat zapraszający.......................................................................................................... 83
Spis treści Komunikat wyjaśniający.......................................................................................................... 83 Przepływ wyjątkowy................................................................................................................. 84 Klauzula strażnika..................................................................................................................... 84 Wyjątek........................................................................................................................................86 Wyjątki sprawdzane.................................................................................................................. 87 Propagacja wyjątków................................................................................................................ 87 Wniosek....................................................................................................................................... 88 Rozdział 8. M etody................................................................................................................................. 89 Metoda złożona .........................................................................................................................92 Nazwa określająca przeznaczenie ..........................................................................................93 Widoczność metody ................................................................................................................ 94 Obiekt m etody.......................................................................................................................... 96 Metoda przesłonięta ................................................................................................................ 98 Metoda przeciążona................................................................................................................. 98 Typ wynikowy m etody............................................................................................................ 99 Komentarz do metody........................................................................................................... 100 Metoda pomocnicza .............................................................................................................. 100 Metoda komunikatu informacyjnego .................................................................................101 Konwersja................................................................................................................................. 102 Metoda konwertująca............................................................................................................ 102 Konstruktor konwertujący.................................................................................................... 103 Utworzenie...............................................................................................................................103 Kompletny konstruktor......................................................................................................... 104 Metoda wytwórcza................................................................................................................. 105 Fabryka wewnętrzna.............................................................................................................. 106 Metoda dostępu do kolekcji.................................................................................................. 106 Metoda określająca wartości logiczne .................................................................................108 Metoda zapytania....................................................................................................................108 Metoda równości.....................................................................................................................109 Metoda pobierająca................................................................................................................ 110 Metoda ustawiająca................................................................................................................ 111 Bezpieczna k op ia.....................................................................................................................112 W niosek.................................................................................................................................... 113 Rozdział 9. Kolekcje.............................................................................................................................. 115 Metafory ................................................................................................................................... 116 Zagadnienia..............................................................................................................................117 Interfejsy................................................................................................................................... 119 Tablice (klasa Array) ....................................................................................................... 120 Interfejs Iterable............................................................................................................... 120 Interfejs Collection — kolekcje......................................................................................121 Interfejs List — listy......................................................................................................... 121 Interfejs Set — zbiory...................................................................................................... 121 Interfejs SortedSet — zbiory posortowane.................................................................. 122 Interfejs Map — mapy .................................................................................................... 123 Implementacje .........................................................................................................................123
9
10
Spis treści Implementacje interfejsu Collection.............................................................................124 Implementacje interfejsu List ........................................................................................125 Implementacje interfejsu S e t..........................................................................................125 Implementacje interfejsu M ap .......................................................................................126 Klasa Collections .....................................................................................................................128 Wyszukiwanie .................................................................................................................. 128 Sortowanie.........................................................................................................................128 Kolekcje niezmienne ....................................................................................................... 129 Kolekcje jednoelementowe.............................................................................................129 Kolekcje puste .................................................................................................................. 129 Rozszerzanie kolekcji............................................................................................................. 130 W niosek.................................................................................................................................... 131 Rozdział 10. Rozwijanie p latform ..................................................................................................... 133 Modyfikowanie platform bez zmian w aplikacjach..........................................................133 Niezgodne aktualizacje.......................................................................................................... 134 Zachęcanie do wprowadzania zgodnych zmian ............................................................... 136 Klasa biblioteczna............................................................................................................ 137 Obiekty...............................................................................................................................137 Wnioski .................................................................................................................................... 146 Dodatek A. Pomiary wydajności....................................................................................................... 149 Przykład.................................................................................................................................... 150 A P I............................................................................................................................................. 150 Implementacja.........................................................................................................................151 Klasa MethodTimer............................................................................................................... 152 Eliminacja narzutów czasowych ..........................................................................................154 T esty .......................................................................................................................................... 154 Porównywanie kolekcji................................................................................................... 155 Porównywanie kolekcji ArrayList i LinkedList..........................................................157 Porównywanie zbiorów .................................................................................................. 158 Porównywanie m ap ......................................................................................................... 159 Wnioski .................................................................................................................................... 160 Bibliografia............................................................................................................................................. 163 Ogólne zagadnienia programistyczne.................................................................................163 Filozofia .................................................................................................................................... 165 Java ............................................................................................................................................ 166 Spis szablonów....................................................................................................................................... 167 Skorowidz ............................................................................................................................................... 169
Wstęp
Ta książka jest poświęcona programowaniu — a konkretnie rzecz biorąc, program o waniu w taki sposób, by inni program iści mogli zrozumieć nasz kod. W pisaniu kodu, który inni mogą przeanalizować, nie ma nic magicznego. Niczym się ono nie różni od zwyczajnego pisania — wystarczy znać odbiorców, pamiętać o ogólnej strukturze i wyra żać szczegóły tak, by wspólnie przyczyniały się do powstania jednej całości. Java udo stępnia dobre środki przekazu. Przedstawione w tej książce wzorce im plem entacyjne są przykładami dobrych nawyków programistycznych, zapewniających możliwość tworzenia czytelnego kodu. O wzorcach implementacyjnych można także myśleć inaczej — jako o odpowiedzi na pytanie: „Co chcę powiedzieć innym o tym kodzie?”. Programiści spędzają tak dużo czasu, koncentrując się na swoich własnych myślach, że wszelkie próby spojrzenia na świat z punktu widzenia kogoś innego są dla nich ogromną zmianą. To już nie jest pyta nie: „Co komputer zrobi, gdy będzie wykonywał ten kod?”, lecz: „W jaki sposób przeka zać innym, co myślę?”. Ta zmiana punktu widzenia jest zdrowa i potencjalnie korzystna, gdyż znaczna część kosztów tworzenia oprogramowania to koszty zrozumienia kodu. Jest taki teleturniej Va banque, w którym prowadzący podaje odpowiedzi, a uczestnicy starają się zadać właściwe pytanie. „To słowo oznaczające zostanie wyrzuconym przez okno”, „Czym jest defenestracja?”. „To prawidłowe pytanie”. Kodowanie przypomina ten teleturniej. Java udostępnia pytania wyrażone w for mie podstawowych konstrukcji. Program iści zazwyczaj sami muszą określać, jakie są pytania — jakie problemy rozwiązują konkretne konstrukcje języka. Jeśli odpowiedzią jest „Zadeklarować pole typu S e t”, to pytaniem mogłoby być: „W jaki sposób pokazać innym programistom, że w kolekcji nie mogą występować powtórzenia?”. W zorce im plem entacyjne stanowią katalog najczęściej występujących problemów program i stycznych wraz z narzędziami, które Java udostępnia do ich rozwiązywania. Zarządzanie zakresem jest równie ważne podczas pisania książek, jak i podczas pi sania oprogramowania. Oto kilka inform acji o tym, czym ta książka nie jest. Nie jest przewodnikiem o stylu programowania, gdyż zawiera zbyt dużo wyjaśnień, a podjęcie ostatecznych decyzji pozostawia czytelnikowi. Nie jest książką o projektowaniu, gdyż w przeważającej większości przypadków zajm uje się decyzjami o mniejszej skali, które 11
12
W stęp programista musi podejmować wiele razy dziennie. Nie jest książką o wzorcach, gdyż format ich przedstawiania jest specyficzny i doraźny (a dosłownie: „zastosowany w szcze gólny sposób”). Nie jest to także książka o samym języku programowania, ponieważ choć opisuje wiele możliwości języka Java, to jednak zakłada, że czytelnicy już go znają. W łaściwie ta książka opiera się na dosyć kruchej przesłance, że dobry kod ma zna czenie. W idziałem już zbyt wiele razy, że pisząc kiepski kod, i tak można zarobić duże pieniądze, by wierzyć, że jakość kodu jest niezbędna lub nawet wystarczająca do od niesienia komercyjnego sukcesu i zdobycia dużej popularności. Niemniej jednak i tak wierzę, że jakość kodu ma znaczenie, choć nie zapewnia kontroli nad przyszłością. Firmy, które tworzą oprogramowanie i wydają je, mając wiarę we własne siły, które mogą zm ieniać działania w odpowiedzi na nadarzające się okazje i konkurencję i zachowują wysokie morale nawet w obliczu wyzwań i niepowodzeń, zazwyczaj będą w stanie od nosić większe sukcesy niż firmy, które tworzą kod tandetny i pełen błędów. Nawet gdyby uważne pisanie kodu nie miało żadnego długofalowego wpływu na eko nomiczne aspekty tworzenia oprogramowania, to i tak decydowałbym się pisać jak najlepszy kod. Siedemdziesiąt lat życia to jedynie trochę ponad dwa miliardy sekund. To nie aż tak du żo, by choćby sekundę marnować na pracę, z której nie byłbym dumny. Pisanie dobrego kodu daje satysfakcję, i to zarówno ze względu na świadomość wysokiej jakości, jak i wie dzę, że inni będą w stanie zrozumieć, docenić, wykorzystać i rozwijać efekty mojej pracy. A zatem jest to właściwie książka o odpowiedzialności. Jako program ista otrzyma łeś, Czytelniku, czas, talent, pieniądze i sposobność. Co zrobisz, by sensownie wyko rzystać te dary? W tej książce znajdziesz m oją odpowiedź na to pytanie, a jest nią pi sanie kodu z myślą o innych, a nie tylko o sobie i najlepszym kumplu — procesorze.
Podziękowania Przede wszystkim chciałbym podziękować Cynthii Andres, mojej partnerce, redaktorce oraz osobie, która mnie wspierała i motywowała. M ój przyjaciel Paul Petralia otrzy m ał ten projekt wraz ze mną i podczas jego trwania wspierał mnie licznymi telefona mi. Z Chrisem Guzikowskim, m oim redaktorem, w trakcie tego projektu nauczyliśmy się współpracować. Zapewnił mi wsparcie ze strony wydawnictwa Pearson, którego potrzebowałem, by dokończyć pisanie książki. Chciałbym także podziękować zespołowi wydawnictwa Pearson: Julie Nahil, Johnow i Fullerowi oraz Cynthii Kogut. Ilustracje do książki, łączące w sobie inform acje i przejrzystość, stworzyła Jennifer Kohnke. R e cenzentam i książki, którzy szybko dostarczyli przejrzystych opinii o niej, byli: Erich Gamma, Steve Metsker, D iom idis Spinellis, Tom D eM arco, M ichael Feathers, Doug Lea, Brad Abrams, Cliff Click, Pekka Abrahamson, Gregor Hohpe oraz Michele Marchesi. Chciałbym także podziękować Davidowi Saffowi za wskazanie symetrii pomiędzy stanem i zachowaniem. Dziękuję również Bobowi M artinowi oraz Arturo Flacowi za wyłapa nie i poprawienie błędów typograficznych. Dziękuję też m oim dzieciom, które pozo stawały w domu i przypominały, dlaczego chcę skończyć pracę: Lincolnowi, Lindsey, Forrestowi oraz Joelle Andres-Beck.
Rozdział 1
Wprowadzenie
I tak oto jesteśm y razem. Kupiłeś m oją książkę (i teraz jest Tw oja). Na pewno pisałeś już kod. Zapewne na podstawie zdobytych doświadczeń wypracowałeś sobie także własny styl tworzenia go. Niniejsza książka ma za zadanie pom óc Ci w przekazywaniu intencji za pośred nictwem kodu, który piszesz. Zaczyna się ona od ogólnego przedstawienia zagadnień programowania i wzorców (rozdziały 2. - 4.). Kolejna część (rozdziały 5. - 8.) zawiera serię krótkich esejów i wzorców opisujących, jak wykorzystywać poszczególne możli wości języka Java do pisania zrozumiałego kodu. Ostatni rozdział zawiera natomiast inform acje o tym, jak zmodyfikować rady zamieszczone w poprzednich rozdziałach w przypadku, gdy zamiast aplikacji tworzymy platformy. Cała książka koncentruje się na technikach programistycznych umożliwiających rozszerzenie przekazu. Komunikowanie za pośrednictwem tworzonego kodu wymaga przejścia kilku eta pów. Przede wszystkim musiałem zacząć programować świadomie. Programowałem już wiele lat, zanim zacząłem stosować wzorce im plementacyjne. Byłem zaskoczony, że choć decyzje programistyczne podejmowałem z łatwością i szybko, to jednak nie byłem w stanie wyjaśnić, dlaczego miałem pewność, że konkretna metoda powinna nosić taką, a nie inną nazwę, bądź też dlaczego konkretny fragment logiki powinien należeć do tego, a nie innego obiektu. A zatem pierwszym krokiem na drodze do ko m unikacji było skończenie z udawaniem, że pisząc kod, robię to instynktownie. Drugim krokiem było uzmysłowienie sobie znaczenia innych osób. Programowanie dawało mi satysfakcję, jednak jestem egocentrykiem. Zanim mogłem zacząć tworzyć ko munikatywny kod, musiałem uwierzyć w to, że inni są równie istotni jak ja sam. Progra mowanie rzadko kiedy jest samoistnym obcowaniem jednego człowieka i jednego kompute ra. Zwracanie uwagi na innych jest świadomą decyzją, i to taką, która wymaga praktyki. W ten sposób docieramy do trzeciego etapu. Kiedy już ujawniłem swe myśli, wy stawiając je na widok publiczny, i potwierdziłem, że inni mają takie samo prawo do istnienia jak ja, musiałem jakoś zademonstrować ten nowy punkt widzenia w praktyce. W tym celu, aby zacząć programować świadomie, zarówno dla siebie, jak i dla innych, zacząłem stosować wzorce implementacyjne. 13
14
r o z d z ia ł
1
W p r o w a d z e n ie
Tę książkę można czytać wyłącznie w poszukiwaniu inform acji technicznych — przydatnych sztuczek i wyjaśnień. Niemniej jednak uznałem, że uczciwość wymaga ostrzeżenia czytelnika, iż zawiera ona znacznie więcej; przynajm niej w m ojej opinii. Te techniczne inform acje można odnaleźć, przeglądając rozdziały poświęcone wzorcom. Jedną z efektywnych strategii przyswajania inform acji zamieszczonych w tej książce jest czytanie jej, gdy pojawi się taka konieczność. M ożna by to nazwać „czyta niem na bieżąco”. W takim przypadku sugeruję, by przejść od razu do rozdziału 5., przejrzeć zawartość książki aż do końca i m ieć ją pod ręką w trakcie programowania. Po zastosowaniu wielu różnych wzorców można w rócić do początkowych rozdziałów, by poznać filozoficzne podstawy stosowanych przeze mnie pomysłów i idei. Jeśli jednak czytelnik jest zainteresowany dokładnym zrozum ieniem inform acji zamieszczonych w książce, to warto zacząć jej lekturę od samego początku. Jednak w odróżnieniu od wielu innych napisanych przeze mnie książek tutaj rozdziały są cał kiem długie, zatem przeczytanie jej w całości będzie wymagało sporej koncentracji. W iększość materiału zamieszczonego w książce została zorganizowana w formie wzorców. W programowaniu gros decyzji przypomina te podejmowane już wcześniej. W trakcie naszej programistycznej kariery możemy zdefiniować m iliony zmiennych. Określając nazwę każdej z nich, nie musimy wymyślać za każdym razem zupełnie nowa torskiej metody. Ogólne uwarunkowania za każdym razem są takie same: nazwa musi przekazywać czytelnikom kodu inform ację o przeznaczeniu zm iennej, jej typie i dłu gości istnienia; powinna także być czytelna oraz łatwa do zapisania i sformatowania. D o tych ogólnych uwarunkowań należy dodać specyfikę konkretnej zmiennej. Tak uzyskujemy użyteczną nazwę zmiennej. Sposób określania nazw zmiennych jest przy kładem wzorca: decyzja oraz wpływające na nią czynniki powtarzają się, choć tworzo na nazwa może być za każdym razem inna. Uważam, że poszczególne wzorce często wymagają różnego sposobu prezentacji. Czasami najlepszym w yjaśnieniem wzorca będzie szczegółowy esej, czasami diagram, czasami dydaktyczny przykład, a czasami przykładowy kod. Zamiast dostosowywać inform acje dotyczące każdego wzorca do sztywnego formatu, przekazałem je w spo sób, który według mnie najlepiej się do tego nadaje. Ta książka przedstawia 77 jawnie nazwanych wzorców, z których każdy dotyczy pew nego aspektu tworzenia czytelnego kodu. Oprócz tego w tekście w spom inam o wielu mniejszych wzorcach lub wariantach wzorców. Pisząc tę książkę, chciałem zawrzeć w niej porady dotyczące metod radzenia sobie z najczęściej występującymi, codzien nymi zadaniami programistycznymi, tak by ułatwić przyszłym czytelnikom zrozu mienie przeznaczenia kodu. Tę pozycję można um iejscowić gdzieś między książką W zorce p ro jek to w e a pod ręcznikiem programowania w języku Java. W zorce p ro jek to w e opisują decyzje, które zapewne będziemy musieli podejmować kilka razy dziennie, pisząc kod, i które zazwy czaj będą związane z określaniem interakcji pomiędzy obiektami. Podczas tworzenia kodu ze wzorców im plementacyjnych będziemy korzystali bardzo często. Chociaż podręcz niki doskonale opisują, co można zrobić w Javie, to jednak nie wyjaśniają, dlaczego
P r z e w o d n ik
należy zastosować konkretną konstrukcję oraz do jakich wniosków dojdą osoby, które zobaczą ją w kodzie. Jednym z założeń, jakie przyjąłem podczas pisania tej książki, było trzymanie się tematów, które dobrze znałem. Na przykład prezentowane w niej wzorce nie obejm ują zagadnień programowania współbieżnego. Nie wynika to z faktu, że są one mało waż ne; przyczyną jest raczej to, że nie m am wiele do powiedzenia na ten temat. M oja strategia pisania kodu wykonywanego współbieżnie zawsze polegała na wyizolowaniu wszystkich współbieżnych fragmentów aplikacji w możliwie jak największym stopniu. Choć zazwyczaj udaje mi się to zrobić, nie jestem w stanie tego dobrze wyjaśnić. Dla tego osobom poszukującym praktycznego podejścia do zagadnień współbieżności polecam książkę Ja v a C oncurrency in P ractice. Kolejnym zagadnieniem, które nie zostało przedstawione w tej książce, jest zapis procesu tworzenia oprogramowania. Sugestię, której jest poświęcona ta książka, by korzystać z kom unikacji za pośrednictwem kodu, będzie można uwzględnić zarówno podczas długiego cyklu tworzenia kodu, jak i zaraz po napisaniu testów awaryjnych. Zawsze warto, by ogólny koszt tworzonego oprogramowania był mniejszy, bez wzglę du na socjologiczne uwarunkowania związane z jego tworzeniem. Unikam także zbliżania się do technologicznych granic języka Java. Gdy podej muję decyzje technologiczne, jestem raczej konserwatywny, ponieważ za często się sparzyłem, starając się w zbyt dużym stopniu wykorzystywać najnowsze możliwości języka (to świetna strategia, jeśli chodzi o naukę, natomiast przy tworzeniu oprogra mowania zazwyczaj jest ona za bardzo ryzykowna). Dlatego w tej książce korzystam tylko z podstawowego zbioru możliwości Javy. Jeśli czytelnikowi zależy na poznaniu najnowszych cech i możliwości tego języka, to może ich poszukać w innych źródłach.
Przewodnik Niniejsza książka została podzielona na siedem głównych części, przedstawionych na rysunku 1.1. Oto one: ■ Wprowadzenie — te początkowe, krótkie rozdziały opisują znaczenie kom unikacji za pośrednictwem kodu oraz filozofię stojącą u podstaw stosowania wzorców, a także pokazują płynące z tego korzyści. ■
Klasa — ten rozdział opisuje wzorce wyjaśniające, jak oraz dlaczego tworzymy kla sy oraz w jaki sposób klasy im plementują logikę.
■
Stan — ten rozdział zawiera wzorce związane z przechowywaniem i pobieraniem stanu.
■
Zachowanie — tutaj zostały opisane wzorce służące do reprezentacji logiki działa nia, a zwłaszcza zamiennych ścieżek.
15
16
r o z d z ia ł
1
W p r o w a d z e n ie
Wprowadzenie •wzorce •zalety i zasady •motywacja
Platformy Rysunek 1.1. Przegląd zawartości książki ■
M etoda — ten rozdział przedstawia wzorce służące do tworzenia metod, przypo mina nam, do jakich wniosków najprawdopodobniej dojdą inni program iści na podstawie podziału zagadnienia na metody oraz sposobu doboru ich nazw.
■
Kolekcje — w tym rozdziale zostały przedstawione wzorce związane z wyborem i stosowaniem kolekcji.
■
Tworzenie platform — rozdział omawia odmiany opisanych wcześniej wzorców, wykorzystywane w przypadku tworzenia platform, a nie aplikacji.
A teraz... ...przejdźm y do tego, co najważniejsze. Jeśli zamiarem czytelnika jest przeczytanie tej książki w całości, to wystarczy kontynuować jej lekturę od następnej strony (choć przypuszczam, że każdy m ógł na to wpaść bez m oich wyjaśnień). Jeśli jednak czytelnik woli przejrzeć opisywane wzorce, to powinien przejść do rozdziału 5., na stronę 33. Uda nego kodowania.
Rozdział 2
Wzorce
W iele decyzji programistycznych ma unikalny charakter. Sposób tworzenia witryny W W W będzie się diametralnie różnił od sposobu tworzenia aplikacji dla biegaczy. Jednak gdy podejmowane decyzje zaczną m ieć coraz bardziej techniczny charakter, pojawi się także wrażenie rozpoznawania znajom ych elementów. Czy takiego kodu nie napisa łem już wcześniej? Programowanie byłoby znacznie bardziej wydajne, gdyby progra m iści tracili mniej czasu na prozaiczne, powtarzające się zadania, a mogli poświęcić go więcej na rozwiązywanie naprawdę wyjątkowych problemów. W iększość problemów podlega niewielkiej grupie praw: ■
Programy są częściej czytane niż pisane.
■ Nie ma czegoś takiego jak „napisany” program. Znacznie więcej czasu będziemy poświęcać na modyfikowanie programu niż na jego początkowe napisanie. ■
Struktura programu jest tworzona przy wykorzystaniu prostego zestawu pojęć zwią zanych ze stanem oraz kontrolą przepływu sterowania.
■
Czytelnicy muszą rozumieć zarówno szczegóły kodu programu, jak i koncepcje przy świecające jego twórcom. Czasami analizują go, zaczynając do szczegółów i dążąc do koncepcji, a czasami w przeciwnym kierunku. W zorce bazują na tych wspólnych cechach. Na przykład każdy programista musi
zdecydować, jaką postać będą miały iteracje. W chwili kiedy zaczniemy myśleć o tym, jakich użyć pętli, na wszystkie pytania związane z dziedziną problemu będą już od ja kiegoś czasu znane odpowiedzi, a my będziemy mogli skoncentrować się na zagadnie niach czysto technicznych, takich jak te, by pętle były zrozumiałe, łatwe do napisania, weryfikacji, modyfikacji oraz by działały wydajnie. Lista takich założeń stanowi początek wzorca. W ym ienione wcześniej ograniczenia lub siły odnoszą się do każdej pętli w pisanym programie. Siły te powtarzają się w spo sób łatwy do przewidzenia, co zresztą sprawia, że wzorce są właśnie wzorcami: okre ślają wzór występujących sił. 17
18
r o z d z ia ł
2
W zorce
Istnieje kilka sensownych sposobów tworzenia pętli. Każdy z nich oznacza nadanie odmiennych priorytetów poszczególnym ograniczeniom. Struktura pętli może być in na, gdy ważniejsza jest wydajność działania pętli, niż gdy ważniejsze jest zapewnienie łatwości jej modyfikacji. Każdy wzorzec ilustruje inny punkt widzenia na względne priorytety poszczegól nych sił. Do większości z nich dołączyłem krótkie eseje opisujące inne rozwiązania oraz tłumaczące, dlaczego przedstawione rozwiązanie jest optymalne. Przedstawienie rozumowania leżącego u podstaw utworzenia konkretnego wzorca ma zachęcić użyt kownika do podjęcia samodzielnych decyzji związanych z wyborem metody rozwią zywania często występujących problemów. Zgodnie z tym, o czym wspominałem powyżej, do każdego wzorca dołączyłem su gestię rozwiązania problemu. W przypadku wzorca pętli operującej na kolekcji suge stia ta mogłaby być następująca: „Do wyrażenia iteracji można skorzystać z pętli fo r języka Java 5”. W zorce stanowią pom ost łączący abstrakcyjne zasady z praktycznymi rozwiązaniami. Pom agają nam w pisaniu kodu. W zorce współpracują ze sobą. Na przykład wzorzec sugerujący wykorzystanie pętli fo r jest przyczyną pojawienia się kolejnego problemu: jak nazwać zm ienną używaną w pętli. Zamiast gromadzić wszystkie te zagadnienia w ram ach jednego wzorca, moż na przedstawić inny sposób nazywania zmiennych. Sposoby prezentacji wzorców zastosowane w tej książce bardzo się od siebie różnią. Czasami wzorce mają jasno określone nazwy, a ich opisy zawierają sekcje przedsta wiające możliwości i rozwiązania. Jednak niektóre mniejsze wzorce zostały opisane w ramach większych; ich wyjaśnienie może wymagać tylko jednego zdania bądź dwóch. Stosowanie wzorców może się czasami wydawać ograniczające, lecz jednocześnie pozwala zaoszczędzić czas i energię. Na przykład ścielenie łóżka będzie wymagało znacz nie mniej wysiłku, jeśli będziemy to robić z przyzwyczajenia, niż gdybyśmy za każdym razem musieli analizować poszczególne czynności tego procesu oraz ich kolejność. Dysponujem y jednak zapewne określonym wzorcem opisującym ścielenie łóżka, co znacznie upraszcza ten przykry obowiązek. Jeśli łóżko jest umieszczone przy ścianie lub jeśli prześcieradło jest zbyt małe, to strategia postępowania zostanie odpowiednio do stosowana do sytuacji, jednak ogólną procedurę możemy wykonać z pamięci, co po zwala nam skoncentrować uwagę na bardziej interesujących i unikalnych problemach. Przekonałem się, że bardzo doceniam brak konieczności rozmyślania nad sposobem tworzenia pętli, które stają się niepotrzebne, gdy stosowanie wzorca wejdzie w nawyk. Kiedy cały zespół przestanie być zadowolony ze wzorca, można przedyskutować opi nie i wprowadzić nowy. Żaden zestaw wzorców nie będzie się nadawał do zastosowania we wszystkich możli wych sytuacjach. W dalszej części książki przedstawiłem wzorce, których używam i które — jak wynika z m oich obserwacji — dobrze się sprawdzają podczas pisania aplikacji (z drobnymi modyfikacjami w przypadku pisania platform). Ślepe naśladowanie cudzego stylu programowania nie jest tak efektywne jak przeanalizowanie i wypracowanie wła snych rozwiązań oraz przedstawienie ich innym członkom zespołu i przedyskutowanie.
W zorce
Wzorce najlepiej zdają egzamin, gdy pomagają nam w podejmowaniu decyzji. N iektó re wzorce im plem entacyjne zostaną w końcu wykorzystane w językach programowa nia, tak jak strukturalne wykorzystanie funkcji setjm p() i longjmp() stało się podsta wą stosowanych obecnie systemów obsługi wyjątków. Dopóki to nie nastąpi, stosowanie wzorców może jednak wymagać pewnych adaptacji. Ten rozdział rozpoczął się od poszukiwania tańszych, szybszych i m niej energo chłonnych sposobów rozwiązywania często powtarzających się problemów program i stycznych. Stosowanie wzorców pomaga program istom tworzyć sensowne rozwiąza nia powracających problemów i sprawia, że więcej czasu, energii i kreatywności mogą poświęcić na rozwiązywanie problemów o bardziej unikalnym charakterze. Każdy wzo rzec kojarzy popularny problem programistyczny z rozważaniami o czynnikach m ają cych na niego wpływ oraz jest uzupełniony konkretną radą opisującą sposób szybkie go i satysfakcjonującego rozwiązania tego problemu. W efekcie możemy lepiej, taniej i szybciej radzić sobie z nudnymi aspektami programowania oraz poświęcać więcej czasu i energii na opracowywanie unikalnych problemów stawianych przez każdy tworzony program. W następnym rozdziale, „Teoria programowania”, opisałem zalety i zasady leżące u podstaw stylu programowania reprezentowanego przez przedstawione w książce wzorce implementacyjne.
19
20
rozdział 2
W zorce
Rozdział 3
Teoria programowania
Żadna lista wzorców, niezależnie od tego, jak jest wyczerpująca, nie będzie w stanie objąć wszystkich sytuacji, z którym i możemy się zetknąć podczas programowania. W cześniej czy później (a nawet całkiem często) zetkniemy się z sytuacjami, w których nie da się zastosować żadnego z naszych chytrych rozwiązań. Ta potrzeba wskazania ogólnego podejścia do rozwiązywania unikalnych problemów jest jednym z powodów studiowania teorii programowania. Kolejnym jest poczucie władzy i możliwości, jakie zapewnia wiedza o tym, co trzeba zrobić i dlaczego. Poza tym rozważania na tem at programowania są znacznie bardziej interesujące, kiedy obejm ują zagadnienia zarów no teoretyczne, jak i praktyczne. Każdemu wzorcowi towarzyszą pewne rozważania teoretyczne. N iem niej jednak programowanie daje znacznie większe możliwości niż te, które dają poszczególne wzorce. W tej części zajmiemy się właśnie takimi przekrojowymi zagadnieniami. Zostały one podzielone na dwa typy: w artości oraz zasady. W artości są uniwersalnymi my ślami przewodnimi programowania. Kiedy pracuję dobrze, zwracam uwagę na zna czenie kom unikacji z innymi osobami, by dbać o zachowanie możliwie jak największej prostoty kodu i by zapewnić możliwie dużą liczbę potencjalnych rozwiązań. Te warto ści — komunikatywność, prostota oraz elastyczność — nadają barw wszystkim decy zjom, które podejm uję podczas tworzenia kodu. Z kolei zasady nie m ają aż tak dużego oddziaływania jak wartości, jednak każda jest wyrażana przez jeden wzorzec lub kilka wzorców. Zasady te stanowią pom osty łą czące poszczególne wartości, które choć są uniwersalne, trudno jednak stosować je bezpośrednio, oraz wzorce, które z kolei można łatwo stosować, lecz są bardzo ściśle zdefiniowane. Przekonałem się, że stosowanie precyzyjnych reguł przydaje się w sytu acjach, w których nie można zastosować żadnych wzorców bądź też gdy można zastoso wać dwa wzajemnie się wykluczające. Zrozumienie zasad w obliczu niejednoznaczności pozwala mi na „wymyślenie czegoś”, co będzie spójne z m oim i pozostałymi doświad czeniam i i będzie miało spore szanse okazać się dobrym rozwiązaniem.
21
22
r o z d z ia ł
3
T e o r ia p r o g r a m o w a n ia
Te trzy elementy — wartości, zasady oraz wzorce — stanowią zrównoważony wy raz stylu programowania. W zorce określają, co należy zrobić. W artości tłumaczą m o tywacje. A zasady pomagają w przekształceniu pobudek na działania. W artości, zasady oraz wzorce przedstawione w tej książce zostały określone na podstawie m oich własnych doświadczeń, przemyśleń oraz dyskusji z innymi progra mistami. W szyscy bazujemy na doświadczeniach wcześniejszych generacji program i stów. Efektem jest raczej ogólny niż szczególny styl programowania. Inne wartości oraz zasady będą prowadzić do wykształcenia innego stylu. Jedną z zalet przedstawiania stylu programowania jako zbioru wartości, zasad i praktyk jest zwiększenie szansy na uzy skanie konstruktywnych efektów w razie występowania rozbieżnych opinii. Jeśli Ty chcesz zrobić coś inaczej niż ja, możemy określić poziom rozbieżności i uniknąć tra cenia czasu. Jeśli różnim y się w opiniach dotyczących zasad, to spory o to, gdzie um ie ścić nawiasy klamrowe, nie pomogą nam w uzgodnieniu poglądów.
W artości Trzema wartościami współgrającymi z doskonałością w programowaniu są: komunika tywność, prostota oraz elastyczność. C hoć czasami są one sprzeczne, to jednak znacznie częściej się uzupełniają. Najlepsze programy zapewniają możliwość przyszłej rozbu dowy, nie zawierają żadnych elementów zewnętrznych oraz są łatwe do przeanalizo wania i zrozumienia.
Komunikatywność Kod prawidłowo wyraża intencje swojego twórcy, jeśli czytelnik może go zrozumieć, zmodyfikować i używać. Podczas tworzenia kodu łatwo ulec pokusie myślenia wyłącznie o komputerze. Jednak myślenie o innych programistach także zapewnia m i wiele ko rzyści. Wówczas tworzę bardziej przejrzysty kod, który można łatwiej zrozumieć, jest on bardziej efektywny pod względem kosztów wytworzenia, m am jaśniejsze myśli, uzyskuję świeży punkt widzenia, opada poziom stresu i zaspokajam niektóre z moich po trzeb społecznych. Jednym z elementów, które przyciągają mnie do programowania, jest okazja do obcowania z czymś zewnętrznym. Niemniej jednak nie chcę mieć do czynienia z ludźmi ograniczonymi, zagadkowymi czy denerwującymi. Programowanie w taki spo sób, jak gdyby ludzie nigdy nie istnieli, trwało jedynie kilka dziesięcioleci. Budowanie w myślach coraz bardziej rozbudowanych pałaców stało się bezbarwne i nieatrakcyjne. Jednym z początkowych doświadczeń, które pchnęły mnie do zainteresowania się zagadnieniem kom unikacji, było odkrycie tego, co Knuth określił jako p ro g ra m o w a n ie p iśm ien n e (ang. literate prog ram m in g ) — założenia, że program powinno się czytać jak książkę. Powinien mieć swoją fabułę, rytm oraz cudowne niewielkie zmiany fraz. Kiedy wraz z Wardem Cunninghamem po raz pierwszy usłyszeliśmy o programach piśmiennych, zdecydowaliśmy się je wypróbować. W ybraliśm y jeden z najbardziej
W artości przejrzystych fragmentów kodu napisanych w Smalltalku, ScrollControllera, i spró bowaliśmy przekształcić go w opowiadanie. Po kilku godzinach udało się nam całko wicie przepisać kod i uzyskać sensowny opis. Za każdym razem, gdy jakiś fragment kodu było trudno wyjaśnić, łatwiej było go napisać od początku, niż wytłumaczyć, dlaczego był tak trudny do zrozumienia. W ymóg kom unikacji za pośrednictwem ko du zm ienił nasz punkt widzenia. Istnieje bardzo silna ekonom iczna podstawa przemawiająca za koncentrowaniem się na kom unikacji podczas programowania. Przeważająca część kosztów tworzenia oprogramowania jest generowana przez prace wykonywane po początkowym napisa niu programu. Gdy rozmyślam na tem at m oich doświadczeń związanych z modyfika cją kodu, muszę przyznać, że znacznie więcej czasu poświęcam na czytanie istniejące go kodu niż na pisanie nowego. Dlatego, chcąc sprawić, by kod był tańszy, muszę zadbać o to, by jego analiza była łatwiejsza. Skoncentrowanie uwagi na kom unikacji poprawia myślenie przez zmuszanie mnie do bycia większym realistą. Usprawnienia częściowo wynikają z faktu większego zaan gażowania mózgu. Kiedy staram się odpowiedzieć sobie na pytanie: „Jak ktoś inny to zrozumie?”, zaczynają pracować inne neurony, niż gdy koncentruję się jedynie na sobie samym i swoim komputerze. Robię krok wstecz, oddalam się od mojego egocentrycz nego punktu widzenia i patrzę na problemy i ich rozwiązania z zupełnie nowej per spektywy. Kolejnym usprawnieniem jest mniejszy poziom stresu, wynikający ze świa domości, że dbam o biznes i robię wszystko jak należy. I w końcu, ponieważ jesteśm y istotami społecznymi, jawne uwzględnienie zagadnień społecznościowych jest znacznie bardziej realistycznym podejściem niż udawanie, że zagadnienia te w ogóle nie istnieją.
Prostota Edward Tufte w książce The Visual D isplay o f Q uantitative Inform ation zamieścił przy kład, w którym przedstawił wykres, a następnie zaczął z niego usuwać wszystkie znaczni ki, które nie wnosiły żadnych inform acji. Ostateczny wykres był nowatorski, a jed no cześnie dużo łatwiejszy do zrozum ienia niż oryginalny. W yeliminowanie nadmiernej złożoności umożliwia osobom czytającym programy, modyfikującym je czy też z nich korzystającym ich szybsze zrozumienie. W niektórych przypadkach złożoność ma kluczowe znaczenie, gdyż właściwie odzwierciedla stopień kom plikacji samego problemu. Jednak niekiedy złożoność można sobie wyobrazić ja ko ślady paznokci pozostawione w trakcie naszej walki o samo uruchom ienie progra mu. Jest to właśnie ta nadmierna złożoność, która prowadzi do zmniejszenia wartości oprogramowania, gdyż zmniejsza prawdopodobieństwo prawidłowego działania pro gramu, a w przyszłości utrudni wprowadzanie w nim modyfikacji. Jednym z elem en tów programowania jest spojrzenie na wykonaną pracę i oddzielenie ziarna od plew. Prostota jest rzeczą względną. To, co jest proste dla doświadczonego programisty, znającego zaawansowane narzędzia, może być przytłaczająco złożone dla nowicjusza. Dobra proza jest tworzona, gdy autor myśli o czytelnikach; to samo dotyczy dobrych
23
24
r o z d z ia ł
3
T e o r ia p r o g r a m o w a n ia
programów. Stawianie przed odbiorcam i niewielkich wyzwań nie jest niczym złym, jednak stracimy ich, kiedy złożoność przekroczy pewien poziom. W rozwoju informatyki można wyróżnić fale złożoności i prostoty. Architektury komputerów typu mainframe stawały się coraz bardziej złożone aż do m om entu po jawienia się minikomputerów. Minikomputery nie rozwiązały problemów komputerów typu mainframe, jednak okazało się, że w wielu zastosowaniach problemy te w ogóle nie były istotne. Także w rozwoju języków programowania można wyróżnić podobne fale większej złożoności, a następnie prostoty. Na podstawie C powstał język C++, z niego powstała Java, a teraz z kolei ten język sam staje się bardziej złożony. Poszukiwanie prostoty umożliwia wprowadzanie innowacji. Biblioteka JU nit jest znacznie prostsza niż narzędzia testowe, które zastąpiła. Łączy ona w sobie wiele po dobnych rozwiązań, dodatków i nowych technik programowania i testowania. Jej naj nowsza wersja, JU nit 4, utraciła nieco ze swojego dotychczasowego charakteru, który cechowało udostępnianie wyłącznie podstawowych, najważniejszych możliwości, jednak osobiście zgadzam się ze wszystkimi decyzjami, jakie doprowadziły do wzrostu jej zło żoności. Kiedyś ktoś wymyśli znacznie prostszy sposób pisania tekstów, niż na to obecnie pozwala JUnit. To nowe rozwiązanie umożliwi kolejną falę innowacji. Prostotę należy stosować na wszystkich poziomach. Kod należy formatować tak, by żadnego z jego fragmentów nie można było usunąć bez utraty informacji. Projektując kod, nie należy stosować w nim żadnych zewnętrznych elementów. W ymagania nale ży zmieniać, by wskazać te, które mają kluczowe znaczenie. Elim inacja nadmiernej złożoności pokazuje kod w nowym świetle, dając nam możliwość podejścia do niego w nowy, świeży sposób. Komunikatywność oraz prostota często idą w parze. Im większa prostota kodu, tym łatwiej go będzie zrozumieć. W im większym stopniu będziemy się koncentrować na komunikacji, tym łatwiej nam będzie zauważyć złożoności, które można wyeliminować. Czasami jednak spotykałem się z sytuacjami, gdy upraszczanie programu sprawiało, że był on trudniejszy do zrozumienia. W takich przypadkach wybierałem łatwość kom u nikacji, a nie prostotę. Takie sytuacje są sporadyczne i zazwyczaj sugerują możliwość wprowadzenia uproszczeń o większym zakresie, których jeszcze nie udało się zauważyć.
Elastyczność Spośród wszystkich trzech wymienionych wcześniej wartości elastyczność jest tą, któ ra usprawiedliwia stosowanie nieefektywnych rozwiązań programistycznych i pro jektowych. Zdarzało mi się widzieć programy odwołujące się w celu pobrania wartości stałej do zmiennej środowiskowej zawierającej nazwę katalogu, w którym był prze chowywany plik z tą wartością. Czemu miało służyć stosowanie tak skomplikowanego rozwiązania? Zapewniało elastyczność. Programy powinny być elastyczne, jednak tyl ko w tych aspektach, które mogą być zmienne. Jeśli wartość stałej nigdy nie będzie się zmieniać, to stosowanie równie złożonego rozwiązania byłoby narażeniem się na koszty bez żadnych korzyści.
Z a sa d y
Ponieważ większość kosztów programu pojawia się po jego wdrożeniu, programy powinny zapewniać łatwość modyfikacji. Ta elastyczność, którą m am na myśli, będzie potrzebna w przyszłości, choć najprawdopodobniej nie będzie tym, czego będę po trzebował podczas modyfikowania kodu. To właśnie dlatego elastyczność zapewniana przez prostotę oraz wyczerpujące testy jest znacznie bardziej wydajna niż elastyczność, którą daje hipotetyczny projekt. W arto zatem wybierać wzorce umożliwiające zachowanie prostoty i czerpać z tego natychmiastowe korzyści. Natomiast w przypadku wzorców, których stosowanie niesie ze sobą zwiększone koszty, a korzyści są bardziej odległe, warto zachować ostrożność i cier pliwość. Warto trzymać je w odwodzie aż do momentu, gdy staną się naprawdę potrzeb ne. W tedy będzie można je zastosować w dokładnie taki sposób, w jaki należy to zrobić. Zapewnienie elastyczności może się odbyć kosztem zwiększonej złożoności. Na przy kład opcje, które użytkownik może modyfikować, zapewnią elastyczność, jednak spowo dują zwiększenie złożoności pliku konfiguracyjnego i pojawi się konieczność uwzględnie nia ich podczas programowania. Z kolei prostota może prowadzić do zwiększenia elastyczności. W powyższym przykładzie, jeśli uda się nam znaleźć sposób wyeliminowa nia opcji konfiguracyjnej bez utraty jej wartości, uzyskamy program, który w przy szłości będzie łatwiej zmodyfikować. Rozszerzanie możliwości kom unikacji za pośrednictwem kodu poprawia także je go elastyczność. Im więcej będzie osób, które będą w stanie szybko przeczytać, zrozu mieć i zmodyfikować kod, tym firma będzie mieć większe możliwości zaktualizowania oprogramowania w przyszłości. Zamieszczone w tej książce wzorce pozwalają poprawiać elastyczność kodu i po magają program istom w tworzeniu prostych, zrozumiałych aplikacji, które można łatwo modyfikować.
W zorce im plementacyjne nie są tym, czym są, bez przyczyny. Każdy z nich odzwier ciedla co najm niej jedną zaletę, którą niosą ze sobą kom unikacja za pośrednictwem kodu, prostota oraz elastyczność. Zasady są kolejnym poziom em ogólnych idei, bar dziej charakterystycznym dla programowania niż wartości, i także one stanowią pod stawę wzorców. Poznawanie zasad jest wartościowe z kilku powodów. Przejrzyste zasady mogą prowa dzić do powstawania nowych wzorców, tak jak układ okresowy doprowadził do od krycia nowych pierwiastków. Zasady mogą dostarczać wyjaśnień tłumaczących cele, któ rym służą wzorce, zwłaszcza te łączone z ideami ogólnymi, a nie szczegółowymi. O decyzjach związanych z wykorzystywaniem przeciwstawnych wzorców często najlepiej jest dyskutować, opierając się na zasadach, a nie szczegółach działania tych wzorców. A poza tym zrozumienie zasad może pomagać w określaniu postępowania w nowych sytuacjach.
25
26
r o z d z ia ł
3
T e o r ia p r o g r a m o w a n ia
Na przykład kiedy stykam się z nowym językiem programowania, korzystam z mojej znajom ości zasad, by określić efektywny styl pisania kodu w tym języku. Nie muszę przy tym bezmyślnie kopiować istniejącego stylu ani, co byłoby jeszcze gorsze, kur czowo trzymać się stylu programowania stosowanego w innym języku (w każdym języku programowania można pisać kod typowy dla FO RTRA N -u, jednak nie należy tego robić). Dlatego zrozumienie zasad daje mi możliwość szybkiej nauki oraz spójnego działania w nowych sytuacjach. Poniżej została przedstawiona lista zasad leżących u podstaw wzorców implementacyjnych.
Lokalne konsekwencje Kod powinien m ieć taką strukturę, by wprowadzane zmiany miały jedynie lokalne konsekwencje. Jeśli zmiana tutaj może spowodować problem y g d zie indziej, to koszt takich zmian drastycznie wzrasta. Poza tym kod, w którym zmiany mają w większości lokalne konsekwencje, zapewnia lepsze możliwości kom unikacji. M ożna go poznawać stopniowo, bez konieczności wcześniejszego zrozumienia całości. Ponieważ chęć obniżenia kosztów wprowadzanych modyfikacji jest podstawową przyczyną stosowania wzorców projektowych, zasada lokalnych konsekwencji jest jedną z głównych przyczyn sformułowania wielu wzorców.
Minimalizacja powtórzeń Z zasadą dążenia do lokalnych konsekw encji współdziała zasada unikania powtórzeń. Jeśli ten sam fragment kodu występuje w kilku m iejscach, zm ieniając jedno z jego wy stąpień, będziemy musieli zdecydować, czy zmienić także wszystkie pozostałe. Taka zm ia na przestaje mieć lokalny charakter. Im więcej będzie powtarzających się fragmentów kodu, tym większy będzie koszt wprowadzenia modyfikacji. Kopiowanie kodu jest tylko jedną z form powtarzania. Równoległe hierarchie klas także stanowią powtórzenie i naruszają zasadę lokalnych konsekwencji. Jeśli wprowadze nie zmiany pojęciowej wymaga zmiany dwóch lub większej liczby hierarchii klas, to kon sekwencje takiej zmiany mają szerszy zakres. W takich przypadkach zmiana struktury klas tak, by zmiany nabrały lokalnego charakteru, pozwoliłaby poprawić kod. Powtórzenia nie zawsze będą oczywiste, zanim do nich doprowadzimy, a czasami będzie można je zauważyć dopiero po dłuższym czasie. Po wykryciu takiego powtórzenia nie zawsze mogę wymyślić dobry sposób jego wyeliminowania. Powtórzenia nie m u szą być czymś złym — powodują jedynie zwiększenie kosztów wprowadzania zmian. Jednym ze sposobów unikania powtórzeń jest dzielenie programów na wiele m niej szych części — niewielkie instrukcje, małe metody i obiekty, niewielkie pakiety. Duże fragmenty kodu zazwyczaj mają tendencję do powielania elementów z innych dużych fragmentów — choć pomiędzy nim i mogą występować niewielkie różnice, to jednak wykazują one także dużo podobieństw. Czytelny przekaz wskazujący, które fragmenty programu są identyczne, które jedynie podobne, a które całkowicie się od siebie róż nią, ułatwia zrozumienie programu i zmniejsza koszty jego modyfikacji.
Z a sa d y
Połączenie logiki i danych Kolejną zasadą wynikającą z zasady lokalnych konsekwencji jest łączenie logiki i danych. Logikę oraz dane, na których ona operuje, warto umieścić w jednej metodzie (o ile to tylko możliwe), w jednym obiekcie lub, w ostateczności, w jednym pakiecie. Przy wprowadza niu zmian najprawdopodobniej konieczne będzie zmodyfikowanie zarówno logiki, jak i danych. Jeśli będą one połączone, to konsekwencje zmian będą miały lokalny charakter. Nie zawsze można z góry określić, gdzie należy um ieścić logikę i dane, by zastoso wać się do założeń tej zasady. M oże się zdarzyć, że pisząc kod obiektu A, uzmysłowi my sobie, iż potrzebne są nam dane z obiektu B. Dopiero gdy kod będzie działał, zdamy sobie sprawę, że jest on oddzielony od danych, na których operuje. W takim przypadku m usimy określić, co należy zrobić: przenieść kod do danych, dane do kodu czy um ie ścić je razem w jakim ś nowym obiekcie pomocniczym; m ożemy także dojść do w nio sku, że aktualnie nie jesteśm y w stanie wymyślić żadnego sposobu połączenia danych z kodem, który sprawiłby, że efektywnie wyrazimy nasze intencje.
Symetria Kolejną zasadą, z której korzystam cały czas, jest zasada symetrii. W programach symetria występuje bardzo często. Zazwyczaj oprócz metody add() tworzona jest także metoda remove(). Istnieją grupy metod, które mają dokładnie te same parametry. Wszystkie pola obiektu istnieją przez dokładnie ten sam czas. Określenie i jawne wskazanie symetrii ułatwia analizę kodu. Kiedy czytelnik zrozumie jeden z symetrycznych elementów, zrozum ienie drugiego będzie znacznie szybsze i prostsze. Symetrię często opisuje się przy użyciu specyficznych terminów: dwustronna, osiowa itd. Jednak w programach symetrię rzadko kiedy można wyrazić graficznie — zazwyczaj jest to symetria pojęciowa. O symetrii w kodzie mówimy, gdy we wszyst kich m iejscach dana idea jest wyrażana dokładnie w taki sam sposób. Oto przykład kodu, w którym symetria nie występuje: void process() { input(); count++; output(); } Druga instrukcja jest bardziej konkretna od dwóch pozostałych. Opierając się na zasa dzie symetrii, można by zmodyfikować powyższy kod następująco: void process() { input(); incrementCount(); output(); } Jednak nawet taka modyfikacja narusza zasadę symetrii. Nazwy operacji in p u t() i output() stanowią odzwierciedlenie intencji, natom iast nazwa incrementCount() jest
27
28
r o z d z ia ł
3
T e o r ia p r o g r a m o w a n ia
ściśle powiązana z im plem entacją. Poszukując symetrii, zastanowiłbym się, d laczeg o inkrementuję zmienną count, co mogłoby doprowadzić do zmiany kodu na następujący: void process() { input(); t a l ly O ;1 output(); } Stosunkowo często odszukanie i wyrażenie symetrii jest wstępnym krokiem na drodze do eliminacji powtórzeń w kodzie. Jeśli podobna idea występuje w kilku miejscach kodu, to wprowadzenie symetrii jest dobrym pierwszym krokiem do ich ujednolicenia.
Przekaz deklaratywny Kolejną zasadą leżącą u podstaw wzorców im plementacyjnych jest dążenie od wyra żania intencji w sposób deklaratywny. Programowanie imperatywne zapewnia wielkie możliwości i jest elastyczne, jednak w jego przypadku analiza kodu wymaga prześledzenia sposobu jego działania w całości. Konieczne jest skonstruowanie w myślach modelu stanu programu i modyfikowanie go na podstawie danych i instrukcji. W przypadku tych fragmentów programu, które bardziej przypominają proste fakty, bez żadnych sekwencji i rozgałęzień warunkowych, analiza kodu deklaratywnego jest łatwiejsza. Na przykład we wcześniejszych wersjach JU nit klasy mogły zawierać statyczną metodę s u i t ( ) , która zwracała zestaw tekstów do wykonania. public s ta tic junit.framework.Test su ite() { Test result= new TestSu ite(); . . . złożone działania . . . return resu lt; } A teraz czas na zadanie prostego, częstego pytania: jakie teksty należy wykonać? W większości przypadków metoda s u it ( ) łączy testy z całej grupy klas. Niemniej jed nak, ponieważ jest ona metodą ogólną, by m ieć co do tego pewność, należy ją prze analizować i zrozumieć. W JU nit 4 ten sam problem rozwiązano dzięki wykorzystaniu zasady przekazu de klaratywnego. Zamiast metody zwracającej zestaw testów stosowany jest specjalny m echanizm uruchomieniowy, który (najczęściej) wykonuje testy dostępne w pewnym zbiorze klas. @RunWith(Suite.class) @TestClasses({ SimpleTest.class, ComplicatedTest.class }) class AllTests { } 1 Czasownik tally oznacza w języku angielskim m.in. zliczać lub notować — przyp. tłum.
Z a sa d y
Jeśli wiadomo, że testy są grupowane w taki sposób, wystarczy spojrzeć na adnota cję T estC lasses, by dowiedzieć się, które z nich zostaną wykonane. Ponieważ zestaw testów został określony deklaratywnie, nie trzeba podejrzewać występowania jakich kolwiek podstępnych wyjątków. To rozwiązanie nie dysponuje możliwościami orygi nalnej metody s u it( ) ani nie ma porównywalnie ogólnego charakteru, jednak zastosowa nie stylu deklaratywnego ułatwia zrozumienie kodu. (Adnotacja RunWith zapewnia jeszcze większą elastyczność wykonywania testów niż metoda s u i t ( ) , jednak to już zupełnie inna historia, nadająca się na odrębną książkę).
Tempo zmian Ostatnią zasadą jest zgrupowanie logiki i danych, które zmieniają się w takim samym tempie, i oddzielenie ich od logiki i danych, które zm ieniają się szybciej lub wolniej. To tempo zmian można potraktować jako formę symetrii czasowej. Niekiedy zasada tem pa zmian odnosi się także do zmian wprowadzanych przez programistów. Na przy kład pisząc program do wyliczania podatków, oddzieliłbym kod odpowiadający za ogólne obliczenia od kodu wyliczającego podatki według ustaleń dla konkretnego roku. Kod zmienia się w różnym tempie. Kiedy będę wprowadzać zmiany w następnym roku, chciałbym mieć pewność, że kod z poprzednich lat wciąż działa. Separacja tych kodów pozwoli mi uzyskać pewność, że ewentualne modyfikacje będą miały lokalny charakter. Szybkość zmian odnosi się do danych. Wszystkie pola obiektu powinny zmieniać się w mniej więcej tym samym tempie. Na przykład pola modyfikowane wyłącznie w trakcie działania konkretnej metody powinny być zmiennymi lokalnymi. Dwa pola obiektu, które zm ieniają się jednocześnie, lecz w innym tempie niż pozostałe, najprawdopo dobniej należałoby um ieścić w obiekcie pomocniczym. Jeśli wartość oraz waluta jakie goś instrum entu finansowego mogą się zm ieniać jednocześnie, to można przypusz czać, że najlepiej byłoby je wyrazić w formie obiektu pom ocniczego, takiego ja k Money. A zatem poniższy kod: setAmount(int value, String currency) { this.value= value; this.currency= currency; } można by przekształcić do postaci: setAmount(int value, String currency) { this.value= new Money(value, currency); } a następnie: setAmount(Money value) { this.value= value; } Zasada tempa zmian jest zastosowaniem symetrii, choć chodzi w tym przypadku o symetrię czasową. W powyższym przykładzie dwa istniejące początkowo pola, value oraz cu rrency, są sym etryczne. Z m ieniają się w tym samym czasie. Jed n ak nie są
29
30
r o z d z ia ł
3
T e o r ia p r o g r a m o w a n ia
symetryczne względem innych pól obiektu. W yrażenie tej symetrii poprzez umiesz czenie ich w odrębnym obiekcie stanowi jasny kom unikat odnośnie do ich wzajemnej relacji i można sądzić, że w przyszłości pozwoli na dalszą elim inację powtórzeń i za chowanie lokalnego charakteru zmian.
Wnioski Ten rozdział stanowi wprowadzenie do teoretycznych podstaw wzorców im plem enta cyjnych. W artości, które niosą ze sobą komunikatywność, prostota oraz elastyczność, stanowią główną motywację opracowywania i stosowania wzorców. Zasady konse kwencji lokalnych, m inim alizacji powtórzeń, grupowania danych i logiki, przekazu deklaratywnego oraz tempa zmian ułatwiają przełożenie tych wartości na konkretne postępowanie. A zatem docieramy do samych wzorców, konkretnych rozwiązań czę sto powtarzających się problemów programistycznych. Następny rozdział, „Motywacja”, przedstawia czynniki ekonom iczne, które spra wiają, że kom unikacja za pośrednictwem kodu jest warta uwagi.
Rozdział 4
Motywacja
Trzydzieści lat temu Yourdon i Constantine w książce Structured D esign wskazali, że to ekonomia jest czynnikiem w arunkującym projektowanie oprogramowania. O pro gramowanie powinno być projektowane w taki sposób, by redukować jego koszty. Koszt oprogramowania składa się z kosztu początkowego oraz kosztów utrzymania: k o s z t całkow ity = k o s z t napisania + k o s z t utrzym ania
Kiedy przemysł nabrał doświadczenia w wytwarzaniu oprogramowania, dużym zasko czeniem okazał się fakt, że koszt jego utrzymania znacznie przewyższał koszt napisania. (W projektach, w których koszty utrzymania nie istnieją lub są minimalne, należy stoso wać odmienne wzorce implementacyjne od tych przedstawionych w dalszej części książki). Utrzymanie oprogramowania jest kosztowne, gdyż zrozum ienie istniejącego kodu jest czasochłonne i podatne na błędy. Wprowadzanie zmian jest zazwyczaj proste, o ile tylko wiadomo, co trzeba zmienić. Kosztowne jest natomiast zrozumienie, jak kod działa. A po wprowadzeniu zmian należy je jeszcze przetestować i wdrożyć. k o s z t utrzym ania = k o s z t zrozum ienia + k o s z t m odyfikacji + k o s z t testow ania + k o s z t w drożenia
Jedną ze strategii redukcji ogólnych kosztów oprogramowania jest zainwestowanie w jego początkowe wytworzenie w nadziei na zredukowanie lub całkowite wyelimi nowanie kosztów utrzymania. Jednak ogólnie rzecz biorąc, takie wysiłki nie prowadzą do obniżenia ogólnych kosztów oprogramowania. Kiedy kod musi być modyfikowany na nieprzewidziane sposoby, to niezależnie od tego, jak byliśmy przezorni, i tak nie uda się perfekcyjnie przygotować go na przyszłe zmiany. Przedwczesne próby napisa nia kodu na tyle ogólnego, by zaspokoił wszystkie przyszłe potrzeby, niejednokrotnie utrudniają wprowadzanie nieoczekiwanych, acz koniecznych zmian. Znaczące zwiększenie początkowych kosztów wytworzenia oprogramowania jest sprzeczne z dwiema ważnymi zasadami ekonom ii: czasową wartością pieniądza oraz brakiem pewności co do sytuacji w przyszłości. Dzisiejsza wartość dolara jest większa, niż będzie w przyszłości, zatem w zasadzie strategia implementacji powinna sugerować opóźnianie generowania kosztów. Co więcej, ze względu na brak pewności, co przy niesie przyszłość, strategia im plem entacyjna powinna dążyć do natychm iastow ych 31
32
r o z d z ia ł
4
M o t y w a c ja
korzyści, a nie m ieć na uwadze zyski długoterminowe. C hoć może to brzm ieć ja k su gestia, by programować bez zwracania szczególnej uwagi na to, co będzie w przyszło ści, to jednak wzorce implementacyjne koncentrują się na sposobach odnoszenia właśnie takich, natychmiastowych korzyści, a jednocześnie gwarantują tworzenie przejrzyste go kodu ułatwiającego wprowadzanie przyszłych modyfikacji. M oja strategia redukcji ogólnych kosztów polega na przekonywaniu wszystkich programistów, by zmniejszyli koszt analizy i zrozumienia kodu w fazie utrzymania opro gramowania poprzez skoncentrowanie uwagi na komunikacji, myśleniu o innych pro gramistach. Natychmiastowe korzyści, jakie zapewnia przejrzysty kod, to mniejsza liczba defektów, łatwiejsze współdzielenie kodu i bardziej płynna praca. Kiedy stosowanie wzorców implementacyjnych wejdzie w nawyk, można programo wać szybciej i spada liczba zagadnień, które nas dekoncentrują. Kiedy zacząłem tworzyć swój pierwszy zestaw wzorców im plementacyjnych (opisany w książce T he S m alltalk B est P ractice P atterns, wydanej przez wydawnictwo Prentice Hall w roku 1996), są dziłem, że jestem biegłym programistą. Aby przekonać się do skoncentrowania uwagi na wzorcach, zdecydowałem, że nie napiszę nawet jednego znaku kodu, zanim nie określę wzorca, według którego taki kod miałby zostać napisany. Było to bardzo frustrujące, zupełnie jakbym próbował pisać ze sklejonymi palcami. W ciągu pierwszego tygodnia każda minuta kodowania była poprzedzona godziną pisania wzorców. W drugim ty godniu zorientowałem się, że opracowałem już lwią część podstawowych wzorców, których używałem przez większość czasu. W trzecim tygodniu zauważyłem, że pracuję znacznie szybciej niż wcześniej, gdyż dokładnie przeanalizowałem własny styl kodo wania i nie dekoncentrowały mnie żadne wątpliwości. W zorce implementacyjne, które określiłem i zapisałem, tylko częściowo były moim dziełem. M ój własny styl został w znacznej mierze skopiowany od wcześniejszych ge neracji programistów. T o właśnie te wzorce były nawykami, które doprowadziły do po wstania bardziej przejrzystego i zrozumiałego kodu, łatwiejszego w zrozumieniu, a po przez ich opisanie byłem w stanie tworzyć kod szybciej i bardziej płynnie niż wcześniej. M ogłem przygotować się na przyszłość, a równocześnie szybciej pisać niezbędny kod. Szukając inspiracji do wzorców przedstawionych w tej książce, przeanalizowałem zarówno swój własny kod, jak i nawyki. Przejrzałem kody pochodzące z JD K oraz Eclipse i porównałem je z własnymi doświadczeniami. Opracowane w ten sposób wzorce mają stanowić spójny punkt widzenia na tworzenie kodu, który będzie łatwy do zrozumienia dla innych. Przyjęcie innego punktu widzenia lub innego zestawu wartości doprowa dziłoby do powstania innych wzorców. Na przykład w ostatnim rozdziale, „Rozwijanie platform ”, przedstawiłem wzorce im plementacyjne przeznaczone do zastosowania przy tworzeniu platform. Bazują one na innych priorytetach i dlatego różnią się od m oje go podstawowego stylu kodowania. W zorce im plementacyjne m ają pomagać ludziom i umożliwiać osiągnięcie celów ekonomicznych. Kod jest tworzony przez ludzi i dla ludzi. Program iści mogą używać wzorców im plementacyjnych, by zaspokajać potrzeby innych programistów, takie jak potrzeba bycia dumnym z dobrze wykonanej pracy czy też bycia godnym zaufania członkiem społeczności. Rozważania na tem at tego ludzkiego oraz ekonomicznego wpływu na wzorce zawarłem w opisach wzorców w kolejnych rozdziałach książki.
Rozdział 5
Klasy
Idea klasy sięga czasów na długo przed Platonem. Bryły platońskie były klasami, któ rych instancje można było zobaczyć w rzeczywistym świecie. Platońska sfera była ab solutnie doskonała, ale nie była materialna. Sfer nas otaczających można było dotykać, lecz pod pewnymi względami były one niedoskonałe. Programowanie obiektowe wykorzystało tę ideę, zmodyfikowaną nieco przez za chodnioeuropejskich filozofów, dzieląc programy na klasy, stanowiące ogólne opisy całych grup znajom ych rzeczy, oraz obiekty, będące komputerowymi odpowiednika mi tych rzeczy. Klasy są bardzo ważne dla kom unikacji, gdyż potencjalnie mogą opisywać wiele konkretnych rzeczy. Spośród wszystkich wzorców implementacyjnych te związane z kla sami m ają największy zasięg. W odróżnieniu od nich wzorce projektowe ogólnie opi sują związki między klasami. W tym rozdziale zostały przedstawione następujące wzorce: ■
Klasa (ang. Class) — klasy używamy, by powiedzieć: „Te dane stanowią grupę, z którą są powiązane te zachowania”.
■
Prosta nazwa klasy bazowej (ang. S im ple Superclass N am e) — określa nazwy klas bazowych całych hierarchii klas, wykorzystując w tym celu proste nazwy utworzo ne na podstawie tej samej metafory.
■
Kwalifikowana nazwa klasy pochodnej (ang. Q ualified Subclass N am e) — określa na zwy klas pochodnych, informując o podobieństwach i różnicach z klasami bazowymi.
■ ■
Interfejs abstrakcyjny (ang. A bstract Interface) — oddziela interfejs od implementacji. Interfejs (ang. In terface) — określa abstrakcyjny interfejs, który zazwyczaj nie róż ni się zbytnio od interfejsu w języku Java.
■
Interfejs wersjonowany (ang. V ersioned In terface) — bezpiecznie rozszerza inter fejsy poprzez wprowadzenie nowych interfejsów pochodnych.
33
34
r o z d z ia ł
■
5
K la sy
Klasa abstrakcyjna (ang. A bstract Class) — określa interfejs abstrakcyjny, który najprawdopodobniej będzie się zm ieniał wraz z klasą abstrakcyjną.
■
Obiekt wartościowy (ang. Value Object) — pozwala utworzyć obiekt zachowujący się jak wartość matematyczna.
■
Specjalizacja (ang. S pecialization ) — przejrzyście pokazuje podobieństwa powią zanych ze sobą obliczeń i różnice między nimi.
■
Klasa pochodna (ang. Subclass) — wyraża jednowymiarową odm ienność za pom o cą klasy pochodnej.
■
Im plem entator (ang. Im p lem en tor) — przesłania metodę, by wyrazić inny wariant obliczeń.
■
Klasa wewnętrzna (ang. In n er Class) — lokalnie grupuje użyteczny kod w formie klasy prywatnej.
■
Zachowanie zależne od instancji (ang. In stan ce-S pecific B eh av ior) — modyfikuje logikę w zależności od instancji.
■
Konstrukcja warunkowa (ang. C on dition al) — modyfikuje logikę na podstawie jawnie określonego warunku.
■
Delegacja (ang. D elegation ) — modyfikuje logikę poprzez wybór jednego z kilku typów obiektów.
■
Selektor dołączany (ang. P luggable Selector) — modyfikuje logikę poprzez przemy ślany wybór wywoływanej metody.
■
Anonimowa klasa wewnętrzna (ang. A n on ym ou s In n er Class) — modyfikuje logi kę, przesłaniając jedną metodę lub kilka metod bezpośrednio w metodzie, we wnątrz której jest tworzony nowy obiekt.
■
Klasa biblioteczna (ang. L ibrary Class) — reprezentuje zbiór funkcjonalności, które nie pasują do żadnych innych obiektów, wyrażając je w formie metod statycznych.
Klasa Dane zmieniają się szybciej niż logika. To właśnie to spostrzeżenie leży u podstaw tworze nia i stosowania klas. Każda klasa stanowi deklarację: „Ta logika jest ze sobą powiązana i zmienia się wolniej niż dane, na których operuje. Także te dane są ze sobą powiązane, zmieniają się w podobnym tempie, a operuje na nich powiązana z nimi logika”. Ta ścisła separacja podlegających zmianom danych oraz niezmiennej logiki nie jest bezwzględna. Czasami logika może się nieznacznie zm ieniać w zależności od wartości danych; cza sami różnice te będą znaczące. Zdarza się także, że w trakcie obliczeń dane nie ulegają żadnym zmianom. Poznanie sposobów grupowania logiki w klasy i wyrażania jej zm ien ności jest jednym z elementów efektywnego programowania obiektowego.
P r o st a n a z w a k la sy b a z o w ej
Organizowanie klas w hierarchie jest formą kompresji, polegającą na pobraniu klasy bazowej i tekstowym wstawieniu jej do klas pochodnych. Utrudnia to analizę kodu, co stanowi zresztą wspólną cechę wszystkich technik kompresji. W przypadku klas zro zumienie klasy pochodnej wymaga zrozumienia kontekstu klasy bazowej. Wyważone stosowanie dziedziczenia jest kolejnym aspektem mającym wpływ na efektywność programowania obiektowego. Utworzenie klasy pochodnej można porównać do stwierdzenia: „Jestem podobna do tej klasy bazowej, choć nieco inna”. (Czy to nie jest dziwne, że mówimy o przesłanianiu metody w klasie pochodnej? O ile lepsi m o gliby być programiści, gdyby rozważnie dobierali używane metafory?). Klasy są stosunkowo kosztownymi elementami projektowymi programów obiek towych. Klasa powinna realizować coś o dużym znaczeniu. Ograniczenie liczby klas uży wanych w systemie może być korzystne, o ile tylko pozostałe klasy nie staną się prze ładowane i zbyt duże. W zorce przedstawione w dalszej części rozdziału pokazują, w jaki sposób kom uni kować intencje poprzez odpowiednie deklarowanie klas.
Prosta nazwa klasy bazowej znalezienie odpowiedniej nazwy jest jednym z najbardziej satysfakcjonujących m o mentów podczas pisania kodu. K onfrontujem y bowiem swoje siły z ideą. Czasami nasz kod staje się skomplikowany, choć wydaje się, że wcale nie musi taki być. A póź niej, podczas rozmowy, ktoś stwierdzi: „A ... rozumiem, to właściwie jest Term inarz”, i wszyscy siadają z westchnieniem zrozumienia. Odpowiednia nazwa może wyzwolić lawinę dalszych uproszczeń i usprawnień. Jednymi z najważniejszych nazw, które należy dobierać właściwie, są nazwy klas. Klasy są bowiem kluczowymi pojęciam i całego projektu. Kiedy zostaną im już nadane nazwy, można zająć się określaniem nazw operacji. Rzadko kiedy można określać nazwy w odwrotnej kolejności, chyba że początkowe nazwy klas zostały wybrane niewłaściwie. Określając nazwy klas, trzeba zachować równowagę pomiędzy ich długością i w arto ścią informacyjną. Nazw klas będziemy używali w rozmowach: „Czy pamiętałeś, żeby obrócić Figurę przed jej przesunięciem?”. Dlatego nazwy klas powinny być krótkie i tre ściwe. Jednak aby mogły być odpowiednio precyzyjne, czasami będą się musiały skła dać z kilku słów. Sposobem rozwiązania tego dylematu jest wybór odpowiednio m ocnej metafory. Jeśli o niej pamiętamy, może się okazać, że nawet pojedyncze słowo będzie przywodzić na myśl rozległą sieć asocjacji, powiązań i implikacji. Na przykład pisząc platformę gra ficzną HotDraw, pierwszą nazwą, jaką nadałem obiektowi rysunku, było DrawingObject. W ard Cunningham zaproponował jednak skorzystanie z metafory związanej z typografią: rysunek można porównać do wydrukowanej strony, której zawartość została od powiednio rozmieszczona. Graficzne elementy umieszczane na stronie są rysunkami, dla tego też nadaliśmy tej klasie nazwę Figure. W kontekście tej metafory nazwa Figure jest zarówno krótsza, bardziej treściwa, jak i bardziej precyzyjna od nazwy DrawingObject.
35
36
r o z d z ia ł
5
K la sy
Czasami odnalezienie dobrej nazwy zajm uje sporo czasu. Może się zdarzyć, że w chwili odnalezienia lepszej nazwy klasy nasz kod został napisany i działa już od wielu tygodni, miesięcy, a może nawet i lat (co zdarzyło mi się w jednym, godnym uwagi przypadku). Odnalezienie właściwej nazwy może także wymagać większego wysiłku, na przykład trzeba będzie sięgnąć po słownik wyrazów bliskoznacznych, spisać listę po tencjalnych nazw, przemyśleć wszystko podczas spaceru. Czasami trzeba będzie zająć się kolejnym i sprawami, ufając, że czas, frustracja i podświadomość pozwolą w końcu znaleźć lepszą nazwę. Narzędziem, które nieodm iennie ułatwia znajdowanie lepszych nazw, są rozmowy. Próby wyjaśnienia innym przeznaczenia obiektu pozwalają mi wyobrazić sobie bogate i ekspresyjne obrazy umożliwiające jego opisanie. Te obrazy mogą się przyczynić do wska zania nowych nazw. W arto starać się, by nazwy ważnych klas składały się z jednego wyrazu.
Kwalifikowana nazwa klasy pochodnej Nazwy klas pochodnych pełnią dwie funkcje. M ają przekazywać inform ację o tym, do której klasy dana klasa jest p o d o b n a oraz pod jakim i względami się od niej różni. Tak że w tym przypadku konieczne jest zachowanie równowagi pomiędzy długością i warto ścią inform acyjną nazwy. W odróżnieniu od klas bazowych hierarchii nazwy klasy po chodnych nie są używane w rozmowach równie często, a zatem mogą przekazywać nieco więcej inform acji kosztem długości. M ożna je tworzyć, poprzedzając nazwę kla sy bazowej jednym modyfikatorem lub kilkoma. W yjątkiem od tej reguły jest sytuacja, gdy klasy pochodne są tworzone wyłącznie jako mechanizm udostępniania im plem entacji i same w sobie stanowią ważne pojęcia. Takim klasom pochodnym, tworzącym własne hierarchie klas, także należy nadawać proste nazwy. Na przykład w skład platformy HotDraw wchodzi klasa Handle, repre zentująca operację edycji rysunku, wykonywaną, gdy został on zaznaczony. Jak widać, zastosowałem prostą nazwę Handle, a nie nazwę utworzoną poprzez rozszerzenie słowa Figure. Istnieje cała rodzina takich operacji i te noszą odpowiednio dobrane nazwy, takie jak StretchyHandle czy też TransparencyHandle. Ponieważ Handle jest klasą ba zową swojej własnej hierarchii klas, ważniejsze jest nadanie jej prostej nazwy klasy ba zowej niż bardziej rozbudowanej nazwy klasy pochodnej. Kolejne problemy związane z określaniem nazw klas pochodnych występują w wielo poziomowych hierarchiach klas. Takie wielopoziomowe hierarchie są zazwyczaj two rzone z intencją wykorzystania delegacji, jednak wymagają użycia odpowiednich nazw. Dlatego zamiast bezmyślnie dodawać modyfikatory do nazwy bezpośredniej klasy ba zowej, warto przyjrzeć się potencjalnej nazwie z punktu widzenia osoby analizującej kod. Którą klasę powinna przypominać ta klasa? A zatem, określając nazwę klasy po chodnej, warto skorzystać z nazwy klasy bazowej. Kom unikacja z innymi osobami jest najważniejszym aspektem określania nazw klas. Z punktu widzenia komputera nazwa klasy równie dobrze mogłaby być liczbą.
I n t e r f e js a b s t r a k c y jn y
Zbyt długie nazwy klas utrudniają czytanie oraz formatowanie kodu. Zbiory klas, któ rych nazwy nie są ze sobą w żaden sposób powiązane, będzie trudno zrozum ieć i za pamiętać. Nazwy klas powinny opowiadać historię, którą nasz kod opisuje.
Interfejs abstrakcyjny Stare powiedzenie programistyczne stwierdza, że należy tworzyć kod, opierając się na interfejsach, a nie implementacjach. Stwierdzenie to jest jedynie innym sposobem wyraże nia sugestii, że decyzje projektowe nie powinny być widoczne w większej liczbie miejsc, niż to konieczne. Jeśli większość mojego kodu będzie wiedzieć, że posługuję się kolek cją, to później będę w stanie zm ienić konkretną, używaną w nim klasę. Niem niej jed nak kiedyś trzeba będzie wskazać konkretną klasę, by komputer mógł wykonać operacje. W tym przypadku, gdy używam term inu „interfejs”, mam na myśli „zbiór operacji pozbawionych konkretnych implementacji”. W języku Java można je przedstawić w po staci zarówno interfejsu, jak i klasy bazowej. Przedstawione poniżej wzorce pokazują, kiedy wybrać każde z tych rozwiązań. Każdy poziom interfejsu wiąże się z pewnymi kosztami. To kolejny element, który trzeba poznać, zrozumieć, udokumentować, przetestować, zorganizować, przejrzeć i na zwać. M aksymalizacja liczby interfejsów wcale nie prowadzi do m inim alizacji kosztów stworzenia oprogramowania. Koszty stosowania interfejsów warto ponosić tylko wtedy, gdy potrzebujem y elastyczności, którą zapewniają. Ponieważ rzadko z góry wiadomo, kiedy będzie nam potrzebna elastyczność interfejsu, należy minimalizować koszty poprzez próby przewidzenia, gdzie interfejsy mogą nam być potrzebne, połączone z wprowa dzaniem ich wtedy, gdy okaże się to niezbędne. C hoć tak bardzo uskarżamy się na brak elastyczności oprogramowania, to jednak pod wieloma względami wcale nie musi być ono elastyczne. Zaczynając od zagadnień podstawowych, takich jak liczba bitów w liczbie całkowitej, a kończąc na zmianach o ogromnym zakresie, jak na przykład wprowadzanie nowego modelu biznesowego, większość oprogramowania wcale nie musi zapewniać elastyczności tam, gdzie jednak można by ją zapewnić. Kolejnym ekonom icznym czynnikiem związanym ze stosowaniem interfejsów jest nieprzewidywalność oprogramowania. Nasz przemysł ma bzika na punkcie twierdzenia, że gdybyśmy tylko byli w stanie prawidłowo zaprojektować oprogramowanie, to nie musielibyśmy modyfikować tworzonych systemów. Ostatnio przeczytałem listę przy czyn, które sprawiają, że oprogramowanie się zmienia. Pojawili się na niej programiści, którzy nie są w stanie określić prawidłowych wymagań, sponsorzy zm ieniający zdanie itd. Jednak czynnikiem, którego na niej nie znalazłem, były uzasadnione zmiany opro gramowania. Jej twórcy zakładali, że każda zmiana zawsze jest błędem. A dlaczego jedna prognoza pogody nie może sprawdzać się przez cały czas? Ponieważ pogoda zmienia się w nieprzewidywalny sposób. Dlaczego nie można stworzyć listy wszystkich możliwych cech systemu, które muszą zapewniać elastyczność? Ponieważ zmiany wymagań, jak również technologii są nieprzewidywalne. Oczywiście nie zwalnia nas to z obowiązku
37
38
r o z d z ia ł
5
K la sy
dołożenia wszelkich starań, by stworzyć system, którego klient potrzebuje w danej chwili, niemniej jednak stanowi także sugestię, że istnieją pewne granice sensowności przygotowywania oprogramowania na wszelkie możliwe okoliczności, które mogą zaistnieć w przyszłości. Połączenie tych wszystkich czynników — konieczność zapewnienia elastyczności, koszty jej uzyskania oraz brak możliwości przewidzenia, czy ta elastyczność faktycznie b ę dzie potrzebna — doprowadziło mnie do przekonania, że elastyczność należy wpro wadzać wtedy, kiedy okazuje się niezbędna. Zapewnianie elastyczności oprogramowania kosztuje, gdyż wiąże się z koniecznością modyfikacji istniejącego oprogramowania. Jeśli nie możemy osobiście zm ienić całego oprogramowania, które takiej zmiany wymaga, to koszty modyfikacji wzrastają jeszcze bardziej; zagadnienie to zostanie szerzej opisa ne w rozdziale poświęconym tworzeniu platform. Dwa dostępne w języku Java mechanizmy służące do tworzenia interfejsów, czyli klasy bazowe oraz interfejsy, mają różne charakterystyki kosztowe wprowadzania zmian.
Interfejs W Javie jednym ze sposobów stwierdzenia: „To jest to, co bym chciał zrobić, a szcze góły nie mają dla mnie znaczenia” jest zadeklarowanie interfejsu. Interfejsy stanowią jedną z ważnych innowacji, która po raz pierwszy została przedstawiona szerokiej pu bliczności właśnie w języku Java. Stanowią dobry punkt równowagi. Zapewniają pewne elementy elastyczności, jaką daje wielokrotne dziedziczenie, a jednocześnie nie są aż tak złożone i niejednoznaczne. Jedna klasa może im plementować wiele różnych interfej sów. Interfejsy udostępniają jedynie operacje, a nie pola, dlatego też mogą efektywnie zabezpieczyć użytkowników przed zmianam i ich im plementacji. Choć interfejsy umożliwiają modyfikowanie im plem entacji, nie zaleca się zm ie niania samych interfejsów. Jakakolwiek zmiana interfejsu — dodanie do niego jakiejś metody lub jej usunięcie — stwarza konieczność wprowadzenia zmian we wszystkich jego im plementacjach. Jeśli nie mamy możliwości zmiany im plem entacji, to popular ność interfejsu stanowi czynnik, który poważnie hamuje jego dalszą ewolucję. Jedną ze specyficznych cech interfejsów, ograniczających ich wartość jako środka przekazu, jest to, że wszystkie operacje wchodzące w skład interfejsu muszą być publiczne. Bardzo często chciałbym, aby istniała możliwość deklarowania w interfejsach operacji dostępnych jedynie w ramach pakietu. Zapewnianie publicznego dostępu do elem en tów projektowych nie jest szczególnym problemem w przypadkach, gdy są one prze znaczone do naszego prywatnego użytku, jednak jeśli będziemy udostępniać interfejs sze rokiej rzeszy odbiorców, korzystniejsze byłoby precyzyjne określenie dostępności operacji niż stosowanie rozwiązań, które ograniczają wprowadzanie zmian w oprogramowaniu. Istnieją dwa style określania nazw interfejsów, a wybór jednego z nich zależy od tego, jak pojm ujem y interfejsy. Interfejsy rozumiane jako klasy bez im plem entacji powinny być nazywane w taki sam sposób jak klasy (czyli z wykorzystaniem wzorców: prosta nazwa klasy bazowej oraz kwalifikowana nazwa klasy pochodnej). Rozwiązanie
K l a s a a b s t r a k c y jn a
to ma jednak pewną wadę. Otóż powoduje, że dobre nazwy zostają wykorzystane, jeszcze zanim dojdziemy do określania nazw klas. W razie utworzenia interfejsu o na zwie F ile im plem entująca go klasa musiałaby nosić taką nazwę jak A ctu a lF ile, C on creteF ile, a nawet (o zgrozo!) FileImpl (przy czym można by dodać albo przedro stek, albo końcówkę). Ogólnie rzecz biorąc, informacja, czy posługujemy się konkretnym obiektem, czy też obiektem abstrakcyjnym, ma duże znaczenie, natom iast znacznie mniej istotne jest to, czy obiekt abstrakcyjny został zaimplementowany jako klasa ba zowa, czy jako interfejs. Ten styl określania nazw sprzyja odłożeniu w czasie decyzji co do wykorzystania interfejsów lub klas bazowych i zapewnia nam możliwość zmiany decyzji, jeśli w przyszłości pojawi się taka konieczność. Czasami stosowanie prostych nazw w konkretnych klasach ma większe znaczenie dla kom unikacji niż ukrywanie faktu wykorzystania interfejsów. W takich przypad kach nazwy interfejsów poprzedzane są wielką literą „I”. Jeśli interfejs będzie nosił na zwę I F ile , to im plementującej go klasie można nadać prostą nazwę F ile .
Klasa abstrakcyjna Innym sposobem wyrażenia w języku Java rozróżnienia pomiędzy abstrakcyjnym in terfejsem i konkretną im plem entacją jest użycie klasy bazowej. Klasa bazowa jest abs trakcyjna pod tym względem, że w trakcie wykonywania programu można ją zastąpić dowolną klasą pochodną, niezależnie od tego, czy w jej deklaracji został użyty modyfi kator a b s tra c t, czy też nie. W ybór, czy zastosować klasy abstrakcyjne, czy interfejsy, zależy od dwóch czynni ków: zmian interfejsów oraz konieczności, by jedna klasa obsługiwała wiele interfejsów. Interfejsy abstrakcyjne muszą dawać możliwość wprowadzania dwóch rodzajów zmian: zm ian im plem entacji oraz zmian samego interfejsu. Interfejsy języka Java kiepsko so bie radzą ze zmianam i tego drugiego rodzaju. Jakakolwiek zmiana interfejsu wymaga bowiem odpowiedniego zmodyfikowania wszystkich im plem entacji. Jeśli interfejs b ę dzie powszechnie stosowany, to bez trudu może to doprowadzić do paraliżu istnieją cego projektu i pozwolić na jego ewolucję wyłącznie poprzez wykorzystanie interfej sów wersjonowanych. Ograniczenia te nie występują w przypadku klas abstrakcyjnych. Nowe operacje moż na dodawać do klasy abstrakcyjnej bez konieczności wprowadzania zmian w istnieją cych im plem entacjach, o ile tylko domyślna im plem entacja będzie konkretna. Jedynym ograniczeniem klas abstrakcyjnych jest to, że dana klasa może m ieć tylko jedną klasę bazową. Jeśli zatem chcem y m ieć możliwość zaprezentowania danej klasy w inny sposób, to musimy to zrobić, korzystając z interfejsów. Zastosowanie słowa kluczowego a b stra ct w deklaracji klasy informuje czytelników, że chcąc jej użyć, będą musieli ją zaimplementować. Jeśli istnieje jakakolwiek możliwość, by klasa bazowa hierarchii była użyteczna i pozwalała na tworzenie instancji, to należy utworzyć ją w taki sposób. Kiedy już wejdziemy na drogę abstrakcji, łatwo jest pójść zbyt daleko i tworzyć abstrakcje, które nigdy nie okażą się opłacalne. Dążenie do zapewnienia
39
40
r o z d z ia ł
5
K la sy
możliwości tworzenia instancji klasy bazowej zachęca do elim inacji abstrakcji, których zalety nigdy nie zrównoważą kosztów. Interfejsy oraz hierarchie klas nie są rozwiązaniami wzajemnie się wykluczającymi. N ic nie stoi na przeszkodzie, by stworzyć interfejs stwierdzający: „Oto, jak można sko rzystać z następujących funkcjonalności” oraz klasę bazową deklarującą: „A to jest je den ze sposobów im plem entacji tych funkcjonalności”. W takim przypadku w dekla racjach zmiennych należy korzystać z interfejsu, co zapewni możliwość wykorzystania nowej im plem entacji, jeśli w przyszłości będzie to konieczne.
Interfejs wersjonowany Co zrobić, kiedy musimy zm ienić interfejs, którego zm ienić nie można? Zazwyczaj ta ka sytuacja ma miejsce, gdy trzeba dodać do interfejsu jakąś operację. Ponieważ dodanie operacji spowoduje problemy we wszystkich istniejących im plem entacjach interfejsu, nie można tego zrobić. Niem niej jednak można zadeklarować interfejs rozszerzający ten istniejący i do niego dodać nową operację. Użytkownicy, którzy chcieliby skorzy stać z nowych funkcjonalności, mogą użyć nowego interfejsu, natomiast wszyscy po zostali mogą nawet nie mieć pojęcia, że taki interfejs istnieje. W każdym miejscu kodu, w którym będziemy chcieli użyć nowych operacji, konieczne będzie jawne sprawdze nie typu używanego obiektu i rzutowanie go do typu nowego interfejsu. Przeanalizujmy interfejs reprezentujący proste polecenie: interface Command { void run(); } Kiedy taki interfejs zostanie udostępniony i zastosowany tysiące razy, wprowadzanie w nim zmian stanie się bardzo kosztowne. Niem niej jednak, aby zapewnić możliwość cofnięcia modyfikacji, konieczne będzie wprowadzenie w nim odpowiednich zmian. Rozwiązanie tego problemu przy użyciu interfejsu wersjonowanego będzie miało na stępującą postać: interface ReversibleCommand extends Command { void undo(); } Dotychczasowe instancje interfejsu Command będą działały dokładnie tak samo jak wcześniej. Instancji interfejsu ReversibleCommand będzie można używać wszędzie tam, gdzie instancji interfejsu Command. Aby skorzystać z nowych operacji, konieczne będzie zastosowanie rzutowania w dół: Command recent= . . . ; i f (recent instanceof ReversibleCommand) { ReversibleCommand downcasted= (ReversibleCommand) recent; downcasted.undo(); }
O b ie k t w a r t o ś c io w y
Ogólnie rzecz biorąc, zastosowanie interfejsu in sta n ce o f zmniejsza elastyczność kodu, gdyż wiąże go z konkretnym i typami. Niem niej jednak w tym przypadku to rozwiązanie może być usprawiedliwione, ponieważ daje możliwość modyfikacji uży wanych interfejsów. Gdyby w grę wchodziło kilka różnych interfejsów, to ich użyt kownicy musieliby włożyć wiele pracy w obsłużenie wszystkich możliwych wariantów. Takie sytuacje mogą stanowić sygnał, że nadszedł czas, by przemyśleć projekt. Zamienne interfejsy są kiepskim rozwiązaniem trudnego problemu. Interfejsy nie zapewniają możliwości łatwego modyfikowania swojej struktury, choć pozwalają łatwo modyfikować im plem entacje. Jednak interfejsy będą ulegać zmianom , podobnie jak wszystkie inne elementy projektu oprogramowania. W szyscy uczymy się projektow a nia na podstawie im plem entacji oraz utrzymania programów. Różne interfejsy tworzą nowy język programowania, który przypomina Javę, ale podlega innym regułom. Tworzenie nowych języków to zupełnie inna zabawa, której reguły są znacznie trud niejsze niż przy pisaniu aplikacji. Niemniej jednak, jeśli znajdziemy się w sytuacji, która zmusza nas do rozszerzenia interfejsu, to warto wiedzieć, ja k to można zrobić.
Obiekt wartościowy C hoć obiekty o zmiennym stanie są jedynym sensownym sposobem pojmowania obli czeń, to jednak nie jest to jedyna istniejąca metoda. Matematyka była rozwijana przez stulecia jako sposób myślenia o sytuacjach, które można sprowadzić do abstrakcyjnego świata absolutnej prawdy i pewności — w nim stwierdzenia dotyczą prawd wiecznych. Stosowane obecnie języki programowania stanowią połączenie dwóch stylów. Używa ne w Javie tak zwane typy podstawowe należą (w większości) do świata matematyki. Kiedy powiększam liczbę o 1, używam do tego instrukcji matematycznych (może z wyjątkiem tego, że ktoś uznał, iż komputery mogą liczyć tylko do wartości 232 lub 264, a po ich przekroczeniu mają zacząć liczyć od nowa). Kiedy dodaję 1 do zm iennej, nie zmieniam jej wartości — tworzę nową wartość. Nie można bowiem zmienić wartości 0, co odróżnia ją od przeważającej większości obiektów, których stan można modyfikować. Taki funkcyjny styl programowania nigdy nie prowadzi do zmiany stanu, pozwala jedynie na tworzenie nowych wartości. Nadaje się do zastosowania, gdy znajdujemy się w (prawdopodobnie trwającej bardzo krótko) sytuacji statycznej, o której chcieli byśmy sformułować jakieś stwierdzenie lub pytanie. Kiedy sytuacja zmienia się w czasie, bardziej odpowiednim rozwiązaniem będzie wykorzystanie stanu. Jednak o niektó rych sytuacjach można myśleć na oba sposoby. Jak określić, który z nich będzie bar dziej użyteczny? Na przykład rysowanie obrazka można przedstawić jako zmiany stanu jakiegoś m e dium graficznego, takiego jak mapa bitowa. Ten sam obraz można także przedstawić w formie opisu statycznego (patrz rysunek 5.1). To, która z tych reprezentacji będzie bardziej użyteczna, zależy w pewnym stopniu od osobistych preferencji, lecz także od złożoności rysowanego obrazka oraz tego, jak często będzie on modyfikowany.
41
42
r o z d z ia ł
5
K la sy
Z w a r t o * graficzna
[*
dfawKecta^
]
5 ®
Rysunek 5.1. Grafika przedstawiona ja k o procedury i obiekty Interfejsy proceduralne są stosowane znacznie częściej niż interfejsy funkcjonalne. Jednym z problemów związanych ze stosowaniem interfejsów proceduralnych jest to, że dla znaczenia interfejsu bardzo ważna staje się kolejność wywoływanych procedur (choć niejednokrotnie nie jest to wyraźnie widoczne). Modyfikowanie takich programów jest kłopotliwe i trudne, gdyż w razie zmiany niejawnego znaczenia sekwencji wywołań nawet pozornie niewielkie zmiany mogą powodować niezamierzone konsekwencje. Całe piękno reprezentacji matematycznych polega na tym, że kolejność sekwencji rzadko kiedy ma znaczenie. Tworzymy świat, w którym możemy podawać absolutne, po nadczasowe stwierdzenia. Takie matematyczne mikroświaty warto tworzyć zawsze, gdy to tylko możliwe. Można nimi zarządzać przy wykorzystaniu obiektów o zmiennym stanie. Na przykład system księgowy można zaimplementować w taki sposób, że podsta wowe transakcje nie będą powodowały zm ian w artości matematycznych.
Wartość |
Wartość
Wartość Rysunek 5.2. Obiekty o zmiennym stanie odwołujące się do niezmiennych obiektów class Transaction { int value; Transaction(int value, Account cred it, Account debit) { this.value= value; credit.addC redit(this); debit.addD ebit(this); } int getValue() { return value; } } Po utworzeniu obiektu Transaction nie można już zm ienić żadnej z jego wartości. Co więcej, jego konstruktor jasno pokazuje, że wszystkie transakcje są przeprowadzane na dwóch kontach. Analizując taki kod, wiem, że nie muszę się martwić możliwością przypadkowego pom inięcia transakcji bądź zmianą wartości transakcji po jej prze prowadzeniu.
S p e c ja l iz a c ja
Aby zaimplementować takie obiekty przypominające stałe (czyli obiekty działające jak liczby całkowite, a nie jak pojem niki przechowujące stan, który może się zm ie niać), w pierwszej kolejności należy wyznaczyć granicę między światem stanu a świa tem wartości. W powyższym przykładzie obiekt Transaction jest wartością, natomiast obiekt Account zawiera zmienny stan. W przypadku obiektów mających przypominać wartości ich stan należy określać w konstruktorze, a w pozostałym kodzie obiektu nigdzie nie należy umieszczać instrukcji przypisania zmieniających stan ich pól. O peracje na takich obiektach zawsze zwracają nowe obiekty, które muszą zostać zapisane przez kod żądający wykonania operacji. bounds.translateBy(10, 20); // zmienny obiekt Rectangle bounds= bounds.translateBy(10, 20); // obiekt Rectangle zachowujący się jak wartość Najpoważniejszym argumentem przeciwko stosowaniu takich obiektów zachowu jących się jak wartości zawsze była wydajność działania. Konieczność tworzenia tych wszystkich obiektów pośrednich zawsze stanowiła poważne obciążenie systemów zarzą dzania pamięcią. Z punktu widzenia ogólnego kosztu programu argum ent ten często traci na znaczeniu, gdyż pod względem wydajności działania większość kodu programu nie stanowi wąskiego gardła. Innym i przyczynami, które mogą przemawiać przeciwko stosowaniu obiektów tego rodzaju, są nieznajomość stylu programowania oraz problemy z określeniem granicy między tymi częściami systemu, których stan się zmienia, a tymi, w których obiekty nie powinny się zmieniać. Najgorszym możliwym rozwiązaniem są obiekty, których stan niemalże się nie zmienia, gdyż ich interfejsy zazwyczaj są skompli kowane, a jednocześnie nie można przyjąć założenia, że ich stan nigdy się nie zmieni. Przypuszczam, że skoro już to sobie wyjaśniliśmy, warto by napisać znacznie wię cej na tem at programowania przy użyciu trzech wymienionych wcześniej stylów — obiektów, funkcji i procedur — i dokładniej wyjaśnić, ja k należy je efektywnie łączyć. Jednak na potrzeby tej książki ograniczę się jedynie do powtórnego przypomnienia, że czasami najbardziej zrozumiałe będzie wykorzystanie w programach kombinacji obiek tów o zmiennym stanie oraz obiektów reprezentujących wartości matematyczne.
Specjalizacja Odpowiednie wyrażanie wzajemnych oddziaływań podobieństw i różnic występujących pomiędzy obliczeniami ułatwia zrozumienie programu, korzystanie z niego oraz jego ewentualne modyfikowanie. W praktyce programy nie są unikalne. Wiele z nich wyraża podobne idee, a czasami zdarza się nawet, że te same idee są wyrażane przez wiele części jednego programu. Precyzyjne wyrażenie podobieństw i różnic pozwala czytelnikom zro zumieć istniejący kod, określić, czy jedna z istniejących jego odmian wyraża bieżące inten cje, a jeśli nie, to jak najlepiej go odpowiednio zmodyfikować lub napisać od nowa. Najprostszymi rodzajami odm ienności są te, w których różni się stan obiektów. Łańcuch znaków "abc" różni się od łańcucha "d ef". Algorytmy operujące na obu tych łańcuchach znaków są takie same. Na przykład w taki sam sposób jest wyznaczana długość łańcucha.
43
44
r o z d z ia ł
5
K la sy
W iększość złożonych odm ienności całkowicie różni się pod względem logiki. Pro cedura całkowania symbolicznego nie będzie miała nic wspólnego z procedurą m ate matycznego opisu czcionki, choć obie mogą m ieć takie same dane wejściowe. Pomiędzy tymi dwoma skrajnościam i — tą samą logiką operującą na różnych da nych oraz odmienną logiką operującą na tych samych danych — leży ogromny wspólny mianownik programowania. Dane mogą być w większości identyczne, lecz różnić się drobnymi szczegółami. Podobnie logika może być w przeważającej części iden tyczna, lecz różnić się w szczegółach. (Domyślam się, że procedury całkowania symbo licznego oraz matematycznego opisu czcionki nie m ają zbyt wiele wspólnego kodu). Nawet granica między logiką i danymi może być niejednoznaczna. Flaga jest wartością logiczną, jednak może wpływać na przepływ sterowania. Obiekt pom ocniczy może być przechowywany w polu, a jednocześnie wywierać wpływ na przebieg obliczeń. Przedstawione poniżej wzorce reprezentują sposoby wyrażania podobieństw i róż nic, przy czym głównie tych związanych z logiką. O dm ienności danych nie wydają się aż tak skomplikowane i subtelne. Efektywne wyrażanie podobieństw i różnic w logice otwiera nowe możliwości dalszego rozwoju oprogramowania.
Klasa pochodna Zadeklarowanie klasy pochodnej można uznać za stwierdzenie: „Te obiekty są podob ne do tamtych, z w yjątkiem ...”. Jeśli dysponujemy właściwymi klasami bazowymi, to tworzenie klas pochodnych może dawać ogromne możliwości przy tworzeniu opro gramowania. Jeśli możemy przesłonić odpowiednią metodę, to wystarczy kilka wier szy kodu, by utworzyć jego odmianę. W czasie gdy programowanie obiektowe stawało się popularne, tworzenie klas po chodnych wydawało się magicznym lekarstwem. Przede wszystkim klasy pochodne były używane do klasyfikacji, klasa Train była klasą pochodną klasy V ehicle, niezależnie od tego, czy obie miały jakiekolwiek wspólne fragmenty im plem entacji. W raz z upływem czasu zauważono, że dziedziczenie pozwala na współużytkowanie im plem entacji, za tem można je wykorzystać do wydzielania jej wspólnych fragmentów. Jednak równie szybko ujawniły się ograniczenia dziedziczenia. Po pierwsze jest to karta, którą można zagrać tylko raz. Jeśli zauważymy, że pewnego zbioru odmienności nie można właściwie wyrazić przy użyciu klasy pochodnej, to konieczne będzie poświęcenie pewnego nakładu pracy na uproszczenie kodu, zanim będzie można zm ienić jego strukturę. Po drugie, zanim będzie można zrozumieć klasę pochodną, należy zrozumieć klasę bazową. W raz ze wzrostem złożoności klas bazowych zadanie to staje się coraz trudniejsze. Po trzecie wszelkie zmiany klas bazowych są ryzykowne, gdyż klasy pochodne mogą bazować na ce chach ich im plementacji. I w końcu wszystkie te problemy potęgują się wraz ze zwięk szeniem głębokości hierarchii klas. Szczególnie szkodliwym sposobem wykorzystania dziedziczenia jest tworzenie równoległych hierarchii klas, w których dla każdej klasy pochodnej należącej do je d n e j hierarchii musi istnieć odpowiadająca jej klasa pochodna należąca do drugiej hierarchii.
K la sa p o c h o d n a
Jest to pewna forma powielania, która prowadzi do powstawania powiązań między obie ma hierarchiam i klas. W takich przypadkach, aby m óc wprowadzić nową odm ien ność, konieczne jest zmodyfikowanie obu hierarchii. c h o ć osobiście często spotykam się z takim i hierarchiam i równoległymi, w których sposób elim inacji powtórzenia nie zawsze jest od razu widoczny, to jednak podjęty w tym celu wysiłek może poprawić cały projekt kodu. Jednym z przykładów takich równoległych hierarchii klas jest system ubezpiecze niowy przedstawiony na rysunku 5.3. W tej hierarchii ewidentnie coś jest nie w po rządku, gdyż InsuranceContract nie może odwoływać się do PensionProduct, a przenie sienie pola produktu do klas pochodnych także nie wydaje się atrakcyjnym rozwiązaniem. Rozwiązaniem, do którego nigdy nie dotarliśmy, choć byliśmy blisko, było wprowa dzenie takiej odmienności, by klasa Contract działała tak samo, niezależnie od tego, czy została użyta w produkcie ubezpieczeniowym, czy emerytalnym. W ymagało to utwo rzenia nowego obiektu, reprezentującego odpowiedni przepływ gotówki (i przedsta wionego na rysunku 5.4).
Contract*
Product
Rysunek 5.3. Hierarchie równoległe
Rysunek 5.4. Hierarchia, w której wyeliminowano powtórzenia Jeśli będziemy pamiętać o tych wszystkich ostrzeżeniach, tworzenie klas pochodnych może być potężnym narzędziem do wyrażania obliczeń mogących występować w wielu różnych wariantach. Właściwa klasa pochodna może pomóc wielu osobom w wyrażeniu, przy użyciu jednej metody lub dwóch, dokładnie takiego obliczenia, o jakie im cho dziło. Jednym z kluczowych czynników warunkujących możliwość tworzenia użytecz nych klas pochodnych jest uważne rozdzielenie logiki w klasie bazowej na metody wy konujące dokładnie jedno zadanie. Tworząc klasę pochodną, należy mieć możliwość przesłonięcia dokładnie jednej metody. Jeśli metody klasy bazowej będą zbyt skom pli kowane, konieczne będą skopiowanie i edycja ich kodu (rysunek 5.5). Skopiowany kod wprowadza fatalne, jawne powiązanie między obiema klasami. Nie można bezpiecznie zm ienić kodu w klasie bazowej bez przeanalizowania i poten cjalnego zmodyfikowania wszystkich miejsc, do których został on skopiowany.
45
46
r o z d z ia ł
5
K la sy
Rysunek 5.5. Skopiowany i zmodyfikowany kod klasy pochodnej Kiedy projektuję oprogramowanie, m oim celem jest uzyskanie możliwości dowol nego zmieniania strategii w zależności od bieżących potrzeb kodu. Kod wyrażany przy użyciu konstrukcji warunkowych można przedstawiać, korzystając z klas pochodnych i delegacji. Czy można sądzić, że wykorzystanie innej strategii niż ta aktualnie używa na może przynieść korzyści? M ożna wykonać kilka kroków w tym kierunku i spraw dzić, jakie to da efekty. Ostatnim ograniczeniem dziedziczenia jest to, że nie pozwala ono na wyrażanie zmian w logice. Wszelkie odmienności muszą być znane w momencie tworzenia obiektu, a póź niej nie można ich już zmieniać. Do wyrażenia logiki, która może się zmieniać, ko nieczne będzie wykorzystanie konstrukcji warunkowych lub delegacji.
Implementator Podstawowym sposobem wyrażania warunków w programach obiektowych jest kom uni kat polimorficzny. Aby dawał on możliwość dokonania wyboru, musi istnieć więcej niż jeden rodzaj obiektów potencjalnie zdolnych do odebrania komunikatu. Wielokrotna implementacja tego samego protokołu, niezależnie od tego, czy będzie on wyrażony w formie interfejsu Javy i deklaracji używającej słowa kluczowego implements, czy też klasy pochodnej deklarowanej przy użyciu słowa kluczowego extends, może być rozumiana jako stwierdzenie: „Z punktu widzenia pewnego fragmentu obliczeń szczegóły tego, co się zdarzyło, nie mają znaczenia, o ile tylko zdarzenie to wyraża in tencje kodu”. Piękno takiego polimorficznego komunikatu polega na tym, że jest on otwarty na wszelkie zmiany systemu. Jeśli pewna część programu zapisuje bity w innym systemie, to wprowadzenie abstrakcyjnej klasy Socket pozwala na zmianę im plem entacji gniaz da (ang. socket) bez konieczności modyfikacji kodu, który z niej korzysta. W porównaniu z proceduralnym sposobem wyrażenia intencji, cechującym się wykorzystaniem jawnej i zamkniętej logiki warunkowej, metoda wyrażenia jej przy użyciu obiektu (kom uni katu) jest znacznie bardziej przejrzysta — zapewnia możliwość oddzielenia wyrażenia intencji (na przykład zapisania kilku bajtów) od jej im plem entacji (wywołania stosu TCP/IP z odpowiednimi param etram i). Jednocześnie wyrażenie pewnego obliczenia w formie obiektów i komunikatów zapewnia, że system będzie pozwalał na przyszłe rozszerzenia w stopniu, który wcześniej nawet trudno było sobie wyobrazić. To fanta styczne połączenie przejrzystości wyrazu oraz elastyczności jest głównym powodem, dla którego języki obiektowe stały się dom inującym paradygmatem programowania.
K l a sa w e w n ę t r z n a
To doskonałe rozwiązanie łatwo jednak zmarnować, tworząc w języku Java pro gramy proceduralne. W zorce przedstawione w tej książce m ają pomagać w przejrzy stym pisaniu kodu, zapewniającym dużą łatwość rozszerzania go.
Klasa wewnętrzna Czasami może się pojawić konieczność zgrupowania gdzieś pewnego fragmentu obli czeń, lecz nie będziemy przy tym chcieli narażać się na koszty tworzenia niezależnej klasy zdefiniowanej w odrębnym pliku. Zadeklarowanie niewielkiej, prywatnej klasy (klasy wewnętrznej) przynosi wiele korzyści, jakie dają klasy, a jednocześnie nie nara ża nas na wszystkie koszty związane z ich tworzeniem. Czasami klasa wewnętrzna dziedziczy wyłącznie po klasie O bject. Niektóre klasy wewnętrzne dziedziczą po innych klasach bazowych, co może być użyteczne, jeśli za leży nam na wyrażeniu usprawnień wprowadzonych w stosunku do innych klas, inte resujących wyłącznie w lokalnym kontekście. Jedną z interesujących cech klas wewnętrznych jest to, że do ich instancji w ukryty sposób są przekazywane kopie obiektów, w których zostały one utworzone. Rozwią zanie to jest bardzo wygodne, gdy chcem y skorzystać z danych obiektu zewnętrznego bez jawnego określania ich wzajemnej relacji: public class InnerClassExample { private String fie ld ; public class Inner { public String example() { return fie ld ; // wykorzystanie pola z obiektu zewnętrznego } @Test public void passes() { fi eld= "abc"; Inner bar= new Inner(); assertEquals("abc", bar.example()); } } Niem niej jednak konstruktor klasy wewnętrznej takiej jak ta przedstawiona powy żej nie jest w rzeczywistości konstruktorem bezargumentowym, choć właśnie tak zo stał zadeklarowany. Przysparza to problemów w przypadku tworzenia instancji klas wewnętrznych przy wykorzystaniu mechanizmów odzwierciedlania. public class InnerClassExample { public class Inner { public Inner() { } } @Test(expected=NoSuchMethodException.class) public void innerHasNoNoArgConstructor() throws Exception {
47
48
r o z d z ia ł
5
K la sy
Inner.class.getConstructor(new C lass[0]); } } Aby stworzyć klasę wewnętrzną całkowicie niezależną od klasy, wewnątrz której została umieszczona, należy ją zadeklarować przy użyciu modyfikatora s t a t ic .
Zachowanie zależne od instancji Teoretycznie wszystkie instancje tej samej klasy dysponują tą samą logiką. Złagodzenie tego ograniczenia zapewnia możliwość uzyskania nowych stylów przekazu. Wszystkie wiążą się jednak z określonymi kosztami. Kiedy logika obiektu jest całkowicie okre ślana przez jego klasę, czytelnicy mogą przeanalizować kod klasy i zrozumieć, co b ę dzie się działo. Kiedy jednak pojawią się instancje, których zachowanie będzie się różnić, to zrozumienie, jak zachowa się określony obiekt, będzie wymagało przeanalizowania konkretnego przykładu lub przepływu danych. Koszty korzystania z zachowań zależnych od instancji rosną jeszcze bardziej, kiedy logika zmienia się wraz z postępem obliczeń. Aby zapewnić łatwość analizy kodu, spo sób działania konkretnej instancji należy określać podczas tworzenia obiektu i już potem go nie zmieniać.
Konstrukcja warunkowa Najprostszymi form am i zachowania zależnego od instancji są instrukcje warunkowe if / e ls e oraz switch. Korzystając z nich, różne obiekty będą wykonywały różną logikę, zależnie od danych, jakim i dysponują. Konstrukcje warunkowe pojmowane jako for ma przekazu mają tę zaletę, że pozwalają, by cała logika była umieszczona w jednej klasie. Osoby przeglądające kod nie muszą zm ieniać plików, by przeanalizować różne możliwe ścieżki wykonywania obliczeń. Jednak konstrukcje warunkowe m ają tę wadę, iż jakiekolwiek modyfikacje wymuszają zmianę kodu obiektu. Każda ścieżka realizacji programu wiąże się z prawdopodobieństwem tego, że b ę dzie poprawna. Zakładając, że prawdopodobieństwo poprawności każdej ze ścieżek jest niezależne, im więcej ścieżek będzie istnieć w programie, tym mniejsze będą szanse na to, że będzie on poprawny. C hoć prawdopodobieństwo poprawności konkretnych ścieżek nie jest całkowicie niezależne, to jednak i tak programy, w których ścieżek jest więcej, są bardziej narażone na występowanie błędów niż programy, w których jest ich mniej. W zrost liczby konstrukcji warunkowych prowadzi do zm niejszenia niezawod ności kodu. Problem ten staje się jeszcze poważniejszy w chwili powielania konstrukcji warun kowych. Przeanalizujmy prosty program graficzny. W szystkie rysowane kształty będą wymagały metody d is p la y (): public void display() { switch (getTypeQ) { case RECTANGLE :
K o n s t r u k c ja w a r u n k o w a
//... break; case OVAL : / /... break; case TEXT : / /... break; default : break; } } Kształty będą także musiały dysponować metodą określającą, czy konkretny punkt znajduje się wewnątrz nich: public boolean contains(Point p) { switch (getType()) { case RECTANGLE : //... break; case OVAL : //... break; case TEXT : //... break; default : break; } } Załóżmy teraz, że chcem y dodać do programu nową figurę. W pierwszej kolejności trzeba dodać odpowiednią klauzulę do każdej instrukcji swi tch. Oprócz tego, by wprowa dzić taką zmianę, konieczne jest zmodyfikowanie klasy Figure, co stanowi ryzyko dla całej zaimplementowanej już funkcjonalności. I w końcu wszyscy, którzy chcą rysować nowe figury, będą musieli skoordynować swoje zmiany wprowadzone w jednej klasie. w szystkie te problemy można wyeliminować, zastępując logikę warunkową ko munikatami przez wykorzystanie w tym celu bądź klas pochodnych, bądź delegacji (przy czym to, która z tych technik będzie lepsza, zależy od konkretnego kodu). Powtarzają cą się logikę warunkową lub logikę, w której sposoby przetwarzania znacząco różnią się w zależności od wybranej gałęzi konstrukcji warunkowej, zazwyczaj lepiej jest wy rażać w form ie komunikatów niż w postaci jawnej. O prócz tego w postaci kom unika tów lepiej jest wyrażać logikę warunkową, która podlega częstym zmianom, gdyż po zwala to na uproszczenie wprowadzania zmian w wybranej gałęzi i minim alizację wpływu tych zmian na pozostałe gałęzie logiki. M ówiąc krótko, mocne strony konstrukcji warunkowych — ich prostota oraz lo kalny charakter — stają się problematyczne, jeśli będą wykorzystywane zbyt często.
49
50
r o z d z ia ł
5
K la sy
Rysunek 5.6. Logika warunkowa wyrażona w form ie klas pochodnych i delegacji
Delegacja Innym sposobem wykonywania odmiennej logiki w różnych instancjach klasy jest prze kazywanie pracy do jednego z kilku rodzajów obiektów. W tym przypadku wspólna logika jest umieszczona w klasie dokonującej wyboru i przekazującej prace do wyko nania, natom iast odm ienności zostają zlokalizowane w samych delegacjach. Przykładem wykorzystania delegacji do wydzielania odm ienności jest obsługa da nych wprowadzanych przez użytkownika w edytorze graficznym. Czasami kliknięcie przycisku może oznaczać „utwórz nowy prostokąt”, czasami „przesuń figurę” itd. Jednym ze sposobów wyrażenia takich odm ienności między narzędziami jest wy korzystanie logiki warunkowej: public void mouseDown() { switch (getTool()) { case SELECTING : //... break; case CREATING_RECTANGLE : //... break; case EDITING_TEXT : //... break; default : break; } } Takie rozwiązanie przysparza jednak wszystkich opisanych wcześniej problemów związanych z wykorzystaniem konstrukcji warunkowych: dodanie nowego narzędzia wymaga powielenia kodu, natomiast powielanie konstrukcji warunkowych (w metodach mouseUp(), mouseMove() itd.) sprawia, że dodawanie nowych narzędzi staje się trudne. Także wykorzystanie klas pochodnych nie będzie dobrym rozwiązaniem w tym przypadku, gdyż edytor musi dawać możliwość zmiany używanych narzędzi. O dpo wiednią elastyczność zapewnia wykorzystanie delegacji. public void mouseDown() { getTool().mouseDown(); }
D e l e g a c ja
W takim przypadku kod umieszczony wcześniej w poszczególnych klauzulach in strukcji switch może zostać przeniesiony do różnych narzędzi. M ożna także wprowa dzać nowe narzędzia, bez konieczności modyfikacji kodu samego edytora ani istniejących narzędzi. Jednakże nieco więcej zachodu będzie wymagała analiza kodu, gdyż logika obsługi kliknięcia jest umieszczona w kilku różnych klasach. Zrozumienie zachowania edytora będzie w tym przypadku wymagało znajomości aktualnie używanego narzędzia. Delegacje można przechowywać w polach (wówczas określa się je jako „obiekty dołą czane”) bądź tworzyć na bieżąco. JUnit 4 w taki dynamiczny sposób tworzy obiekt, który będzie wykonywał testy w danej klasie. Jeśli klasa zawiera testy zdefiniowane tradycyj nie, tworzona jest jedna delegacja, jeśli natom iast będzie ona zawierać testy nowego typu, utworzona delegacja będzie inna. Takie rozwiązanie stanowi połączenie logiki warunkowej (używanej do tworzenia delegacji) oraz delegacji. Delegacji można używać zarówno do współużytkowania kodu, jak i do tworzenia zachowań charakterystycznych dla konkretnych instancji. Obiekt przekazujący wyko nanie operacji do klasy Stream może wykazywać działanie charakterystyczne dla in stancji, o ile obiekt Stream może się zm ieniać w trakcie działania programu bądź też może współdzielić im plem entację typu Stream z innym i użytkownikami. Często stosowaną odmianą delegacji jest przekazywanie obiektu delegującego jako parametru metody mającej wykonać zadanie. G ra p h ic E d ito r
public void mouseDown() { tool.mouseDown(this); } RectangleTool
public void mouseDown(GraphicEditor editor) { editor.add(new RectangleFigure()); } Jeśli delegacja musi przekazać kom unikat sama do siebie, to mogą się pojawić wąt pliwości, o jaki obiekt chodzi. Czasami konieczne jest przekazanie obiektu do obiektu delegującego, a czasami do delegacji. Przedstawiona w poniższym przykładzie klasa Rectangl eTool dodaje nową figurę, lecz nie do siebie, a do obiektu GraphicEdi tor. Obiekt ten mógłby zostać przekazany jako parametr do delegacji, czyli metody mouseDown(), jednak w tym przypadku wygodniejszym rozwiązaniem było trwałe zapisanie referen cji w obiekcie RectangleTool. Przekazywanie obiektu G raphicEditor jako parametru pozwalałoby na używanie tego samego narzędzia (obiektu RectangleTool) w wielu edytorach, jeśli jednak taka możliwość nie ma wielkiego znaczenia, to zapisane refe rencji może być prostszym rozwiązaniem. G ra p h ic E d ito r
public void mouseDown() { tool.mouseDown(); }
51
52
r o z d z ia ł
5
K la sy
RectangleTool
private GraphicEditor editor; public RectangleTool(GraphicEditor editor) { this.editor= editor; } public void mouseDown() { editor.add(new RectangleFigure()); }
Selektor dołączany Załóżmy, że musimy skorzystać z zachowania zależnego od instancji, jednak wyłącznie w odniesieniu do jednej metody lub dwóch, a co więcej, nie zależy nam wcale na tym, by kod wszystkich niezbędnych różnic był umieszczony w tej samej klasie. W takim przypadku nazwę metody, którą należy wywołać, można zapisać w polu, a następnie wywołać ją przy wykorzystaniu mechanizmów odzwierciedlania. Początkowo każdy test wykonywany przez JU nit musiał być zapisywany w swojej własnej klasie (jak to zilustrowano na rysunku 5.7). Każda z klas pochodnych definiowała tylko jedną metodę. Trzeba przyznać, że jako sposób wyrażenia pojedynczej klasy ta kie rozwiązanie nie było szczególnie eleganckie.
Rysunek 5.7. Bardzo proste klasy pochodne reprezentujące różne testy Przy im plem entacji ogólnej metody runTest()poszczególne klasy pochodne klasy L is tT e s t dają możliwość wykonywania różnych metod testowych. Zakładamy, że na zwa testu jest także nazwą metody, którą należy pobrać i wywołać podczas testu. Oto prosta wersja kodu stanowiącego im plem entację selektora dołączanego i służącego do wykonywania testów. String name; public void runTest() throws Exception { Class[] noArguments= new Class[0]; Method method= getClass().getMethod(name, noArguments); method.invoke(this, new O bject[0]); } Uproszczona hierarchia klas pozwala na wykorzystanie tylko jednej klasy (przed stawionej na rysunku 5.8). Jak we wszystkich technikach kom presji tak zmodyfikowa ny kod będzie łatwiejszy do przeanalizowania wyłącznie w przypadku, gdy rozumiem y zastosowaną „sztuczkę”. Początkowo, gdy wzorzec selektora dołączanego zdobył popularność, program iści mieli tendencję, by go nadużywać. W tam tym czasie było prawdopodobne, że anali zując kod, mogliśmy dojść do wniosku, iż najpewniej nie m ógł on zostać wywołany,
A n o n im o w a k l a sa w e w n ę t r z n a
Rysunek 5.8. Zastosowanie selektora dołączanego pozwala umieścić testy w jednej klasie a w konsekwencji usunąć go i doprowadzić do awarii całego systemu, który gdzieś wykorzystywał wzorzec selektora dołączanego. Koszty związane ze stosowaniem tego wzorca są znaczące, jednak wykorzystanie go w ograniczonym zakresie w celu rozwią zania poważnego problemu może je usprawiedliwić.
Anonimowa klasa wewnętrzna Java udostępnia jeszcze jeden zam iennik dla zachowań charakterystycznych dla in stancji; są nią anonimowe klasy wewnętrzne. Ich idea polega na utworzeniu klasy używanej tylko w jednym miejscu i służącej do przesłonięcia co najmniej jednej metody używanej lokalnie. Ponieważ taka klasa jest używana tylko w jednym miejscu, można się do niej odwoływać niejawnie, a nie za pom ocą nazwy. Efektywne wykorzystanie anonimowych klas wewnętrznych bazuje na stosowaniu niezwykle prostych interfejsów programowania — takich ja k im plem entacja interfejsu Runnable z jego jedyną metodą run() — bądź też na wykorzystaniu klasy bazowej, która udostępni większość niezbędnych im plem entacji, dzięki czemu anonimowa klasa ba zowa będzie mogła być bardzo prosta. Kod anonimowej klasy wewnętrznej znacząco zaciemnia kod klasy, wewnątrz której jest umieszczony, dlatego też musi być krótki, by nie rozpraszał programistów, którzy będą go analizować. Pewnym ograniczeniem anonimowych klas wewnętrznych jest to, że kod umiesz czany w ich instancjach musi być znany podczas pisania klasy (co odróżnia je od delegacji, które można dodawać później), a po utworzeniu instancji nie można go już zmieniać. Oprócz tego bezpośrednie testowanie anonimowych klas wewnętrznych jest dosyć trud ne, przez co nie powinny one zawierać złożonej logiki. Poza tym, ponieważ nie mają nazwy, nie można ich wykorzystać do przekazania intencji, z jaką zostały stworzone.
Klasa biblioteczna Gdzie należy umieszczać funkcjonalności, które nie pasują do żadnego z obiektów w sys temie? Jednym z rozwiązań może być utworzenie odrębnej klasy, która będzie zawie rała wyłącznie metody statyczne. Nikt nie powinien tworzyć instancji takiej klasy. Słu ży ona wyłącznie do tego, by gromadzić funkcje w swoistej bibliotece. Choć klasy biblioteczne są dosyć często stosowane, to jednak nie zapewniają do brych możliwości skalowania. Umieszczenie logiki w metodach statycznych przekreśla
53
54
r o z d z ia ł
5
K la sy
największą zaletę programowania obiektowego: istnienie prywatnej przestrzeni nazw, w której można umieszczać dane i która pomaga uprościć logikę. Dlatego zawsze, gdy to tylko możliwe, należy starać się zastępować klasy biblioteczne norm alnym i obiektami. Czasami sprowadza się to jedynie do odnalezienia lepszego miejsca, w którym moż na umieścić metodę. Na przykład klasa biblioteczna C ollectio n udostępnia metodę s o r t ( L is t ) . Tak konkretnie określony parametr może stanowić podpowiedź sugeru jącą, że metoda ta powinna raczej należeć do klasy L ist. Inkrem entalny sposób konwersji klasy bibliotecznej na obiekt polega na prze kształcaniu metod statycznych w metody instancji. Początkowo można zachować ten sam interfejs, modyfikując metody statyczne w taki sposób, by wywoływały metody instancji. Na przykład przedstawioną poniżej metodę klasy Library: public s ta tic void method(... parametry . . . ) { . . . jakaś logika . . . } można zm ienić następująco: public s ta tic void method(... parametry . . . ) { new Library().instanceMethod(... parametry . . . ) ; } private void instanceMethod(... parametry . . . ) { . . . jakaś logika . . . } Jeśli kilka takich metod będzie miało podobne listy parametrów (a jeśli nie, to naj prawdopodobniej powinny należeć do innych klas), to parametry metody można przekształcić w parametry konstruktora: public s ta tic void method(... parametry . . . ) { new L ib rary (... parametry ...).instanceM ethod(); } private void instanceMethod() { . . . jakaś logika . . . } Następnie pozostaje już jedynie zm ienić interfejs, przenosząc utworzenie instancji nowej klasy do kodu korzystającego z logiki, i usunąć metody statyczne. public void instanceMethod(... parametry . . . ) { . . . jakaś logika . . . } Taki proces może także dostarczyć pomysłów na nową nazwę klasy oraz metod, tak by kod, w którym są one używane, był odpowiednio czytelny.
Wniosek Klasy gromadzą stan, na który składają się logicznie powiązane dane. W następnym roz dziale zostały przedstawione wzorce inform ujące o decyzjach związanych ze stanem.
Rozdział 6
Stan
W zorce przedstawione w tym rozdziale opisują, jak można kom unikować sposoby wykorzystania stanu. O biekty stanowią wygodne połączenie z a ch o w a ń udostępnia nych światu zewnętrznemu oraz stanu, który ma te zachowania wspierać. Jedną z zalet obiektów jest to, że dzielą cały stan programu na wiele małych elementów, z których każ dy można uznać za niezależny, mały komputer. Duże biblioteki stanu, używane bez skrępowania, dodatkowo utrudniają modyfikowanie kodu, gdyż trudno jest przewidzieć, jaki będzie wpływ wprowadzanych zmian na stan programu. Dzięki zastosowaniu obiek tów znacznie łatwiej można przeanalizować wpływ zmian na stan, gdyż zakres stanu, do którego można się odwołać, jest dużo mniejszy. W tym rozdziale zostały opisane następujące wzorce: ■
Stan (ang. State) — zapewnia możliwość wykonywania obliczeń z wykorzystaniem wartości, które zm ieniają się wraz z upływem czasu.
■
Dostęp (ang. A ccess) — zapewnia elastyczność poprzez ograniczenie dostępu do stanu.
■
Dostęp bezpośrednio (ang. D irect A ccess) — zapewnia bezpośredni dostęp do sta nu obiektu.
■
Dostęp pośredni (ang. In d irect A ccess) — opisuje dostęp do stanu przy wykorzy staniu metody, zapewniając tym samym większą elastyczność.
■
W spólny stan (ang. C o m m on State) — pozwala zapisywać stan wspólny wszystkim obiektom klasy w jej polu.
■
Stan zm ienny (ang. V ariab le State) — pozwala przechowywać w formie mapy stan, którego występowanie zmienia się w różnych instancjach.
■
Stan zewnętrzny (ang. Extrinsic State) — pozwala przechowywać w formie mapy stan o specjalnym przeznaczeniu; stan ten jest dołączany do obiektu, który go używa.
■
Zmienna (ang. V ariable) — zmienne stanowią przestrzeń nazw dla korzystania ze stanu. 55
56
r o z d z ia ł
■
6
S ta n
Zmienna lokalna (ang. L o c a l V ariable) — zmienna lokalna przechowuje stan prze znaczony do użycia w jednym zakresie.
■
Pole (ang. F ield) — pola przechowują stan przez cały okres istnienia obiektu.
■
Parametr (ang. P aram eter) — parametry informują o stanie podczas aktywacji kon kretnej metody.
■
Parametr zbierający (ang. Collecting P aram eter) — reprezentuje przekazanie parame tru służącego do gromadzenia złożonych wyników zwracanych przez wiele metod.
■
Obiekt parametru (ang. P a ra m eter O bject) — grupuje często używane, długie listy parametrów w formie jednego obiektu.
■
Stała (ang. C onstant) — zapisuje w formie stałej stan, który się nie zmienia.
■
Nazwa sugerująca znaczenie (ang. R ole-Suggesting N am e) — zaleca nadawanie zmiennym nazw określanych na podstawie funkcji, jaką pełnią w obliczeniach.
■
Zadeklarowany typ (ang. D eclared Type) — pozwala zadeklarować ogólny typ zmiennych.
■
Inicjalizacja (ang. In itialization ) — zaleca, by w możliwie jak największym stopniu inicjalizować zmienne deklaratywnie.
■
Inicjalizacja wczesna (ang. E ager in itialization ) — inicjalizuje pola w m om encie tworzenia instancji.
■
Inicjalizacja leniwa (ang. L azy initialization) — zaleca, by w przypadku pól, których inicjalizacja jest kosztowna, wykonywać ją dopiero wtedy, gdy jest to konieczne.
Stan Świat wciąż istnieje. Jeśli minutę temu słońce świeciło wysoko na niebie, można mieć pewność, że wciąż tam jest, choć nieco się przesunęło. Gdyby chciało mi się wykonać odpowiednie obliczenia, to na podstawie wcześniejszych obserwacji, znajom ości szyb kości obrotowej Ziem i oraz upływu czasu mógłbym nawet wyznaczyć jego położenie. Już dawno temu okazało się, że pojmowanie świata jako zbioru rzeczy podlegających zm ianom jest bardzo użyteczne. Rodowici Amerykanie w okolicach, w których miesz kam, zwykli na wiosnę obserwować szczyt góry Mt. McLaughlin. Kiedy śnieg stopnieje na tyle, że jest na nim widoczny zarys lecącego orła, oznacza to, że nadszedł czas, by zejść do rzeki Rogue River na wiosenne połowy łososia. Stan śniegu na górskim szczycie jest cenną wskazówką, informującą o dostępności pysznego posiłku w okolicznych wodach. Kiedy pionierzy ery komputerów wybierali metafory programistyczne, uczepili się tej idei stanu, który zmienia się wraz z upływem czasu. Ludzki umysł dysponuje wie lom a strategiami posługiwania się stanem, zarówno wrodzonymi, jak i wyuczonymi.
Dostęp Niem niej jednak z punktu widzenia programistów stan przysparza także pewnych problemów. Kiedy tylko przyjmiemy, czym jest pewien element stanu, nasz kod staje w obliczu zagrożenia. Może się bowiem okazać, że przyjęliśmy błędne założenie bądź że stan się zmieni. Gdyby nie istniało coś takiego jak stan, znacznie łatwiej byłoby tworzyć wiele, niezwykle pożądanych, narzędzi programistycznych, jak programy do automatycznej refaktoryzacji. I w końcu trzeba także zauważyć, że współbieżność i stan nie współpracują ze sobą zbyt dobrze. W iele problemów, których przysparza programowanie równoległe, zni ka, kiedy z kodu zostanie wyeliminowany stan. W funkcyjnych językach programowania problem zmiany stanu został całkowicie wyeliminowany. Żadne z nich nie zyskały jednak szerszej popularności. Uważam, że stan jest dla nas wartościową metaforą, gdyż ludzkie mózgi zostały ukształtowane i zapro gramowane do posługiwania się pojęciem zmiennego stanu. Sposoby programowania bazujące na pojedynczym przypisaniu lub całkowicie pozbawione zmiennych zmu szają nas do rezygnacji ze zbyt wielu efektywnych strategii rozumowania, by stanowiły atrakcyjny zamiennik. Języki obiektowe są doskonałą strategią operowania na stanie. Zapewniają możli wość uniknięcia problemu zmieniania stanu „za naszymi plecam i”, gdyż pozwalają podzielić stan systemu na wiele fragmentów, z których każdy dysponuje ściśle ograni czonym dostępem do innych. Znacznie łatwiej jest zarządzać kilkoma bajtami niż całymi mega- lub gigabajtami danych. C hoć problem niewłaściwego określenia jakiegoś stanu wciąż występuje, to jednak dzięki wykorzystaniu obiektów mamy możliwość szybkie go i dokładnego zweryfikowania wszystkich odwołań i zastosowania zmiennej. Kluczowym aspektem efektywnego zarządzania stanem jest zgrupowanie stanów, które są do siebie podobne, oraz odseparowanie tych, które podobne nie są. Istnieją dwie podpowiedzi mogące sugerować, że jakieś dwa elementy stanu są do siebie podobne. Pierwszą jest to, że oba elementy stanu są używane w tych samych obliczeniach; drugą — że są one tworzone i usuwane w tym samym czasie. Jeśli dwa elementy stanu są używane wspólnie i istnieją przez taki sam czas, to można przypuszczać, że przecho wywanie ich blisko siebie może być dobrym pomysłem.
Dostęp Pewnym rozdźwiękiem występującym w językach programowania jest rozróżnienie na dostęp do przechowywanej wartości oraz wywoływanie obliczeń. Każde z tych pojęć jest zrozumiałe w kontekście drugiego. Dostęp do pamięci przypomina nieco wywoływanie funkcji, która zwraca aktualnie przechowywaną wartość. Wywoływanie funkcji jest z kolei podobne do odczytywania obszaru pamięci, z tą różnicą, że jej zawartość jest wyko nywana, a nie zwracana bezpośrednio. Niemniej jednak używane przez nas języki pro gramowania oddzielają od siebie wywoływanie operacji oraz odwołania do pamięci, a to powoduje, że musimy mieć możliwość jasnego poinformowania innych o tych różnicach.
57
58
r o z d z ia ł
6
S ta n
Decyzje dotyczące tego, co zapisać, a co obliczyć, mają wpływ na czytelność, elastycz ność oraz wydajność działania programów. Czasami niektóre z tych celów są ze sobą sprzeczne i nie odpowiadają preferowanym przez nas sposobom programowania. Niekiedy zmienia się kontekst i uzasadniony niegdyś podział na to, co jest zapisywane, a co obliczane, przestaje być sensowny. Podejmowanie praktycznych decyzji w chwili obecnej i zachowanie elastyczności pozwalającej na zmianę opinii w przyszłości to kluczowe czynniki wpływające na tworzenie dobrego oprogramowania. To właśnie po trzeba przyszłych zmian sprawia, że tak ważne jest czytelne wyrażanie decyzji co do zapisywania i obliczania. Jednym z celów wprowadzenia programowania obiektowego było zarządzanie przechowywaniem inform acji. Każdy obiekt działa jak niewielki komputer, dysponu jący swoją własną pamięcią i do pewnego stopnia odseparowany od innych komputerów. Aktualnie używane języki programowania, w tym także Java, zacierają jednak granicę pomiędzy obiektami, umożliwiając tworzenie pól publicznych. Jednak łatwość dostę pu do informacji w innych obiektach nie jest warta utraty niezależności tych obiektów.
Dostęp bezpośredni Najprostszym sposobem stwierdzenia „pobieram dane” lub „zapisuję dane” jest zasto sowanie bezpośredniego dostępu do zm iennej: x= 10; Zaletą takiego bezpośredniego dostępu do zmiennej jest klarowność wyrażenia. Kiedy widzę kod x=10;, dokładnie wiem, co się stanie. Ta klarowność jest jednak możliwa kosztem elastyczności. Jeśli zapiszę wartość w zm iennej, to jest to jedyna rzecz, jaką mogę z nią zrobić. Gdybym zapisywał w tej zmiennej wartości z wielu m iejsc progra mu, to aby go zmodyfikować, musiałbym wprowadzić zm iany w każdej z tych części. Kolejną wadą dostępu bezpośredniego jest to, że stanowi on szczegół im plem enta cyjny, znajdujący się wśród zagadnień, na które nie zwracamy uwagi podczas progra mowania. Przypisanie pewnej zmiennej wartości 1 może powodować otworzenie bram y do garażu, jednak kod z tym szczegółem im plem entacyjnym niezbyt dobrze kom uni kuje swoje intencje. Porów najm y instrukcję: doorRegister= 1; z następującym wywołaniem: openDoor(); bądź z wywołaniem metody obiektu: door.open(); Podczas tworzenia kodu większość moich myśli nie koncentruje się na przechowywa niu inform acji. Zbyt powszechne wykorzystanie dostępu bezpośredniego zaciemnia przekaz. W tych fragmentach kodu, w których naprawdę myślę o tym, co gdzie zapisać,
Dostęp pośredni wykorzystuję dostęp bezpośredni, by odpowiednio wyrazić swoje myśli. Decyzje doty czące przechowywania danych mają różne znaczenie dla różnych programistów, dla tego też nie można wskazać jednej metody wykorzystania dostępu bezpośredniego, która wszystkim by pasowała. W ciąż podejmowane są próby sformułowania takich reguł: dostępu bezpośredniego można używać wyłącznie w akcesorach oraz, ewentualnie, w konstruktorach; dostępu bezpośredniego można używać wyłącznie w jednej klasie oraz, ewentualnie, w jej klasach pochodnych, a może w jednym pakiecie. Ale nie istnieje jedna, uniwersalna reguła. Program iści cały czas muszą myśleć, komunikować inten cje i się uczyć. To nieodłączne elementy bycia profesjonalistą.
Dostęp pośredni Dostęp do stanu oraz jego modyfikację można ukryć w metodach. Takie metody, na zywane akcesorami, zapewniają elastyczność kosztem przejrzystości i bezpośredniości. Klienci nie zakładają już, że pewne wartości są zapisywane bezpośrednio. Dlatego też programiści mogą zmieniać decyzje związane z przechowywaniem informacji bez wymu szania konieczności wprowadzania zmian w kodzie klientów. M oja domyślna strategia dostępu do stanu zezwala na stosowanie dostępu bezpo średniego w ramach klasy (w tym także w klasach wewnętrznych) oraz dostępu po średniego w kodzie klientów. Ma ona tę zaletę, że pozwala na stosowanie przejrzystego, bezpośredniego dostępu w większości odwołań do stanu. Uwaga: jeśli większość od wołań do stanu obiektu pochodzi spoza niego, może to sugerować występowanie ja kiegoś poważniejszego problemu w projekcie kodu. Inną strategią jest wykorzystanie wyłącznie dostępu pośredniego. Uważam jednak, że prowadzi to do utraty przejrzystości kodu. Zazwyczaj wszystkie metody akcesorów — pobierające oraz zapisujące — są bardzo proste. Czasami jest ich także znacznie więcej niż metod, które wykonują faktycznie użyteczne operacje, a to utrudnia analizę kodu. Jednak stosowanie metod akcesorów jest bardzo kuszące. Niekiedy zamiast m ę czyć się, próbując określić, gdzie należałoby um ieścić konkretne obliczenia, wygodniej jest zaimplementować je gdziekolwiek i użyć odpowiednich metod, by uzyskać infor macje o stanie niezbędne do ich działania. Jednym z oczywistych przypadków, kiedy należy zastosować dostęp pośredni, są sytuacje, gdy istnieje kilka powiązanych ze sobą danych. Czasami takie powiązanie jest bardzo bezpośrednie, jak w poniższym przykładzie: Rectangle void setWidth(int width) { this.width= width; area= width * height; } Czasami natom iast jest mniej bezpośrednie i wykorzystuje odbiorcę zdarzeń: Widget void setBorder(int width) { this.width= width; notify L isteners(); }
59
60
r o z d z ia ł
6
S ta n
Takie powiązanie nie jest atrakcyjne (łatwo można bowiem zapom nieć o zachowa niu sugerowanych ograniczeń), jednak może być najlepszą z dostępnych opcji. W ta kim przypadku dostęp bezpośredni będzie najlepszym rozwiązaniem.
Wspólny stan W iele obliczeń wykorzystuje te same elementy danych, chociaż ich w artości mogą być inne. Kiedy napotkamy takie obliczenia, należy o tym poinformować, deklarując w klasie stosowne pola. Na przykład obliczenia wykorzystujące punkty w kartezjańskim ukła dzie współrzędnych wymagają zastosowania odciętych i rzędnych. Ponieważ wszystkie punkty w takim układzie współrzędnych wymagają określenia tych wartości, najbar dziej zrozumiałym sposobem ich wyrażenia będzie użycie pól: class Point { int x; int y; } W arto porównać tę technikę ze wzorcem stanu zmiennego, w którym obiekty tej samej klasy potencjalnie mogą zawierać różne elementy danych. Zaletą wspólnego stanu jest to, że zapewnia przejrzystość kodu — wyraźnie widać, jakie dane są nie zbędne do utworzenia prawidłowo sformułowanego obiektu, na podstawie bądź to jego pól, bądź parametrów konstruktora. Osoba analizująca kod będzie chciała wiedzieć, co jest potrzebne w celu poprawnego użycia funkcjonalności obiektu, a wzorzec wspólnego stanu kom unikuje to jasno i precyzyjnie. Cały wspólny stan w obiekcie powinien mieć ten sam zakres i istnieć przez ten sam czas. Niekiedy kusi mnie, by utworzyć pole, które będzie używane wyłącznie przez pewien podzbiór metod obiektu bądź też jedynie w trakcie realizacji jednej metody. W ta kich przypadkach zawsze mogę poprawić kod, umieszczając dane w jakimś innym m iej scu, na przykład w obiekcie pomocniczym, lub przekazując je w formie parametru.
Stan zmienny Czasami może się okazać konieczne, by ten sam obiekt zawierał różne elementy da nych w zależności od sposobu, w jaki jest używany. Nie chodzi tu jedynie o inne war tości danych, lecz o zupełnie inne elementy danych przechowywane w obiektach tej samej klasy. Stan zmienny jest często przechowywany w form ie mapy, której klucze są nazwami elementów (wyrażonymi jako łańcuchy znaków lub wartości jakiegoś typu wylicze niowego), a w artości — przechowywanymi wartościam i danych. class FlexibleObject { Map
properties= new HashMap(); Object getProperty(String key) { return properties.get(key); }
S t a n z m ie n n y
void setProperty(String key, Object value) { properties.set(key, value); } } Stan zm ienny zazwyczaj jest znacznie bardziej elastyczny do stanu wspólnego. Jego podstawową wadą jest natomiast to, że nie pozwala na sprawne komunikowanie in tencji. Jakie elementy danych są niezbędne, by obiekt zawierający taki zmienny stan mógł prawidłowo funkcjonować? Na to pytanie można znaleźć odpowiedź wyłącznie dzięki uważnej analizie kodu oraz ewentualnie prześledzeniu realizacji metod. M iałem okazję analizować kod, w którym programiści nadużywali stosowania sta nu zmiennego. Dokładnie w każdym obiekcie danej klasy w mapie właściwości były używane te same klucze. Znacznie łatwiej byłoby mi analizować ten kod, gdyby te sa me inform acje zostały zadeklarowane w formie pól. D o sytuacji, w których zastosowanie stanu zmiennego wydaje się uzasadnione, należą przypadki, gdy stan jednego z pól stwarza konieczność zastosowania innych pól. Na przykład gdybym chciał użyć widżetu i jego fladze bordered przypisać wartość tru e , to jednocześnie musiałbym określić wartości pól borderWidth oraz borderColor. O takiej sytuacji można by poinform ow ać przy wykorzystaniu stanu zmiennego, ta kiego jak ten przedstawiony w górnej części rysunku 6.1.
Rysunek 6.1. Obramowanie widżetu wyrażone w form ie stanu zmiennego oraz stanu wspólnego To samo można także wyrazić w formie stanu wspólnego, jak pokazano w dolnej części rysunku. Jednak rozwiązanie wykorzystujące stan wspólny narusza zasadę, w myśl której wszystkie zmienne w obiekcie powinny mieć ten sam czas istnienia. Polim orfizm wy jaśnia tę sytuację. Jedna klasa może reprezentować stan, w którym widżet nie ma ob ramowania, a druga stan, w którym obramowanie jest używane. W takim przypadku klasa Bordered będzie miała wspólny stan reprezentujący param etry obramowania.
Rysunek 6.2. Obiekt pomocniczy upraszcza projekt
61
62
r o z d z ia ł
6
S ta n
Jeśli w kodzie będzie występować kilka zmiennych, których nazwy będą miały taki sam prefiks, może to stanowić sugestię, by stworzyć jakiś obiekt pomocniczy. Zawsze, kiedy to jest możliwe, należy używać stanu wspólnego. Stanu zmiennego należy używać w przypadkach, gdy jakieś pola obiektu będą lub nie będą potrzebne w określonych okolicznościach.
Stan zewnętrzny Czasami jakaś część naszego programu może potrzebować dostępu do stanu skojarzo nego z pewnym obiektem, który nie ma żadnego znaczenia dla całej reszty systemu. Na przykład inform acje o tym, gdzie obiekt jest zapisany na dysku, mogą być ważne dla m echanizmu zapewniania trwałości obiektów, lecz nie dla pozostałych fragm en tów kodu. Umieszczenie takich danych w polu byłoby sprzeczne z zasadą symetrii — wszystkie pozostałe pola obiektu są bowiem użyteczne dla całego kodu. Skojarzone z obiektem informacje specjalnego przeznaczenia należy przechowywać w tych m iejscach kodu, w których są używane, a nie w obiekcie. W powyższym przy kładzie mechanizm zapewniania trwałości obiektów może tworzyć mapę IdentityMap, której kluczami będą zapisywane obiekty, a wartościam i — inform acje o miejscu ich przechowywania. Jednym ze słabych punktów stanu zewnętrznego jest to, że znacznie utrudnia on kopiowanie obiektów. Powielenie obiektu korzystającego ze stanu zewnętrznego nie jest równie proste jak skopiowanie jego pól. W takim przypadku konieczne jest także odpowiednie skopiowanie całego stanu zewnętrznego, co w zależności od sposobu używania tego stanu może wymagać różnych rozwiązań. Kolejną wadą tego rozwiązania są trudności, jakich przysparza debugowanie obiektów korzystających ze stanu zewnętrz nego. Debuggery nie pokazują bowiem takiego stanu wraz z zawartością obiektu. Ze względu na te utrudnienia stan zewnętrzny jest stosowany sporadycznie, choć w razie konieczności okazuje się bardzo użyteczny.
Zmienna W języku Java do obiektów odwołujemy się przy użyciu zmiennych. Czytelnicy anali zujący kod muszą znać zakres, czas istnienia, przeznaczenie oraz typ zmiennych. Choć zostały wypracowane rozwinięte schematy umieszczania wszystkich tych inform acji w nazwach zmiennych, to jednak preferowane jest upraszczanie kodu poprzez stoso wanie prostych nazw. M ożna wyróżnić trzy typy zakresu zmiennych, czyli obszaru, w którym można się do nich odwoływać. Są to: zmienne lokalne — dostępne wyłącznie w bieżącym zakresie; pola — dostępne w całym obiekcie; oraz zmienne statyczne, które są dostępne we wszyst kich obiektach danej klasy. Zakres pól można dodatkowo określać za pom ocą modyfi katorów p u b lic, package (odpowiada on domyślnemu zakresowi, co zresztą jest nieco dziwne, gdyż jednocześnie jest on najrzadziej używany), p rotected oraz p riv ate.
Z m ie n n a l o k a l n a
Jeśli swobodnie korzystamy ze wszystkich kom binacji dostępu, to z punktu widze nia osoby analizującej kod ważne jest, by je przejrzyście rozróżniać w kodzie poprzez zastosowanie odpowiednich nazw. Jednocześnie, w celu uniknięcia powielania, należy używać przede wszystkim zmiennych lokalnych i pól, a jedynie sporadycznie pól sta tycznych ( s t a t i c ) oraz prywatnych (p riv a te). Dzięki wykorzystaniu takiego ograni czonego zbioru typów dostępu już sam kontekst wystarczy, by przekazać inform ację 0 tym, czy dana nazwa jest zm ienną lokalną, czy polem. Jeśli widoczna jest deklaracja, to chodzi o zmienną lokalną, a jeśli jej nie ma, to oznacza to, że nazwa reprezentuje pole. W ten sposób można uniknąć konieczności umieszczania w nazwach zmiennych inform acji o zakresie. A to sprawia, że stosowane w kodzie nazwy zmiennych będą jednolite i łatwe. W szystkie te rozważania zakładają, że dysponujemy możliwością po dzielenia kodu na niewielkie fragmenty; można ją uzyskać, stosując inne wzorce im plementacyjne, a przede wszystkim wzorzec metody złożonej. Czas istnienia zmiennych musi obejmować cały ich zakres. Pole obiektu może być ważne wyłącznie w czasie, gdy pewne metody będą umieszczone na stosie. Takie roz wiązanie byłoby okropne. Należy dołożyć wszelkich starań, by czas istnienia zm ien nych był w jak największym stopniu zbliżony do ich zakresu. Oprócz tego należy się starać także o to, aby inne zmienne zdefiniowane w tym samym zakresie miały taki sam czas istnienia. Do właściwego przekazania inform acji o typie zm iennej wystarcza jej deklaracja. Trzeba zadbać o to, by zadeklarowany typ zmiennej precyzyjnie i jasno przekazywał nasze intencje (patrz wzorzec: typ zadeklarowany). Jedynym wyjątkiem są zmienne zawierające wiele wartości (kolekcje) — ich nazwy powinny mieć liczbę mnogą. Z punktu widzenia osób analizujących kod to, czy zmienna zawiera jedną wartość, czy wiele wartości, ma duże znaczenie. Skoro zakres, czas istnienia oraz typ zmiennych można odpowiednio wyrazić in nymi sposobami, ich nazwy można wykorzystać do przekazania inform acji o roli, jaką zmienne odgrywają w obliczeniach. Dzięki jak największemu ograniczeniu inform acji przekazywanych przy użyciu nazw zmiennych mogą one być proste i czytelne.
Zmienna lokalna Zmienne lokalne są dostępne wyłącznie w m iejscu, w którym zostały zadeklarowane, aż do końca zakresu. Postępując zgodnie z zasadą, by dane w jak najmniejszym stopniu rozpraszać po kodzie, zmienne lokalne należy deklarować bezpośrednio przed uży ciem i to w możliwie najm niejszym zakresie. M ożna wskazać kilka najczęściej występujących ról, odpowiadających przyczynom 1 przeznaczeniu zmiennych lokalnych: ■
Gromadzenie — zm ienna gromadzi inform acje przeznaczone do późniejszego wy korzystania. Często zdarza się, że wartość takiej zm iennej jest zwracana jako wynik wywołania funkcji. W takich przypadkach warto nadać zm iennej nazwę r e s u lt lub r e s u lts .
63
64
r o z d z ia ł
■
6
S ta n
Licznik — są to zmienne służące do gromadzenia inform acji o liczbie innych obiektów.
■
W yjaśnienie — jeśli używamy jakiegoś skomplikowanego wyrażenia, to zapisując jego cząstkowe wyniki w zmiennych lokalnych, możemy pom óc innym zrozum ieć jego przeznaczenie: int top= . . . ; int left= . . . int height= . . . ; int bottom= . . . ; return new Rectangle(top, le f t , height, width); C hoć z punktu widzenia obliczeniowego nie jest to niezbędne, to jednak opi sowe nazwy zmiennych znacząco upraszczają ostatnie wywołanie, które, gdyby nie one, byłoby długim i złożonym wyrażeniem. Stosowanie zmiennych lokalnych w celu wyjaśnienia jakiegoś wyrażenia często sta nowi pierwszy krok na drodze do tworzenia metod pomocniczych. W takich przypad kach wyrażenie przypisywane zmiennej staje się zazwyczaj ciałem metody, a nazwa zmiennej sugeruje, jaką nazwę należy nadać metodzie. Czasami takie metody pomoc nicze są tworzone w celu uproszczenia metod, w których początkowo były umieszczo ne wyrażenia; w innych przypadkach mogą one służyć do eliminacji powtórzeń.
■ W ielokrotne wykorzystanie — jeśli wartość wyrażenia zmienia się, lecz tej samej wartości należy użyć więcej niż jeden raz, to można ją zapisać w zmiennej lokalnej. Na przykład kiedy w kilku obiektach chcemy użyć tego samego znacznika czasu, to nie możemy pobierać go niezależnie w każdym z obiektów: for (Clock each: getClocks()) each.setTime(System.currentTimeMillis()); Zamiast tego znacznik czasu należy zapisać w zm iennej lokalnej, która następ nie będzie użyta do każdego z obiektów: long now= System.currentTimeMillis(); for (Clock each: getClocks()) each.setTime(now); ■
Elem ent — kolejnym popularnym zastosowaniem zm iennych lokalnych jest prze chowywanie aktualnie przetwarzanego elementu kolekcji. W powyższym przykładzie each stanowi czytelną i zrozumiałą nazwę zmiennej lokalnej. Gdybyśmy chcieli zapy tać, co dokładnie oznacza to „każdy” (ang. each ), wystarczy spojrzeć na pętlę for. W przypadku pętli zagnieżdżonych w celu rozróżnienia zmiennych przecho wujących poszczególne przetwarzane elementy można dodać do nich nazwę kolekcji: broadcast() { for (Source eachSender: getSenders()) for (Destination eachReceiver: getReceivers()) }
P ole
Pole Zakres oraz czas istnienia pola odpowiadają zakresowi oraz czasowi istnienia obiektu, do którego to pole należy. Pola są przede wszystkim elem entami obiektów, dlatego też należy je deklarować na samym początku lub końcu klas. Deklaracje pól umieszczone na samym początku zapewniają ważny kontekst, z którego czytający mogą korzystać podczas analizy dalszego kodu klasy. Z kolei umieszczenie deklaracji na końcu klasy stanowi sygnał: „Najważniejsze jest zachowanie; dane są jedynie szczegółem implementa cji”. Choć pod względem filozoficznym zgadzam się ze stwierdzeniem, że w programach obiektowych ważniejsza jest logika niż dane, to jednak pomimo to analizę kodu wolę za czynać od przejrzenia deklaracji, i to niezależnie od tego, gdzie będą one umieszczone. Jedną z opcji deklarowania pól jest dodanie do nich modyfikatora f in a l. In for m uje on osobę analizującą kod, że po utworzeniu obiektu wartość danego pola się nie zmieni. Doskonale pamiętam, które z pól są sfinalizowane, a które nie, dlatego w swoim kodzie nie używam modyfikatora f in a l, by jawnie to deklarować. Uważam, że uzy skiwana w ten sposób przejrzystość przekazu nie jest warta wzrostu złożoności kodu. Niem niej jednak gdybym wiedział, że piszę kod, który po pewnym czasie będzie m o dyfikowany przez wiele innych osób, takie jawne odróżnienie pól o stałej wartości od pól, których wartości mogą się zmieniać, byłoby uzasadnione. W porównaniu ze zmiennymi lokalnymi ról, które mogą odgrywać pola, jest nieco m niej; kilka najczęściej stosowanych zostało przedstawionych na poniższej liście: ■
Dane pom ocnicze — takie pole zawiera referencję do obiektów używanych przez wiele metod obiektu. Jeśli obiekt jest przekazywany jako param etr wywołania wielu metod, to można się zastanowić nad zastąpieniem go polem, którego wartość będzie określana w kompletnym konstruktorze.
■
Flaga — pole będące flagą logiczną przekazuje kom unikat: „Ten obiekt może działać na dwa różne sposoby”. Jeśli wartość flagi jest ustawiana przy użyciu m eto dy, stanowi ona dodatkową inform ację: „ ...a jego działanie może się zm ieniać w trakcie istnienia obiektu”. Pola pełniące taką funkcję można z powodzeniem sto sować, jeśli są używane wyłącznie w kilku warunkach. Jeśli kod podejm ujący decy zję na podstawie flagi powtarza się kilkakrotnie, to można rozważyć zmianę roli pola na tę opisaną w kolejnym punkcie — strategię.
■
Strategia — jeśli chcemy wyrazić, że istnieje kilka sposobów realizacji pewnego fragmentu obliczeń wykonywanych przez obiekt, to w polu można zapisać służący do tego obiekt. Jeśli ten zmienny fragment zachowania nie będzie ulegać modyfika cjom w trakcie istnienia obiektu, to warto go określać w kompletnym konstrukto rze. W przeciwnym razie lepiej będzie go określać przy użyciu metody.
■
Stan — pola pełniące funkcję stanu przypominają te z poprzedniego punktu pod tym względem, że także one zawierają wydzielone fragmenty funkcjonalności obiektu. Różnica między nim i polega na tym, że pola stanu, kiedy już zostaną uaktywnione,
65
66
r o z d z ia ł
6
S ta n
same określają stan. Z kolei jeśli modyfikowane są pola strategii, zmiany takie wprowadzają inne obiekty. Analiza maszyn stanu im plementowanych w taki spo sób może być trudna, gdyż zarówno poszczególne stany, ja k i przejścia pomiędzy nim i nie są umieszczone w jednym miejscu. ■
Kom ponenty — te pola zawierają obiekty lub dane używane przez dany obiekt.
Parametr Pom ijając zmienne inne niż prywatne (pola i pola statyczne), jedynym sposobem przeka zywania między obiektam i inform acji o stanie jest wykorzystanie parametrów. P onie waż zmienne nieprywatne wprowadzają silne powiązanie między klasami oraz ponie waż takie powiązania mają tendencję do pogłębiania się wraz z upływem czasu, we wszystkich przypadkach, w których możliwe jest wykorzystanie zarówno parametrów, jak i pól statycznych, preferowane są parametry. Powiązania wprowadzane przez parametry są zazwyczaj słabsze od tych powstają cych przy stosowaniu trwałych odwołań między dwoma obiektami. Na przykład obli czenia wykonywane w strukturze drzewiastej czasami wymagają użycia węzła rodzica. Rezygnując z trwałego przechowywania referencji do rodzica (patrz rysunek 6.3) i przeka zując ją do metody, która jej wymaga, w postaci parametru, można zm niejszyć stopień wzajemnych powiązań między węzłami drzewa. Przykładowo rezygnacja z takiego trwałego zapisywania referencji do węzła rodzica stwarza możliwość, by poddrzewo stanowiło fragment kilku różnych drzew.
Rysunek 6.3. Struktura drzewiasta o silnych powiązaniach między węzłami, używająca wskaźników Jeśli wiele komunikatów przesyłanych z jednego obiektu do drugiego wymaga użycia tego samego parametru, to być może lepszym rozwiązaniem będzie trwałe skojarzenie te go parametru z obiektem. Parametry są bardzo słabymi nićmi wiążącymi obiekty ze sobą, jednak, jak pokazuje przykład Guliwera w krainie Liliputów, nawet tak wątłe połączenia mogą sprawić, że utracim y możliwość wprowadzania w obiekcie jakichkolw iek zmian. Rysunek 6.4 przedstawia pojedynczy parametr.
Server Rysunek 6.4. Pojedynczy param etr stanowi bardzo słabe powiązanie
Parametr zbierający Temu schematowi odpowiada następujący fragment kodu: Server s= new Server(); s .a (th is ); Jednak pięciokrotne powtórzenie tego samego parametru znacząco zwiększy po wiązanie między obiektami: Server s= new Server(); s.a (th is) s.b (th is) s .c (th is ) s.d (th is) s .e (th is )
Server
Rysunek 6.5. P ow tórzenie param etru zw iększa p ow iązan ie m iędzy obiektam i
W takim przypadku oba obiekty będą mogły lepiej działać niezależnie, jeśli para m etr zostanie zastąpiony wskaźnikiem: Server s= new Server(this) s .a () ; s.b () s .c () s.d() s .e () Rysunek 6.6. O dw ołanie osłabia p ow iązan ia m iędzy obiektam i
Parametr zbierający Obliczenia gromadzące wyniki wywołań wielu metod potrzebują jakiegoś sposobu scale nia tych wyników. Jednym z nich jest zwracanie z każdej z tych metod jakiejś wartości. Takie rozwiązanie dobrze się sprawdza, jeśli wartość jest prosta, na przykład jeśli jest nią liczba całkowita. Node
int size() { int result= 1; for (Node each: getChildren()) result+= each .size(); return resu lt; } Jeśli jednak scalanie wyników jest bardziej skomplikowane od zwyczajnego doda wania, to istnieje bardziej bezpośredni sposób przekazywania parametru umożliwiającego zbieranie wyników. Na przykład zastosowanie parametru zbierającego ułatwia linearyzację struktury drzewiastej: Node
a sL ist() { List results= new ArrayList();
67
68
r o z d z ia ł
6
S ta n
addTo(results); return resu lts; } addTo(List elements) { elements.add(getValue()); for (Node each: getChildren()) each.addTo(elements); } Przykładami nieco bardziej skomplikowanych parametrów zbierających są obiekty GraphicsContext, przekazywane wewnątrz drzewa elementów graficznych, oraz TestRe ^ s u l t , przekazywane w drzewie testów JUnit.
Parametr opcjonalny Niektóre metody mogą pobierać parametry, a jeśli parametr nie został przekazany, podawać jego wartość domyślną. W takich przypadkach wszystkie wymagane parametry powinny znaleźć się na początku listy parametrów, przed ewentualnymi param etram i opcjonalnym i. Takie rozwiązanie sprawia, że możliwie jak najwięcej parametrów jest takich samych, a parametry opcjonalne, jako alternatywa, są umieszczone na końcu. Przykładem zastosowania parametrów opcjonalnych są konstruktory klasy Server ^ S o c k e t. Najprostszy z nich nie wymaga podawania żadnych argumentów, dostępne są jednak inne wersje konstruktorów, z których jedna ma jeden param etr opcjonalny służący do określania numeru portu, a druga dwa parametry określające odpowiednio num er portu oraz długość kolejki żądań: public ServerSocket() public ServerSocket(int port) public ServerSocket(int port, int backlog) Języki programowania, które umożliwiają określanie wartości wybranych parametrów przy wykorzystaniu ich nazw, pozwalają na przedstawianie parametrów opcjonalnych bardziej bezpośrednio. Ponieważ jednak w Javie w artości parametrów są określane na podstawie ich położenia, to, czy parametr jest opcjonalny, czy nie, można wyłącznie wyrazić, posługując się pewną konwencją. Takie rozwiązanie jest nazywane przez nie których wzorcem parametrów teleskopowych, co ma stanowić analogię do zależności dalszych parametrów od tych znajdujących się przed nimi.
Zmienna lista argumentów Niektóre metody pozwalają na przekazywanie dowolnej liczby parametrów konkret nego typu. Najprostszym rozwiązaniem zapewniającym taką możliwość jest przeka zywanie parametru będącego kolekcją. Niem niej jednak w razie wykorzystania takiego rozwiązania kod wywołujący jest niepotrzebnie komplikowany poprzez wprowadze nie obiektu pośredniego — przekazywanej kolekcji:
O b ie k t p a r a m e t r ó w
Collection keys= new ArrayList(); keys.add(key1); keys.add(key2); object.index(keys); Ten problem występuje na tyle często, że w języku Java wprowadzono specjalny mechanizm służący do przekazywania w wywołaniu metody zmiennej liczby argu mentów. Jeśli metoda z powyższego przykładu zostanie zadeklarowana przy użyciu konstrukcji: m eto d a(C lass.. .
k lasy ), to w jej wywołaniu będzie można um ieścić do
wolną liczbę argumentów odpowiedniego typu: object.index(key1, key2); Trzeba przy tym pamiętać, że takie określenie zm iennej liczby argumentów musi być podane jako ostatni param etr metody. Jeśli oprócz niego metoda korzysta także z argumentów opcjonalnych, to muszą one zostać podane wcześniej.
Obiekt parametrów Jeśli w wielu metodach używana jest ta sama grupa parametrów, to można zastanowić się nad utworzeniem obiektu zawierającego pola odpowiadające wszystkim param etrom i zastąpieniem nim dotychczasowej grupy parametrów. Po zastąpieniu listy param e trów jednym parametrem obiektowym można spróbować odszukać fragmenty kodu używające wyłącznie pól tego obiektu i przekształcić je na jego metody. Na przykład w bibliotece graficznej języka Java często jest stosowane rozwiązanie polegające na reprezentowaniu prostokątów przy użyciu czterech niezależnych para metrów: x, y, width oraz height. Czasami parametry te są przekazywane w dół długiego łańcucha wywołań metod, co sprawia, że wynikowy kod jest dłuższy i znacznie trud niejszy do analizy, niż to konieczne. setOuterBounds(x, y, width, height); setInnerBounds(x + 2, y + 2, width - 4, height - 4); Jawne wyrażenie prostokąta w formie niezależnego obiektu sprawia, że kod będzie znacznie bardziej zrozumiały: setOuterBounds(bounds); setInnerBounds(bounds.expand(-2)); Wprowadzenie parametru obiektowego znacząco skróciło kod, umożliwiło jasne wyrażenie intencji i pozwoliło na wprowadzenie algorytmu powiększania i zmniejszania prostokąta — w przeciwnym razie algorytm ten musiałby być powielany we wszyst kich wymagających tego m iejscach kodu (co mogłoby być źródłem częstych błędów, gdyż bardzo łatwo zapomnieć, że w przypadku długości i wysokości wartość, o jaką prostokąt jest powiększany lub zmniejszany, musi zostać podwojona). W iele obiektów o bardzo dużych możliwościach powstało właśnie jako obiekty parametrów. C hoć podstawową przyczyną wprowadzania parametrów obiektowych jest popra wienie czytelności kodu, to dodatkowo stanowią one ważne m iejsce do umieszczania
69
70
r o z d z ia ł
6
S ta n
logiki. Spostrzeżenie, że te same dane pojawiają się wspólnie na kilku listach parametrów, jest ewidentnym sygnałem świadczącym o tym, że są one ze sobą silnie powiązane. Klasa wraz ze swoją ustaloną listą pól stanowi jasny i precyzyjny komunikat: „Tę grupę tworzą dane ściśle ze sobą powiązane”. Najczęściej wysuwanym argumentem przeciwko stosowaniu obiektów parametrów jest efektywność działania — przydzielanie pamięci dla tych obiektów zajm uje czas. W praktyce w większości przypadków nie będzie to stanowiło większego problemu. Jeśli rezerwacja pamięci dla obiektów stanie się wąskim gardłem programu, to można zrezy gnować ze stosowania obiektów parametrów i zastąpić je jawną listą parametrów. Kod, który najlepiej nadaje się do optymalizacji, jest czytelny, przemyślany i dobrze przetesto wany; stosowanie obiektów parametrów wydatnie przyczynia się do osiągnięcia celu.
Stałe Czasami w programie występują dane, które są używane w kilku m iejscach kodu, lecz których wartości nie zmieniają się. Jeśli wartości takich danych są znane w czasie kom pi lacji, można je zapisać w zmiennych zadeklarowanych jako statyczne i sfinalizowane ( s t a t i c f in a l) i odwoływać się do nich przez posłużenie się tymi zmiennymi. Bardzo często stosuje się rozwiązanie polegające na zapisywaniu nazw stałych wyłącznie wiel kim i literam i, co podkreśla, że nie są to zwyczajne zmienne. Jednym z czynników, które sprawiają, że stosowanie stałych ma tak wielkie zna czenie, jest to, że dzięki nim można uniknąć całej grupy problemów. Jeśli w kodzie uży wamy wartości 5, a później zdecydujemy się zm ienić ją na 6, to łatwo można pom inąć jedno z jej wystąpień. Jeśli wartość 5 będzie używana w dwóch niejawnych znaczeniach, na przykład „narysuj obramowanie” oraz „kolejne dane stanowią pakiet potwierdze nia”, to w takiej sytuacji próby zmiany wartości będą jeszcze bardziej podatne na wy stępowanie błędów. Jednak najważniejszym powodem przemawiającym za stosowaniem stałych jest możliwość wykorzystania ich nazw w celu poinform owania o znaczeniu wartości. Osoby analizujące kod znacznie łatwiej zrozum ieją stałą Color.WHITE niż wartość 0xFFFFFF. Co więcej, jeśli zmieni się sposób zapisu wartości koloru, to w ko dzie używającym stałych nie trzeba będzie wprowadzać żadnych zmian. Częstym zastosowaniem stałych jest wyrażanie zm ienności komunikatów wystę pujących w interfejsie. Na przykład aby wyśrodkować wyświetlany tekst, można by użyć wywołania o postaci setJu stific a tio n (Ju stifica tio n .C E N T E R E D ). Jedną z zalet tego stylu tworzenia interfejsów programowania aplikacji jest możliwość dodawania nowych wersji istniejących metod, bez powodowania błędów w używanym kodzie, który korzysta z ich dotychczasowych wersji. Jednak siła przekazu takich kom unika tów nie jest aż tak duża jak w przypadku stworzenia odrębnych metod reprezentujących każdą z istniejących odmienności. Wówczas kom unikat mógłby m ieć następującą po stać: ju s tify C e n te re d (). Interfejs, w którym wszystkie wywołania metod wykorzy stują stałe, można poprawić, tworząc oddzielne metody odpowiadające każdej stałej.
N a z w a s u g e r u ją c a z n a c z e n ie
Nazwa sugerująca znaczenie W jaki sposób określamy, jaką nazwę nadać zm iennej? Odpowiedź na to pytanie jest uzależniona od wielu sprzecznych ze sobą ograniczeń. Osobiście staram się, by nazwy w pełni przekazywały informacje o przeznaczeniu zmiennej, co często może sugerować, że nazwy te powinny być długie. A chciałbym stosować krótkie nazwy, które ułatwiłyby formatowanie kodu. Nazwy dużo częściej będą odczytywane niż wpisywane w kodzie, dlatego też należy je optymalizować pod względem czytania, a nie wpisywania. N ie m niej jednak nazwy powinny odzwierciedlać sposób wykorzystania danych przecho wywanych w zmiennych oraz ich rolę w wykonywanych obliczeniach. By zrozumieć przeznaczenie zmiennej, potrzebuję kilku informacji. Jakie jest przezna czenie wykonywanych obliczeń? W jaki sposób jest używany obiekt, do którego od wołuje się zmienna? Jakie są zakres oraz czas istnienia zmiennej? Jak często zmienna jest używana w kodzie? W iele schematów nazewniczych pozwala na umieszczanie tych inform acji w na zwach zmiennych. Jednak ja ich nie stosuję. Niby po co mam bezustannie informować kom pilator o typie zmiennej? Do tego przecież sprowadza się umieszczanie inform acji 0 typie w jej nazwie. M ogę sobie wyobrazić celowość takiego rozwiązania w językach, które w znacznie m niejszym stopniu zapobiegają błędom związanym z nieprawidło wym stosowaniem typów, takich jak język C. Jednak Java jest wyposażona w dosta tecznie dobre mechanizmy pozwalające na unikanie tego rodzaju błędów. Jeśli chcę poznać typ jednej ze zmiennych zawartych w kodzie, używane ID E szyb ko dostarczy mi takiej inform acji. Stosowanie krótkich, zwartych metod także ułatwia zorientowanie się, jakie jest przeznaczenie najczęściej stosowanych pól, zm iennych lo kalnych oraz parametrów. Kolejnym aspektem zmiennych, który koniecznie należy zrozumieć, jest ich zakres. Niektóre metodologie określania nazw zmiennych pozwalają na umieszczenie w nich inform acji o zakresie w formie prefiksu, na przykład fCount oznacza pole1, a lCount — zm ienną lokalną2. Jednak przekonałem się, że także w tym przypadku, pisząc odpo wiednio krótkie metody, rzadko kiedy mam problemy z określeniem prawidłowego zakresu zmiennych. Jeśli nie widzę deklaracji zmiennej wewnątrz metody, oznacza to, że najprawdopodobniej jest ona polem (innych technik używam, by eliminować ko nieczność stosowania większości pól statycznych). Dzięki temu nazwa zmiennej może służyć głównie do przekazania inform acji o roli, jaką ta zmienna odgrywa w kodzie. To z kolei sprawia, że nazwy te mogą być krótkie 1 bardziej przejrzyste. Jeśli określenie nazwy zmiennej przysparza m i większych pro blemów, to dzieje się tak głównie dlatego, że niezbyt dobrze rozum iem wykonywane obliczenia.
1 Litera „f” pochodzi od angielskiego słowa field oznaczającego pole — przyp. tłum. 2 Litera „l” pochodzi od angielskiego słowa local oznaczającego zmienną lokalną — przyp. tłum.
71
72
r o z d z ia ł
6
S ta n
W kodzie, który piszę, często pojawia się kilka nazw zmiennych: ■
r e s u lt — ta zmienna przechowuje obiekt, który zostanie zwrócony jako wynik wykonania funkcji;
■
each — ta zmienna przechowuje kolejne elementy przetwarzanej kolekcji (choć ostatnio coraz bardziej podoba mi się stosowanie liczby pojedynczej w nazwach kolekcji, na przykład: fo r (Node c h ild : g etC h ild re n ());
■
count — przechowuje wartości liczników. Jeśli w kodzie występuje kilka zmiennych, którym mógłbym nadać tę samą nazwę, to
dodaję do nich dodatkowe określenie, na przykład eachX oraz eachY czy też rowCount oraz columnCount. Czasami kusi mnie, by w nazwach zmiennych stosować skróty. Byłby to jednak przejaw optymalizacji wpisywania kosztem czytania kodu. A ponieważ nazwy zm ien nych nieporównanie częściej są odczytywane niż wpisywane, takie podejście nie jest opłacalne. Niekiedy kusi mnie także, by w nazwach zmiennych umieszczać kilka słów, co mogłoby sprawić, że byłyby one zbyt długie, by ich wpisywanie było wygodne. W ta kich przypadkach staram się analizować kontekst, w którym zmienna jest umieszczo na. zastanaw iam się, dlaczego potrzebuję tak wielu słów do opisania przeznaczenia tej zmiennej. Takie rozważania często pozwalają mi na uproszczenie projektu, a to na skrócenie nazw zmiennych. Podsumowując, nazwy zmiennej używam do przekazania inform acji o jej roli. Wszystkie inne istotne inform acje dotyczące zm iennej — jej czasu istnienia, zakresu oraz typu — zazwyczaj można wyrazić, korzystając z kontekstu.
Zadeklarowany typ Jedną z cech Javy oraz innych języków wykorzystujących typowanie pesymistyczne jest konieczność deklarowania typu zmiennych. Ponieważ zadeklarowanie typu jest konieczne, można go wykorzystać jako środek przekazu inform acji. Ten deklarowany typ można zatem dobrać tak, by dawał inform acje o sposobie wykorzystania zmiennej, a nie tylko o jej im plementacji. Deklaracja o postaci List members= new ArrayList() inform uje m nie, że zmienna members będzie używana jako lista. M ogę zatem oczekiwać, że w ko dzie pojawią się operacje jak g e t() oraz s e t ( ) , gdyż czynnikiem odróżniającym listy ( L is t) od kolekcji (C o llectio n ) jest właśnie dostęp do konkretnych elementów przy wykorzystaniu indeksów. Kiedy po raz pierwszy określałem ten wzorzec, bazowałem na dogmatach. Późnej jednak spróbowałem skorzystać ze ścisłej reguły, że wszystkie zmienne powinny być deklarowane w sposób możliwie jak najbardziej ogólny. o kazało się, że dodatkowy wysiłek związany z próbam i uogólnienia wszystkich typów nie był wart zachodu. Zda rzało się, że używana zm ienna była typu L is t, a w jakim ś m iejscu kodu próbowałem
I NICJALIZACJA
przekazać ją do wywołania metody wymagającej danej typu C o lle ctio n . Dla osób analizujących kod brak spójności pomiędzy deklaracjami był większym problemem niż brak precyzji, wynikający z deklarowania zmiennej jako typu L is t wszędzie tam, gdzie była ona używana. Obecnie wyraziłbym się nieco ostrożniej: w deklaracjach zmiennych i metod warto używać bardziej ogólnych typów, o ile jest to możliwe. Pewna utrata precyzji i ogólności w celu zachowania spójności jest rozsądnym kom promisem. Największą zaletą stosowania w deklaracjach możliwie jak najbardziej ogólnych typów jest to, że pozwalają one na zmianę konkretnej, używanej klasy podczas póź niejszych modyfikacji. Jeśli w deklaracji zmiennej użyję typu A rrayL ist, to nie będę mógł później zm ienić go na HashSet równie łatwo, ja k po użyciu typu C o llectio n . Ogólnie rzecz ujm ując, im dalej jest przekazywana pewna decyzja, tym mniejsze będą elastyczność i możliwości późniejszego wprowadzania zmian. Aby zachować elastycz ność, należy starać się udostępniać jak najm niej inform acji, i to w możliwie jak naj mniejszym zakresie. Stwierdzenie: „Zmienna members zawiera daną typu A rrayL ist” przekazuje więcej inform acji niż: „Zmienna members zawiera daną typu C o lle c tio n ”. Koncentracja na zapewnieniu przekazu jest dobrym podejściem pozwalającym na zachowanie elastyczności. Dobrze to widać na przykładzie deklarowanych typów. Kiedy stwierdzam, że zm ienna zawiera daną typu C o lle ctio n , wyrażam się precyzyj nie. Odpowiedni przekaz zapewnia największą elastyczność.
Inicjalizacja Przed rozpoczęciem pisania kodu należy zastanowić się, na co można liczyć. M ożli wość zrobienia precyzyjnych założeń umożliwia i ułatwia skoncentrowanie uwagi na określeniu tego, o czym trzeba wiedzieć. Stan zm iennych jest jednym z tych czynni ków, w przypadku których możliwość robienia założeń jest bardzo przydatna. Inicjalizacja jest procesem przypisywania zmiennym określonego stanu, zanim program za cznie z nich korzystać. Z inicjalizacją zmiennych wiąże się kilka zagadnień. Jednym z nich jest chęć za pewnienia, by inicjalizacja miała jak najbardziej deklaratywny charakter. Jeśli inicjalizacja i deklaracja zmiennej występują razem, to wszelkich inform acji na jej tem at b ę dzie można szukać w jednym miejscu. Kolejnym zagadnieniem jest wydajność. Jeśli inicjalizacja pewnych zmiennych jest bardzo kosztowna, to może się zdarzyć, że trzeba będzie je inicjow ać jakiś czas po ich utworzeniu. Na przykład w zintegrowanym śro dowisku programistycznym Eclipse w celu możliwie największego skrócenia czasu je go uruchamiania klasy są wczytywane tak późno, jak to tylko możliwe. Poniżej zostały przedstawione dwa wzorce inicjalizacji: wczesna i leniwa.
Inicjalizacja wczesna Jednym ze sposobów inicjalizacji jest określanie wartości zmiennych od razu w m o m encie ich tworzenia — podczas deklarowania zm iennej bądź tworzenia obiektu
73
74
r o z d z ia ł
6
S ta n
zawierającego zmienną (czyli: w deklaracji lub w konstruktorze). Jedną z zalet takiego podejścia jest gwarancja, że zmienne będą zainicjalizowane, zanim zaczniemy z nich korzystać. Jeśli to tylko możliwe, zmienne należy inicjalizować w deklaracjach. Dzięki temu osoby analizujące kod łatwo będą mogły zorientować się, jaki jest zadeklarowany typ zm iennej oraz faktyczny typ jej wartości. class Library { List members= new ArrayList(); } Jeśli pola obiektu nie mogą zostać zainicjalizowane w swoich deklaracjach, to nale ży je zainicjalizować w konstruktorze: class Point { int x, y; Point(int x, int y) { this.x= x; this.y= y; } } Inicjalizowanie wszystkich pól obiektu w tym samym miejscu, niezależnie, czy b ę dą to deklaracje, czy też konstruktor, cechuje się pewną symetrią. Niem niej jednak nawet zastosowanie obu tych stylów inicjalizacji nie powoduje większego zamieszania, o ile tylko obiekty nie są zbyt duże.
Inicjalizacja leniwa o p isan a wcześniej inicjalizacja leniwa zdaje egzamin, kiedy nie zwracamy uwagi na koszty związane z określaniem w artości zmiennych podczas ich tworzenia. Jeśli obli czenia takie są kosztowne i nie chcem y tych kosztów ponosić (na przykład dlatego, że zm ienna może w ogóle nie zostać użyta), lepszym rozwiązaniem będzie utworzenie metody, która będzie zwracać wartość pola i inicjalizować ją w m om encie pierwszego wywołania: Library.Collection getMembers() { i f (members == null) members= new ArrayList(); return members; } Inicjalizacja leniwa była niegdyś techniką stosowaną znacznie częściej. W ynikało to z faktu, że wcześniej ograniczenie m ocy przetwarzania znacznie częściej było pro blemem. Ten sposób inicjalizacji nabiera znaczenia, gdy m oc obliczeniowa komputera jest zasobem podlegającym ograniczeniom. Środowiska, w których zasoby są ograni czone, takie jak Eclipse, korzystają z inicjalizacji leniwej, by unikać wczytywania wty czek aż do momentu, gdy będą one potrzebne.
I NICJALIZACJA LENIWA
Analiza pola inicjalizowanego w taki sposób jest nieco trudniejsza niż pól, których w artości są podawane w deklaracji lub konstruktorze. Osoba analizująca kod będzie musiała zajrzeć w dwa miejsca, zanim będzie mogła określić typ pola używany w im plementacji. Tw orząc kod programu, zapisujemy inform acje przeznaczone dla osób, które w przyszłości będą go analizować. Na szczęście istnieje ograniczona liczba czę stych pytań, które można zadawać, dlatego też na większość z nich można odpowie dzieć, używając jedynie kilku technik. Wykorzystanie inicjalizacji leniwej jest kom u nikatem: „W tym przypadku najważniejsza jest wydajność działania”.
Wniosek W zorce stanu łączą się ze sposobami przekazywania inform acji o decyzjach związa nych z reprezentacją stanu programu. W zorce opisane w następnym rozdziale stano wią drugą stronę medalu — pokazują, jak wyrażać decyzje związane ze sterowaniem przepływem.
75
76
rozdział 6
Stan
Rozdział 7
Zachowanie
John von Neumann wprowadził jedną z najważniejszych metafor programowania komputerów — sekwencję instrukcji wykonywanych kolejno jedna po drugiej. To właśnie dzięki niej mogły powstać języki programowania, w tym także Java. Ten roz dział jest poświęcony sposobom przekazywania inform acji o zachowaniu programów. Zostały w nim opisane następujące wzorce: ■
Przepływ sterowania (ang. C on trol Flow ) — wyraża obliczenia jako sekwencję czynności.
■
Przepływ główny (ang. M ain Flow ) — precyzyjnie wyraża główny przepływ stero wania.
■
Kom unikat (ang. M essage) — wyraża przepływ sterowania w formie wysyłania komunikatów.
■
Kom unikat wybierający (ang. C hoosin g M essage) — zmienia im plem entujące ko munikaty w celu wyrażenia wyboru.
■
Podwójne przydzielanie (ang. D o u b le D ispatch) — zmienia elementy im plem en tujące kom unikat względem dwóch osi, wyrażając w ten sposób wybór kaskadowy.
■
Kom unikat dekom ponujący (ang. D ecom p osin g M essage) — dzieli złożone obli czenia na spójne fragmenty.
■
Kom unikat odwracający (ang. R eversin g M essage) — zapewnia symetrię przepływu sterowania poprzez wysyłanie sekwencji komunikatów do tego samego odbiorcy.
■
Kom unikat zapraszający (ang. Invitin g M essage) — zachęca do wprowadzania przyszłych zmian poprzez wysyłanie komunikatu, który może być im plementowa ny na różne sposoby.
■
Kom unikat wyjaśniający (ang. E xplain in g M essage) — wysyła kom unikat wyja śniający przeznaczenie pewnego fragmentu logiki.
77
78
r o z d z ia ł
■
7
Z a c h o w a n ie
Przepływ wyjątkowy (ang. E xception al Flow ) — jak najbardziej precyzyjnie wyraża wyjątkowy przepływ sterowania, bez zakłócania głównego przepływu sterowania.
■
Klauzula strażnika (ang. G u ard Clause) — wyraża lokalny przepływ wyjątkowy poprzez wcześniejsze zakończenie wywołania.
■
W yjątek (ang. E xception) — wyraża nielokalne przepływy wyjątkowe, używając w tym celu wyjątków.
■
W yjątek sprawdzany (ang. C h ecked E xception) — wymusza przechwytywanie wy jątków poprzez ich jawne deklarowanie.
■
Propagacja wyjątków (ang. E xception P ropag ation ) — przekazuje wyjątki, prze kształcając je w razie konieczności tak, by dostępne w nich inform acje były odpo wiednie dla kodu, który będzie je obsługiwać.
Przepływ sterowania Dlaczego przepływ sterowania pojawia się we wszystkich programach? Istnieją języki programowania, takie jak Prolog, w których nie istnieje jawne pojęcie przepływu ste rowania. W programach pisanych w takich językach wszystkie elementy logiki są wy mieszane i czekają na zaistnienie odpowiednich warunków, by mogły się uaktywnić. Java należy do rodziny języków programowania, w których sekwencja kontroli jest podstawową zasadą organizacji kodu. Sąsiadujące ze sobą instrukcje są wykonywane kolejno, jedna po drugiej. Warunki sprawiają, że dany kod będzie wykonywany tylko w określonych okolicznościach. Pętle pozwalają na cykliczne wykonywanie fragmentu kodu. W ysyłanie komunikatów powoduje aktywację jednej z kilku podprocedur. W y jątki umożliwiają przeskakiwanie do kodu położonego w górze stosu. Wszystkie te m echanizm y sumują się, tworząc wspólnie bogate medium służące do wyrażania sposobu działania obliczeń. Jako autorzy (program iści) decydujemy, czy przepływ sterowania, który sobie wyobrażamy, zostanie wyrażony jako jeden prze pływ główny, wiele przepływów dodatkowych, z których każdy ma równie duże zna czenie, czy też jako pewna kom binacja obu powyższych rozwiązań. Grupujemy po szczególne elementy przepływu sterowania, tak by przeciętny odbiorca mógł je od razu zrozumieć na dosyć abstrakcyjnym poziomie, dodając jednocześnie więcej szcze gółów dla tych, którzy będą chcieli je zrozum ieć dokładniej. Grupowanie to czasami przybiera postać procedur umieszczanych w klasach, a czasami przekazywania stero wania do innych obiektów.
Przepływ główny Program iści zazwyczaj wiedzą, jak ma wyglądać główny przepływ sterowania w two rzonych programach. Przetwarzanie rozpoczyna się i kończy w precyzyjnie określonych miejscach. W jego trakcie mogą być podejmowane decyzje lub występować wyjątki,
Komunikat niem niej jednak obliczenia mają ściśle wytyczoną ścieżkę, po której przebiega ich re alizacja. Jest ona jasno wyrażana przy użyciu wybranego języka programowania. W przypadku niektórych programów, zwłaszcza tych, które mają działać nieza wodnie w niesprzyjających okolicznościach, przepływ główny nie jest widoczny. Jed nak takie programy spotyka się raczej sporadycznie. Wykorzystanie dużych możliwości wyrazu używanego języka programowania w celu informowania o rzadko wykonywa nych i modyfikowanych faktach dotyczących naszego programu sprawia, że mniej za uważalne stają się jego ważniejsze fragmenty: te, które będą często analizowane, rozu miane i zmieniane. Nie chodzi o to, że takie wyjątkowe warunki nie mają znaczenia, lecz o to, że dużo cenniejsze jest skoncentrowanie uwagi na precyzyjnym i jasnym wyraża niu głównego przepływu obliczeń. Dlatego też ten główny przepływ programu należy wyrażać przejrzyście, a wyjątków oraz klauzul strażników używać jedynie w sytuacjach niezwykłych oraz przy błędach.
Komunikat Jednym z podstawowych sposobów wyrażania logiki w języku Java są komunikaty. W proceduralnych językach programowania m echanizm em służącym do ukrywania inform acji są wywołania procedur: compute() { input(); process(); output(); } Powyższa procedura przekazuje następujące inform acje: „W celu zrozumienia tych obliczeń wystarczy wiedzieć, że składają się one z trzech kroków; aktualnie wszelkie pozostałe szczegóły nie mają znaczenia”. Jednym z najpiękniejszych aspektów pro gramowania obiektowego jest to, że ta sama procedura wyraża także znacznie więcej. Dla każdej metody istnieje potencjalnie cały zbiór obliczeń o podobnej strukturze, różniących się jedynie szczegółami. A dodatkową korzyścią jest to, że tworząc nie zm ienny fragment obliczeń, nie trzeba zwracać uwagi na wszystkie szczegóły ewentu alnych przyszłych odmian. Wykorzystanie komunikatów jako głównego mechanizmu przepływu sterowania sprawia, że zmiany są podstawową cechą programów. Każdy komunikat jest potencjal nym miejscem, w którym można coś zmienić bez konieczności modyfikowania kodu bę dącego źródłem tego komunikatu. Procedury opierające się na idei komunikatów nie stwierdzają: „Tam coś się dzieje, ale szczegóły nie są na razie istotne”; zamiast tego komu nikują: „W tym miejscu naszej historii z danymi wejściowymi dzieje się coś interesującego. Szczegóły tego, co się dzieje, mogą się jednak zmienić”. Rozważne korzystanie z tej ela styczności, jasne i bezpośrednie wyrażanie logiki zawsze, gdy to jest możliwe, i odpowied nie opóźnianie przedstawiania szczegółów to bardzo ważne umiejętności, przydatne, gdy zależy nam na pisaniu programów, które efektywnie wyrażają nasze intencje.
79
80
r o z d z ia ł
7
Z a c h o w a n ie
Komunikat wybierający Czasami wysyłam kom unikat służący do wyboru im plem entacji, podobny do sposobu, w jaki w językach proceduralnych działa instrukcja wyboru switch. Na przykład aby wyświetlić jakiś element graficzny na jeden z kilku dostępnych sposobów i zaznaczyć, że wybór może zostać dokonany w trakcie działania programu, można skorzystać z ko munikatu polimorficznego. public void displayShape(Shape subject, Brush brush) { brush.display(subject); } Komunikat d isp lay () wybiera implementację na podstawie faktycznego typu, który w trakcie działania programu przyjmie parametr brush. Dzięki temu później będzie m oż na zaimplementować różne typy parametrów brush: ScreenBrush, PostscriptBrush itd. Swobodne korzystanie z komunikatów wybierających prowadzi do powstawania kodu, w którym występuje niewiele jawnych warunków. Stosowanie tych komunikatów jest zaproszeniem do późniejszego wprowadzania rozszerzeń. Każdy jawnie podany warunek jest punktem, w którym, w razie chęci zmiany działania całego programu, ko nieczne będzie wprowadzenie jawnych modyfikacji. Analiza kodu, który w dużym stopniu korzysta z komunikatów wybierających, wymaga nabycia doświadczenia i um iejętności. Jedną z wad stosowania takich kom u nikatów jest to, że zrozumienie konkretnej ścieżki działania programu może wymagać przeanalizowania kilku klas. Twórcy kodu mogą jednak upraszczać życie innym, nadając m etodom nazwy inform ujące o ich przeznaczeniu. Dodatkowo należy także zwracać uwagę na to, kiedy stosowanie komunikatów wybierających będzie przesadą. Jeśli w wy konywanych obliczeniach nie może się nic zmieniać, to nie należy tworzyć metody tylko po to, by zapewnić możliwość wprowadzenia ewentualnej zmiany.
Dwukrotne przydzielanie Kom unikaty wybierające dobrze nadają się do wyrażania jednego wymiaru zm ienno ści. W przykładzie podanym w poprzednim podrozdziale wymiarem tym był rodzaj medium, na którym był rysowany kształt. Jeśli jednak konieczne jest wyrażenie dwóch niezależnych wymiarów zm ienności, to można to osiągnąć, tworząc kaskadę składają cą się z dwóch komunikatów wybierających. załóżm y przykładowo, że należy w jakiś sposób wyrazić, iż postscriptowy owal jest obliczany inaczej niż prostokąt wyświetlany na ekranie. W pierwszej kolejności nale żałoby określić, gdzie mają się znaleźć obliczenia. W ydaje się, że podstawowe oblicze nia należą do klasy Brush, dlatego też kom unikat wybierający w pierwszej kolejności zostanie wysłany do klasy Shape, a dopiero potem do klasy Brush: displayShape(Shape subject, Brush brush) { subject.displayWith(brush); }
Komunikat dekomponujący (sekwencjonujący) Wówczas każdy typ Shape ma możliwość zaimplementowania metody displayW ith() w odmienny sposób. Jednak zamiast wykonywać jakieś szczegółowe operacje, dodają one jedynie do komunikatu swój typ i przekazują działanie do klasy Brush: Oval.displayWith(Brush brush) { brush.displayOval (th is ); } Rectangle.displayWith(Brush brush) { brush.displayRectangle(this); } Dzięki temu do poszczególnych typów pochodnych klasy Brush przekazywane są inform acje niezbędne do prawidłowego działania: PostscriptBrush.displayRectangle(Rectangle subject) { writer p rin t(su b je c t.le ft() +" "+ ...+ re ct); } Takie rozwiązanie wprowadza pewne powtórzenia, którym towarzyszy utrata ela styczności. Nazwy typów, do których są przekazywane pierwsze komunikaty wybierające, zostają bowiem podane w kodzie metod obsługujących drugi kom unikat wybierający. W powyższym przykładzie oznacza to, że aby dodać nowy kształt — klasę pochodną Shape — konieczne będzie dodanie odpowiednich metod do wszystkich klas pochod nych klasy Brush. Jeśli wiadomo, że prawdopodobieństwo zmienienia się jednego wy miaru jest większe niż drugiego, to ten pierwszy wymiar powinien się stać odbiorcą drugiego komunikatu. Naukowiec, który czasami się we mnie odzywa, chciałby uogólnić to rozwiązanie i stworzyć wzorzec przydzielania trzykrotnego, czterokrotnego itd. Niemniej jednak udało mi się jedynie wypróbować rozwiązanie przydzielania trzykrotnego, lecz nawet ono nie zdało egzaminu na dłuższą metę. Zawsze znajdowałem bardziej przejrzyste i zrozum iałem sposoby wyrażania logiki wielowymiarowej.
Komunikat dekomponujący (sekwencjonujący) Kiedy pisany program wykorzystuje złożony algorytm, na który składa się wiele kroków, to czasami można pogrupować kroki powiązane ze sobą i wykonywać je, wysyłając je den komunikat. Taki kom unikat jest tworzony w celu zapewnienia możliwości wpro wadzania specjalizacji lub jakichkolwiek bardziej wyszukanych rozwiązań. Stanowi on odpowiednik staromodnej dekompozycji funkcjonalnej. Komunikat jest w tym przypad ku stosowany tylko po to, by wywołać podsekwencję kroków umieszczoną w procedurze. Kom unikat dekomponujący musi nosić odpowiednią, opisową nazwę. W iększość osób analizujących kod powinna uzyskać wszystkie inform acje konieczne do określe nia przeznaczenia procedury wyłącznie na podstawie jej nazwy. Konieczność prze analizowania całego kodu wywoływanego przez ten kom unikat powinna dotyczyć je dynie osób, które chcą poznać szczegóły implementacyjne.
81
82
r o z d z ia ł
7
Z a c h o w a n ie
Problem y z doborem nazwy komunikatu dekomponującego mogą sugerować, że użycie tego wzorca nie jest właściwym rozwiązaniem. innym sygnałem może być dłu ga lista parametrów. W idząc takie sygnały, zastępuję kom unikat dekom ponujący wy woływanymi w nim metodami i staram się zastosować inny wzorzec (taki ja k obiekt metody), który będzie mi w stanie pom óc właściwie wyrazić strukturę programu.
Komunikat odwracający Symetria wpływa na poprawę czytelności kodu. Przeanalizujmy poniższą metodę: void compute() { input(); helper.process(this); output(); } Choć składa się ona z wywołań trzech różnych metod, brakuje w niej symetrii. Jej czytelność można by poprawić, wprowadzając metodę pom ocniczą, której użycie za pewniłoby symetrię. Analizując zmodyfikowaną wersję metody compute(), nie trzeba już śledzić, do kogo jest wysyłany kom unikat — wszystkie kom unikaty są bowiem kie rowane do bieżącego obiektu (th is ). void process(Helper helper) { helper.process(this); } void compute() { input(); process(helper); output(); } W takim przypadku osoba analizująca kod może zrozumieć strukturę metody compute(), przeglądając kod jednej klasy. Czasami zdarza się, że dużego znaczenia nabiera sama metoda wywoływana przez kom unikat odwracający. M oże się także zdarzyć, że przesadne stosowanie kom uni katów odwracających skutecznie ukryje potrzebę przeniesienia funkcjonalności. Gdy by w programie pojawił się następujący kod: void input(Helper helper) { helper.input(this); } void output(Helper helper) { helper.output(this); } to najprawdopodobniej można by poprawić całą strukturę kodu, przenosząc metodę compute() do klasy Helper: compute() { new Helper(this).compute(); }
Komunikat zapraszający Helper.compute() { input(); process(); output(); } Osobiście czasami czuję się trochę głupio, tworząc metody „tylko” po to, by zaspo koić „estetyczne” dążenie do uzyskania symetrii. Jednak ta estetyka ma znacznie głębsze podłoże. Angażuje mózg w większym stopniu niż liniowy proces myślowy. Kiedy już wykształcimy w sobie dążenie do estetyki kodu, to wrażenia estetyczne, które kod b ę dzie nam zapewniał, okażą się cenną inform acją o jego jakości. Te odczucia wydosta jące się spod powierzchni symbolicznych myśli mogą być równie cenne, jak jawnie nazwane i uzasadnione wzorce.
Komunikat zapraszający Czasami, pisząc kod, można oczekiwać, że inni program iści będą chcieli zmodyfiko wać fragment obliczeń przez wprowadzenie klasy pochodnej. Możliwość wprowadzenia takich późniejszych usprawnień da się wyrazić poprzez wysłanie komunikatu o odpo wiednio dobranej nazwie. Taki kom unikat zaprasza innych programistów do uspraw nienia obliczeń wedle własnych potrzeb. Jeśli dostępna jest jakaś domyślna im plem entacja logiki, to należy użyć jej jako im plem entacji komunikatu. Jeśli jednak takiej domyślnej im plem entacji nie ma, to chcąc jawnie przekazać zaproszenie, wystarczy zadeklarować metodę jako abstrakcyjną.
Komunikat wyjaśniający W programowaniu zawsze duże znaczenie miało rozróżnienie intencji i implementacji. To właśnie dzięki niemu można najpierw zrozum ieć wykonywane obliczenia, a póź niej, w razie konieczności, poznać ich szczegóły. Rozróżnienie to można utworzyć, ko rzystając z komunikatów, wystarczy zacząć od wysłania komunikatu, którego nazwa określa rozwiązywany problem, a w obsługującym go kodzie wysłać kolejny kom uni kat o nazwie odpowiadającej sposobowi rozwiązania problemu. Pierwszy raz spotkałem się z takim rozwiązaniem w języku Smalltalk. Konkretnie rzecz biorąc, m oją uwagę zwróciła następująca metoda: highlight(Rectangle area) { reverse(area); } Zastanawiałem się: „Dlaczego takie rozwiązanie jest przydatne? Dlaczego nie wywoły wać metody reverse() bezpośrednio, tylko przy użyciu dodatkowej metody highli ght() ?”. Jednak po przemyśleniu zagadnienia zrozumiałem, że choć metoda h ig h lig h t() nie ma żadnego znaczenia z punktu widzenia wykonywanych obliczeń, to jednak służy do wyra żenia intencji. W kodzie wywołującym tę metodę można było zwrócić uwagę na rozwią zywany problem, którym w tym przypadku było wyróżnienie fragmentu ekranu.
83
84
r o z d z ia ł
7
Z a c h o w a n ie
M ożna zastanowić się nad wykorzystaniem komunikatu wyjaśniającego w sytu acjach, gdy odczuwamy potrzebę skomentowania jakiegoś wiersza kodu. Kiedy widzę następujący wiersz kodu: flags|= LOADED_BIT; // ustawienie wartości bitu zajętości wolałbym użyć następującego wywołania: setLoadedFlag(); Choć im plem entacja metody setLoadedFlag() jest trywialnie prosta, została ona jednak utworzona w celu wyrażenia intencji. void setLoadedFlag() { fla g s|= LOADED_BIT; } Zdarza się, że metody pom ocnicze wywoływane przez kom unikaty wyjaśniające stają się cennymi miejscami dalszego rozwijania kodu. Miło jest móc skorzystać z możli wości, kiedy się ona nadarzy. Niemniej jednak podstawowym celem, w którym stosuję komunikaty wyjaśniające, jest bardziej precyzyjne wyrażenie m oich intencji.
Przepływ wyjątkowy O prócz przepływu głównego programy mają także co najm niej jeden przepływ wyjąt kowy. Stanowią one ścieżki obliczeń, których wyrażanie nie jest tak ważne, gdyż są rzadziej wykonywane, rzadziej zmieniane, a czasami także m niej istotne od przepływu głównego z koncepcyjnego punktu widzenia. Przepływ główny należy wyrażać w spo sób przejrzysty, natom iast ścieżki wyjątkowe — w możliwie jak najbardziej przejrzy sty, lecz jednocześnie taki, by nie zaciem niał przekazu przepływu głównego. Klauzule strażników oraz wyjątki są dwoma sposobami wyrażania przepływu wyjątkowego. Analiza programów jest znacznie łatwiejsza, jeśli tworzące je instrukcje są wyko nywane sekwencyjnie, jedna po drugiej. W takich przypadkach, aby zrozum ieć inten cję programu, osoby przeglądające kod mogą skorzystać z wygodnych i dobrze zna nych um iejętności czytania prozy. Czasami jednak może się zdarzyć, że program będzie m iał kilka ścieżek realizacji. Wyrażanie ich wszystkich w taki sam sposób może doprowadzić do dużego chaosu i sytuacji, gdy flagi ustawiane w jednym miejscu kod są używane w innych, a wartości wynikowe mogą mieć specjalne znaczenia. Wówczas znale zienie odpowiedzi na pytanie: „Które instrukcje są wykonywane?” staje się zadaniem z pogranicza archeologii i logiki. Należy zatem wybrać przepływ główny i przejrzyście go wyrazić. A do wyrażenia wszelkich innych ścieżek w programie użyć wyjątków.
Klauzula strażnika Choć programy mają pewien przepływ główny, to jednak, w niektórych sytuacjach, może zaistnieć konieczność wykonywania ich innymi ścieżkami. Klauzula strażnika pozwala wyrazić prostą i logiczną sytuację wyjątkową, posiadającą wyłącznie lokalne konse kwencje. Porów najm y dwa przedstawione poniżej fragmenty kodu:
K l a u z u l a s t r a ż n ik a
void in itia liz e () { i f ( !is In itia liz e d ()) { } } oraz: void in itia liz e () { i f (is In itia liz e d ()) return; } W pierwszym z nich, kiedy zaczynam analizować pierwszą klauzulę instrukcji warun kowej, zwracam uwagę, by później poszukać klauzuli else. Zupełnie jakbym w myślach odłożył warunek na stos. Jest to czynnik, który rozprasza uwagę. W drugim przykła dzie pierwsze dwa wiersze m etody inform ują m nie jedynie o fakcie — odbiorca nie został zainicjowany. Konstrukcja if - t h e n - e ls e wyraża alternatywne przepływy sterowania, z których oba są równie ważne. Klauzula strażnika nadaje się natomiast do wyrażenia innej sytuacji — sytuacji, w której jeden przepływ sterowania jest ważniejszy od drugiego. W przed stawionym wyżej przykładzie ze sprawdzaniem inicjalizacji ważniejszym przepływem sterowania jest ten określający, co się stanie, kiedy obiekt będzie zainicjowany. Oprócz niego w kodzie można zwrócić uwagę tylko na jeden prosty fakt, że niezależnie od te go, ile razy zażądamy zainicjowania obiektu, kod wykonujący tę inicjalizację zostanie wykonany tylko jeden raz. Niegdyś istniało pewne przykazanie programistyczne, nakazujące, by każdy pod program miał tylko jeden punkt wejścia i jeden punkt wyjścia. Sformułowano go, by za pobiec zamieszaniu, które mogłoby wystąpić, gdyby realizacja podprogramu mogła się zaczynać w wielu miejscach jego kodu i w wielu miejscach sterowanie mogłoby go opusz czać. Takie rozwiązanie było sensowne w języku FORTRAN oraz w programach pisanych w języku asemblera, które w dużym stopniu korzystały z danych globalnych i w któ rych już samo wskazanie wykonywanych instrukcji było trudnym zadaniem. Natomiast w języku Java, w którym tworzone metody są niewielkie i korzystają przeważnie z da nych lokalnych, takie podejście jest niepotrzebnie konserwatywne. Niem niej jeśli ten swoisty programistyczny folklor będzie rygorystycznie przestrzegany, uniemożliwi stosowanie wzorca klauzuli strażnika. Klauzule strażników są szczególnie użyteczne w sytuacjach, gdy kontrolowanych jest wiele warunków: void compute() { Server server= getServer(); i f (server != null) { Client client= server.getC lient(); i f (clien t != null) { Request current= client.getR equest(); i f (current != null)
85
86
r o z d z ia ł
7
Z a c h o w a n ie
processRequest(current); } } } Zagnieżdżone warunki reprezentują jakieś problemy. W razie wykorzystania klau zul strażników ten sam kod wskazuje wymagania wstępne, niezbędne do obsługi żądania, a przy tym nie wymaga stosowania żadnych złożonych struktur sterujących: void compute() { Server server= getServer(); i f (server == null) return; Client client= server.getC lient(); i f (clien t == nul l) return; Request current= client.getR equest(); i f (current == null) return; processRequest(current); } Pewnym wariantem wzorca klauzuli strażnika jest instrukcja continue umieszcza na w pętlach. Jej użycie stanowi komunikat: „Nie przejmuj się tym elem entem i zajmij się następnym”. while (lin e = reader.readline()) { i f (lin e.startsW ith ('# ') || line.isEmpty()) continue; // Normalne przetwarzanie } Także w tym przypadku chodzi o wskazanie (jedynie lokalnej) różnicy między przetwarzaniem normalnym i wyjątkowym.
Wyjątek W yjątki są przydatne do wyrażania przeskoków w przepływie programu, przekracza jących wiele poziomów wywołań. Jeśli w m om encie wystąpienia problemu, takiego jak zapełnienie nośnika danych lub utrata połączenia sieciowego, na stosie będzie się znajdo wało wiele poziomów wywołań, to najprawdopodobniej problem ten będzie można sensownie obsłużyć jedynie na znacznie niższym poziomie stosu. Zgłoszenie wyjątku w m om encie wykrycia problemu oraz przechwycenie go w miejscu, gdzie problem ten można obsłużyć, jest znacznie lepszym rozwiązaniem niż umieszczanie w kodzie jaw nych testów sprawdzających zaistnienie wszelkich możliwych warunków wyjątko wych, z których i tak w danym miejscu żadnego nie można obsłużyć. Jednak stosowanie wyjątków jest kosztowne. Z punktu widzenia projektu progra mu są one problematyczne. Fakt, że wywoływana metoda zgłasza wyjątek, ma bowiem wpływ zarówno na projekt, jak i na implementację wszystkich metod od momentu zgło szenia wyjątku aż do dotarcia do metody, w której zostanie on przechwycony. W yjątki
W y ją t k i s p r a w d z a n e
utrudniają także śledzenie przepływu sterowania, gdyż sąsiadujące ze sobą instrukcje mogą znajdować się w innych metodach, obiektach lub pakietach. Kod, który zamiast z konstrukcji warunkowych i komunikatów korzysta z wyjątków, jest diabelnie trudny do zrozumienia, gdyż bezustannie trzeba starać się odgadnąć, co, oprócz standardowej struktury sterowania, może się w nim jeszcze zdarzyć. Krótko mówiąc, zawsze gdy to tylko możliwe, przepływ sterowania należy wyrażać przy użyciu sekwencji, komunika tów, iteracji oraz konstrukcji warunkowych (w takiej kolejności). W yjątków można używać, jeśli zrezygnowanie z nich utrudni proste wyrażenie przepływu głównego.
Wyjątki sprawdzane Jednym z niebezpieczeństw stosowania wyjątków jest możliwość wystąpienia sytuacji, gdy zgłoszony w yjątek nigdy nie zostanie przechwycony. Takie sytuacje mają tylko je den koniec — przerwanie działania programu. M ożna jednak przypuszczać, że twórca programu chciałby m ieć kontrolę nad tym, kiedy program zostanie nieoczekiwanie przerwany, m óc wyświetlić wówczas inform acje niezbędne do zdiagnozowania pro blemu i poinform ow ać użytkownika o tym, co się stało. Takie nieprzechwycone wyjątki są jeszcze poważniejszym problemem, kiedy kto inny pisze kod, który je zgłasza, a kto inny kod, który je obsługuje. W ówczas każdy chybio ny przekaz prowadzi do nagłego i nieuprzejmego zakończenia działania programu. Aby nie dopuścić do takich sytuacji, w języku Java wprowadzono wyjątki spraw dzane. Są one jawnie deklarowane przez programistę oraz sprawdzane przez kom pi lator. Kod, w którym może się pojawić taki wyjątek, musi go przechwycić lub przeka zać dalej. Stosowanie wyjątków sprawdzanych wiąże się ze znaczącymi kosztami. Pierwszym z nich jest koszt samej deklaracji. Użycie wyjątku bez trudu może powodować nawet dwukrotne wydłużenie deklaracji metody i dodanie kolejnego elementu, który należy przeczytać, zrozumieć i uwzględnić na wszystkich poziomach pomiędzy m om entem zgłoszenia i obsługi. Poza tym wyjątki sprawdzane znacząco utrudniają modyfikowa nie kodu. Refaktoryzacja kodu korzystającego z takich wyjątków jest znacznie trud niejsza i bardziej żmudna niż kodu, w którym nie są one stosowane, i to bez względu na to, że nowoczesne narzędzia programistyczne ułatwiają takie zmiany.
Propagacja wyjątków W yjątki pojawiają się na różnych poziomach abstrakcji. Przechwytywanie i raporto wanie o wyjątkach niskiego poziomu może być kłopotliwe dla osób, które się ich nie spodziewały. Kiedy serwer W W W wyświetla stronę błędu z obrazem stosu rozpoczynają cym się od Null PointerException, to nie wiem, co m am zrobić z tą inform acją. W o lałbym zobaczyć kom unikat taki jak: „Programista nie przewidział zaistniałego scena riusza”. Nie miałbym także nic przeciwko temu, żeby na stronie zostały wyświetlone wskazówki dotyczące dodatkowych informacji, które można by przesłać do programisty,
87
88
r o z d z ia ł
7
Z a c h o w a n ie
aby ułatwić mu zdiagnozowanie problemu; natom iast wyświetlanie niewyjaśnionych w żaden sposób technicznych szczegółów nie jest ani użyteczne, ani pomocne. W yjątki niskiego poziomu często zawierają cenne inform acje, przydatne do dia gnozowania zaistniałych problemów. Takie wyjątki można umieszczać wewnątrz wyjąt ków wyższych poziomów; dzięki temu podczas prezentowania informacji o wyjątku (na przykład w dzienniku) znajdzie się w nich wszystko, co potrzebne do odnalezienia i roz wiązania problemu.
Wniosek W programach obiektowych sterowanie jest przekazywane pomiędzy metodami. W na stępnym rozdziale zostało opisane wykorzystanie metod w celu wyrażenia koncepcji występujących w obliczeniach.
Rozdział 8
Metody
Logika nie tworzy jednego wielkiego ciągu instrukcji, lecz jest podzielona na metody. Dlaczego? Jakie problemy można by rozwiązać, wprowadzając nową, dodatkową m e todę? Jaki w ogóle jest sens stosowania metod? Teoretycznie można by napisać pro gram w formie jednej gigantycznej procedury, w której sterowanie byłoby przekazy wane pomiędzy jej różnymi miejscami. C hoć właśnie w taki sposób były tworzone pierwsze programy (a sporadycznie dzieje się tak nawet teraz), to jednak takie rozwiązanie przysparza wielu poważnych problemów. Najpoważniejszym jest znaczące utrudnie nie analizy kodu. W takiej jednej wielkiej procedurze bardzo trudno jest określić, któ re fragmenty kodu są ważne, a które nie. Trudno jest analizować jakiś fragment kodu, pozostawiając sobie zrozumienie pewnych szczegółów na później. Trudno jest odróż nić to, co jest istotne dla osób korzystających z jakiejś funkcjonalności, od tego, co jest istotne dla osób, które będą chciały tę funkcjonalność modyfikować. K olejny problem wynika z faktu, że większość problemów napotykanych podczas programowania nie jest unikalna. Zam iast za każdym razem im plementować wszystko od początku, wy godniej (i produktywniej) byłoby, gdyby istniała możliwość wywołania przygotowanego wcześniej rozwiązania. Jednak w takiej jednej gigantycznej procedurze nie istnieje ża den wygodny sposób wielokrotnego odwoływania się do któregoś z fragmentów kodu. Podział logiki programu na metody pozwala na stwierdzenie: „Te fragmenty logiki nie są ze sobą ściśle powiązane”. Dalszy podział metod na klasy, a klas na pakiety sta nowi dodatkowy komunikat. Umieszczenie jednego fragmentu kodu w takiej metodzie, a drugiego w innej jest dla czytelnika sygnałem, że oba fragmenty nie są ze sobą bez pośrednio związane. M ożna je analizować i zrozum ieć niezależnie. Co więcej, nazwy nadawane m etodom pozwalają przekazać czytelnikom inform acje o przeznaczeniu umieszczonego w nich kodu, i to niezależnie od jego im plem entacji. Często zdarza się, że czytelnicy mogą uzyskać potrzebne inform acje, przeglądając same nazwy metod. O prócz tego metody doskonale rozwiązują problem wielokrotnego użycia. Gdyby śmy pisali jakąś nową procedurę i musieli wykorzystać w niej fragment logiki, który został już wcześniej zaimplementowany w formie metody, to wystarczyłoby ją wywołać.
89
90
r o z d z ia ł
8
M eto d y
Pod względem koncepcyjnym dzielenie dużych obliczeń na metody jest stosunko wo proste: wystarczy zgrupować te elementy, które powinny być razem, i oddzielić od nich te, które nie są z nimi związane. W praktyce trzeba jednak będzie poświęcić sporo czasu, energii i kreatywności, by określić w pierwszej kolejności, jakie fragmenty logiki należy zgrupować, a następnie jaki będzie najlepszy sposób ich podziału. To, co aktualnie wy daje się dobrym sposobem podziału, w przyszłości, po zmianie logiki systemu, może się okazać kiepskim rozwiązaniem. Wprowadzane podziały powinny ograniczać sumarycz ny nakład pracy. U m iejętność określania, które podziały będą najlepsze, przychodzi wraz z doświadczeniem. Poniżej podałem sugestie wynikające z m oich doświadczeń. Częste problemy z podziałem programu na metody wiążą się z ich wielkością, prze znaczeniem oraz nazewnictwem. W razie utworzenia zbyt wielu małych metod osoby analizujące program będą miały kłopoty ze śledzeniem i zrozum ieniem idei wyrażo nych w formie wielu elementów. Poza tym zbyt mała liczba metod prowadzi do po wielania oraz towarzyszącej mu utraty elastyczności. W programowaniu występuje wiele często powtarzających się zadań, a tworzenie nowych metod jest krokiem, który pozwala na wykonywanie wielu z nich. Zazwyczaj określanie nazw metod realizują cych takie ciągle powtarzające się zadania nie przysparza większych kłopotów. D obie ranie nazw metod rozwiązujących unikalne problemy jest trudniejsze, a jednocześnie ważne dla osób analizujących kod. Oto lista wzorców związanych z metodami, opisanych w tym rozdziale: ■
M etoda złożona (ang. C o m p osed M eth od ) — metody można tworzyć, grupując wywołania innych metod.
■
Nazwa określająca przeznaczenie (ang. In ten tion -R ev ealin g N am e) — nazwy m e tod należy dobierać tak, by oddawały ich przeznaczenie.
■
W idoczność metody (ang. M eth o d V isibility) — metody powinny być prywatne.
■
Obiekt metody (ang. M eth o d O bject) — złożone metody można przekształcać w niezależne obiekty.
■
M etoda przesłonięta (ang. O verrided M ethod) — metody można przesłaniać w celu wyrażenia specjalizacji.
■
M etoda przeciążona (ang. O v erloaded M ethod ) — zapewnia alternatywny interfejs tych samych obliczeń.
■
Typ wynikowy metody (ang. M eth o d R eturn Type) — deklarowany typ wyniku metody powinien być możliwie jak najbardziej ogólny.
■
Komentarz do metody (ang. M eth o d C om m en t) — do metod należy dodawać ko mentarze, aby przekazać inform acje, których nie można łatwo uzyskać na podsta wie analizy kodu.
■ M etoda pom ocnicza (ang. H elp er M ethod) — tworząc niewielkie, prywatne m eto dy, można zwięźlej wyrażać główne obliczenia.
M eto d y
■
M etoda komunikatu inform acyjnego (ang. D ebu g P rint M eth od ) — używa metody t o S t r in g ( ) , by wyświetlać informacje, które mogą się przydać podczas debugowania.
■
Konwersja (ang. C onversion) — wyraża konwersję obiektu jednego typu na obiekt innego typu.
■
M etoda konwertująca (ang. C onversion M eth od ) — w przypadku prostych kon wersji zaleca tworzenie w obiekcie źródłowym metody zwracającej skonwertowany obiekt.
■
Konstruktor konwertujący (ang. C onversion C onstructor) — przy większości kon wersji zaleca tworzenie w klasie obiektu skonwertowanego metody pobierającej obiekt źródłowy jako parametr.
■ ■
Utworzenie (ang. C reation ) — pozwala jasno wyrazić utworzenie obiektu. Kompletny konstruktor (ang. C om plete C onstructor) — tworzone konstruktory powinny zwracać w pełni ukształtowane obiekty.
■
M etoda wytwórcza (ang. F actory M eth od ) — pozwala wyrazić bardziej złożony proces tworzenia obiektu w formie metody statycznej, a nie konstruktora.
■
Fabryka wewnętrzna (ang. In tern al F actory) — hermetyzuje w metodzie pom ocni czej proces tworzenia obiektu, który w przyszłości możne wymagać wyjaśnienia lub usprawnienia.
■
M etoda dostępu do kolekcji (ang. C ollection A ccessor M eth od ) — tworzy metodę zapewniającą ograniczony dostęp do kolekcji.
■
M etoda określająca wartości logiczne (ang. B o o lea n Setting M eth od ) — jeśli to po może w kom unikacji, można stworzyć dwie metody określające dwa możliwe stany danej logicznej.
■
M etoda zapytania (ang. Q uery M ethod) — pozwala odczytywać wartości logiczne, wykorzystując w tym celu metody nazywane według schematu asXXX.
■
M etoda równości (ang. E qu ality M ethod) — zaleca, by definiować obie metody: eq u als() oraz hashCode().
■
M etoda pobierająca (ang. G etting M ethod) — czasami dostęp do pola można za pewnić, udostępniając metodę zwracającą jego wartość.
■
M etoda ustawiająca (ang. Setting M ethod) — nieco bardziej sporadycznie można tworzyć metody dające możliwość określania wartości pola.
■
Bezpieczna kopia (ang. S afe Copy) — pozwala unikać błędów związanych z utoż samianiem nazw poprzez kopiowanie metod przekazywanych do akcesorów oraz obiektów przez nie zwracanych.
91
92
r o z d z ia ł
8
M eto d y
Metoda złożona M etody należy tworzyć, umieszczając w nich wywołania innych metod, z których każ da posiada mniej więcej taki sam poziom abstrakcji. Jednym z sygnałów świadczących o niewłaściwym projekcie jest występowanie w kodzie różnych poziomów abstrakcji: void compute() { input(); fla g s|= 0x0080; output(); } Z punktu widzenia osoby analizującej program taki kod może drażnić. Kod można łatwiej zrozumieć, jeśli jest spójny i konsekwentny, a nagłe zmiany poziomu abstrakcji nie sprzyjają temu. Przeglądając przedstawioną wyżej metodę, można sobie zadać py tanie, co jest z nią nie tak. Co ona oznacza? Jednym z czynników przemawiających przeciwko stosowaniu wielu niewielkich metod jest ograniczenie wydajności związane z ich wywoływaniem. W ram ach prac nad tym rozdziałem książki napisałem niewielki program pomiarowy, który porów nywał pętlę o milionie powtórzeń z wygenerowaniem m iliona komunikatów. Narzut związany z wywoływaniem metod wynosił średnio 20 - 30%, czyli nie na tyle dużo, by miał wpływ na wydajność działania programu. Połączenie szybszych procesorów oraz bardziej lokalnego charakteru wąskich gardeł wydajności sprawia, że zagadnienia związane z wydajnością działania kodu najlepiej jest zostawić na później, kiedy będzie już można zebrać statystyki oparte na realnym zbiorze danych. Jak duża powinna być metoda? Niektórzy proponują stosowanie limitów m ierzo nych liczbą wierszy kodu, stwierdzając na przykład, że długość metod powinna wahać się w granicach od 5 do 15 wierszy. C hoć być może prawdą jest, że metody mieszczące się w tych granicach są najbardziej czytelne, to jednak trudno nie zadać pytania, dla czego te lim ity są akurat takie, a nie inne. Dlaczego fragmenty logiki działają najlepiej, kiedy mają mniej więcej właśnie taką wielkość? Osoby analizujące kod muszą rozwiązać kilka problemów, które mogą m ieć różny, czasami przeciwstawny wpływ na długość metod. W czasie przeglądania kodu w celu określenia jego ogólnej struktury możliwość przeanalizowania jego dłuższego frag m entu jest dosyć cenna. Odpowiednie zastosowanie wcięć w kodzie stanowi dosko nałą podpowiedź co do jego ogólnej struktury i złożoności. Czy występują w nim jakieś instrukcje warunkowe i pętle? Jaki jest poziom zagnieżdżenia konstrukcji sterujących? Jak duży jest nakład pracy niezbędny do wykonania zadania? Ta sama duża metoda, która pomogła mi zorientować się, jaka jest ogólna struktu ra kodu, stanie się jednak utrapieniem przy próbach szczegółowego zrozum ienia spo sobu jego działania. M ój umysł jest w stanie objąć tylko określoną ilość szczegółowych inform acji w danej chwili, a w metodzie o długości tysiąca wierszy jest ich zdecydo wanie zbyt dużo. Próbując dokładnie zrozum ieć tajniki działania kodu, chciałbym,
N a z w a o k r e ś l a ją c a p r z e z n a c z e n ie
aby szczegóły ściśle ze sobą powiązane były zgrupowane razem i oddzielone do tych, które nie mają takiego znaczenia. Jednocześnie program iści dzielący logikę na metody stają przed jeszcze jednym wyzwaniem — koniecznością zapewnienia tego, by tworzony kod można było łatwo przeglądać, przemyśleć i usystematyzować. Zauważyłem, że mój kod jest najbardziej czytelny, kiedy dzielę go na stosunkowo krótkie metody (przynajm niej wedle standar dów stosowanych w języku C). Cała sztuczka polega na odnajdywaniu w kodzie dość niezależnych zbiorów szczegółów, które można wydzielać w formie metod pomocniczych. Czasami liczba szczegółów jest zbyt duża, by można je wszystkie zrozumieć, a jed no cześnie nie można ich prosto podzielić. W takich przypadkach stosuję obiekt metody, tworząc w ten sposób miejsce, w którym mogę zorganizować wszystkie szczegóły. Kolejnym zagadnieniem związanym z doborem wielkości metod jest specjalizacja. Metody, które mają odpowiednią wielkość, mogą być przesłaniane w całości — bez konieczności kopiowania kodu do klas pochodnych i modyfikowania go ani przesła niania dwóch metod w celu wprowadzenia jednej zmiany koncepcyjnej. M etody należy konstruować, opierając się na faktach, a nie spekulacjach. Kod na leży doprowadzić do stanu, w którym będzie działał, a dopiero potem zastanawiać się, jaką strukturę mu nadać. Jeśli na wstępie poświęcimy dużo czasu na zachowanie od powiedniej struktury kodu, to tę samą pracę będziemy musieli wykonać jeszcze raz, kiedy później, na etapie im plem entacji, dowiemy się czegoś, co zmusi nas do wprowa dzenia zmian. Kiedy naszkicuję i ułożę przed sobą wszystkie szczegóły logiki, znacznie łatwiej jest mi poskładać z nich sensowne metody. Czasami sądzę, że wiem, jak należy utworzyć metody, lecz kiedy podzielę logikę na części, okazuje się, że uzyskany w ten sposób kod jest mało czytelny. Zauważyłem, że w takich sytuacjach przydatne jest po nowne umieszczenie całego kodu w jednej gigantycznej metodzie i podzielenie go na fragmenty zgodnie ze zdobytymi doświadczeniami.
Nazwa określająca przeznaczenie Nazwy metod powinny być określane zgodnie z celami, w jakich dana metoda będzie wywoływana. Istnieją także inne inform acje, które możemy chcieć przekazywać w na zwach metod; jedną z nich jest na przykład zastosowana strategia im plementacyjna. Niem niej jednak nazwa metody powinna służyć do wyrażenia jej intencji, a wszelkie dodatkowe inform acje o metodzie można przekazywać inaczej. Strategia im plementacyjna jest inform acją uboczną, która najczęściej jest dodawa na do nazwy metody. O to przykład: Customer.linearCustomerSearch(String id) Taka nazwa wydaje się znacznie lepsza do poniższej: Customer.find(String id) gdyż przekazuje więcej inform acji o metodzie. Jednakże naszym celem jako twórców oprogramowania nie jest przekazywanie wszystkich możliwych inform acji na temat
93
94
r o z d z ia ł
8
M eto d y
kodu tak szybko, jak to tylko możliwe. Czasami należy zachować powściągliwość. Jeśli strategia im plem entacyjna nie ma większego znaczenia dla użytkowników, to infor m acji o niej nie warto umieszczać w nazwie metody. Osoby zainteresowane sposobem im plem entacji metody będą go mogły poznać, zaglądając do jej kodu. Nawet gdyby klasa Customer udostępniała zarówno wyszukiwanie liniowe, jak i z użyciem funkcji skrótu, to z perspektywy kodu wywołującego różnicę między nim i najlepiej będzie wyrazić poprzez odpowiedni dobór nazw: Customer.find(String id) Customer.fastFind(String id) (W łaściw ie w tym przypadku lepiej byłoby udostępnić tylko jedną metodę f in d () , która byłaby w stanie spełnić wymagania wszystkich użytkowników, ale to już zupełnie inna historia). To, czy szybka wersja metody f i n d ( ) będzie zaim plementowana przy użyciu tablicy mieszającej, czy drzewa, nie ma większego znaczenia dla jej użytkowników. Wybierając nazwy metod, należy się także zastanowić, jak będą one wyglądały w ko dzie wywołującym. W końcu to właśnie tam użytkownicy najprawdopodobniej po raz pierwszy zauważą te nazwy. Dlaczego została wywołana akurat ta, a nie inna metoda? Na to pytanie z powodzeniem może odpowiedzieć nazwa metody. M etoda wywołują ca inne metody powinna opowiadać pewną historię. Dlatego nazwy metod należy do bierać tak, by pomagały ją opowiadać. Jeśli im plem entacja metod stanowi analogię do istniejącego interfejsu, ich nazwy powinny być takie same jak te zastosowane w interfejsie. Jeśli tworzymy specjalny ro dzaj iteratora, to będzie on definiował metody hasN ext() oraz n e x t(), choć formalnie nie musimy przy tym implementować interfejsu It e r a t o r . Jeśli jednak tworzone m e tody jedynie w pewnym stopniu coś przypominają, to w pierwszej kolejności należy się zastanowić, czy została wybrana odpowiednia metafora, a dopiero potem wyrażać różnice poprzez dodawanie do nazw metod odpowiednich prefiksów.
Widoczność metody Każdy z czterech dostępnych poziomów w idoczności — publiczny, pakietu, chronio ny oraz prywatny — przekazuje pewne inform acje o przeznaczeniu metody. Istnieją dwa podstawowe i przeciwstawne ograniczenia związane z widocznością metod. Pierwszym z nich jest konieczność ujawnienia i udostępnienia pewnej funk cjonalności zewnętrznym użytkownikom, a drugim potrzeba zachowania elastyczno ści na wypadek przyszłych zmian. Im więcej metod zostanie ujawnionych, tym trud niej będzie zm ienić interfejs obiektu, jeśli w przyszłości pojawi się taka konieczność. Podczas tworzenia JU nit często spieraliśmy się z Erichem G am m ą o w idoczność m e tod. M oje doświadczenia wyniesione z korzystania z języka Smalltalk podpowiadały, że ujawnianie metod może potencjalnie być przydatne dla innych programistów. Z kolei doświadczenia Ericha nabyte podczas tworzenia środowiska Eclipse nauczyły go cenić elastyczność, którą zapewnia ujawnienie jak najm niejszej liczby metod. P o woli także zaczynam zgadzać się z tym punktem widzenia.
W id o c z n o ś ć m e t o d y
Przy określaniu widoczności metod konieczne jest zachowanie równowagi między dwoma potencjalnym i kosztami. Pierwszym z nich jest koszt zapewnienia elastyczno ści na przyszłość. Niewielki interfejs sprawi, że wprowadzanie zmian będzie dużo ła twiejsze. Drugim kosztem, który należy uwzględnić, jest koszt wywoływania obiektów. zb y t mały interfejs sprawi, że korzystanie z obiektów będzie wymagało większego na kładu pracy. Zrównoważenie tych kosztów jest najważniejszym aspektem właściwego doboru widoczności metod. Sam stosuję strategię polegającą na jak największym ograniczaniu widoczności. Gdyby jednak określanie widoczności metod sprowadzało się wyłącznie do tego, to naprawdę nie stanowiłoby żadnego problemu. Prawdziwe wyzwania pojawiają się jednak w sytuacjach, gdy wiedza, którą dysponujemy, nie jest pewna, gdy nasze metody za czynają być wywoływane przez kod, nad którym nie m amy kontroli. W ówczas trzeba spekulować i na podstawie tych spekulacji określać, które metody m ają być prywatne, a które chronione. Te decyzje wpływają na zwiększenie możliwości utrzymania kodu lub konieczność ponoszenia większych kosztów w razie jego modyfikacji. ■
M etody publiczne — deklarując metody jako publiczne, wyrażamy przekonanie, że mogą one być użyteczne poza pakietem, w którym zostały zadeklarowane. o z n a cza to także, że przyjm ujem y odpowiedzialność za ich utrzymanie, bądź to przez powstrzymanie się od ich modyfikacji, bądź też poprzez wprowadzenie odpowied nich zmian w całym kodzie, który je wywołuje, a w najgorszym razie poprzez po informowanie innych programistów korzystających z tych metod o tym, że zostały zmodyfikowane. public Object next(); Ta deklaracja stwierdza, że zarówno teraz, jak i w przewidywalnej przyszłości metoda n e x t ( ) będzie widoczna dla klientów.
■
M etody dostępne w pakiecie — tworzenie metod dostępnych w pakiecie stanowi komunikat, że przydadzą się obiektom wchodzącym w skład tego pakietu, a jed no cześnie że nie mamy zamiaru udostępniać ich obiektom spoza pakietu. Ten kom u nikat jest dosyć dziwny — takie metody mogą być potrzebne innym obiektom, lecz nie wszystkim, a jedynie napisanym przez twórcę metody. Występowanie tego rodzaju w idoczności należy potraktować jako inform ację bądź to o konieczności przenie sienia funkcjonalności, tak by metody były mniej widoczne, bądź o tym, że metoda jest bardziej użyteczna, niż początkowo sądzono, i warto udostępnić ją publicznie, ponosząc związane z tym koszty.
■
M etody chronione — ten rodzaj widoczności jest użyteczny tylko w przypadku tworzenia kodu, który ma być używany wyłącznie w klasach pochodnych. Choć można sądzić, że ten rodzaj dostępu jest bardziej restrykcyjny od opisanego powy żej, to jednak w rzeczywistości są one ortogonalne, gdyż klasy pochodne należące do innych pakietów mają dostęp do metod chronionych i mogą je wywoływać.
95
96
r o z d z ia ł
■
8
M eto d y
M etody prywatne — metody prywatne są najlepsze w celu zachowania elastyczno ści, gdyż dają pewność, że będzie można odszukać i zmodyfikować wszystkie m iej sca kodu, w których są one wywoływane, i to niezależnie od tego, czy nasz kod zo stał rozszerzony w jakichś zewnętrznych klasach, czy nie. Deklarując metodę jako prywatną, stwierdzamy, że jej znaczenie dla kodu zewnętrznego nie równoważy kosztów ich szerszego udostępniania. M etody należy udostępniać powoli, zaczynając od najbardziej restrykcyjnego do
stępu, jaki można zastosować dla danej metody, i zwiększając ich w idoczność w razie potrzeby. Jeśli metoda nie będzie już musiała być dostępna w równie dużym stopniu, to można ograniczyć jej widoczność. Jednak ograniczenie w idoczności metody jest możliwe wyłącznie wtedy, gdy mamy dostęp do wszystkich miejsc kodu, w których jest wywoływana — tylko w takim przypadku można mieć pewność, że usunięcie dostępu do metody nie spowoduje awarii kodu, który z niej korzysta. Często zauważam, że metody, które początkowo uznawałem za prywatne, później, kiedy inaczej zacząłem korzystać z obiektów, stawały się cennymi elementami interfejsu. Deklarowanie metod jako sfinalizowanych ( f in a l) przypomina nieco określanie ich widoczności. Stanowi sygnał, że choć nie mamy nic przeciwko temu, by inni pro gramiści używali naszych metod, to jednak nie chcemy, by ktokolwiek je zmieniał. Je śli niezm ienniki zawarte w metodzie są dostatecznie złożone i subtelne, to taki poziom zastosowanych zabezpieczeń może być uzasadniony. Ceną uzyskania pewności, że nikt przypadkowo nie uszkodzi naszego obiektu, jest uniemożliwienie innym program i stom uzasadnionego przesłaniania jego metod, co może ich zmusić do zrealizowania zamiarów w inny sposób, przy większym nakładzie pracy. Jeśli o mnie chodzi, staram się nie używać modyfikatora f in a l , a kilka razy zdarzyło mi się wpaść we frustrację, gdy okazywało się, że metody, które z uzasadnionych powodów chciałem przesłonić, były sfinalizowane. Deklarowanie metod jako statycznych ( s t a t i c ) sprawia, że będą one dostępne, nawet jeśli kod wywołujący nie będzie dysponował instancją danej klasy (choć oczywiście będzie to także zależne od zastosowanych modyfikatorów widoczności). M etody sta tyczne są w pewnym stopniu ograniczone, gdyż nie mogą korzystać ze stanu obiek tów, a zatem nie za dobrze nadają się do im plem entacji jakiejś złożonej logiki. M etody statyczne można dziedziczyć, jednak po przesłonięciu w klasie pochodnej znika moż liwość wywołania metody zdefiniowanej w klasie bazowej. Jednym z dobrych zasto sowań metod statycznych jest wykorzystanie ich jako zamiennika konstruktorów.
Obiekt metody To jeden z m oich ulubionych wzorców, zapewne dlatego, że używam go bardzo rzad ko, lecz kiedy już to robię, efekty są spektakularne. Utworzenie obiektu metody może nam pom óc przekształcić bezładną masę kodu umieszczoną w jakiejś nieprawdopo dobnie dużej metodzie w czytelny i przejrzysty kod, który stopniowo będzie ujawniał
O b ie k t m e t o d y
czytelnikom swoje szczegóły. Używam tego wzorca, kiedy już dysponuję działającym kodem, a im przekształcana metoda jest bardziej skomplikowana, tym lepiej. W celu utworzenia obiektu metody należy poszukać metody posiadającej wiele pa rametrów i zmiennych tymczasowych. Próby wydzielenia jakiegokolwiek fragmentu takiej metody zakończyłyby się zapewne powstaniem kolejnej metody z długą listą pa rametrów i trudną, złożoną nazwą. Poniższa lista przedstawia czynności, które należy wykonać w celu utworzenia obiektu metody (trzeba je wykonać samemu, gdyż ja k na razie nie ma narzędzi, które byłyby w stanie przeprowadzić taką refaktoryzację auto matycznie): 1. Utworzyć klasę o nazwie zgodnej z nazwą metody. Na przykład metoda complex ^ C a lc u la tio n () mogłaby zostać przekształcona w klasę ComplexCalculator. 2. W tej nowej klasie zdefiniować pole dla każdego parametru, zmiennej lokalnej oraz pola używanego wcześniej w metodzie, przy czym ich nazwy mogą być takie same jak w kodzie metody (w razie potrzeby później będzie można je zmienić). 3. Utworzyć konstruktor, którego parametram i będą parametry wywołania oryginal nej metody oraz pola używanych w niej oryginalnych obiektów. 4. Skopiować kod oryginalnej metody i um ieścić go w jakiejś metodzie (na przykład: c a lc u la t e ( ) ) nowej klasy. Parametry, zmienne lokalne oraz pola używane w ory ginalnej metodzie zostaną przekształcone w odwołania do pól nowego obiektu. 5. Zastąpić ciało oryginalnej metody kodem, który utworzy instancję nowej klasy i wywoła jej metodę c a lc u la t e ( ) . Oto przykład: complexCalculation() { new ComplexCalculator().calculate(); } 6. Jeśli oryginalna metoda określała wartości jakichś pól, to należy je podać po wy wołaniu metody c a l c u la t e ( ) : complexCalculation() { ComplexCalculator calculator= new ComplexCalculator(); calculator.calculate(); mean= calculator.mean; variance= calculator.variance; } Należy przy tym zadbać, by zmodyfikowany kod działał dokładnie tak samo jak oryginalny. A teraz zaczyna się najlepsza zabawa. Kod umieszczony w nowej klasie można bardzo łatwo modyfikować i poddawać refaktoryzacji. M ożna go dzielić na mniejsze metody, i to bez konieczności przekazywania do nich jakichkolw iek para metrów, gdyż wszystkie dane używane w metodzie będą umieszczone w polach obiektu. Po rozpoczęciu wydzielania takich metod często okazuje się, że część pól można prze kształcić w zmienne lokalne. Niekiedy zdarza się także, że pojawią się informacje, które można przekazywać do pojedynczej metody w formie parametru, zamiast przechowywać w polu. Może się również okazać, że fragmenty kodu lub wyrażenia, które wcześniej było
97
98
ROZDZIAŁ 8
M ETODY
trudno wydzielić, teraz staną się użytecznymi metodam i pom ocniczym i o znaczących, sensownych nazwach. Czasami, zanim zorientujemy się, że nadarza się okazja do utworzenia obiektu metody, kod oryginalnej metody zostanie już podzielony na fragmenty. W takim przypadku należy je wszystkie scalić w jednym miejscu, a dopiero potem przystępować do tworzenia obiektu metody. Wyraźnym sygnałem, że przed utworzeniem obiektu metody do kodu, który chcemy w niej umieścić, trzeba będzie dodać kolejne fragmenty, jest konieczność odwoły wania się do oryginalnego obiektu. Wówczas należy odnaleźć te fragmenty, dołączyć je do kodu oryginalnej metody i ponownie rozpocząć tworzenie nowej klasy.
Metoda przesłonięta Jedną z najwspanialszych cech programowania obiektowego jest to, iż udostępnia ono wiele sposobów wyrażania różnic między podobnymi obliczeniami. Przesłanianie metod pozwala przejrzyście wyrażać odmienności. Metody, które w klasie bazowej zostały zadeklarowane jako abstrakcyjne, są jawnym zaproszeniem do specjalizacji wykony wanych obliczeń, niemniej jednak wszystkie metody z wyjątkiem sfinalizowanych dają możliwość wyrażenia odm ienności od istniejących obliczeń. odpow iednie utworzenie metod w klasie bazowej pozwala na zastosowanie własnego kodu. Jeśli kod takiej klasy bazowej został podzielony na małe, spójne fragmenty, to w klasach pochodnych b ę dzie można przesłaniać całe metody. Przesłanianie metod nie jest operacją porównywalną z logiczną alternatywą wyklu czającą. M ożna wywołać metodę zdefiniowaną w klasie pochodnej, jak również jej wersję z klasy bazowej — wystarczy użyć wywołania o postaci super.m etoda(). W ten sposób można wywoływać tylko metodę o tej samej nazwie. Jeśli klasa potom na jaw nie decyduje się na wywoływanie własnego kodu, a czasami na wywoływanie wielu różnych metod klasy bazowej, to trudno będzie ją zrozumieć i stosunkowo łatwo bę dzie doprowadzić do niezamierzonych problemów z jej działaniem. Jeśli zaistnieje ko nieczność wywoływania innych metod klasy bazowej, to można poprawić kod, zmieniając strukturę przepływu sterowania aż do momentu, gdy naprzemienne wywoływanie metod klasy bazowej i pochodnej nie będzie już potrzebne. Zbyt duże metody klasy bazowej stawiają przed nam i pewien dylemat: czy kopio wać ich kod do klasy pochodnej i modyfikować go, czy też spróbować odnaleźć inny sposób wyrażenia odm ienności? Problem z kopiowaniem polega na tym, że w przy szłości ktoś może wprowadzić zmiany w kodzie klasy bazowej, który skopiowaliśmy, doprowadzając przez to do awarii klasy pochodnej, a my (ani ta inna osoba) nawet nie będziemy o tym wiedzieli.
Metoda przeciążona Gdy definiujemy tę samą metodę, używając przy tym parametrów innych typów, przeka zujemy komunikat: „To są inne dopuszczalne formaty parametrów tej metody”. Jako przykład można podać metodę, która pozwala na przekazanie łańcucha znaków (S trin g )
T y p w y n ik o w y m e t o d y
określającego nazwę pliku wynikowego lub strumienia OutputStream. Udostępnia w ten sposób prosty interfejs użytkownikom, którzy chcieliby się posługiwać nazwami plików, a jednocześnie zachowuje elastyczność z myślą o tych, którzy chcieliby prze kazać do metody sformatowany strumień (na przykład w celach testowych). Jeśli tylko istnieje kilka sensownych sposobów przekazywania parametrów, to wykorzystanie metod przeciążonych zwalnia programistów z obowiązku odpowiedniego konw erto wania parametrów. Innym wariantem przeciążania jest stosowanie tej samej nazwy metody, lecz z inną liczbą parametrów. Ten styl przeciążania metod jest jednak problematyczny, gdyż osoby, które chciałyby zapytać: „Co się stanie, kiedy wywołam tę m etodę?”, zanim b ę dą w stanie określić efekt wywołania, będą musiały spojrzeć nie tylko na jej nazwę, ale także na listę parametrów. Jeśli przeciążanie jest skomplikowane, to osoby analizujące kod będą musiały zrozumieć subtelne reguły wyboru, zanim będą mogły określić, któ ra wersja przeciążonej metody zostanie wywołana dla danego zestawu argumentów. W szystkie metody przeciążone powinny służyć do tego samego celu, a różnić się jedynie typami parametrów. Użycie różnych typów wynikowych w poszczególnych m eto dach przeciążonych może znacznie utrudnić analizę kodu. W takich przypadkach du żo lepszym rozwiązaniem będzie znalezienie innej nazwy dla nowej metody. Różne obliczenia powinny m ieć różne nazwy.
Typ wynikowy metody Typ wynikowy metody przede wszystkim inform uje, czy dana metoda działa jako pro cedura bazująca na efektach ubocznych, czy też jako funkcja zwracająca obiekt okre ślonego typu. Czarodziejski typ wynikowy void sprawia, że w języku Java nie jest po trzebne żadne słowo kluczowe rozróżniające procedury i funkcje. Zakładając, że tworzona jest funkcja, jej typ wynikowy należy dobrać tak, by wyra żała nasze intencje. Czasami tą intencją będzie ściśle określony typ zwracanej wartości wynikowej — obiekt określonej klasy lub wartość jednego z typów prostych. Niemniej jednak dobrze jest, aby tworzone metody miały jak najszersze możliwości zastosowania, dlatego też należy wybierać jak najbardziej abstrakcyjny typ wynikowy, który um ożli wia wyrażenie naszych intencji. Dzięki temu zachowujemy możliwość zmiany typu wynikowego na bardziej konkretny, gdyby w przyszłości okazało się to konieczne. Oprócz tego generalizacja typu wynikowego metody może stanowić sposób na ukrycie jej szczegółów implementacyjnych. Na przykład użycie typu C ollection zamiast L is t może skłonić użytkowników, by nie zakładali, że zwracane elementy są zapisane w jakim ś określonym porządku. Typy wynikowe bardzo często zmieniają się podczas modyfikowania programów. M o że się zdarzyć, że początkowo metody będą zwracać konkretne klasy, a później odkryjemy, że kilka powiązanych ze sobą metod zwraca różne, konkretne klasy, które mają lub po winny mieć pewien wspólny interfejs. Wówczas dzięki zadeklarowaniu wspólnego inter fejsu (o ile będzie to konieczne) i zastosowaniu go jako typu wynikowego wszystkich me tod możemy pomóc czytelnikom zrozumieć podobieństwo między metodami.
99
100
r o z d z ia ł
8
M eto d y
Komentarz do metody W arto starać się przekazywać możliwie dużo inform acji poprzez wybierane nazwy oraz strukturę kodu. Z kolei komentarze należy stosować, by wyrażać inform acje, któ rych nie można w prosty sposób określić podczas analizy kodu. Tam , gdzie mogą być oczekiwane wyjaśnienia dotyczące metod lub klas, należy dodawać kom entarze do kum entujące języka Java. W kodzie napisanym z myślą o kom unikacji wiele kom entarzy jest nadmiarowych. W takich przypadkach korzyści, jakie daje komentarz, nie równoważą kosztów jego napisania oraz zapewnienia jego zgodności z kodem. W iększość kom entarzy jest pisana na nieodpowiednim poziomie abstrakcji. Jeśli pomiędzy dwoma metodami występują pewne powiązania (na przykład jedna musi zostać wywołana przed drugą), to gdzie należy um ieścić kom entarz? Kom entarze trze ba aktualizować niezależnie od kodu, a oprócz tego nie ma żadnych jawnych sygnałów, że komentarze przestają być aktualne. Inform acje, których nie można po prostu umieszczać w kom entarzach, mogą być wyrażanie przy użyciu zautomatyzowanych testów. Do przedstawionego wcześniej przykładu można by napisać test, który spowoduje zgłoszenie odpowiedniego wyjątku, je śli metody nie zostaną wywołane we właściwej kolejności (choć, jeśli o mnie chodzi, wolałbym takie ograniczenie wyeliminować lub hermetyzować). Zautomatyzowane testy mają wiele zalet. Pisanie ich jest cennym doświadczeniem projektowym, zwłasz cza jeśli będziemy je pisać jeszcze przed rozpoczęciem im plementacji. Jeśli testy zosta ną wykonane, będą spójne z kodem. Dostępne są także zautomatyzowane narzędzia refaktoryzujące, dzięki którym koszt aktualizacji takich testów będzie niewielki. Niem niej jednak najważniejszą wartością w tych wszystkich wzorcach im plem en tacyjnych jest komunikacja. Jeśli komentarz do metody jest najlepszym medium w kom u nikacji, to taki dobry kom entarz warto napisać.
Metoda pomocnicza Metody pomocnicze są konsekwencją stosowania metod złożonych. Jeśli mamy zamiar podzielić duże metody na kilka mniejszych, to te mniejsze metody będą konieczne. To właśnie te mniejsze metody są metodam i pomocniczymi. Ich przeznaczeniem jest po prawienie przejrzystości złożonych obliczeń poprzez ukrycie chwilowo nieistotnych szczegółów i zapewnienie programiście możliwości wyrażenia swoich intencji w na zwie metody. M etody pom ocnicze są zazwyczaj deklarowane jako prywatne, choć jeśli klasa ma być usprawniana poprzez wykorzystanie dziedziczenia, to można je także deklarować jako chronione. Może się zdarzyć, że początkowo napiszemy metodę jako prywatną metodę po mocniczą, a później przekonamy się, że dobrze byłoby ją wywoływać w zewnętrznym kodzie. Jeśli metoda jest użyteczna wewnątrz klasy, może się okazać, że znajdzie za stosowanie także w kodzie spoza tej klasy. Lecz nawet jeśli nasza mała metoda pomocnicza nie doczeka się takiej „promocji”, to i tak wciąż może być cenna jako środek przekazu.
Metoda komunikatu informacyjnego M etody pom ocnicze zazwyczaj są krótkie, choć może się zdarzyć, że będą zbyt krótkie. Na przykład dziś usunąłem z kodu metodę pom ocniczą, której jedynym za daniem było zwracanie nowego obiektu konkretnej klasy. uw ażam , że fragment kodu: return testClass.getConstructor().newInstance(); wyraża intencje równie dobrze jak następujący: return getTestConstructor().newInstance(); Niemniej jednak zastosowanie metody pomocniczej byłoby uzasadnione, gdyby w klasach potom nych był przeciążany sposób wyznaczania konstruktora. M etody pom ocnicze należy eliminować (przynajmniej tymczasowo), jeśli ich logi ka staje się mało czytelna. W takim przypadku cały kod metody pom ocniczej należy przenieść z powrotem w miejsce, w którym jest ona wywoływana, od nowa przeanali zować logikę i podzielić ją na metody inaczej, bardziej sensownie. o sta tn im powodem tworzenia metod pom ocniczych jest elim inacja często powta rzających się podwyrażeń. Jeśli metoda pom ocnicza jest wywoływana we wszystkich m iejscach klasy, w których potrzebne jest wykonanie pewnych obliczeń, to modyfika cja używanego wyrażenia jest całkiem łatwa. Jeśli ten sam wiersz (lub grupa wierszy) powtarza się w kodzie obiektu, to tracim y możliwość poinform ow ania o jego przezna czeniu poprzez odpowiedni dobór nazwy metody, a także utrudniamy jego modyfikację.
Metoda komunikatu informacyjnego istnieje wiele potencjalnych przyczyn wyświetlania obiektu w postaci łańcucha znaków. W ten sposób obiekt można wyświetlić i pokazać użytkownikowi, zapisać go w celu póź niejszego odtworzenia bądź też udostępnić innemu programiście jego dane wewnętrzne. interfejs klasy O b ject jest stosunkowo niewielki — składa się jedynie z jedenastu metod. Jedna z nich — to S tr in g () — wyświetla obiekt w formie łańcucha znaków. Ale czemu to ma służyć? Stworzono ją z zamiarem, by miała kilka zastosowań jed no cześnie. Jednak takie kom prom isy rzadko kiedy są skuteczne. To, co na tem at obiektu będą chcieli wiedzieć makler zajm ujący się obrotem obligacjami, programista oraz ba za danych, w każdym z tych przypadków będzie czymś innym. Wyświetlanie odpowiednio dobranych inform acji może być bardzo użyteczne. uzyskanie ważnych wewnętrznych inform acji o obiekcie może wymagać pół m inuty klikania. Wystarczy jednak wyświetlić te same szczegóły w metodzie to S trin g (), a okaże się, że identyczne inform acje można uzyskać w ciągu sekundy, wykonując przy tym jedno kliknięcie. Jeśli o mnie chodzi, wolę diagnozować pisane programy, używając takich komunikatów, niż przeglądając obiekty w środowisku programistycznym. Pod czas intensywnych sesji debugowania zachowanie koncentracji może zaoszczędzić minuty, a nawet godziny. Ponieważ metoda to S trin g () jest publiczna, może być nadużywana. Zdarza się, że jeśli obiekt nie udostępnia potrzebnego protokołu, to użyteczne inform acje na jego temat są pobierane w trakcie analizy treści generowanych przez metodę to S tr in g ().
101
102
r o z d z ia ł
8
M eto d y
Jednak takie rozwiązanie jest w dużym stopniu narażone na błędy, gdyż program iści często przesłaniają tę metodę w swoich klasach. Najlepszym sposobem zapobiegania takim nadużyciom jest dołożenie starań, by obiekty udostępniały wszystkie interfejsy wymagane przez kod, który będzie ich używał. Dlatego też metodę to S tr in g () warto przesłaniać, kiedy z myślą o programistach chcemy zapewnić możliwość wyświetlania inform acji o obiekcie. G enerację innych tekstowych reprezentacji obiektu można implementować w form ie odrębnych metod bądź też w zupełnie niezależnych klasach.
Konwersja Czasami zdarza się, że dysponujemy obiektem A, a do dalszych obliczeń musimy przekazać obiekt B. W jaki sposób można wyrazić taką konwersję obiektu źródłowego na obiekt docelowy? Podobnie jak w innych wzorcach celem wzorca konwersji jest przejrzyste wyraże nie intencji programisty. Niem niej jednak istnieją pewne techniczne czynniki, które mają wpływ na to, co może być najbardziej efektywnym sposobem wyrażenia konwer sji. Jednym z nich jest liczba potrzebnych konwersji. Jeśli dany obiekt musi być skonwertowany wyłącznie do jednego, innego obiektu, to można zastosować proste roz wiązanie. W przypadku gdy liczba potencjalnych konwersji jest znacznie większa, konieczne będzie wykorzystanie innego rozwiązania. Kolejnym czynnikiem, który należy rozważyć, są zależności między klasami. Nie warto wprowadzać nowej zależno ści tylko po to, by uzyskać możliwość wygodnego wyrażenia konwersji. Całkowicie odrębnym zagadnieniem jest im plem entacja konwersji. Czasami może ona polegać na utworzeniu nowego obiektu innego typu i skopiowaniu do niego in form acji z obiektu źródłowego. Kiedy indziej można zaimplementować interfejs obiektu docelowego bez kopiowania informacji z obiektu źródłowego. Zdarza się także, że alternatywą dla konwersji będzie określenie wspólnego interfejsu dostępnego w obiek cie źródłowym i docelowym i korzystanie z niego w kodzie.
Metoda konwertująca Jeśli istnieje konieczność wyrażenia konwersji między obiektam i podobnych typów, a przy tym liczba dostępnych konwersji jest ograniczona, to taką konwersję można wyrazić w formie metody obiektu źródłowego. Załóżmy na przykład, że mam zaimplementować współrzędne kartezjańskie i bie gunowe. W celu zaimplementowania metody konwertującej można by zdefiniować następującą metodę: class Polar { Cartesian asCartesian() { } }
K o n s t r u k t o r k o n w e r t u ją c y
I analogicznie w drugiej klasie. Należy przy tym zwrócić uwagę, że typem wynikowym metody konwertującej jest klasa obiektu docelowego. Celem konwersji jest bowiem uzy skanie obiektu o innym protokole. Innym rozwiązaniem może być zaimplementowanie w klasie Polar metod getX() oraz getY (), zadeklarowanie w klasach Polar i Cartesian protokołu klasy Point i całkowite wyeliminowanie konieczności stosowania konwersji. M etody konwersji mają tę zaletę, że ułatwiają analizę kodu. Są stosowane dosyć często (na przykład w środowisku Eclipse istnieje ponad 100 takich metod). Niemniej jednak w celu utworzenia takiej metody trzeba m ieć możliwość wprowadzenia zmian w protokole obiektu źródłowego. Oprócz tego uzależniają one obiekt źródłowy od obiektu docelowego. Jeśli wcześniej takiego uzależnienia nie było, to nie warto go wprowadzać tylko po to, by m óc zaimplementować metodę konwertującą. Poza tym metody kon wersji stają się niewygodne, jeśli liczba potencjalnych konwersji jest bardzo duża. Kla sa z 20 różnymi metodami typu asTo() i asTamto() staje się mało czytelna. Alterna tywnym rozwiązaniem może być zmiana klienta w taki sposób, by obsługiwał on obiekt źródłowy i nie wymagał żadnej konwersji. Wszystkie te wady sprawiają, że sam sporadycznie używam metod konw ertują cych, a robię to wyłącznie w sytuacjach, gdy konwertowane są obiekty podobnych ty pów. W e wszystkich pozostałych sytuacjach wyrażam konwersję przy użyciu kon struktora konwertującego.
Konstruktor konwertujący Konstruktor konwertujący pobiera obiekt źródłowy jako parametr wywołania i zwraca obiekt docelowy. Jest przydatny, gdy jeden obiekt źródłowy ma zostać skonwertowany na wiele obiektów docelowych, gdyż obiekt źródłowy nie ulega żadnym modyfikacjom. Na przykład klasa F ile definiuje konstruktor konwertujący, który przekształca łańcuch znaków reprezentujący nazwę pliku w obiekt, gotowy, by go używać do odczytywania, za pisywania oraz usuwania plików. Choć zapewne całkiem wygodnie byłoby m óc skorzy stać z metody S t r in g .a s F il e ( ) , to jednak liczba takich konwersji mogłaby być ogrom na; dlatego też lepszym rozwiązaniem jest tworzenie konstruktorów konwertujących, takich jak: F ile (S trin g name), URL(String spec) czy Strin g R ea d er(Strin g co n ten ts). Gdyby nie one, w klasie S trin g mogłaby się pojawić niczym nieograniczona liczba m etod konwertujących. Jeśli potrzebna jest nieskrępowana możliwość im plem entacji konwersji poprzez zwracanie czegoś innego niż obiekt konkretnej klasy, to można wyrazić konstruktor konw ertujący w formie metody wytwórczej zwracającej wynik bardziej ogólnego typu (bądź też umieszczonej w innej klasie niż ta, w której obiekt jest tworzony).
Utworzenie W dawnych czasach (jakieś pół wieku temu) programy były ogromną masą kodu i da nych, które trudno było od siebie odróżnić. Sterowanie mogło być przekazywane pom ię dzy dowolnymi m iejscam i tych programów. Dostęp do danych był możliwy z każdego
103
104
r o z d z ia ł
8
M eto d y
ich miejsca. Obliczenia, stanowiące pierwszy powód opracowania komputerów, były wykonywane z ogromną szybkością (oczywiście relatywnie) oraz perfekcyjną dokład nością. Jednak zauważono pewien dziwny fakt: program y były pisane zarówno po to, by z nich korzystać, jak i po to, by je modyfikować. W szystkie te przeskoki sterowania, samomodyfikujący się kod oraz możliwość dostępu do danych z dowolnego miejsca programu były wspaniałe z punktu widzenia realizacji programów, jednak były czymś upiornym, kiedy przychodziło do ich modyfikacji. I w ten sposób rozpoczęła się długa i wyboista droga do odnalezienia takich modeli obliczeń, by zmiany wprowadzane tutaj nie powodowały niezamierzonych problemów g d zie indziej. Zazwyczaj modyfikowanie małych programów jest łatwiejsze niż dużych. Jedną z wczesnych strategii, które miały ułatwić modyfikowanie programów, było dzielenie jednego dużego komputera wykonującego duży program na grupę mniejszych kom puterów (obiektów) wykonujących małe programy. Obiekty ułatwiały przyszłe zmia ny, dostarczając horyzontu zdarzeń, po którego przekroczeniu modyfikowanie pro gramu nie było kosztowne. Taki podział jest przeprowadzany z myślą o nas — ludziach — i naszych omylnych, zmiennych i twórczych umysłach, a nie z myślą o komputerach. Kom putery działają tak samo, niezależnie od tego, czy program stanowi jedną ogrom ną mieszaninę kodu, czy też jest artystycznie zaprojektowaną siecią wzajemnie wspierających się obiektów. Dla człowieka analizującego kod utworzenie obiektu jest stwierdzeniem: „Ten stan jest używany jako wsparcie dla pewnych obliczeń, których szczegóły nie są w tym m o mencie istotne”. Zapewnienie odpowiedniego przekazu poprzez wykorzystanie tworzenia obiektów wymaga zachowania równowagi między potrzebą przejrzystości, bezpośredniością przekazu a potrzebą elastyczności. W zorce im plem entacyjne związane z tworzeniem reprezentują techniki pozwalające na wyrażenie zm ienności w tworzeniu obiektów.
Kompletny konstruktor Obiekty do działania potrzebują określonych informacji. Te wymagania wstępne obiek tów można wyrazić poprzez udostępnienie konstruktora, który będzie zwracał obiekty gotowe od działania. Jeśli istnieje wiele różnych sposobów przygotowywania obiek tów, to można je wyrazić, definiując kilka konstruktorów, z których każdy będzie zwracał prawidłowo przygotowany obiekt. new Rectangle(0, 0, 50, 200); W iększą elastyczność zapewnia jednak wykorzystanie konstruktora bezargumentowego oraz odpowiedniej sekwencji metod ustawiających. Takie rozwiązanie pozwala też poprawnie wyrazić, jaka kom binacja parametrów jest niezbędna do prawidłowego działania obiektu. Rectangle box= new Rectangle(); box.setL eft(0); box.setWidth(50);
M eto d a w ytw ó rc za
box.setHeight(200); box.setTop(0); Czy można się obyć bez któregoś z tych parametrów? Analizując powyższy kod, nie sposób odpowiedzieć na to pytanie. Jeśli jednak zobaczymy w kodzie czteroargumentowy konstruktor, to będziemy wiedzieć, że wszystkie cztery argumenty są niezbędne. Konstruktory udostępniają konkretne obiekty. A zatem, pisząc kod wywołujący konstruktor, będziemy zapewne przekonani, że chcemy użyć obiektu konkretnej klasy. Je śli zechcemy, by kod był bardziej abstrakcyjny, możemy skorzystać z metody wytwór czej. Jednak nawet w takim przypadku warto udostępnić kompletny konstruktor, tak by zainteresowani czytelnicy mogli się szybko zorientować, jakie parametry są nie zbędne do utworzenia obiektu. Gdy implementujemy kompletny konstruktor, wszystkie konstruktory powinny od woływać się do jednego konstruktora głównego, który będzie odpowiadał za zainicjo wanie obiektu. Takie rozwiązanie gwarantuje, że wszystkie wersje konstruktorów będą spełniały wymagania niezbędne do zapewnienia prawidłowego działania obiektu oraz będą informowały o tych wymaganiach osoby, które w przyszłości zechcą zmodyfiko wać klasę.
Metoda wytwórcza Alternatywnym sposobem utworzenia obiektu jest utworzenie w klasie odpowiedniej m etody statycznej. W porównaniu z konstruktoram i takie rozwiązanie ma kilka zalet: metody wytwórcze mogą zwracać obiekty bardziej abstrakcyjnego typu (na przykład obiekty klasy pochodnej lub obiekty stanowiące im plem entację jakiegoś interfejsu), a oprócz tego mogą mieć nazwy wyrażające intencje, a nie odpowiadające nazwie kla sy. N iem niej jednak wprowadzenie metod wytwórczych powoduje wzrost złożoności kodu, dlatego warto je stosować, kiedy faktycznie dają konkretne korzyści, a nie jedy nie dla zasady. Utworzenie obiektu Rectangle przekształcone do postaci m etody wytwórczej m o głoby mieć następującą postać: Rectangle.create(0, 0, 50, 200); Jeśli chodzi nam o coś więcej niż samo utworzenie obiektu, na przykład o umieszcze nie nowego obiektu w pamięci podręcznej lub podjęcie decyzji, czy utworzyć obiekt klasy pochodnej, to zastosowanie metody wytwórczej może się okazać pomocne. Jeśli jednak o mnie chodzi, to odnalezienie w kodzie metody wytwórczej zawsze rozbudza m oją ciekawość. Czy dzieje się w niej coś jeszcze oprócz zwyczajnego utworzenia obiektu? Nie lubię marnować czasu innych programistów analizujących mój kod, dlatego też jeśli nie muszę robić nic więcej poza zwykłym utworzeniem obiektu, to wyrażam to poprzez zastosowanie konstruktora. Jeśli jednak chodzi o coś więcej, to korzystam z m e tody wytwórczej, by zwrócić na ten fakt uwagę czytelników.
105
106
r o z d z ia ł
8
M eto d y
Stosowana jest także wersja tego wzorca polegająca na zebraniu większej liczby metod wytwórczych i zaimplementowaniu ich w formie metod instancji pewnego obiektu wytwórczego. Takie rozwiązanie jest przydatne, gdy używamy kilku konkretnych klas, które mogą się jednocześnie zmieniać. Na przykład każdy system operacyjny może dysponować różnym i obiektam i wytwórczymi tworzącymi obiekty służące do wyko nywania wywołań systemowych.
Fabryka wewnętrzna Co można zrobić w sytuacji, gdy utworzenie obiektu pomocniczego jest operacją pry watną, a jednocześnie jest problemem złożonym lub może się zm ienić w klasie po chodnej? Rozwiązaniem jest utworzenie metody, która tworzy i zwraca nowy obiekt. Fabryki wewnętrzne są często używane w rozwiązaniach korzystających z inicjalizacji leniwej. Na przykład metoda ustawiająca jest używana w celu zasygnalizowania, że zmienna może być inicjalizowana w sposób leniwy: getX() { i f (x == nul l) x= . . . ; return x ; } Jak na jedną metodę podaje ona całkiem sporo inform acji. Jeśli obliczenie zm ien nej x jest bardzo skomplikowane, to przeniesienie go do fabryki wewnętrznej może być sensownym i opłacalnym rozwiązaniem: getX() { i f (x == nul l) x= computeX(); return x ; } op ró cz tego fabryki wewnętrzne zapewniają możliwość wprowadzania usprawnień w klasach pochodnych. M ożna ich używać także do wyrażania obliczeń, które korzy stają z tego samego algorytmu, lecz operują na innych danych. Ewentualnie można przekazywać struktury danych w formie parametru do obiektu pomocniczego.
Metoda dostępu do kolekcji Załóżmy, że dysponujemy obiektem zawierającym kolekcję. W jaki sposób można za pewnić dostęp do tej kolekcji? Najprostszym rozwiązaniem będzie utworzenie metody pobierającej, która zwróci tę kolekcję: List getBooks() { return books; } Takie rozwiązanie zapewnia klientom maksymalną elastyczność, lecz jednocześnie rodzi wiele problemów. M oże doprowadzić do unieważnienia wewnętrznego stanu
M e t o d a d o s t ę p u d o k o l e k c ji
obiektu, zależnego od zawartości tej kolekcji. Oprócz tego zapewnienie ogólnego do stępu do kolekcji przekreśla okazję do stworzenia w obiektach bogatego i zrozum iałe go interfejsu. Jednym z rozwiązań mogłoby być zapisanie kolekcji w innej kolekcji, której zawartości nie można modyfikować. Niestety, taki zewnętrzny obiekt będzie jedynie udawał ko lekcję na potrzeby kompilatora. Jeśli ktokolwiek spróbuje zmodyfikować jej zawartość, zostanie zgłoszony wyjątek. Debugowanie takich błędów jest kosztowne, zwłaszcza w kodzie znajdującym się w fazie tworzenia. List getBooks() { return Collections.unmodifiableList(books); } Zamiast tego należy stworzyć metodę zapewniającą ograniczony, lecz sensowny do stęp do inform acji zgromadzonych w kolekcji: void addBook(Book arrival) { books.add(arrival) ; } int bookCount() { return books.size(); } Jeśli klienci będą mieli potrzebę przejrzenia wszystkich elementów kolekcji, to można stworzyć metodę zwracającą iterator: Iterato r getBooks() { return books.iterator(); } Takie rozwiązanie w zasadzie uniemożliwia wprowadzanie zmian w zawartości kolek cji, choć pozostawia możliwość skorzystania z nieznośnej metody remove() interfejsu I t e r a t o r . Aby mieć całkowitą pewność, że klienci nie będą w stanie zmodyfikować zawartości kolekcji, należy zwracać iterator, który w razie usunięcia elementu będzie zgłaszał wyjątek. Także w tym przypadku ryzykowne i kosztowne są tylko te rozwiązania, w których inform acje o błędach będą przekazywane w trakcie działania programu. Iterator getBooks() { final Iterator reader= books.iterator(); return new Iterator() { public boolean hasNext() { return reader.hasNext(); } public Book next() { return reader.next(); } public void remove() { throw new UnsupportedOperationException(); } }; }
107
108
r o z d z ia ł
8
M eto d y
Jeśli okaże się, że musimy powielić większą część interfejsu kolekcji, zapewne bę dzie to świadczyć o jakim ś błędzie projektowym. Gdyby obiekt wykonywał więcej za dań na rzecz swoich klientów, to nie musiałby zapewniać tak szerokiego dostępu do swoich wewnętrznych szczegółów.
Metoda określająca wartości logiczne Jaki jest najlepszy sposób udostępniania protokołu służącego do określania stanu prze chowywanego w formie wartości logicznych? Najprostszym rozwiązaniem będzie udo stępnienie odpowiedniej metody ustawiającej: void setValid(boolean newState) { } Jeśli klienci potrzebują elastyczności, którą zapewnia taka metoda, to takie rozwiązanie doskonale wykona swoje zadanie. Niemniej jednak, jeśli we wszystkich wywołaniach takiej metody będą pojawiały się jedynie stałe true lub fa lse , to istnieje możliwość udostępnie nia lepszego, bardziej ekspresyjnego interfejsu — polega ona na zdefiniowaniu dwóch metod, z których każda będzie służyć do zapisywania jednej w artości logicznej: void valid() { . . . void invalid() { . . . Kod korzystający z takiego interfejsu jest bardziej czytelny i łatwiej w nim odnaleźć miejsca, w których jest określany stan obiektu. Jednak jeśli zauważymy fragment kodu o następującej postaci: i f (...w yrażenie_logiczne...) cache.valid(); else cache.invalid(); to dwie metody — v a lid () oraz in v alid () — należy zastąpić jedną metodą s e tV a lid ity ^ (b o o le a n ).
Metoda zapytania Czasami jeden obiekt musi podejmować decyzje na podstawie stanu innego obiektu. Nie jest to sytuacja idealna, gdyż generalnie rzecz biorąc, obiekty powinny być w stanie samodzielnie podejmować decyzje. Niemniej jednak, jeśli w ramach protokołu obiektu mają być udostępniane kryteria służące do podejmowania decyzji, to należy to zrobić przy wykorzystaniu metody, której nazwa jest poprzedzona jakąś formą czasownika być (np: je s t lub był bądź w wersji angielskiej is lub w as) lub m ieć. Jeśli działanie jednego obiektu w dużym stopniu zależy od stanu innego obiektu, może to stanowić sugestię, że logika została umieszczona w niewłaściwym miejscu. Na przykład jeśli w kodzie pojawi się następujący fragment:
M eto d a ró w n o śc i
i f (w idget.isV isible()) widget.doSomething(); else widget.doSomethingElse(); może on świadczyć o tym, że w widżecie brakuje jakiejś metody. W takim przypadku należy przenieść logikę i sprawdzić, czy uzyskany kod będzie bardziej czytelny. Czasami takie modyfikacje mogą się kłócić z naszymi wstępnymi zało żeniami odnośnie do tego, które obiekty są odpowiedzialne za określone fragmenty obliczeń. Jednak zaakceptowanie tego, co widzimy, i odpowiednie postępowanie za zwyczaj prowadzi do usprawnienia projektu. Efekt uzyskany dzięki takim modyfikacjom będzie bardziej czytelny i najczęściej bardziej użyteczny niż ten, który może zostać osiągnięty przez uparte trzymanie się rozwiązania opracowanego przed zdobyciem dodatkowych doświadczeń.
Metoda równości Jeśli pojawi się konieczność sprawdzenia równości dwóch obiektów, na przykład dla tego, że są one używane jako klucze w tablicy mieszającej, a jednocześnie ich tożsamość nie ma znaczenia, to należy zaimplementować dwie metody: equals() oraz hashCode(). Ponieważ dwa obiekty, które są sobie równe, muszą mieć taką samą wartość skrótu, podczas jej obliczania należy używać tylko tych danych, które są uwzględniane pod czas określania równości. Na przykład podczas pisania oprogramowania finansowego może się pojawić ko nieczność operowania na instrumentach finansowych dysponujących numerami seryj nymi. W takim programie metoda używana do testowania równości mogłaby przyjąć postać: Instrum ent
public boolean equals(Object other) { i f ( ! other instanceof Instrument) return fa lse ; Instrument instrument= (Instrument) other; return getSerialNumber().equals(instrument.getSerialNumber()); } W arto zwrócić uwagę na instrukcję warunkową umieszczoną na początku metody i odgrywającą rolę wartownika. Teoretycznie każde dwa obiekty można porównać pod względem równości, dlatego też kod powinien być przygotowany na taką ewentualność. Jeśli jednak wiemy, że próba sprawdzenia równości obiektów dwóch różnych klas za kończy się błędem, taką instrukcję warunkową można usunąć i pozwolić, by w razie czego został zgłoszony wyjątek C lassC astException. Ewentualnie wewnątrz instrukcji warunkowej można zgłaszać wyjątek IllegalArgum entException. Ponieważ num er seryjny jest jedyną inform acją używaną podczas sprawdzania równości obiektów, jest to także jedyna inform acja, która powinna być wykorzysty wana do wyznaczania wartości skrótu:
109
110
r o z d z ia ł
8
M eto d y
Instrum ent
public int hashCode() { return getSerialNumber.hashCode(); } W arto zauważyć, że w przypadku niewielkich zbiorów danych także 0 stanowi ak ceptowalną wartość skrótu. Wszystkie te rozważania na temat równości wydawały się znacznie ważniejsze 20 lat temu. Doskonale pamiętam, że poświęcałem naprawdę dużo czasu na projektowanie rozbudowanych schematów określania równości. Rysunkowy żart z tam tych czasów przedstawiał dwie osoby siedzące przy kontuarze. Pierwsza z nich mówi do serwera: „Chcę m ieć to, co on”, a w odpowiedzi serwer chwyta talerz drugiej osoby i stawia przed pierwszą. Stosowane obecnie metody eq u a ls() oraz hashCode() są szczątkowymi pozostało ściam i po tamtych sposobach określania równości. Aby ich używać, trzeba się stoso wać do określonych zasad. Jeśli o tym zapomnimy, możemy narazić kod na dziwne defekty, takie jak brak możliwości odczytania z kolekcji obiektu, który zapisaliśmy w niej w poprzednim wierszu kodu. innym sposobem radzenia sobie z zagadnieniem równości jest zagwarantowanie, że jeśli dwa niezmienne obiekty są sobie równe, to będą tym samym obiektem. Na wy korzystanie takiego rozwiązania pozwala na przykład tworzenie obiektów w metodzie wytwórczej: Instrum ent
s ta tic Instrument create(String serialNumber) { i f (cache.containsKey(serialNumber)) return cache.get(serialNumber); Instrument result= new Instrument(serialNumber); cache.put(serialNumber, re su lt); return resu lt; }
Metoda pobierająca Jednym ze sposobów zapewnienia dostępu do stanu obiektu jest napisanie metody, która będzie go zwracać. W języku Java nazwy takich metod zwyczajowo zaczynają się od przedrostka „get”1. Oto przykład: int getX() { return x; } Ta konw encja jest pewną formą metadanych. Niegdyś starałem się nadawać m eto dom pobierającym nazwy odpowiadające zmiennym, których wartości metody te zwra cały, jednak w miarę szybko z tego pomysłu zrezygnowałem. Skoro osoby analizujące
1 W języku angielskim czasownik ten ma wiele znaczeń, takich jak: uzyskiwać, zdobywać czy dostawać — przyp. tłum.
M e t o d a u s t a w ia ją c a
kod uważają, że nazwa „getX” jest łatwiejsza do zrozumienia niż „x”, to uznałem, że niezależnie od m oich osobistych preferencji lepiej będzie pisać kod zgodny z ich ocze kiwaniami. Sposób, w jaki należy implementować metody pobierające, nie jest ani równie in teresujący, ani ważny jak odpowiedź na pytanie, czy w ogóle warto je pisać bądź czy należy je udostępniać kodowi spoza obiektu. Zgodnie z zasadą zalecającą umieszczanie danych i logiki w jednym miejscu potrzeba utworzenia publicznej lub dostępnej w ramach pa kietu metody pobierającej może stanowić sugestię, że logika powinna być umieszczona gdzie indziej. Zatem zamiast tworzyć metodę pobierającą, można spróbować przenieść logikę korzystającą z danych, które ta metoda zwraca. Istnieje kilka wyjątków od m ojej awersji do publicznych metod pobierających. Jednym z nich są sytuacje, gdy dysponuję zbiorem algorytmów zaimplementowanych w odrębnych obiektach. Algorytmy wymagają dostępu do danych i muszą je uzyskiwać przy wykorzystaniu metod pobierających. Kolejnym wyjątkiem są sytuacje, gdy chcę dysponować metodą publiczną i tak się składa, że jej im plem entacja zwraca wartość pola. I w końcu jeśli metody pobierające będą używane przez jakieś narzędzia, to za pewne także będą musiały być zadeklarowane jako publiczne. W ewnętrzne metody pobierające (prywatne lub chronione) są przydatne w razie stosowania inicjalizacji leniwej lub rozwiązań korzystających z przechowywania obiektów w pamięci podręcznej. Podobnie jak wszystkie dodatkowe abstrakcje, także te usprawnie nia należy odłożyć w czasie do chwili, gdy będą naprawdę potrzebne.
Metoda ustawiająca Jeśli potrzebujemy metody określającej wartość pola, to jej nazwą powinna być nazwa tego pola poprzedzona słowem „set”. Oto przykład: void setX(int newX) { x= newX; } Jeśli o m nie chodzi, jestem jeszcze większym przeciwnikiem tworzenia publicznie dostępnych metod ustawiających niż metod pobierających. Ich nazwy są określane na podstawie im plem entacji, a nie intencji. Jeśli jakiś użyteczny fragm ent interfejsu naj lepiej jest zaimplementować poprzez ustawienie wartości pola, to nic w tym złego, nie mniej jednak nazwa takiej operacji powinna być określana z perspektywy kodu klien ta. Najlepiej będzie zrozumieć, jaki problem rozwiązuje klient, podając wartość pola, i utworzyć metodę stanowiącą bezpośrednie rozwiązanie tego problemu. Używanie metod ustawiających jako elementów interfejsu prowadzi do rozpo wszechnienia inform acji o im plem entacji: paragraph.setJustifi cation(Paragraph.CENTERED); Natom iast stosowanie w interfejsie nazw odpowiadających przeznaczeniu metod pozwala na komunikowanie intencji: paragraph.centered();
111
112
r o z d z ia ł
8
M eto d y
choć sama im plem entacja metody cen tered () może korzystać z metody ustawiającej: Paragraph:centered() { setJustification(CENTERED); } M etody ustawiające stosowane wewnętrznie (czyli prywatne lub chronione) mogą być bardzo użyteczne, na przykład kiedy trzeba zaktualizować jakieś dane zależne. Konty nuując poprzedni przykład, zmiana wyrównania akapitu może wymagać jego ponownego wyświetlenia. Z powodzeniem można to zaimplementować w metodzie ustawiającej: private void s e t J u s t if ic a tio n ( ...) { redisplay(); } Takie zastosowanie metod ustawiających można uznać za prosty m echanizm za leżności, który zapewnia, że jeśli te dane się zmienią, to tam te zależne od nich informacje zostaną odpowiednio zaktualizowane (w podanym przykładzie będą to wewnętrzne ustawienia akapitu oraz inform acje wyświetlane na ekranie). Metody ustawiające sprawiają, że kod staje się wrażliwy. Jedną z zasad jest unikanie akcji wykonywanych na większą odległość. Jeśli obiekt A zależy od szczegółów we wnętrznej reprezentacji obiektu B, to zmiana kodu obiektu B spowoduje konieczność zmiany kodu obiektu A, i to nie dlatego, że obiekt ten zm ienił się w jakiś kluczowy sposób, lecz jedynie dlatego, że zmieniły się założenia, na podstawie których był two rzony. Optymalnym rozwiązaniem będzie przeniesienie danych bądź logiki, tak by były one umieszczone w jednym miejscu. Być może to obiekt A powinien zawierać dane, a może obiekt B powinien udostępniać sensowny protokół. Jeśli dysponujemy narzędziem odwołującym się do metod ustawiających, to należy je odpowiednio opisać i zadeklarować jako publiczne; tak jak w przypadku metod po bierających. Jednak pisząc kod z myślą o innych programistach, warto zapewnić bar dziej komunikatywny i modularny interfejs.
Bezpieczna kopia Korzystając z metod pobierających i ustawiających, potencjalnie narażamy się na proble my z utożsamianiem nazw, polegające na tym, że dwa obiekty mogą przyjąć, iż dyspo nują wyłącznym dostępem do jakiegoś trzeciego obiektu. Problemy tego typu są oznaką innych, głębszych problemów, takich jak brak przejrzystego określenia, które obiekty są odpowiedzialne za konkretne dane. Niektórych defektów związanych z takim i pro blem am i można uniknąć, tworząc kopię obiektu, zanim zostanie on zwrócony lub trwale zapisany: List getBooks() { List result= new ArrayList(); result.addAll(books); return resu lt; }
B e z p ie c z n a k o p ia
W tym przypadku lepszym rozwiązaniem byłoby pewnie udostępnienie metod do stępu do kolekcji. Niem niej jednak, jeśli konieczne jest zapewnienie dostępu do całej kolekcji, w przedstawiony sposób można to zrobić bezpiecznie. Także metody ustawiające można tworzyć tak, by korzystały z bezpiecznych kopii: void setBooks(List newBooks) { books= new ArrayList(); books.addAll(newBooks); } Przypominam sobie analizę pewnego systemu bankowego, w którym bezpieczne kopie były nadużywane. Każda metoda pobierająca i ustawiająca była w nim zaim plementowana w dwóch wersjach: „bezpiecznej” i niebezpiecznej. W celu uniknięcia problemów z utożsamianiem nazw całe ogromne struktury obiektów były kopiowane za każdym razem, gdy została wywołana „bezpieczna” wersja metody. System był tak wolny, że klienci korzystali zazwyczaj z wersji niebezpiecznej, narażając się przez to na występowanie wielu problemów. Kluczowy błąd projektowy tego systemu, polegający na tym, że obiekty nie udostępniały odpowiednio sensownego protokołu, nigdy nie został naprawiony. Bezpieczne kopie są jedynie środkiem łagodzącym, z którego można korzystać w celu zabezpieczenia kodu przed niekontrolowanym dostępem z zewnątrz. Rzadko kiedy powinny stanowić jeden z elementów podstawowej semantyki im plem entacji. zn acznie prostszym, bardziej komunikatywnym, a jednocześnie m niej narażonym na błędy interfejsem są obiekty niezmienne oraz metody złożone.
Wniosek W tym rozdziale zostały przedstawione wzorce związane z tworzeniem metod. Była to ostatnia grupa wzorców implementacyjnych związanych z językiem Java, które chciałem przedstawić. Następny rozdział prezentuje wzorce powiązane z korzystaniem z klas kolekcji.
113
1 14
rozdział 8
Metody
Rozdział 9
Kolekcje
Muszę przyznać, że nie oczekiwałem, iż ten rozdział będzie aż tak duży. Kiedy zaczynałem go pisać, sądziłem, że będzie zawierał wyłącznie dokum entację API — typy i operacje. Sama idea jest bowiem bardzo prosta: kolekcja odróżnia obiekty, które są w niej umiesz czone, od tych, których w niej nie ma. Trudno powiedzieć na ten tem at coś więcej. Okazało się jednak, że kolekcje są znacznie bardziej obszernym zagadnieniem, niż kiedykolwiek podejrzewałem, i to zarówno pod względem struktury, jak i oferowanych przez nie możliwości wyrażania intencji. Idea kolekcji łączy w sobie kilka różnych metafor. To, na którą z nich zdecydujemy się położyć nacisk, zmienia sposób korzy stania z kolekcji. Każdy z interfejsów kolekcji wyraża inne podejście do zagadnienia zbioru obiektów. Także każda z implementacji komunikuje co innego, przy czym różnice są związane przede wszystkim z wydajnością działania. W efekcie okazuje się, że opa nowanie posługiwania się kolekcjam i jest ważnym elementem um iejętności dobrego komunikowania się przy wykorzystaniu kodu. Niegdyś zachowania przypominające kolekcje były implementowane poprzez udo stępnianie połączeń wewnątrz samej struktury danych: każda strona dokumentu zawie rała połączenia z innym i stronam i — poprzednią i następną. Ostatnio moda się zmie niła i preferowane jest stosowanie odrębnych obiektów reprezentujących kolekcje, pozwalające na grupowanie powiązanych ze sobą obiektów. Takie rozwiązanie zapewnia elastyczność, pozwalającą na umieszczanie jednego obiektu w kilku różnych kolek cjach, bez konieczności modyfikowania jego zawartości. Kolekcje są ważne, gdyż stanowią sposób wyrażania jednej z podstawowych zm ienno ści występujących w programowaniu: zmiennej liczby elementów. Zm ienność logiki jest wyrażana przy użyciu konstrukcji warunkowych oraz komunikatów polimorficznych. Zm ienność ilości danych jest natomiast wyrażana poprzez umieszczanie ich w kolek cjach. Konkretne szczegóły działania kolekcji ujawniają bardzo wiele inform acji, jakie jej twórca m iał dla czytelników. Istnieje stare (w kategoriach informatyki) powiedzenie, że jedynym i interesujący mi liczbam i są zero, jeden oraz wiele (zapewne nie wymyślił go żaden m atematyk 115
116
r o z d z ia ł
9
K o l e k c je
analityk). Jeśli brak pola potraktujem y jako reprezentację „zera”, a istnienie pola jako reprezentację „jedynki”, to pole zawierające kolekcję będzie reprezentowało trzecią możliwość — „wiele”. Niem niej jednak kolekcje egzystują w dziwnym świecie, gdzieś pomiędzy kon strukcjam i języków programowania i bibliotekami. Są one tak ogólnie użyteczne, a ich wykorzystanie zostało tak dobrze zrozumiane, że można oczekiwać, iż już niebawem zamiast instrukcji Collection books= new HashSet(); będzie można napi sać coś takiego: plural unique Book books;. Jednak póki kolekcje nie staną się pełno prawnymi elementami języka, ważne jest, by wiedzieć, jak korzystać z istniejących b i bliotek klas, aby w prosty i zrozumiały sposób wyrażać popularne idee. Reszta tego rozdziału została podzielona na sześć części: metafory leżące u podstaw kolekcji, zagadnienia wyrażane poprzez stosowanie kolekcji, interfejsy kolekcji oraz ich znaczenie dla osób analizujących kod, im plem entacje kolekcji oraz to, co one wy rażają, przegląd funkcji klasy C o lle ctio n i w końcu rozszerzanie kolekcji przy użyciu dziedziczenia.
Metafory Jak już wspominałem wcześniej, kolekcje łączą w sobie różne metafory. Pierwszą z nich jest zmienna mogąca zawierać wiele wartości. Jeden ze sposobów pojmowania kolekcji zakłada, że zmienna odwołująca się do kolekcji jest w rzeczywistości zmienną odwo łującą się jednocześnie do kilku obiektów. M ożna na to spojrzeć tak, że przestajemy traktować kolekcję jako niezależny obiekt. Tożsam ość kolekcji nie ma znaczenia — li czą się tylko obiekty, do których kolekcja się odwołuje. Podobnie ja k w przypadku wszystkich innych zmiennych, do takiej zmiennej mogącej zawierać wiele wartości można przypisać inną zm ienną (czyli dodawać lub usuwać elementy kolekcji), można pobierać jej wartość oraz przesyłać do niej komunikaty (w pętli fo r). W języku Java m etafora wartości mogącej zawierać wiele wartości nie sprawdza się dobrze, gdyż kolekcje są odrębnymi obiektami posiadającymi swoją własną tożsamość. Drugą m etaforą kolekcji są obiekty — kolekcja jest obiektem. M ożna pobrać ko lekcję, przekazywać ją, porównywać i przesyłać do niej komunikaty. Kolekcje mogą także być współużytkowane przez wiele obiektów, choć wiąże się to z niebezpieczeń stwem wystąpienia problemu utożsamiania nazw. Kolekcje są zbiorem powiązanych ze sobą interfejsów oraz implementacji, więc można je rozszerzać, i to zarówno poprzez rozbudowę interfejsów, jak i tworzenie nowych im plem entacji. A zatem kolekcje tak samo „są” zm iennym i mogącym i zawierać wiele wartości, ja k i obiektami. Połączenie tych dwóch metafor daje dosyć dziwne efekty. Ponieważ kolekcje są im plementowane jako obiekty, można je przekazywać; uzyskujemy w ten sposób odpowied nik przekazywania przez referencję, kiedy zamiast zawartości zmiennej przekazywana jest sama zmienna. W takim przypadku zmiany zawartości zmiennej będą zauważalne także w metodzie wywołującej. W projektowaniu oprogramowania przekazywanie przez
Z a g a d n ie n ia
referencję przestało być modne jakiś czas temu ze względu na wiążące się z nim ryzy ko występowania niezamierzonych konsekwencji. Trudno jest debugować programy, kiedy nie można wskazać wszystkich m iejsc, w których zmienna może się zmienić. Opracowano pewne konw encje programowania z wykorzystaniem kolekcji, pozwala jące unikać sytuacji utrudniających analizę kodu oraz określanie m iejsc, w których kolekcje mogą być modyfikowane. Następną metaforą pom ocną podczas myślenia o kolekcjach jest matematyczne pojęcie zbioru. Kolekcja jest zbiorem obiektów, tak jak zbiór matematyczny jest grupą elementów. Każdy zbiór dzieli świat na rzeczy, które się w nim znajdują oraz które do niego nie należą. Z kolei kolekcje dzielą świat obiektów na te, które należą do kolekcji i które do niej nie należą. Zbiory matematyczne udostępniają dwie podstawowe operacje: określanie ich liczebności (której odpowiada metoda s iz e () kolekcji) oraz sprawdzanie, czy zbiór zawiera określony element (której odpowiada metoda con tain s() kolekcji). W odniesieniu do kolekcji metafora zbioru matematycznego jest jedynie pewnym przybliżeniem. Kolekcje nie udostępniają bowiem bezpośrednio innych podstawo wych operacji na zbiorach — wyznaczania ich sumy, przecięcia, różnicy oraz różnicy symetrycznej. Próba odpowiedzi na pytanie, czy dzieje się tak dlatego, że operacje te są po prostu mniej interesujące, czy też dlatego, że nie są dostępne, może stanowić tem at interesującej dyskusji.
Zagadnienia W kontekście programów i programowania kolekcje są stosowane do wyrażania kilku ortogonalnych pojęć. Z zasady należy starać się wyrażać możliwie jak najprecyzyjniej. W przypadku kolekcji oznacza to stosowanie w deklaracjach jak najbardziej szczegółowe go interfejsu oraz jak najbardziej szczegółowej klasy, która go implementuje. Niemniej jednak nie jest to reguła, której trzeba przestrzegać bezwarunkowo. Bardzo dokładnie przeanalizowałem kod JU n it i uogólniłem wszystkie deklaracje zmiennych. W rezulta cie powstał spory bałagan, gdyż w kodzie zabrakło jednorodności. Zamieszanie wyni kające z tego, że ten sam obiekt był w jednym miejscu zadeklarowany jako Ite ra b le , w innym jako C o lle ctio n , a w jeszcze innym jako L is t, sprawiło, że analiza kodu była trudna, a jednocześnie nie przyniosło to żadnych korzyści. Znacznie bardziej przejrzysty kod można było uzyskać, używając we wszystkich deklaracjach zmiennej typu L ist. Pierwszym pojęciem wyrażanym przez kolekcje jest ich wielkość. Tablice (będące prymitywnymi kolekcjam i) mają konkretną wielkość określaną w m om encie ich two rzenia. W iększość kolekcji pozwala na zmienianie wielkości po utworzeniu. D rugim pojęciem wyrażanym przez kolekcje jest to, czy kolejność ich elementów ma znaczenie, czy nie. Obliczenia, w których elementy wpływają na siebie nawzajem bądź w których uporządkowanie elementów ma znaczenie dla zewnętrznych użyt kowników tych obliczeń, wymagają zastosowania kolekcji umożliwiających zachowanie uporządkowania elementów. Uporządkowanie to może odpowiadać kolejności, w jakiej
117
118
r o z d z ia ł
9
K o l e k c je
elementy były dodawane do kolekcji, może też być określane przez jakieś czynniki ze wnętrzne, takie jak porównanie leksykograficzne. Kolejnym zagadnieniem wyrażanym przez kolekcje jest unikalność elementów. Istnieją takie obliczenia, w których istnienie lub brak elementu ma znaczenie, oraz ta kie, które wymagają, by element mógł występować w kolekcji dowolnie wiele razy. A w jaki sposób można się odwoływać do elementów kolekcji? Czasami wystarczy przejrzeć je kolejno i na każdym wykonać jakieś obliczenia. W innych przypadkach konieczna jest możliwość zapisywania i pobierania elementów określanych na pod stawie pewnego klucza. I w końcu wybór takiej, a nie innej kolekcji może być środkiem wyrazu inform ują cym o uwarunkowaniach związanych z wydajnością działania. jeśli wyszukiwanie li niowe sprosta naszym wymaganiom, to z powodzeniem będziemy mogli skorzystać z ogólnej klasy C o lle ctio n . Jeśli jednak kolekcja będzie dostatecznie duża, to większe go znaczenia nabierze możliwość sprawdzania istnienia elementów oraz pobierania ich przy użyciu klucza, to z kolei będzie sugerowało zastosowanie klas S e t lub Map. A zatem przemyślany wybór kolekcji pozwala zoptymalizować zarówno szybkość ko rzystania z niej, jak i jej rozmiary.
Notatka: Wydajność W większości przypadków znaczna część programistów nie musi zwracać uwagi na zagadnienia wydajności działania operacji o niewielkiej skali. To bardzo przyjemna zmiana w porównaniu z dawnymi czasami, kiedy to po prawianie wydajności działania było codziennym zajęciem. Niemniej jednak zasoby dostępne dla programów nie są nieograniczone. Kiedy doświadcze nie pokaże konieczność poprawienia wydajności, a pomiary wykażą wystę powanie wąskich gardeł, to bardzo dużego znaczenia nabierze zrozumiałe wy rażenie decyzji związanych z wydajnością działania. Często poprawę wydajności działania można uzyskać, obniżając którąś z m iar jakości kodu, na przykład jego czytelność lub elastyczność. W ażne jest jednak, by koszt zapewnienia wymaganej wydajności był jak najmniejszy. Pisanie kodu z myślą o zapewnieniu wydajności może naruszać zasadę lokalnych konsekwencji. Niewielka zmiana w jednym miejscu programu m o że doprowadzić do pogorszenia wydajności innego fragmentu kodu. Jeśli metoda działa wydajnie tylko wtedy, gdy przekazywana do niej kolekcja pozwala na szybkie sprawdzanie dostępności obiektów, to pozornie niewinna zmiana kolekcji HashSet na A rrayList w innym miejscu programu może doprowadzić do poważnego spadku jej wydajności. Pojawianie się konse kwencji w odległych miejscach kodu jest kolejnym argumentem, by w przy padkach, gdy wydajność ma kluczowe znaczenie, zachować dużą ostroż ność podczas tworzenia kodu.
I n t e r f e js y
Zagadnienie wydajności wiąże się z kolekcjam i, ponieważ większość z nich może się nadmiernie rozrastać. Struktura danych przechowująca wpisywane przeze mnie litery stanowiące treść tej książki musi pozwalać na przechowywanie m ilionów znaków. Chciałbym, żeby wpisanie m ilio nowego znaku było równie szybkie jak pierwszego. M oja ogólna strategia zapewniania wydajności w przypadku korzystania z kolekcji polega na tym, by początkowo wybierać możliwie jak najprostszą im plem entację, a ewentualnie później, kiedy stanie się to niezbędne, sięgać po bardziej wyspecjalizowane klasy. Podejm ując decyzje związane z wy dajnością działania, staram się, by obszar wywieranego przez nie wpływu był możliwie jak najmniejszy, i to nawet gdyby wymagało to wprowadzenia pewnych zmian w projekcie. Później, kiedy wydajność ponownie jest do statecznie dobra, przerywam usprawnianie kodu.
Interfejsy Patrząc na interfejsy zastosowane w deklaracjach zm iennych oraz wybierane im ple m entacje, osoby analizujące kod korzystający z kolekcji będą poszukiwały odpowiedzi na różne pytania. Użyte deklaracje interfejsów przekazują inform acje na tem at kolek cji: czy uwzględnia ona kolejność elementów, czy mogą w niej występować powtarza jące się elementy oraz czy istnieje możliwość pobrania elementu kolekcji na podstawie klucza, czy też trzeba pobierać wszystkie elementy w pętli. O to dostępne interfejsy kolekcji: ■
Klasa Array — tablice są najprostszymi i najm niej elastycznymi kolekcjam i, posia dają ściśle określoną liczbę elementów, prostą składnię odwołań i dużą szybkość działania.
■
Ite r a b le — to prosty interfejs kolekcji, pozwalający na dostęp do ich zawartości wyłącznie poprzez wyliczanie.
■
C o lle ctio n — ten interfejs zapewnia możliwość dodawania i usuwania elementów oraz sprawdzania, czy podany elem ent jest dostępny w kolekcji.
■
L is t — reprezentuje kolekcję, której elementy są uporządkowane i można się do nich odwoływać na podstawie ich położenia wewnątrz kolekcji (innym i słowy, można stwierdzić: „Proszę o trzeci element kolekcji”).
■ ■
Set — reprezentuje kolekcje, których elementy nie mogą się powtarzać. Sorted Set — reprezentuje uporządkowaną kolekcję, której elementy nie mogą się powtarzać.
■
Map — te kolekcje zapewniają możliwość stosowania kluczy podczas zapisywania i odczytywania elementów.
119
120
r o z d z ia ł
9
K o l e k c je
Tablice (klasa Array) Tablice są najprostszym interfejsem kolekcji. Niestety, nie zapewniają takiego samego protokołu jak pozostałe kolekcje, dlatego zamiana tablic na dowolną inną kolekcję jest znacznie trudniejsza niż zamiana dwóch innych rodzajów kolekcji. W odróżnieniu od większości innych kolekcji, tablice mają stałą wielkość, określaną podczas ich tworze nia. Odróżnia je także to, że są wbudowanym elementem języka, a nie elementem b i blioteki klas. Jeśli chodzi o proste operacje, to tablice są bardziej efektywne niż inne rodzaje ko lekcji, zarówno pod względem szybkości działania, jak i zajętości pamięci. Testy wydajno ści, które wykonałem na potrzeby tej książki, wykazały, że odwołania do tablic (na przykład e le m e n ts [i]) są ponad 10 razy szybsze od analogicznych operacji na kolekcji ArrayList. (Ponieważ wyniki uzyskiwane w różnych systemach operacyjnych znacząco różnią się od siebie, osoby zainteresowane różnicam i w wydajności działania powinny same przeprowadzić odpowiednie testy). Elastyczność pozostałych klas kolekcji sprawia, że w większości przypadków skorzystanie z nich będzie lepszym rozwiązaniem, niemniej jednak tablice są użyteczną sztuczką, którą warto móc wyciągnąć z rękawa, gdy konieczne jest uzyskanie lepszej wydajności działania w jakiejś niewielkiej części aplikacji.
Interfejs Iterable Zadeklarowanie zmiennej jako Ite r a b le stanowi jedynie stwierdzenie, że może ona zawierać wiele wartości. W języku Java 5 interfejs ten stanowi podstawę działania pętli. Każdy obiekt zadeklarowany jako Ite r a b le może być zastosowany w pętli for. Im plem entacja tego rozwiązania w niedostrzegalny sposób bazuje na wywoływaniu m e tody i t e r a t o r ( ) . Jednym z zagadnień, jakie trzeba uwzględnić podczas korzystania z kolekcji, jest to, czy klienci mają możliwość modyfikowania ich zawartości. Niestety, ani interfejs Ite ra b le , ani wspomagający go interfejs It e r a t o r nie zapewniają deklaratywnego przekazania inform acji, czy kolekcja nie powinna być modyfikowana. Jeśli dysponujemy obiektem Ite r a b le i użyjemy go do pobrania obiektu Ite r a to r , to bez przeszkód będziemy m o gli wywołać metodę remove(), by usuwać elementy z kolekcji. C hoć interfejs Ite r a b le nie daje możliwości dodawania do kolekcji nowych elementów, to jednak pozwala na ich usuwanie, i to w taki sposób, że obiekt zawierający kolekcję nie zostanie powiado m iony o żadnych zmianach w jej zawartości. Zgodnie z inform acjam i podanymi w podrozdziale „Metoda dostępu do kolekcji”, na stronie 91., istnieje kilka sposobów pozwalających zagwarantować, że kolekcja nie zostanie zmodyfikowana, takich jak umieszczenie jej wewnątrz innej kolekcji, która nie pozwala na modyfikację zawartości, utworzenie niestandardowego iteratora, który będzie zgłaszał wyjątek w przypadku próby modyfikacji kolekcji, bądź też zwrócenie bezpiecznej kopii kolekcji.
I n t e r f e js y
Interfejs Ite r a b le jest prosty — nie dopuszcza nawet możliwości sprawdzenia liczby elementów w kolekcji, jedyne, na co pozwala, to pobieranie jej kolejnych ele mentów. Bardziej użyteczne możliwości dają natomiast jego interfejsy pochodne.
Interfejs Collection — kolekcje Interfejs C o lle ctio n dziedziczy po Ite r a b le , wzbogacając jego możliwości o operacje dodawania, usuwania, wyszukiwania elementów oraz określania ich liczby. Zadekla rowanie zmiennej lub metody typu C o lle ctio n daje bogate możliwości wykorzystania wielu klas im plementujących. Stosując możliwie najbardziej ogólną deklarację, za pewniamy sobie możliwość późniejszej zmiany użytej klasy im plem entującej, bez ko nieczności ponoszenia konsekw encji tej zmiany w innych m iejscach kodu. Kolekcje przypominają nieco matematyczne pojęcie zbioru, z tą różnicą, że opera cje stanowiące odpowiedniki działań na zbiorach — sumy, przecięcia oraz różnicy (ad d A ll(), r e ta in A ll() oraz removeAl l ( ) ) — zm ieniają istniejący obiekt kolekcji, za miast zwracać nową kolekcję.
Interfejs List — listy Interfejs L is t dodaje do możliwości interfejsu C o lle ctio n ideę stabilnego uporządko wania elementów. Elem ent można pobrać na podstawie jego indeksu w kolekcji. Sta bilne uporządkowanie jest ważne, gdy między poszczególnymi elementam i kolekcji występują jakieś interakcje. Na przykład kolejka wiadomości, które należy przetwarzać w kolejności ich odbierania, powinna zostać zaim plementowana przy użyciu listy.
Interfejs Set — zbiory Interfejs Set reprezentuje kolekcję, która nie może zawierać duplikatów (czyli elementów, które według metody eq u al() są identyczne). Takie kolekcje dosyć dobrze odpowia dają matematycznemu pojęciu zbioru, choć metafora nie jest najlepsza, gdy dodawa nie elementu do kolekcji tego typu modyfikuje samą kolekcję, a nie powoduje zwró cenia nowej, zawierającej dodawany element. Kolekcje typu S e t całkowicie ignorują pewną inform ację, przechowywaną przez inne rodzaje kolekcji — liczbę elementów dostępnych w kolekcji. Nie stanowi to więk szego problemu, gdy interesujący jest sam fakt występowania lub braku elementu, a nie ile razy dany elem ent występuje w kolekcji. Na przykład gdyby interesowały mnie nazwiska wszystkich autorów, których książki znajdują się w bibliotece, to in form acje o tym, ile jest w niej książek poszczególnych autorów, nie miałyby większego znaczenia. Chciałbym znać tylko nazwiska autorów. Elementy przechowywane w kolekcjach typu S e t nie są uporządkowane. Fakt, że podczas ich pobierania są zwracane w określonej kolejności, nie oznacza wcale, że na stępnym razem kolejność ta będzie identyczna. Brak przewidywalnego uporządkowania
121
122
r o z d z ia ł
9
K o l e k c je
zawartości kolekcji nie jest problem em w przypadkach, gdy między poszczególnymi elementami nie występują żadne interakcje. Może się zdarzyć, że będziemy chcieli, aby elementy kolekcji mogły się powtarzać, a jednocześnie konieczne będzie usunięcie tych powtórzeń na potrzeby konkretnej operacji. W takim przypadku można tymczasowo utworzyć kolekcję typu S e t i użyć jej do wykonania operacji: printAuthors(new HashSet(getAuthors());
Interfejs SortedSet — zbiory posortowane Uporządkowanie oraz unikalność elementów nie są wzajemnie wykluczającymi się ce chami kolekcji. M oże się zdarzyć, że będziemy chcieli zachować uporządkowanie ko lekcji, a jednocześnie usunąć z niej wszystkie powtarzające się elementy. W łaśnie takie możliwości daje interfejs SortedSet. W odróżnieniu od uporządkowania wykorzystywanego w kolekcjach L is t, które bazuje na kolejności dodawania elementów lub jawnie określonych indeksach przeka zanych w wywołaniu metody a d d (in t,
O b je ct), uporządkowanie oferowane przez
interfejs Sorted Set bazuje na kom paratorach (Comparator). W razie braku jawnego uporządkowania stosowany jest „naturalny porządek” elementów. Na przykład łańcu chy znaków będą zapisywane w kolejności leksykograficznej. Oto, w jaki sposób można użyć kolekcji typu Sorted Set w celu wyznaczenia auto rów, których książki znajdują się w bibliotece: public Collection getAlphabeticalAuthors() { SortedSet results= new TreeSet(); for (Book each: getBooks()) results.add(each.getAuthor()); return resu lts; } W powyższym przykładzie został wykorzystany domyślny sposób sortowania łań cuchów znaków. Gdyby w obiektach Book autor był reprezentowany przez obiekt ja kiegoś innego typu niż S trin g , to powyższy kod musiałby wyglądać tak: public Collection getAlphabeticalAuthors() { Comparator sorter= new Comparator() { public int compare(Author o1, Author o2) { i f (o1.getLastName().equals(o2.getLastName())) return o1.getFirstName().compareTo(o2.getFirstName()); return o1.getLastName().compareTo(o2.getLastName()); } }; SortedSet results= new TreeSet(sorter); for (Book each: getBooks()) results.add(each.getAuthor()); return resu lts; }
I m p l e m e n t a c je
Interfejs Map — mapy O statnim interfejsem kolekcji jest Map. Stanowi on hybrydę łączącą pozostałe interfej sy. M apy przechowują w artości skojarzone z kluczami, lecz inaczej niż w interfejsie L is t kluczami tymi mogą być dowolne obiekty, a nie tylko liczby całkowite. Klucze muszą być unikalne (podobnie jak w zbiorach), ale wartości umieszczane w kolekcji mogą się powtarzać. Elementy map nie są uporządkowane, podobnie ja k w zbiorach. Ponieważ interfejs Map nie do końca przypomina którykolwiek z pozostałych in terfejsów kolekcji, jest od nich oddzielony i nie dziedziczy po żadnym z nich. Każda mapa składa się z dwóch kolekcji: kolekcji kluczy powiązanej z kolekcją wartości. Nie można tak po prostu poprosić mapy o zwrócenie iteratora, gdyż nie byłoby wiadomo, czy chodzi o iterator operujący na kolekcji kluczy, o wartości, czy też parę klucz - wartość. M apy przydają się podczas im plem entacji dwóch wzorców: stanu zewnętrznego oraz stanu zmiennego. Stan zewnętrzny bazuje na przechowywaniu powiązanych z obiektem danych specjalnego przeznaczenia poza tym obiektem. Jednym ze sposo bów im plem entacji tego wzorca jest wykorzystanie kolekcji typu Map, której kluczami będą obiekty, a wartościami dane powiązane z tymi obiektami. W przypadku stanu zmiennego różne instancje tej samej klasy mogą zawierać różne pola danych. Aby zaim plementować takie rozwiązanie, obiekt może zawierać mapę, która będzie odwzoro wywać łańcuchy znaków (reprezentujące nazwy wirtualnych pól) na wartości.
Implementacje W ybór jednej z dostępnych im plem entacji kolekcji jest uzależniony przede wszystkim od wydajności. A jak to zwykle bywa w przypadkach związanych z wydajnością, najle piej jest zacząć od wybrania prostej im plem entacji, a dopiero potem zm ieniać ją na podstawie zebranych doświadczeń. Każdy z interfejsów przedstawionych w tej części rozdziału posiada jeszcze inne im plementacje. Ponieważ dobór klas im plem entujących kolekcje jest kwestią wydaj ności, do każdej grupy możliwych do zastosowania klas dołączyłem wyniki pomiarów wydajności najważniejszych operacji. Kod źródłowy narzędzia, którego w tym celu używałem, został przedstawiony w dodatku „Pomiary wydajności”. Zdecydowana większość kolekcji została zaimplementowana przy wykorzystaniu klasy A rrayL ist, choć niewielka ich część korzysta z klasy HashSet (w Eclipse i JD K występuje około 3400 odwołań do klasy A rrayL ist oraz jedynie około 800 do klasy HashSet). Szybkim, choć nie najlepszym rozwiązaniem jest wybór dowolnej klasy spełniającej nasze oczekiwania. Jednak w tych przypadkach, gdy doświadczenie wska zuje, że wydajność ma duże znaczenie, można wybrać jedną z możliwych im plem en tacji, przedstawionych w dalszej części rozdziału. Ostatnim czynnikiem branym pod uwagę podczas wybierania implementacji kolekcji jest wielkość kolekcji, na której mamy zamiar operować. Prezentowane w treści rozdziału inform acje o wydajności działania były zbierane na kolekcjach liczących 100 tysięcy
123
124
r o z d z ia ł
9
K o l e k c je
Rysunek 9.1. Interfejsy i klasy kolekcji elementów. jeśli kolekcja ma zawierać jedynie jeden element lub dwa, to zapewne wy brana zostanie inna klasa niż wtedy, gdy oczekujemy, że może się w niej pojawić m i lion elementów. Niemniej jednak korzyści, które można uzyskać, zm ieniając używaną im plem entację kolekcji, stosunkowo często są ograniczone i jeśli konieczne jest uzy skanie bardziej znaczącej poprawy wydajności, niezbędne może się okazać wprowa dzenie poważniejszych zmian w zastosowanym algorytmie.
Implementacje interfejsu Collection Domyślną klasą używaną jako im plem entacja interfejsu C o lle ctio n jest A rrayList. Stosowanie jej wiąże się jednak z pewnym problem em z wydajnością. Polega on na tym, że czas wykonania metody co n tain s (O bject) oraz wszelkich innych metod, które na niej bazują, takich jak rem ove(Object), rośnie proporcjonalnie wraz z powiększa niem się zawartości kolekcji. Jeśli profil wydajnościowy pokazuje, że metody te stano wią wąskie gardło, to warto zastanowić się nad zastąpieniem klasy A rrayL ist klasą HashSet. Jednak przed podjęciem takiej decyzji trzeba się upewnić, że używanemu algo rytmowi nie zaszkodzi fakt usuwania z listy duplikatów. Jeśli używane są dane, o któ rych wiadomo, że i tak nie zawierają duplikatów, to taka zamiana klas nie będzie mieć żadnego znaczenia. Rysunek 9.2 przedstawia porównanie wydajności działania klas A rrayL ist oraz HashSet. (W ięcej inform acji na tem at sposobu, w jaki uzyskałem te dane, można znaleźć w dodatku A).
I m p l e m e n t a c je
— ♦— HashSet sprawdzanie występowania — ♦— ArrayList sprawdzanie występowania — A — HashSet iteracja — A — ArrayList iteracja — B — HashSet modyfikacja — ■ — ArrayList modyfikacja
100000000-,
i------------ 1------------ 1------------ 1------------ 1------------ 1
10 1
10
100
1 000
10000
100000
Liczba elementów
Rysunek 9.2. Porównanie kolekcji ArrayList oraz HashSet ja k o implementacji interfejsu Collection
Implementacje interfejsu List W stosunku do interfejsu C o lle ctio n interfejs L is t został wzbogacony o ideę stabilne go porządku, w jakim występują elementy kolekcji. Dwoma najczęściej używanymi im plem entacjam i interfejsu L is t są A rrayL ist oraz LinkedList. Profile wydajnościowe obu tych klas są niemalże lustrzanymi odbiciami. Klasa A rrayL ist jest bardzo szybka pod względem pobierania elementów, lecz jest wolna, je śli chodzi o ich dodawanie i usuwanie; z kolei klasa LinkedList cechuje się wolnym dostępem do elementów, lecz potrafi szybko je dodawać i usuwać (wyniki pokazano na rysunku 9.3). Jeśli zatem zauważymy, że w kodzie przeważają wywołania metody add() lub remove(), to lepiej będzie zastosować klasę LinkedList zamiast A rrayList.
Implementacje interfejsu Set Najczęściej używanymi im plem entacjam i interfejsu S e t są HashSet, LinkedHashSet oraz T reeSet (przy czym ta klasa im plementuje właściwie interfejs Sorted Set). Klasa HashSet jest szybka, lecz nie gwarantuje, że elementy będą zwracane w jakiejś określonej kolejności. Klasa LinkedHashSet zachowuje uporządkowanie elementów odpowiadające kolejności, w jakiej były one dodawane do kolekcji, wiąże się to jednak z 30-procentowym
125
126
r o z d z ia ł
9
K o l e k c je
Rysunek 9.3. P orów nanie w ydajności kolekcji ArrayList orazL in ked L ist
spadkiem wydajności przy dodawaniu i usuwaniu elementów (co widać na rysunku 9.4). Klasa TreeSet zachowuje elementy posortowane w kolejności określonej przez kom parator (obiekt typu Comparator), przy czym wiąże się to ze spadkiem wydajności przy dodawaniu i usuwaniu elementów z kolekcji oraz sprawdzaniu występowania ele mentu — w przypadku klasy T reeSet wydajność tych operacji jest rzędu log n, gdzie n jest liczbą elementów w kolekcji. Klasy LinkedHashSet należy używać, jeśli zależy nam na zachowaniu stabilnego uporządkowania elementów. Na przykład zewnętrzni użytkownicy mogą docenić możli wość pobierania elementów zawsze w tej samej kolejności.
Implementacje interfejsu Map Im plem entacje interfejsu Map są analogiczne do im plem entacji interfejsu Set. Najszyb sze działanie spośród nich zapewnia klasa HashMap. Klasa LinkedHashMap zachowuje kolejność elementów kolekcji, odpowiadającą kolejności ich dodawania. Z kolei klasa TreeMap (która w rzeczywistości stanowi im plem entację interfejsu SortedMap) zwraca elementy w kolejności odpowiadającej uporządkowaniu kluczy, jednak koszt dodawa nia elementów oraz sprawdzania, czy dany element występuje w kolekcji, jest rzędu log n . Porów nanie wydajności działania poszczególnych im plem entacji interfejsu Map zostało przedstawione na rysunku 9.5.
I m p l e m e n t a c je
100
1000
10 0 00
100000
Liczba elementów
Rysunek 9.4. P orów nanie im plem entacji interfejsu Set
100
1000
Liczba elementów
Rysunek 9.5. P orów nanie im plem entacji interfejsu M ap
10000
1 00000
127
128
r o z d z ia ł
9
K o l e k c je
Klasa Collections Klasa C o lle ctio n s jest klasą biblioteczną, udostępniającą funkcjonalności, które nie pasują do żadnego z interfejsów kolekcji. Funkcjonalności te zostały opisane w dalszej części rozdziału.
Wyszukiwanie Czas wykonania operacji indexO f() jest wprost proporcjonalny do wielkości listy. Niemniej jednak, kiedy zawartość listy zostanie posortowana, dzięki skorzystaniu z wy szukiwania binarnego możliwe jest uzyskanie czasu wyszukiwania rzędu log2 n. W y wołanie metody C o lle c tio n s .b in a r y S e a r c h (lis t, element) zwraca indeks elementu listy. Jeśli element nie występuje na liście, metoda ta zwraca wartość m niejszą od zera, jeśli natomiast spróbujemy jej użyć na liście nieposortowanej, to jej wynik będzie nie przewidywalny. Wyszukiwanie binarne zapewnia poprawę wydajności wyłącznie w przypadku operowania na listach, dla których czas dostępu do poszczególnych elementów jest stały, takich jak klasa A rrayL ist (patrz rysunek 9.6). — ■ — ArrayList metoda indexOf() — ■ — LinkedList metoda indexOf() — &— ArrayList wyszukiwanie binarne — A — LinkedList wyszukiwanie binarne
10 0 0 0 0 0 0
1 000000 100000
Czas (ns) 10000 1 000
100
10 1
10
100
1 000
10 0 0 0
100000
Liczba elementów
Rysunek 9.6. P orów nanie wydajności m etody indexO f() oraz wyszukiw ania binarnego
Sortowanie Klasa C o lle ctio n s udostępnia także operacje służące do modyfikowania kolejności elementów na liście. M etoda r e v e r s e ( l is t ) zmienia kolejność elementów na liście na odwrotną, metoda s h u f f le ( l is t ) zapisuje elementy listy w losowej kolejności, natomiast
K l a sa C o l l e c t io n s
m etody s o r t ( l i s t ) oraz s o r t ( l i s t , comparator) porządkują elementy listy w kolej ności rosnącej. W odróżnieniu od wyszukiwania binarnego wydajność operacji sorto wania dla kolekcji A rrayL ist oraz LinkedList jest mniej więcej taka sama. W ynika to z faktu, że obie te klasy najpierw kopiują elementy kolekcji do tablicy, następnie ją sortują, by w końcu ponownie skopiować posortowane elementy do swojej struktury da nych (wyniki te można sprawdzić, wykonując test Sorting przedstawiony w Dodatku A).
Kolekcje niezmienne Zgodnie z inform acjam i podanymi w punkcie poświęconym interfejsowi Ite r a b le nawet najprostsze interfejsy kolekcji pozwalają na modyfikowanie zawartości kolekcji. Jednak w przypadku przekazywania kolekcji do kodu, którem u nie można zaufać, można zabezpieczyć się przed jej zmodyfikowaniem poprzez umieszczenie jej w spe cjalnej im plem entacji kolekcji, która w razie próby modyfikacji zawartości zgłasza wyjątek. Dostępne są takie klasy implementujące interfejsy Coll e c ti on, L ist, Set oraz Map. @Test(expected=UnsupportedOperationException.class) public void unmodifiableCollectionsThrowExceptions() { List l= new ArrayList(); l.ad d ("a"); Collection unmodifiable= Collections.unmodifiableCollection(l); Iterator all= unm odifiable.iterator(); a ll.n e x t(); all.rem ove(); }
Kolekcje jednoelementowe Jeśli dysponujemy jednym elementem, który należy przekazać do interfejsu oczekują cego na kolekcję, to możemy przekształcić go w jednoelem entową kolekcję, wywołując metodę C o lle c tio n s .s in g le to n (), która zwraca kolekcję typu Set. Dostępne są także inne wersje tej metody, zwracające kolekcje typów L is t oraz Map. Wszystkie te metody zwracają kolekcje, których zawartości nie można modyfikować. @Test public void exampleOfSingletonCollections() { Set longWay= new HashSet(); longWay.add("a"); Set shortWay= C ollections.singleton("a"); assertEquals(shortWay, longWay); }
Kolekcje puste Analogicznie, jeśli musimy skorzystać z interfejsu wymagającego przekazania kolekcji, a nie dysponujemy żadnymi elementami, które moglibyśmy w takiej kolekcji um ie ścić, klasa C o lle ctio n s udostępnia metody zwracające puste kolekcje, których zawar tości nie można modyfikować.
129
130
r o z d z ia ł
9
K o l e k c je
@Test public void exampleOfEmptyCollection() { assertTrue(Collections.emptyList().isEmpty()); }
Rozszerzanie kolekcji Często spotykałem się z klasami, które rozszerzały jedną z klas kolekcji. Na przykład klasa Library przechowująca listę książek mogłaby dziedziczyć po klasie A rrayList: class Library extends ArrayList { . . . } Ta deklaracja zawiera im plem entacje metod add() oraz remove(), iteracji oraz in nych operacji na kolekcjach. Jednak z rozszerzaniem klas kolekcji w celu uzyskania nowych klas o podobnym działaniu wiąże się kilka problemów. Przede wszystkim niektóre z operacji udostęp nianych przez kolekcje nie będą odpowiednie dla klientów. Na przykład klienci nie powinni m óc zazwyczaj opróżnić całej zawartości biblioteki (czyli, kontynuując poda ny wcześniej przykład, wywołać metody c le a r () klasy Library) ani konwertować jej na postać tablicy przy użyciu metody toA rray(). W najlepszym razie metafory zostaną pomieszane i staną się mylące, a w najgorszym — trzeba będzie uniemożliwić korzy stanie ze wszystkich odziedziczonych metod poprzez zaimplementowanie ich i zgła szanie wyjątku UnsupportedOperationException. Nie jest to opłacalny kompromis, gdyż w celu uzyskania kilku użytecznych wierszy kodu trzeba napisać go znacznie więcej, by wyeliminować funkcjonalności, które nie są potrzebne. Kolejnym proble mem związanym z dziedziczeniem po klasach kolekcji jest to, że prowadzi ono do utraty możliwości dziedziczenia. Uzyskanie kilku przydatnych wierszy kodu sprawia, że tracim y możliwość wykorzystania dziedziczenia w jakiś inny, znacznie bardziej wartościowy sposób. W takich przypadkach znacznie lepszym rozwiązaniem będzie odwołanie się do kolekcji, a nie dziedziczenie po niej: class Library { Collection books= new ArrayList(); } Wówczas można udostępnić tylko te operacje, które mają sens, i nadać im odpo wiednio dobrane nazwy. Nic także nie stoi na przeszkodzie, by korzystając z dziedzi czenia, użyć takiej im plem entacji również w innych klasach modeli. Gdyby klasa Library zapewniała dostęp do książek na podstawie kilku różnych kluczy, to można by udostępnić wszystkie te operacje, nadając im odpowiednie nazwy: Book getBookByISBN(ISBN); Book getBookByID(UniqueID); Kolekcje warto rozszerzać wyłącznie w przypadkach, gdy im plem entujem y nowe klasy kolekcji ogólnego przeznaczenia — klasy, które można by dodać do pakietu ja v a .u t i l . W e wszystkich innych przypadkach lepiej będzie utworzyć w klasie pole kolekcji i w niej przechowywać elementy.
W n io s e k
Wniosek W tym rozdziale zostały przedstawione wzorce związane ze stosowaniem klas kolekcji. Tym samym opisane zostały wszystkie wzorce im plem entacyjne odnoszące się do ję zyka Java oraz dostępnych w nim kolekcji. W szystkie te wzorce zaprezentowano pod kątem wykorzystania ich do tworzenia aplikacji, kiedy prostota oraz łatwość kom uni kacji powodują obniżenie kosztów, lecz jednocześnie kiedy możliwa jest zmiana całe go projektu aplikacji za jednym zamachem. Następny rozdział opisuje, w jaki sposób można zmodyfikować te wzorce, by wykorzystać je do tworzenia platform, w których złożoność jest akceptowalna, jeśli pozwala zachować możliwość modyfikacji platfor my bez konieczności wprowadzania zmian w kodzie aplikacji.
131
132
rozdział 9
Kolekcje
Rozdział 10
Rozwijanie platform
Przedstawione wcześniej wzorce im plem entacyjne zakładały, że zmiana kodu nie jest kosztowna, w odróżnieniu od jego zrozum ienia oraz wyrażenia jego intencji. Takie założenie było prawdziwe w przeważającej większości projektów programistycznych, nad którym i pracowałem. Niemniej jednak nie jest ono słuszne w przypadku tworze nia i rozwijania platform, czyli wtedy, gdy kod klienta nie może być modyfikowany przez programistów zajm ujących się rozwojem platformy. Na przykład wprowadzenie zm ian w JU nit jest, ogólnie rzecz biorąc, raczej proste, jednak ich wdrożenie może być niezwykle kosztowne, gdyby wszyscy autorzy narzędzi korzystających z JUnit oraz twórcy testów także musieli zm ieniać swoje kody. Niezgodne aktualizacje są tak kosztowne, że o ile to tylko możliwe, zawsze staramy się ich unikać. Kiedy ostatnio udostępnialiśmy JU nit 4, niemal połowę budżetu projektowego po święciliśmy na m inim alizację kosztów wdrożenia nowej wersji biblioteki przez na szych klientów. Staraliśmy się uzyskać pewność, że testy napisane w nowym stylu będą działały w już istniejących narzędziach, a napisane w starym stylu — w nowych. Stara liśmy się także zagwarantować, że w przyszłości będziemy posiadali możliwość wprowa dzania zmian w JU nit, tak aby nie wywołać przy tym błędów w kodzie klientów. Ten rozdział ogólnie pokazuje, jak zmieniają się wzorce im plem entacyjne w przy padku tworzenia platform. Mowa w nim o zmianach metod tworzenia platform, spo sobach m inim alizacji wpływu niezgodnych aktualizacji oraz projektowania platform tak, by unikać występowania niezgodnych aktualizacji. Rozwijanie platform bez wywoły wania utrudnień i błędów w kodzie klientów wymaga zwiększenia złożoności samych platform, ograniczenia funkcjonalności dostępnych dla klientów i bardzo uważnego komunikowania o niezbędnych zmianach.
Modyfikowanie platform bez zmian w aplikacjach Podstawowym problemem, z jakim stykają się twórcy platform, jest z jednej strony ko nieczność ich rozwijania, a z drugiej wysoki koszt usunięcia błędów, które można przez to wywołać w kodzie klientów. Idealne aktualizacje platform dodają do nich 133
134
r o z d z ia ł
10
R o z w ija n ie p l a t f o r m
nowe możliwości bez wprowadzania jakichkolw iek zm ian w istniejących funkcjonalnościach. Niemniej jednak takie zgodne zmiany nie zawsze są możliwe. Zachowanie zgodności wstecz często wymaga zwiększenia złożoności platformy. W pewnym m om encie koszty zachowania doskonałej zgodności zaczynają przewyż szać wartość tej zgodności dla klientów. Poprawa ekonomicznych aspektów tworzenia platformy opiera się na zmniejszeniu prawdopodobieństwa wystąpienia niezgodnych aktualizacji oraz zminimalizowaniu ich ewentualnych kosztów, gdyby nie można było ich uniknąć. W przypadku standardowego tworzenia aplikacji redukcja złożoności do m inim um jest cenną strategią, pozwalającą zapewnić to, że kod będzie łatwy do zro zumienia. Jednak podczas tworzenia platformy bardziej, z ekonom icznego punktu wi dzenia, opłaca się zwiększyć jej złożoność, aby umożliwić dalszy rozwój bez wywoły wania błędów w kodzie klientów. Chociaż przy tworzeniu platform zgodność odgrywa znacznie większą rolę, to jed nak prostota kodu wciąż jest bardzo cenna. Użytkownicy najprawdopodobniej chętniej będą wybierali proste platformy niż złożone. A zatem złożoność platform należy powięk szać w możliwie jak najmniejszym stopniu, aby zachować równowagę pomiędzy możliwo ściami dalszego rozwijania platformy a kosztami, na jakie zostaną narażeni klienci. Cel leżący u podstaw wszystkich przedstawionych wcześniej wzorców im plem enta cyjnych był taki, aby kod nadawał się do jak najszerszego zastosowania, a jednocześnie wciąż można go było łatwo zrozumieć. W przypadku tworzenia platform możliwości zastosowania są ograniczane kosztem ułatwienia wprowadzania zmian w projekcie kodu. Na przykład, pisząc kod, zazwyczaj preferuję deklarowanie pól jako chronio nych, jednak przy tworzeniu platform deklaruję je jako prywatne. Wówczas stosowanie tych klas przez klientów jest nieco utrudnione, jednak ja zyskuję możliwość zmiany reprezentacji danych używanych przez platformę bez wpływu na aplikacje klientów. Platforma, w której klasy miałyby pola chronione, byłaby łatwiejsza do natychmiastowego użycia, lecz jednocześnie trudniejsze byłoby późniejsze wprowadzanie modyfikacji. Celem, do którego staramy się dążyć, są platformy na tyle złożone, by można je roz wijać, a równocześnie na tyle proste, by były użyteczne oraz odpowiednie do zastoso wań tak ograniczonych, by można je rozwijać, oraz na tyle szerokich, by były przydatne. W łaśnie te dodatkowe uwarunkowania projektowe sprawiają, że tworzenie platform jest znacznie bardziej ryzykowne i kosztowne niż tworzenie zwyczajnych aplikacji. Na szczęście odpowiednio zmodyfikowane wersje wzorców implementacyjnych pozwalają tworzyć, wdrażać i modyfikować platformy, które są zarówno użyteczne, jak i przy gotowane na dalsze zmiany.
Niezgodne aktualizacje Nawet w przypadkach, gdy aktualizacja platformy może doprowadzić do błędów w kodzie klientów, istnieją sposoby pozwalające zredukować koszty ewentualnych aktualizacji. Prezentowanie niewielkich fragmentów aktualizacji pozwala ostrzec klientów przed tym,
N ie z g o d n e a k t u a l iz a c je
co ich czeka, i pozwala im podjąć decyzję o wyborze m om entu, w którym zaczną ak tualizować kod swoich aplikacji. Na przykład oznaczenie pewnych fragmentów kodu jako niezalecanych, lecz pozostawienie możliwości pełnego korzystania z nich stanowi dla klientów sygnał o konieczności użycia nowego API. Oznaczanie fragmentów kodu jako niezalecanych jest przykładem bardziej ogólnej strategii, polegającej na udostęp nianiu dwóch różnych architektur służących do rozwiązywania tego samego proble mu. Takie równoległe architektury zwiększają złożoność, lecz jednocześnie zm niej szają chaos związany z aktualizacją kodu. Kolekcje dostępne w języku Java są przykładem klas posiadających takie równole głe architektury. W momencie wprowadzenia nowych klas implementujących interfejs C o lle ctio n starym klasom V ector oraz Enumerator zapewniono zgodność w przód. Zarówno teraz, jak i w dowolnie odległej przyszłości kod korzystający z tych starych klas kolekcji będzie działał prawidłowo. Sposobem na zapewnienie klientom stopniowego dostępu do kolejnych aktualizacji są pakiety. Udostępniając nowe klasy w nowym pakiecie, można im nadać te same nazwy, które noszą już istniejące klasy. Na przykład gdyby istniejąca klasa o r g .ju n it.A s s e r t została zaktualizowana poprzez udostępnienie nowej klasy org.junit.newandimproved. ^ A sse rt, to klienci mogliby z niej skorzystać, zmieniając wyłącznie deklarację im porto wanych pakietów. Zmienianie samych deklaracji importowanych pakietów jest znacz nie mniej inwazyjne i ryzykowne niż zmienianie kodu. Kolejną strategią przyrostowego wprowadzania zmian jest zmienianie API lub im plem entacji, lecz nie obu naraz. Taka przejściowa wersja platformy, zawierająca bądź to nowy interfejs starego kodu, bądź też stary interfejs nowego kodu, pozwala wszyst kim — zarówno klientom , jak i tw órcom platformy — przyzwyczaić się do kierunku wprowadzanych zmian. Dzięki tem u wszyscy będą m ieć czas na rozwiązanie wszelkich problemów technicznych związanych z nowym rozwiązaniem, kiedy jeszcze ich skala nie będzie zbyt duża. Klasy kolekcji zwracają uwagę na jeszcze jeden kłopot z aktualizowaniem platform: usuwanie starych możliwości funkcjonalnych. Jednym z elementów umowy między tw órcam i platformy a korzystającymi z niej klientam i jest to, jak często trzeba będzie aktualizować kod klientów, by m ógł on korzystać z nowych wersji platformy. Firma Sun zobowiązała się, że stary kod będzie działał zawsze. Z kolei twórcy Eclipse gwa rantują zachowanie zgodności wyłącznie w ram ach wersji o tym samym numerze głównym. W idać zatem, że określając strategię usuwania przestarzałego kodu, twórcy platformy muszą uważnie określić punkt równowagi między koniecznością jej szyb kiego rozwijania a potrzebą, by klienci dysponowali stabilnym rozwiązaniem. Eclipse stanowi przykład jeszcze innego sposobu redukowania kosztów związanych z niezgodnymi aktualizacjami: udostępnia narzędzia do automatycznej aktualizacji kodu klientów. Środowisko to redukuje koszty aktualizacji z wersji 2.x do 3.0 przez zapew nienie, że większość wtyczek przeznaczonych dla wersji 2.x będzie działać także w wer sji 3.0, a jednocześnie udostępnia narzędzia konwersji potrafiące przekształcić starsze wtyczki tak, by były w pełni zgodne z Eclipse 3.0. Narzędzie to dodawało niezbędne
135
136
r o z d z ia ł
10
R o z w ija n ie p l a t f o r m
pliki i przenosiło funkcjonalności między plikami, tak że stary kod mógł bezproblemowo działać w nowej wersji środowiska. Poprzez połączenie strategii twórcy Eclipse zachowali zdolność poprawiania swojej błyskawicznie rozwijającej się platformy, a równocześnie udostępnili dotychczasowym klientom niemal całkowicie stabilne funkcjonalności. Koszty aktualizacji kodu można zredukować, jeśli klienci będą mogli skorzystać z nowej wersji platformy, wykonując wyłącznie proste operacje kopiowania i zastępo wania kodu. Jeżeli zmienia się nazwa metody, to koszt aktualizacji będzie mniejszy, jeśli nie zmieni się kolejność argumentów. Być może kiedyś pojawi się możliwość, by wraz z nową wersją platformy przekazywać także odpowiednie zestawy kodu refaktoryzującego; niem niej jednak na razie nasze opcje ograniczają się do m inim alizacji kosztów. Kolejnym i czynnikami związanymi z zarządzaniem niezgodnymi aktualizacjami są struktura oraz zwiększenie się społeczności klientów platformy. Jeśli klienci są chętni do korzystania z najnowszych możliwości platformy, to także chętnie podejmą związany z tym wysiłek. Jeśli aktualizacja zapewnia nam możliwość drastycznego powiększenia bazy użytkowników, to zapewne zdecydujemy się narazić na skargi dotychczasowych klientów. Pretensje 400 klientów wydadzą się zapewne mało ważne, jeśli w ciągu pół roku możemy dysponować 4000 zadowolonych użytkowników. Trzeba jednak zadbać o to, by nie pomylić rzeczywistych klientów z fantomami, gdyż może się okazać, że za jakiś czas będziemy dysponowali nową wersją platformy, z której nikt nie będzie korzystał. W tym podrozdziale opisałem, jak zarządzać niezgodnymi aktualizacjami plat form. Znacznie bardziej pożądanym rozwiązaniem jest wprowadzanie aktualizacji, które udostępniają nowe funkcjonalności, a jednocześnie nie zmuszają do wprowadzania zmian w kodzie klientów. Dalsza część rozdziału prezentuje wzorce implementacyjne po zwalające na pisanie platform, które można aktualizować bez zmieniania kodu klientów.
Zachęcanie do wprowadzania zgodnych zmian Aby aktualizacje platformy mogły zachować zgodność, kod klientów powinien w jak najm niejszym stopniu zależeć od szczegółów platformy. Jednak musi on zależeć od ja kichś szczegółów, gdyż w przeciwnym razie w ogóle nie istniałby powód, by z plat formy korzystać. W idealnej sytuacji kod klientów będzie uzależniony wyłącznie od takich szczegółów platformy, które nigdy nie będą się zmieniać. Ponieważ nie sposób przewidzieć kierunku rozwoju i zmian platformy, nie da się także z góry określić, które z jej szczegółów nie będą podlegały zmianom. M ożna jednak zwiększyć swoje szanse, zm niejszając liczbę widocznych szczegółów, udostępniając szczegóły, których praw dopodobieństwo zmiany jest mniejsze, i użyteczne funkcjonalności przy zachowaniu możliwości zm iany projektu. Jedną z decyzji, jakie należy podjąć, jest to, jakie warianty zgodności chcem y udo stępnić. Czy wprowadzana aktualizacja zapewni zgodność wstecz, tak by klienci wciąż mogli używać starych metod i przekazywać do nich stare obiekty? Czy aktualizacja będzie zgodna w przód i pozwoli na używanie w kodzie klientów nowych obiektów,
Z a c h ę c a n ie d o w p r o w a d z a n ia z g o d n y c h z m ia n
tak jakby były starymi obiektami? Ten wybrany styl (lub style) zapewniania zgodności ma wpływ na to, jak wiele pracy trzeba będzie włożyć w tworzenie i testowanie aktualizacji. Na przykład ostatnia wprowadzona przez nas aktualizacja JUnit była znacznie bardziej kosztowna, gdyż zdecydowaliśmy się zapewnić zgodność zarówno wstecz, jak i w przód. Użytkownicy zgłosili także kilka problemów ze zgodnością, o których nie pomyśleliśmy podczas wprowadzania zmian. Jeśli o mnie chodzi, jestem zadowolony z podjętych de cyzji związanych z zachowaniem zgodności. Musieliśmy pamiętać o ogromnej bazie już istniejących testów oraz klientach, których znaczna większość wcale nie paliła się do wy korzystania nowej aktualizacji. Niem niej jednak zachowanie zgodności i wstecz, i w przód miało zaskakujące konsekwencje. w iększość platform w języku Java ma postać obiektów, które są tworzone, używa ne i aktualizowane przez klientów. Ta część rozdziału przedstawia, jak można repre zentować platformy, by klienci mogli korzystać z potrzebnych funkcjonalności, a jedno cześnie by twórcy platform mieli możliwość ich rozwijania. Uzyskanie takiej równowagi wymaga bardzo uważnego określenia sposobów używania i tworzenia obiektów oraz odpowiedniej struktury metod.
Klasa biblioteczna Jednym z prostych i stosunkowo bezpiecznych sposobów tworzenia API jest korzystanie z klas bibliotecznych. Jeśli wszystkie funkcjonalności można przedstawić w formie wywołań procedur o prostych parametrach, to istnieje możliwość ścisłego odseparo wania klientów od przyszłych zmian. w takim przypadku, wprowadzając nową wersję klasy bibliotecznej, należy jedynie zadbać o to, by wszystkie metody działały tak jak wcześniej. Nowe funkcjonalności można udostępnić w form ie nowych metod lub wa riantów metod już istniejących. Przykładem API udostępnianego w formie klasy bibliotecznej jest klasa C o lle ctio n s. Klienci korzystają z niej, wywołując metody statyczne, a nie tworząc instancje tej kla sy. Nowe wersje tej klasy dodają do niej nowe metody statyczne, a dotychczasowe funkcjonalności pozostawiają w niezm ienionej postaci. Podstawowym problem em związanym z reprezentacją API w formie klasy biblio tecznej jest ograniczona liczba pojęć i zmienności, jakie można w ten sposób wyrazić. Zwiększająca się liczba zmian w funkcjonalności oraz innych różnic łatwo może do prowadzić do gwałtownego wzrostu liczby metod. c o więcej, klienci mogą zm ieniać jedynie dane przekazywane w wywołaniach metod platformy, nie mogą natomiast przekazywać do niej żadnych zmian w logice.
Obiekty Jeśli platforma ma być reprezentowana w formie obiektów, to przed jej tw órcam i staje jeszcze poważniejsze zadanie: określenie równowagi między prostotą i złożonością a elastycznością i precyzją, tak by platforma była użyteczna i stabilna dla klientów,
137
138
r o z d z ia ł
10
R o z w ija n ie p l a t f o r m
a jednocześnie by dawała tw órcom możliwość jej dalszego rozwijania. Cała sztuczka polega na tym, żeby napisać ją w taki sposób, aby kod klientów zależał jedynie od szczegółów, które raczej nie będą się zmieniać, oczywiście o ile to tylko możliwe. Opiszę cztery zagadnienia związane z reprezentacją platform w form ie obiektów: ■
Styl stosowania: Czy klienci korzystają z platformy poprzez tworzenie instancji obiektów, konfigurowanie istniejących obiektów, czy też poprzez poprawianie bądź im plementację jej klas?
■
Abstrakcja: Czy szczegóły dotyczące klasy będą udostępniane jako interfejsy, czy jako klasy? W jaki sposób zostanie wykorzystana widoczność, by udostępniać tylko te szczegóły, które będą relatywnie niezmienne?
■
Tworzenie: W jaki sposób będą tworzone obiekty?
■
Metody: Jaka powinna być struktura metod, by były przydatne dla klientów i jed nocześnie pozwalały na wprowadzanie zmian?
Styl stosowania Platform y mogą obsługiwać trzy podstawowe style stosowania: tworzenie obiektów, konfigurację oraz im plementację. Każdy z nich zapewnia inną kom binację użyteczno ści, elastyczności oraz stabilności. Można także łączyć te style w ram ach jednej plat formy, aby uzyskać lepszą kom binację możliwości pozwalających na rozwój platformy przez jej twórców oraz poszerzenie oferty funkcjonalności dla użytkowników. Najprostszym stylem stosowania platform jest tworzenie obiektów. Aby skorzystać z gniazda serwera, wystarczy utworzyć obiekt, używając wyrażenia new S erv erS o ck et(). Po utworzeniu takiego obiektu można z niego korzystać, wywołując jego metody. Rozwiązanie to sprawdza się, jeśli jedynym rodzajem zmienności, jakiego wymagają klienci, jest zm ienność danych, a nie logiki. Nieco bardziej skomplikowanym oraz bardziej elastycznym stylem korzystania z platform jest konfiguracja; bazuje ona na wykorzystaniu obiektów platformy, do których klienci przekazują własne obiekty, używane później w ściśle określonych momentach. Na przykład klasa T reeSet pozwala na przekazanie własnego obiektu Comparator, co umożliwia dowolne sortowanie elementów. Comparator byFirstName= new Comparator0 { public int compare(Author book1, Author book2) { return book1.getFirstName().compareTo(book2.getFirstName()); } }; SortedSet sorted= new TreeSet(byFirstName); Konfiguracja jest bardziej elastyczna od tworzenia obiektów, gdyż pozwala na wpro wadzanie zmian nie tylko w danych, lecz także w logice. D aje jednak m niejszą swobo dę projektantom platformy, ponieważ kiedy zacznie już korzystać z obiektów przeka zywanych przez klienta, będzie musiała to robić dalej w ten sam sposób i w tych
Z a c h ę c a n ie d o w p r o w a d z a n ia z g o d n y c h z m ia n
samych m om entach, gdyż w przeciwnym razie zaistnieje ryzyko, że kod klienta prze stanie działać. Kolejne ograniczenie tego stylu stosowania platform wiąże się z faktem, że udostępnia on jedynie kilka wymiarów wprowadzania zmienności. D any obiekt może udostępniać tylko jedną opcję konfiguracyjną lub dwie, gdyż przy ich większej liczbie stałby się zbyt skomplikowany, by można go było łatwo używać. Jeśli klienci potrzebują więcej sposobów na stosowanie własnej logiki, niż dostar cza ich ten styl stosowania platform, konieczne jest skorzystanie z trzeciego rozwiązania — implementacji. W tym przypadku klienci tworzą swoje własne klasy, które są następnie używane przez platformę. Jeśli tylko klasa klienta dziedziczy po klasie platformy lub im plem entuje jej interfejs (zagadnienie wyboru klasy bądź interfejsu zostało opisane w dalszej części rozdziału), będzie w niej można zaimplementować dowolną logikę. Spośród tych trzech stylów stosowania platform im plem entacja może w najwięk szym stopniu ograniczać swobodę wprowadzania zmian. Każdy szczegół klasy bazo wej lub interfejsu dostarczanych przez platformę musi zostać zachowany, gdyż tylko w ten sposób można sprawić, że kod klienta będzie cały czas prawidłowo działał. Każ dy ujawniony szczegół abstrakcji platformy jest mieczem obosiecznym — pozwala klientom na zastosowanie własnego kodu, a jednocześnie zmusza twórców platformy, by ten szczegół zachować, ponieważ w przeciwnym razie narażą się na ryzyko wystą pienia błędów w kodzie klienta. Przykład klasy Comparator stanowi stosunkowo prostą wersję stylu korzystania z platform, bazującego na im plementacji. Kom parator byFirstName stanowi im ple m entację abstrakcji komparatora biblioteki kolekcji (która w jego przypadku przybie ra postać klasy). Przedstawiony przykład jest nieskomplikowany, gdyż platforma wy maga przekazania tylko jednego fragmentu logiki, i to na tyle prostego, by można go um ieścić bezpośrednio w miejscu wykorzystania. Jednak takie im plem entacje można też umieszczać w klasach wewnętrznych bądź w zupełnie niezależnych, jeśli tylko są dostatecznie skomplikowane. Styl bazujący na wykorzystaniu im plem entacji zapewnia znacznie lepszą skalowalność niż opisywany wcześniej styl oparty na konfiguracji. Dzieje się tak dlatego, że po zwala na stosowanie dowolnie dużej liczby niezależnych odmian, z których każda bę dzie reprezentowana przez metodę zdefiniowaną przez platformę. JU nit wykorzystuje każdy z tych czterech stylów: ■
JUnitCore jest klasą biblioteczną udostępniającą statyczną metodę run(C lass . . . ) , służącą do wykonywania wszystkich testów zdefiniowanych we wszystkich klasach.
■
Oprócz tego istnieje możliwość tworzenia obiektów klasy JUnitCore, z których każdy może pozwolić na dokładniejszą kontrolę nad wykonywanymi testam i oraz generowanymi powiadomieniami.
■ Adnotacje @Test, @Before oraz @After stanowią pewną formę konfiguracji, zapew niającą twórcom testów możliwość wskazywania kodu, który należy wykonać w od powiednich sytuacjach.
139
140
ROZDZIAŁ 10
■
R OZWIJANIE PLATFORM
Adnotacja @RunWith stanowi formę im plem entacji, pozwalającą zaim plementować własny m echanizm uruchamiania testów, z którego mogą skorzystać osoby po trzebujące niestandardowych sposobów wykonywania testów.
Abstrakcja W przypadku użycia ostatniego z przedstawionych stylów stosowania platform — im plem entacji — powstaje pytanie, czy abstrakcyjne byty należy reprezentować jako in terfejsy, czy też jako wspólne klasy bazowe. Każde z tych rozwiązań ma swoje zalety i wady, i to zarówno dla twórców platform, jak i ich użytkowników. W arto zauważyć, że rozwiązania te wzajemnie się nie wykluczają. Platforma może udostępniać zarówno interfejs, jak i domyślną im plem entację tego interfejsu.
Interfejs O grom ną zaletą udostępniania interfejsów jest to, że podają one tak niewiele szcze gółów. Klienci nie mogą „przypadkowo” skorzystać z żadnych możliwości platformy, których jej twórcy nie planowali im udostępniać. To zabezpieczenie ma jednak swoją cenę. D opóki interfejsy pozostaną niezm ienione, wszystko będzie w porządku, nie mniej jednak wprowadzenie choćby jednej nowej metody sprawi, że kod klienta prze stanie działać. Jeśli będziemy w stanie sprawić, że klienci będą jedynie korzystali z in terfejsów, lecz nie będą ich implementować, to będziemy także w stanie wprowadzać nowe metody bez wywoływania błędów w kodzie klientów. Bez względu na tę wrażli wość interfejsów są one powszechnie używane w Javie do wyrażania abstrakcji, co już samo w sobie stanowi argument przemawiający za ich stosowaniem. Interfejsy mają także tę zaletę, że każda klasa może implementować dowolną ich liczbę. Zaimplementowanie w jednej klasie kilku powiązanych ze sobą interfejsów może stanowić jasny i bezpośredni środek wyrazu. Jeśli jednak okaże się, że jakaś klasa im plem entuje kilka całkowicie niezależnych interfejsów, to najprawdopodobniej naj lepiej będzie ją podzielić, by w odpowiednio przejrzysty sposób wyrazić to, co chcemy za jej pom ocą zakomunikować. Pewną odmianą interfejsów, która zapewnia dodatkową elastyczność kosztem zwiększonej złożoności, są tak zwane interfejsy wersjonowane. Jeśli do istniejącego interfejsu dodamy nową metodę, to taka zmiana doprowadzi do wystąpienia błędów w kodzie klientów. Niem niej jednak nic nie stoi na przeszkodzie, by utworzyć nowy interfejs pochodny i w nim um ieścić nową metodę. Klienci mogą przekazywać obiekty zgodne z nowym interfejsem wszędzie tam, gdzie jest wymagany stary interfejs, lecz istniejący kod wciąż będzie działał prawidłowo. W tym przypadku dodatkowa elastyczność jest uzyskiwana kosztem większej zło żoności platformy. Jej kod musi bowiem jawnie, w trakcie działania programu, okre ślać, czy chce skorzystać z operacji udostępnianej przez nowy interfejs. Na przykład w bibliotece A W T dostępne są dwie wersje interfejsu zarządcy układu. W kodzie A W T w kilkunastu m iejscach można zobaczyć takie wiersze:
Z a c h ę c a n ie d o w p r o w a d z a n ia z g o d n y c h z m ia n
i f (layout instanceof LayoutManager2) { LayoutManager2 layout2= (LayoutManager2) layout; layout2.newOperat ion(); } interfejsy wersjonowane są rozsądnym kom prom isem w sytuacjach, gdy nie jeste śmy w stanie uniknąć konieczności dodania nowej metody do istniejącej abstrakcji wyrażonej przy użyciu interfejsu, a jednocześnie nie możemy doprowadzić do pro blemów z kodem klientów. Niemniej jednak ze względu na trudności, z jakimi wiąże się ich stosowanie, zarówno po stronie platformy, jak i klienta, nie są one dobrym rozwiąza niem w przypadkach abstrakcji, które często się zmieniają. w łaściw ym sposobem re prezentacji takich abstrakcji jest zaimplementowanie ich w formie klas bazowych.
Klasy bazowe Jedną z innych metod definiowania abstrakcji przy wykorzystaniu interfejsów jest po proszenie użytkowników, by przekazywali instancję jakiejś klasy lub jej klas pochod nych. Zalety oraz wady takiego stylu korzystania z platform są dokładnie odwrotne niż interfejsów: klasy udostępniają więcej szczegółów niż interfejsy, natom iast dodanie operacji do klasy bazowej nie wywołuje błędów w już istniejącym kodzie. Jednak ina czej niż w przypadku interfejsów klasy klientów mogą dziedziczyć tylko po jednej kla sie bazowej należącej do platformy. Szczegółami klasy bazowej, do których dostęp mają klienci, są jej pola i metody publiczne oraz chronione. Udostępnienie każdego takiego pola oraz metody stanowi swoistą obietnicę, że w przyszłości nie ulegną one zmianom. Jeśli jednak klasa udo stępni zbyt wiele takich szczegółów, to tych obietnic będzie bardzo dużo, a to może poważnie ograniczyć możliwość wprowadzania zmian. Pisząc klasy bazowe, należy zatem zwracać uwagę, by minimalizować te ograniczenia i dążyć do uzyskania podobnego poziomu szczegółów, który jest udostępniany pod czas korzystania z interfejsów. Pola w klasach należących do platform y zawsze pow in ny być deklarowane jako prywatne. Jeśli klienci muszą m ieć dostęp do danych prze chowywanych w polach, to należy stworzyć odpowiednie metody pobierające. Trzeba także bardzo uważnie przeanalizować tworzone metody i tylko najważniejsze z nich deklarować jako publiczne (choć jeszcze lepszym rozwiązaniem będzie deklarowanie ich jako chronionych). Postępowanie zgodnie z tymi regułami pozwala definiować klasy ba zowe udostępniające jedynie niewiele więcej szczegółów niż odpowiadające im interfej sy, a jednocześnie daje klientom znacznie większe możliwości stosowania własnej logiki. Słowo kluczowe a b s tra c t inform uje klientów o tym, gdzie powinni udostępnić własną logikę. D ostarczenie sensownych domyślnych im plem entacji metod tam, gdzie jest to możliwe, pozwoli klientom na szybkie rozpoczęcie korzystania z platformy. Jednak dodanie nowych metod abstrakcyjnych do klasy bazowej doprowadzi do powstania niezgodnej aktualizacji, gdyż klienci będą musieli zaimplementować tę nową metodę, zanim będą w stanie ponownie skompilować swoje klasy bazowe.
141
142
r o z d z ia ł
10
R o z w ija n ie p l a t f o r m
Jeśli w definicji klasy zostanie umieszczone słowo kluczowe f in a l, to uniemożliwi ono klientom używanie jej do tworzenia klas pochodnych, wymuszając tym samym stosowanie pozostałych stylów: tworzenia obiektów lub stosowania konfiguracji. To samo słowo kluczowe zastosowane w definicji metody pozwala twórcy platform y za łożyć, że będzie wykonywany konkretny kod, i to nawet w metodzie widocznej dla klientów. Jeśli o m nie chodzi, choć respektuję prawo twórców platformy do uprasz czania sobie życia, zdarzało mi się także popadać we frustrację wynikającą ze stosowa nia sfinalizowanych klas i metod. Kiedyś spędziłem dwa dni na bezowocnych próbach programowego tworzenia zdarzeń SW T w celu przeprowadzenia niezbędnych testów. To właśnie sfinalizowana (w m ojej opinii zupełnie niepotrzebnie) klasa uniemożliwiła mi osiągnięcie tego, co chciałem. W efekcie musiałem napisać swoje własne klasy zdarzeń, powielające klasy zdarzeń SW T; dopiero to pozwoliło mi przetestować kod bez two rzenia faktycznego graficznego interfejsu użytkownika. Zachowanie słowa kluczowego fin a l na te okazje, kiedy jego użycie faktycznie się nam opłaci, a przy tym nie przy sporzy problemów klientom, pozwoli poprawić wzajemne relacje między tw órcami platformy i jej użytkownikami. Skoro już zajmujemy się zagadnieniem widoczności, muszę zwrócić uwagę na pewną lukę w systemie pakietów stosowanym w Javie. Platformy, których zawartość jest umiesz czana w kilku pakietach, wymagają zastosowania w idoczności rozum ianej jako: „wi doczny wewnątrz platformy, lecz nie dla klientów”. Jednym z rozwiązań tego proble mu jest podzielenie pakietów na publiczne oraz wewnętrzne i poinformowanie o tym podziale poprzez dodanie do nazwy „wewnętrznego” pakietu odpowiedniego słowa. Na przykład w kodzie Eclipse można znaleźć takie pakiety, jak o r g . e c l i p s e . j d t . . . oraz o r g .e c l i p s e .jd t .i n t e r n a l ___ Takie wewnętrzne pakiety stanowią kom prom is między ujawnianiem i ukrywa niem szczegółów platformy. Klienci sami mogą zdecydować, jak dużą odpowiedzialność za korzystanie z niestabilnych elementów platformy chcą przyjąć na swoje barki. c z a sami zdarza się, że funkcjonalność, której potrzebują klienci, jest dostępna w platfor mie, a jedynie została błędnie (patrząc z perspektywy użytkownika) sklasyfikowana przez jej twórców.
Tworzenie Jeśli platforma ma udostępniać konkretne klasy, to jej twórcy muszą określić, jak klienci mają tworzyć obiekty tych klas. Podobnie jak w przypadku innych decyzji związanych z projektem platformy wybór stylu tworzenia obiektów musi stanowić kom prom is między ogólnością, złożonością, łatwością nauki i wprowadzania przyszłych modyfi kacji. cz te ry style opisane poniżej to odpowiednio: pom inięcie tworzenia obiektów, konstruktory, metody wytwórcze oraz obiekty wytwórcze. Żaden z nich nie wyklucza innego. W każdym obiekcie można stosować dowolny z tych stylów bądź jakąkolwiek ich kom binację, można także używać różnych stylów programowania w różnych fragmentach kodu platformy.
Z a c h ę c a n ie d o w p r o w a d z a n ia z g o d n y c h z m ia n
Bez możliwości tworzenia Najprostszym rozwiązaniem, dającym jednocześnie najm niejsze możliwości, jest cał kowite uniemożliwienie klientom bezpośredniego tworzenia obiektów klas należących do platformy. Przykładem takiego rozwiązania są zdarzenia SW T, o których już wcze śniej wspominałem. Dzięki temu, że obiekty platformy mogą być tworzone wyłącznie wewnątrz niej, jej twórcy mogą zagwarantować, że będą one prawidłowe. A skoro można przyjąć, że niektóre założenia inwariantne dotyczące tych obiektów będą zawsze speł nione, kod platformy może być prostszy. Jednak taki brak możliwości tworzenia obiektów platformy przez kod klientów przekreśla jednocześnie wszelkie potencjalnie uzasadnione sposoby korzystania z klas platformy, które nie zostały przewidziane przez jej twórców. W przypadku bardzo złożo nych problemów programistycznych, w których elim inacja wszelkich ewentualnych złożoności jest pożądana, takie uniemożliwienie klientom tworzenia obiektów plat formy może być dobrym rozwiązaniem. W artość platformy może czasami polegać na czymś innym, niż początkowo zakładali jej twórcy. W yelim inowanie nieprzewidzia nych sposobów wykorzystania ogranicza szansę odnalezienia innych wartościowych zastosowań platformy.
Konstruktory Umożliwienie klientom tworzenia obiektów przy użyciu konstruktorów jest prostym rozwiązaniem, które jednak poważnie ogranicza możliwość wprowadzania zmian. Udostępnienie konstruktora stanowi obietnicę, że nazwa klasy, parametry niezbędne do utworzenia obiektu, pakiet, do którego należy klasa, a przede wszystkim konkretna klasa zwracanych obiektów (i to właśnie ona stanowi największe ograniczenie) się nie zmienią. W iększość bibliotek języka Java udostępnia możliwość tworzenia obiektów przy użyciu konstruktorów. Kiedy twórcy Javy opublikowali inform ację, że listy są tworzo ne przy użyciu wyrażenia new A rra y L ist(), zobowiązali się do zachowania klasy ArrayList w pakiecie ja v a .u til i zachowania konkretnej klasy, której obiekty będą zwra cane przez to wyrażenie. Są to poważne ograniczenia projektowe, które należy zachować nie wiadomo jak długo i które ograniczają wprowadzanie zmian w bibliotece Javy. Tworzenie obiektów przy użyciu konstruktorów ma tę zaletę, że jest proste i zro zumiałe dla klientów. Jeśli klient potrzebuje prostego interfejsu pozwalającego na two rzenie obiektów, a nam nie zależy na zachowaniu możliwości zmiany nazwy klasy, pa kietu oraz konkretnej klasy wykorzystywanej w udostępnianej abstrakcji, to użycie konstruktorów będzie dobrym rozwiązaniem.
Fabryki statyczne Z punktu widzenia klientów fabryki statyczne powodują zwiększenie złożoności obiek tów, jednak zapewniają tw órcom platformy większe możliwości wprowadzania zmian projektowych. Jeśli klient tworzy listę, używając wywołania A r r a y L is t.c r e a te (), a nie
143
144
r o z d z ia ł
10
R o z w ija n ie p l a t f o r m
konstruktora, to zarówno nazwa, jak i pakiet oraz nazwa konkretnej klasy, której in stancje są zwracane, może się zm ienić bez konieczności wprowadzania jakichkolwiek zmian w kodzie klienta. Kolejnym krokiem mogłoby być umieszczenie metody wy twórczej w klasie bibliotecznej C o lle c tio n s .c re a te A rra y L is t(). W razie zastosowa nia takiego rozwiązania jedyną klasą, która musiałaby pozostać w początkowym pa kiecie ja v a .u t i l , byłaby klasa biblioteczna. Wszystkie inne klasy można by przenieść, gdyby zaistniała taka konieczność. Jednakże im bardziej abstrakcyjny jest sposób two rzenia obiektów, tym trudniej jest użytkownikom platform y określić, gdzie te obiekty są tworzone. Kolejną zaletą stosowania metod wytwórczych jest to, że pozwalają precyzyjnie wyrazić znaczenie zm ienności w procesie tworzenia obiektów. Nie zawsze wiadomo, jakie jest znaczenie dwóch konstruktorów o różnych zbiorach parametrów, natomiast nazwa metody wytwórczej może sugerować, dlaczego klienci mogą ją wybrać do two rzenia obiektów.
Obiekt wytwórczy Tworzenie obiektów można zrealizować poprzez wywoływanie metod statycznych, ale także przez wysłanie komunikatów do obiektów wytwórczych. Na przykład klasa C ollectionFactory mogłaby udostępniać metody służące do tworzenia różnych rodzajów kolekcji. Można by ich używać następująco: C o lle c tio n s.fa c to ry () .createA rray L ist(). ob iekty wytwórcze zapewniają nawet większą elastyczność niż fabryki statyczne, choć kod, który z nich korzysta, jest trudniejszy do analizy i zrozumienia. Trzeba uważnie śle dzić proces jego wykonywania, aby zorientować się, gdzie są tworzone określone klasy. Jeśli obiekt wytwórczy jest używany wyłącznie na poziom ie globalnym, to nie za pewnia większej elastyczności niż statyczne metody wytwórcze. Prawdziwą m oc obiektów wytwórczych można zauważyć dopiero, gdy są stosowane lokalnie. Na przykład gdy byśmy dysponowali specjalnymi — oszczędnymi — klasami kolekcji o niewielkich wymaganiach pamięciowych, przeznaczonymi do użycia w aplikacjach dla urządzeń przenośnych, to moglibyśmy inicjow ać obiekty wymagające utworzenia kolekcji, uży wając do tego celu specjalnych wersji obiektów wytwórczych, gdyby kod był wykony wany na jakim ś urządzeniu przenośnym, oraz norm alnych obiektów wytwórczych, gdyby działał na serwerze. o b ie k ty wytwórcze mogą być użyteczne w przypadkach, gdy konieczne jest two rzenie całych grup obiektów, które ze sobą współdziałają. Jeśli widżety systemu Windows współpracują ze sobą, lecz nie współpracują z widżetami systemu Linux, to zapewnienie możliwości tworzenia obiektów poprzez użycie obiektu wytwórczego stanowi sposób, który ułatwi klientom tworzenie wyłącznie zgodnych, współpracujących ze sobą klas.
Wniosek dotyczący tworzenia obiektów Sposób, w jaki platforma wyrazi proces tworzenia obiektów, będzie miał wpływ na to, ja k łatwo będzie można jej używać i ją modyfikować. Jedna ze stosowanych strategii polega na udostępnieniu metod wytwórczych do tworzenia klas, których prawdopo
Z a c h ę c a n ie d o w p r o w a d z a n ia z g o d n y c h z m ia n
dobieństwo zmian jest wysokie, oraz konstruktorów w klasach, które są stabilne. N ie mniej jednak cenne jest także stosowanie spójnej strategii tworzenia obiektów, pole gającej na wykorzystaniu bądź to metod wytwórczych, bądź obiektów wytwórczych.
Metody Na łatwość użycia oraz modyfikacji platform m ają także wpływ inne metody, a nie tylko te związane z tworzeniem obiektów. o g ó ln a strategia pozostaje taka sama: nale ży ujawniać możliwie jak najm niej szczegółów, starając się przy tym pomagać klien tom w rozwiązywaniu ich problemów. Stosowanie dostępnych dla klientów metod pobierających i ustawiających jest pra widłowym rozwiązaniem wyłącznie w przypadkach, gdy struktury danych są stabilne. Za chęcanie klientów, by polegali na wewnętrznych strukturach danych, drastycznie ogranicza możliwości wprowadzania zmian w platformie. Pod tym względem metody ustawiające są znacznie gorsze od metod pobierających. cz ę sto można określić alter natywny sposób wyliczania wartości przechowywanej w polu. W arto spróbować zro zumieć, jaki problem klient stara się rozwiązać, podając wartość pola. Zamiast metody ustawiającej można udostępnić metodę, której nazwa będzie odpowiadała rozwiązy wanemu problemowi, a nie będzie stanowiła odzwierciedlenia im plem entacji. Na przykład, tworząc bibliotekę graficznych widżetów, można udostępnić w nale żącej do niej klasie Widget metodę ustawiająca s e tV is ib ility (b o o le a n ). Co się jednak stanie, gdybyśmy chcieli wprowadzić trzeci stan widżetu: nieaktywny? Aby ułatwić klientom określanie stanu widżetu, można by udostępnić metody reprezentujące in tencje, takie jak v i s ib l e ( ) oraz in v i s ib le ( ) . W łaśnie takie znaczenie ma dla klientów metoda s e t V i s i b i l i t y ( ) . W przypadku udostępnienia takich metod dodanie do klasy bazowej trzeciej metody — in a c tiv e () — pozwoli wprowadzić trzeci stan widżetu bez konieczności wprowadzania jakichkolwiek zmian w kodzie klientów. Nieco inne są abstrakcje bazujące na interfejsach. Dodanie metody in a c tiv e () do interfejsu uszkodziłoby wszystkie im plem entacje klasy Widget stworzone przez klien tów. Zamiast tego lepiej będzie zdefiniować typ wyliczeniowy S ta te s określający wszystkie dostępne stany widżetów i publikować metodę s e t V is ib ilit y ( S ta t e ) . W ersja tej metody pobierająca argument typu boolean jest przykładem wyciekania do klien tów inform acji projektowych. Zastosowanie typu boolean sugeruje, że istnieją tylko dwa możliwe stany. Z kolei użycie jednej metody i typu wyliczeniowego używanego jak jej parametr pozwala dodawać kolejne stany, jeśli tylko zajdzie taka potrzeba. Nie oznacza to wcale, że nigdy nie należy udostępniać klientom metod ustawiających i pobierających. Jeśli poprzez zwracanie lub ustawianie pola została zaimplementowa na jakaś ważna funkcjonalność platformy, to należy udostępnić odpowiednią metodę pobierającą lub ustawiającą. Niem niej jednak warto im nadać nazwy, które nie będą zdradzały klientom szczegółów implementacyjnych. Kolejną strategią związaną z metodami, z której mogą korzystać twórcy platform w celu zachowania zgodności, jest określanie domyślnych wartości parametrów do dawanych do udostępnionych metod. Jeśli do jakiejś m etody zostanie dodany nowy
145
146
ROZDZIAŁ 10
R OZWIJANIE PLATFORM
parametr, to wszystkie jej wywołania umieszczone w kodzie klienta trzeba będzie zmodyfikować, zanim będzie go można ponownie skompilować. Istnieje jednak możli wość uniknięcia konieczności modyfikowania kodu klienta — wystarczy zachować starą metodę i wywoływać w niej jej nową wersję, podając w niej domyślną wartość parametru. Załóżmy na przykład, że w JUnit chcielibyśmy móc przekazywać obiekt T estR esu lt do metody zwracającej testy dostępne w klasach. W ystarczyłoby zmodyfikować m eto dę przez dodanie do niej parametru. public TestResult run(C lass... classes) { ...wykonuje t esty w klasach... } public void run(TestResult resu lt, C la ss... classes) { ...wykonuje testy w klasach... } W takim przypadku każdy, kto chciałby wywołać metodę ru n(C lasses . . . ) , m u siałby zm ienić jej wywołanie, dodając do niego param etr T estR esu lt. Niem niej jednak można zmodyfikować oryginalną metodę tak, by obsługiwała dodatkowy parametr: public TestResult run(C lass... classes) { TestResult result= new TestResult(); run(result, classes); return resu lt; } Dzięki zastosowaniu domyślnego parametru kod klientów wciąż będzie działał prawidłowo, choć do interfejsu zostanie dodana nowa metoda.
Wnioski Tworzenie i rozwijanie platform wymaga zastosowania nieco odmiennych wzorców implementacyjnych niż tworzenie aplikacji. Zmiana podstaw ekonomicznych tworzenia platform, wynikająca z faktu, że czynnikiem dom inującym jest tu nie koszt zrozum ie nia kodu, a koszt aktualizacji kodu klientów, wymaga znaczących modyfikacji zarów no stosowanych praktyk, jak i uwzględnianych wartości. W przypadku tworzenia platform prostota, która była główną wytyczną tworzenia aplikacji, ma mniejszy prio rytet niż potrzeba zachowania możliwości rozwijania platformy w przyszłości. Cel ten jest dosyć trudny do osiągnięcia, kiedy platforma jest tworzona przy wykorzystaniu fragmentów kodu aplikacji. Utworzenie efektywnej platform y będzie wymagało po nownego przemyślenia wielu spośród decyzji projektowych podejmowanych podczas pisania aplikacji. Platformy rozwijają się na wiele różnych sposobów. Czasami konieczne jest uspraw nienie obliczeń wykonywanych przez istniejące metody. W innych przypadkach do tychczas wykonywane obliczenia muszą być wykonywane przy użyciu nowych para metrów. Zdarza się także, że po wprowadzeniu niewielkich zmian będzie można używać platformy do rozwiązywania zupełnie nieoczekiwanych problemów. Może się też pojawić konieczność udostępnienia szczegółów im plem entacyjnych platformy.
W NIOSKI
M etaforą, która okazała się niezwykle użyteczna podczas tworzenia JU nit, było spojrzenie na platformę jako część wspólną wszystkich użytecznych funkcjonalności do meny, a nie ich sumę. Zadaniem twórców platformy jest zapewnienie klientom m oż liwości jej rozszerzenia w taki sposób, by możliwe było rozwiązanie ich problemów. Bardzo kuszące jest podjęcie próby stworzenia platform y rozwiązującej szerszą gamę problemów. Trudność jednak polega na tym, że ta dodatkowa funkcjonalność ogrom nie utrudnia naukę i korzystanie z platformy. Gdyby 90% wymagań wszystkich potencjalnych użytkowników platformy było identycznych, a jedynie 10% z nich było odmiennych, to platforma, która mogłaby za spokoić wszystkie wymagania wszystkich użytkowników, byłaby znacznie większa od platformy spełniającej wyłącznie wymagania wspólne dla każdego z nich. Celem tw ór cy platformy jest zaspokojenie wspólnych potrzeb użytkowników, lecz już niekoniecz nie ich unikalnych wymagań. Gdyby wszyscy użytkownicy musieli dodawać tę samą funkcjonalność, to powinna ona należeć do platformy, niemniej jednak unikalne wy magania najlepiej pozostawić osobom, które muszą je spełnić. Jednym ze sposobów zapewnienia właściwej wielkości platform jest tworzenie ich na podstawie kilku konkretnych przykładów, a nie ogólnych założeń. Pierwowzór JUnit napisałem po kilku próbach zautomatyzowanego przetestowania tworzonego kodu. Każda z wersji pozwalała na rozwiązanie tylko jednego konkretnego problemu, z którym borykałem się w danej chwili. Dopiero po napisaniu kilku różnych wersji tego samego kodu byłem w stanie określić, które problem y były takie same we wszystkich testach i powinny być rozwiązywane przez platformę, a które były osobliwe dla konkretnych sytuacji. Pojęcia występujące w platform ie należy określać na podstawie co najm niej jednej zrozumiałej i spójnej metafory. Na przykład jeśli zasada podwójnego zapisu jest m e taforą używaną do rejestracji danych historycznych, to klienci będą wiedzieć, że należy szukać klas Account oraz Transaction. Poprzez świadomy wybór i stosowanie m etafor oraz komunikowanie ich klientom można doprowadzić do tego, że tworzone platfor my będą łatwiejsze do poznania, stosowania i rozwijania. W drożenie platformy wcale nie musi oznaczać końca jej ewolucji i rozwoju. Roz waga wykazana podczas projektowania platform y może wspomóc utworzenie stabil nej bazy dla aplikacji użytkowników oraz dynamicznej podstawy do dalszego rozwoju samej platformy.
147
1 48
rozdział 10 Rozwijanie platform
Dodatek A
Pomiary wydajności
W tym dodatku została opisana platforma służąca do pomiarów wydajności działania kolekcji prezentowanych w rozdziale 9. Sformułowany problem był stosunkowo pro sty — chodziło o dokładne porównanie czasu potrzebnego do wykonania kilku operacji na zbiorze danych o różnej wielkości. Problem ten komplikuje się nieco, kiedy dokładność używanego licznika czasu jest znacznie mniejsza od czasu, jaki zajm uje wykonanie operacji. Przedstawiony tu m echanizm pomiarowy rozwiązuje ten problem, wykonu jąc te same operacje wiele razy. Dostosowuje się do dokładności licznika czasu po przez odpowiednie dobieranie faktycznego okresu, przez jaki są wykonywane pomiary poszczególnych operacji. Ze względu na mechanizmy optymalizacji wykorzystywane w języku Java wyko nywanie dokładnych pomiarów wydajności operacji wymaga znacznie większej wie dzy, niż przedstawiłem w tym rozdziale, dotyczy to zarówno platformy, ja k i samych testów. Aby uzyskać dokładne wyniki, trzeba wiedzieć, co m echanizm y optymalizacji najprawdopodobniej zrobią z naszym kodem, gdyż jest to konieczne, by uniknąć sytu acji, w której chytre mechanizmy optymalizacji całkowicie wyeliminują wykonywaną operację. Jeśli uzyskane wyniki nie odpowiadają temu, co podpowiada intuicja, będzie to sugestia do głębszych poszukiwań. Być może trzeba się będzie dowiedzieć czegoś wię cej na tem at pomiarów wydajności bądź kodu, którego wydajność chcem y zmierzyć. Kod przedstawiony w tym dodatku jest dostatecznie dobry, by można go było użyć do zebrania danych prezentowanych w tej książce. Można go było napisać bardziej ogól nie. Na przykład parametry pomiarowe są reprezentowane przez stałe, a nie przez zm ien ne, nie ma interfejsu pozwalającego na wykonywanie testów z poziomu wiersza pole ceń, a wyniki są stosunkowo proste i wyświetlane w oknie konsoli. Jedną z bardzo ważnych um iejętności programistycznych jest dostosowywanie wysiłku do celu, który chcemy osiągnąć. Naukę wzorców im plem entacyjnych należy uzupełnić przez pozna nie, kiedy ich używać, a kiedy lepiej z nich nie korzystać.
149
150
D odatek A
P o m ia r y w y d a jn o ś c i
Przykład Licznik czasu powinien być w stanie mierzyć operacje zapisane w możliwie jak naj prostszy sposób. Biorąc za wzór JU nit, testowane operacje można przedstawić w for mie metod. Podczas tworzenia instancji metod pomiarowych będzie określana liczba elementów, dzięki czemu testy pozwolą na skalowanie danych. Na przykład poniższa klasa reprezentuje test służący do badania czasu przeszukiwania listy: public class ListSearch { private List numbers; private int probe; public ListSearch(int size) { numbers= new ArrayList(); for (in t i= 0; i < size; i++) numbers.add(i); probe= size / 2; } public void search() { numbers.contains(probe); } } Dzięki użyciu tej klasy do przeprowadzania przez platformę testu wydajności po znamy czas wykonania metody search () na kolekcjach złożonych z jednego elementu, dziesięciu, stu elementów itd.
API Zewnętrznym interfejsem pomiarowego licznika czasu jest kasa MethodsTimer. Pod czas tworzenia instancji tej klasy przekazywana jest tablica metod: public class MethodsTimer { private final Method[] methods; public MethodsTimer(Method[] methods) { this.methods= methods; } } Aby wykonać pomiar, należy wywołać metodę rep o rt() klasy MethodsTimer. Na przy kład aby zmierzyć czas wykonania operacji dostępnych w przedstawionej wcześniej klasie L istSearch , trzeba użyć następującej metody: public s ta tic void main(String[] args) throws Exception { MethodsTimer tester= new MethodsTimer(ListSearch.class.getDeclaredMethods()); te ste r.re p o rt(); }
I m p l e m e n t a c ja
W ykonanie tej metody spowoduje wyświetlenie w oknie konsoli wyników, podob nych do tych przedstawionych poniżej: search
34.89
130.61
989.73
9911.19
97410.83
990953.62
Oznaczają one, że operacja wyszukiwania w liście zawierającej jeden element zajęła 35 nanosekund, 131 nanosekund w liście zawierającej dziesięć elementów itd. Używany licznik czasu nie jest doskonały. Wykonanie testu przy użyciu klasy Nothing spowoduje zmierzenie czasu wykonania pustej metody, co teoretycznie powinno zwrócić same zerowe czasy. Jednak czasy, jakie uzyskałem (przynajm niej na m oim kom pute rze), różnią się od tych teoretycznych o kilka nanosekund: nothing 1.92
-3.24
0.62
0.37
-0.74
2.30
O tej dokładności należy pamiętać, mierząc bardzo krótkie operacje, takie jak czas dostępu do tablic. Aby uzyskać dokładne wyniki, musiałem napisać metodę, która odwo ływała się do tablicy aż dziesięć razy. Jednak ogólnie rzecz biorąc, chodzi o to, by pro gramista m ógł pisać w miarę proste operacje i aby platforma powtarzała je tyle razy, ile będzie konieczne do uzyskania dokładnych wyników.
Implementacja Jak pokazują powyższe wyniki, metoda pomiarowa wyświetla po sześć czasów dla każdej metody, której wydajność jest mierzona. Dzieje się tak dlatego, że chodzi o pomiar wydaj ności podczas skalowania ilości danych, na których metoda operuje. M etoda re p o rt() stanowi pętlę wewnętrzną, umieszczoną wewnątrz pętli zewnętrznej operującej na wszystkich mierzonych metodach. M etoda ta operuje na szeregu wartości — 1, 10, ..., 100 000. private s ta tic final int MAXIMUM_SIZE= 100000; public void report() throws Exception { for (Method each : methods) { System.out.print(each.getName() + "\ t"); for (in t size= 1; size <= MAXIMUM_SIZE; size*= 10) { MethodTimer r= new MethodTimer(size, each); r.ru n (); System.out.print(String.format("% .2f\t", r.getMethodTime())); } System .out.println(); } } W yniki pomiarowe prezentowane są w możliwie jak najprostszy sposób poprzez oddzielenie poszczególnych wyników znakami tabulacji, dlatego z łatwością można je skopiować i wkleić do arkusza kalkulacyjnego. Bardziej rozbudowany mechanizm groma dziłby zapewne wyniki zwracane przez poszczególne pomiary (obiekty MethodTimer), a następnie przedstawiał je w drugim kroku, dzięki czemu raportowanie mogłoby być bardziej elastyczne.
151
152
D odatek A
P o m ia r y w y d a jn o ś c i
Klasa MethodTimer Klasa MethodsTimer korzysta z obiektów pom ocniczych klasy MethodTimer, będących poleceniami służącymi do zmierzenia czasu koniecznego do wykonania jednej metody. M etoda ta będzie wielokrotnie wywoływana przez zadany czas, tak długi, by uzyskane wyniki pomiarowe były dokładne. Następnie sumaryczny czas wywoływania metody jest dzielony przez liczbę wywołań. Podczas tworzenia obiektu MethodTimer przekazywana jest metoda, którą należy wywoływać, oraz liczba określająca wielkość zbioru danych. Ponieważ metoda może być wywoływana w ielokrotnie, każdy obiekt MethodTimer przechowuje obiekt, do któ rego można przesłać kom unikat powodujący wykonanie metody. Tworzenie nowego obiektu w ramach każdego wywołania mierzonej metody zajmowałoby zbyt wiele czasu. Jeśli wykonanie mierzonej operacji zajmuje 50 nanosekund, to pomiar trwający 1 sekundę wymagałby wykonania jej aż 20 milionów razy. Utworzenie listy zawierającej 100 tysięcy elementów, takiej jak ta w przedstawionej wcześniej klasie ListSearch, zajmuje na moim komputerze około 50 milisekund. o zn acza to, że wykonanie takiego testu trwałoby około półtora tygodnia. Dzięki wielokrotnemu wykorzystaniu tego samego obiektu test zajm uje nieco ponad sekundę. W ielokrotne wykorzystanie tego samego obiektu jest trochę innym wzorcem im plem entacyjnym od tego, który jest używany w JU nit. W bibliotece JU nit dla każdego wykonywanego testu tworzony jest odrębny obiekt. Dzięki tej niezależności testy m o gą wprowadzać dowolne zmiany w stanie obiektów. Zastosowany tutaj m echanizm pomiarowy nie zapewnia takiej swobody. Każda mierzona metoda musi pozostawić stan obiektu reprezentującego test dokładnie w takiej samej postaci, w jakiej został do niej przekazany. Poniżej został przedstawiony konstruktor klasy MethodTimer: private final int size; private final Method method; private Object instance; MethodTimer(int size, Method method) throws Exception { th is.size= size; this.method= method; instance= createInstance(); } Każda metoda ma swoją klasę i to właśnie obiekt tej klasy jest tworzony podczas po miaru czasu wykonywania danej metody. oznacza to, że bieżąca implementacja platformy pomiarowej nie obsługuje możliwości mierzenia czasów wykonywania odziedziczonych metod, co wynika z użycia w klasie MethodsTimer metody getDeclaredM ethods(). M eto da getDeclaredMethods() zwraca jedynie te metody, które zostały zadeklarowane w prze kazanej klasie, lecz nie w jej klasach bazowych. Bardziej rozbudowane rozwiązanie m o głoby pozwalać na oznaczanie metod przy użyciu adnotacji i przeglądanie całej hierarchii dziedziczenia klasy w poszukiwaniu takich metod. Umożliwienie przeszukiwania hie
K l a sa M e t h o d T im e r
rarchii mogłoby pozwolić na wyeliminowanie pewnych powtórzeń, które można za uważyć w m etodach pomiarowych zastosowanych w tej książce. (M etody te zostały przedstawione w dalszej części dodatku). Trzeba jednak pamiętać, że możliwości przed stawionej platformy pomiarowej miały jedynie pozwolić na uzyskanie danych prezento wanych w tej książce. Tworzenie platformy mającej zaspokoić potrzeby wielu potencjal nych użytkowników wymagałoby zastosowania zupełnie innej filozofii projektowej. Napisanie platformy używanej przez bardzo wiele osób wymaga znacznie większych na kładów, gdyż każde usprawnienie, na które pozwoli potężniejszy (choć droższy) pro jekt, może się tysiąckrotnie zwrócić. Utworzenie obiektu służącego do pomiaru wymaga odnalezienia konstruktora po zwalającego na przekazanie liczby typu in t i wywołanie go. private Object createInstance() throws Exception { Constructor constructor= method.getDeclaringClass().getConstructor(new C la s s []{in t.c la s s }); return constructor.newInstance(new O b je ct[]{siz e }); } Istnieją trzy czynniki związane z wyznaczeniem czasu, jaki zajm uje jednokrotne wywołanie metody. Są to liczba iteracji, całkowity czas niezbędny do wywołania m e tody zadaną liczbę razy oraz narzut związany z wywoływaniem metody przy wykorzy staniu mechanizmów odzwierciedlania. Ponieważ użycie mechanizmów odzwiercie dlania w celu wywołania metody może być kosztowne w porównaniu z samym czasem wykonywania tej metody (na m oim komputerze narzut ten wynosi około 150 nanosekund), uwzględnienie narzutu pozwala poprawić dokładność pomiarów. Każdy z tych czynników zostanie wyznaczony w metodzie run() i zapisany w polu. private long totalTime; private int iteration s; private long overhead; double getMethodTime() { return (double) (totalTime - overhead) / (double) iteration s; } Sercem pomiaru czasu wykonania jest metoda ru n (). Wywołuje ona mierzoną metodę raz, następnie dwa razy, cztery razy itd., aż do momentu, w którym łączny czas pomia ru przekroczy jedną sekundę. Następnie metoda wylicza narzut czasowy, jaki spowodo wało jej dynamiczne wywołanie. Po zakończeniu metody run() obiekt MethodTimer jest już gotowy do zwrócenia wyników. Ta czasowa zależność pomiędzy metodą run() a możliwością pobrania wyników pomiaru (na przykład przy użyciu przedstawionej wcze śniej metody getMethodTime()) nie jest eleganckim rozwiązaniem. Jednak alternatywą byłoby wykonywanie pomiarów bezpośrednio w konstruktorze. Jeśli o mnie chodzi, je stem przeciwnikiem używania konstruktorów do wykonywania jakichkolwiek znaczących operacji, gdyż lubię mieć możliwość odseparowania procesu tworzenia obiektu od dzia łań, które obiekt ma realizować. A zatem zastosowany projekt umożliwia stworzenie kolekcji obiektów MethodTimer (gdybym chciał z niej skorzystać), przekazanie ich do testu, ich serializację lub przesłanie siecią, i to bez zwracania uwagi na wydajność.
153
154
D odatek A
P o m ia r y w y d a jn o ś c i
void run() throws Exception { iterations= 1; while (true) { totalTime= computeTotalTime(); i f (totalTime > MethodsTimer.ONE_SECOND) break; iterations*= 2; } overhead= overheadTimer(iterations).computeTotalTime(); } Należy zwrócić uwagę na wykorzystanie kolejnej stałej: ONE_SECOND. Zastosowanie stałych w celach konfiguracyjnych stanowi tanie rozwiązanie pozwalające zapewnić pewną elastyczność działania osobom, które są chętne do przeanalizowania kodu źró dłowego; warto je jednak umieszczać w jednym miejscu, gdzie będzie je można łatwo odnaleźć. s ta tic final int ONE_SECOND= 1000000000;
Eliminacja narzutów czasowych Kolejną, ostatnią już częścią platformy są metody służące do wyznaczania narzutu związa nego z dynamicznym wywoływaniem metod. Metoda fabryki statycznej overheadTimer() ma na celu przekazanie inform acji o przeznaczeniu tego specjalnego licznika czasu. Rozwiązanie, w którym jeden obiekt MethodTimer korzysta, w ramach swojego działa nia, z innego obiektu tej samej klasy, jest nieco dziwne, jednak kilka eksperymentów wykazało, że właśnie ono pozwala uzyskać najlepszą strukturę kodu. private s ta tic MethodTimer overheadTimer(int iterations) throws Exception { return new MethodTimer(iterations); } private MethodTimer(int iterations) throws Exception { th is(0 , MethodTimer.Overhead.class.getMethod("nothing", new C lass[0])); th is.iteratio n s= iteration s; } public s ta tic class Overhead { public Overhead(int size) { } public void nothing() { } }
Testy Ten podrozdział prezentuje testy użyte w celu zebrania danych przedstawionych w roz dziale „Kolekcje”. Pokazują one sposób wykorzystania platformy pomiarowej, pewne szczególne cechy klasy kolekcji, jak również ograniczenia zastosowanego rozwiązania.
T esty
Porównywanie kolekcji Pierwszy przykład porównuje wykorzystanie typów S e t oraz A rrayL ist jako kolekcji — im plem entacji interfejsu C o lle ctio n . Konstruktor klasy tworzy dwie kolekcje o za danej wielkości, przy czym obie m ają zawierać łańcuchy znaków. W artości mieszające łańcuchów znaków nie będą rozłożone losowo. N iem niej jednak próbowałem użyć ja ko danych obiektów In teg er i zachowanie, jakie w efekcie uzyskałem, było jeszcze dziwniejsze. Ponieważ wartości mieszające dużych zbiorów danych rzadko kiedy są rozłożone losowo, wykonując własne pom iary wydajności działania kolekcji (jeśli du że znaczenie ma pom iar wydajności operacji wykonywanych na dużych zbiorach da nych), można się zdecydować na utworzenie „typowej” reprezentacji danych, na któ rych będą wykonywane obliczenia. Każdy zestaw testów pomiarowych jest reprezentowany przez jedną klasę. Jedne mu testowi odpowiada jedna metoda tej klasy. Klasa przechowuje kolekcje, na których będą wykonywane operacje. W arto zwrócić uwagę, że w deklaracjach obu kolekcji zo stał użyty typ C o lle ctio n . Klasa przechowuje także próbkę reprezentującą element, który będzie wyszukiwany. public class SetVsArrayList { private Collection set; private Collection arrayList; private String probe; } Podczas inicjalizacji obiektu określana jest zawartość obu kolekcji. W arto zauwa żyć, że próbka zostaje umieszczona pośrodku kolekcji, zapewniając tym samym naj gorszy możliwy scenariusz wyszukiwania. Bardziej szczegółowy pom iar wydajności działania kolekcji mógłby uwzględniać przypadki umieszczania próbki na jej począt ku, w środku oraz na końcu. public SetVsArrayList(int size) { set= new HashSet(size); arrayList= new ArrayList(size); for (in t i= 0; i < size; i++) { String element= String.format("a%d", i ) ; set.add(element); arrayList.add(element); } probe= String.format("a%d", size / 2); } Pierwsza para operacji porównuje czasy konieczne do określenia, czy próbka wy stępuje w zbiorze danych. o b ie metody różnią się od siebie wyłącznie kolekcjam i, na których operują. Czas potrzebny na sprawdzenie występowania próbki w kolekcji typu HashSet jest niemalże stały, natomiast w przypadku kolekcji A rrayL ist rośnie liniowo wraz ze zwiększaniem zawartości kolekcji. public void setMembership() { set.contains(probe); }
155
156
D odatek A
P o m ia r y w y d a jn o ś c i
public void arrayListMembership() { arrayList.contains(probe); } Powielanie, którego przykładem są te dwie metody, sugeruje, że warto by stworzyć alternatywne rozwiązanie, pozwalające uniknąć powielania. M ogłoby ono bazować na zapewnieniu możliwości utworzenia klasy bazowej testu, zawierającej abstrakcyjną im plem entację badanej metody. Podczas inicjalizacji każdego testu byłaby określana konkretna klasa, którą należy zbadać: public class CollectionOperations { Collection collection ; String probe; public void membership() { collection.contains(probe); } } Konkretną klasę kolekcji można by inicjalizować bądź to w konstruktorze, bądź w kla sie pochodnej. C hoć taki projekt rozwiązania umożliwiałby zm niejszenie powtórzeń i byłby lepszy w przypadku popularnych i często używanych platform, to jednak za stosowane rozwiązanie w zupełności wystarcza do zebrania danych prezentowanych w książce, dlatego też mogę się zgodzić na pozostawienie powtarzającego się kodu. Kolejna para metod została zdefiniowana w klasie SetV sA rrayList — mierzą one czas przeglądania całej zawartości kolekcji. W tym przypadku czas rośnie liniowo wraz ze wzrostem liczby elementów kolekcji. public void setIteratio n () { Iterator all= s e t.ite r a to r (); while (all.hasN ext()) al l.n e x t(); } public void arrayL istIteration() { Iterator all= a rra y L ist.ite ra to r(); while (all.hasN ext()) al l.n e x t(); } Spróbowałem zoptymalizować przeglądanie zawartości kolekcji poprzez zastoso wanie pętli fo r oraz fo r (Strin g each : s e t), jednak implementacja Javy była na tyle inteligentna, by wykryć puste ciało pętli i całkowicie ją wyeliminować. Ogólnie rzecz biorąc, jednym z największych wyzwań związanych z pisaniem metod do pomiaru czasu jest zachowanie ich prostoty i jednoczesne zagwarantowanie, że mechanizmy opty malizujące języka nie spowodują całkowitego pom inięcia wywoływanej metody. Dla tego zawsze należy sprawdzać uzyskiwane wyniki i upewniać się, czy są one sensowne. Ostatnia para metod pomiarowych służy do określania czasu koniecznego do zmodyfikowania kolekcji. M etody te są na tyle staranne, że po ich wykonaniu kolekcja ma dokładnie taką samą zawartość, jak w m om encie ich wywoływania. Jest to ograni
T ESTY
czenie zastosowanej platformy pomiarowej: każda metoda będzie wywoływana wiele razy i za każdym razem będzie do niej przekazywany ten sam obiekt, dlatego też nie może ona zm ieniać stanu tego obiektu. public void setModification() { set.add("b"); set.remove("b"); } public void arrayListModification() { arrayList.add("b"); arrayList.remove("b"); } Czasy wykonania tych operacji są takie same ja k czasy potrzebne do sprawdzenia, czy konkretny element jest dostępny w kolekcji: dla kolekcji typu HashSet czasy te są mniej więcej stałe, natom iast w przypadku kolekcji A rrayL ist rosną liniowo wraz ze wzrostem liczby elementów.
Porównywanie kolekcji ArrayList i LinkedList Ta grupa testów przypomina opisane powyżej, choć w deklaracjach testowanych zmiennych został zastosowany typ L is t, a nie C o lle ctio n , i są w nich zapisywane ko lekcje typów A rrayL ist oraz LinkedList. public class Lists { private List arrayList; private List linkedList; private final int size; } W odróżnieniu od wcześniej opisanych testów, w których poszukiwany element był zapisywany polu, te testy pamiętają wielkość kolekcji, na której mają operować; używają do tego celu metody g e t( in t ) typu L ist. public L ists(in t size) { th is.size= size; arrayList= new ArrayList(size); linkedList= new LinkedList(); for (in t i= 0; i < size; i++) { String element= String.format("a%d", i ) ; arrayList.add(element); linkedList.add(element); } } Pierwsza para testów mierzy czas konieczny do wprowadzenia w liście modyfika cji, polegających na dodaniu do niej nowego elementu, a następnie na jego usunięciu. W arto zwrócić uwagę, że element ten jest umieszczany na samym początku kolekcji. Klasa A rrayList została zoptymalizowana pod kątem dodawania nowych elementów
157
158
D odatek A
P o m ia r y w y d a jn o ś c i
na końcu kolekcji, dzięki czemu czas wykonania takiej operacji jest stały (podobnie jak podczas analogicznej operacji na kolekcjach typu LinkedList), a nie rośnie liniowo wraz ze wzrostem liczby elementów w kolekcji. public void arrayListModification() { arrayList.add(0, "b"); arrayList.remove(0); } public void linkedListModification() { linkedList.add(0, "b"); linkedList.remove(0); } Kolejne testy mierzą czas dostępu do elementów kolekcji. W yniki odpowiadają tym uzyskiwanym dla operacji modyfikacji: w przypadku klasy ArrayList czas jest stały, a w przypadku klasy LinkedList — rośnie liniowo. Pierwsza napisana przeze mnie wersja tej metody pomiarowej mierzyła czas odwołań do elementu umieszczonego na końcu kolekcji; okazało się jednak, że klasa LinkedList optymalizuje odwołania i jeśli indeks elementu jest większy od połowy liczby elementów kolekcji, jej zawartość jest przeszukiwana od końca. public void arrayListAccess() { arrayL ist.get(size / 2); } public void linkedListAccess() { linkedList.get(size / 2); }
Porównywanie zbiorów Testy zbiorów są wykonywane zgodnie z tym samym wzorcem, co przedstawione wcze śniej testy list. Testowane są dwie operacje: modyfikacja zawartości oraz sprawdzenie występowania elementu, gdyż niemal wszystkie pozostałe możliwości zbiorów bądź to bazują na tych dwóch operacjach, bądź też m ają analogiczny profil wydajności działa nia. Zaprezentowany poniżej kod przedstawia tylko jeden wariant metody pom iaro wej, gdyż drugi jest identyczny i różni się jedynie zbiorem, na którym operuje. W tym przypadku porównywane są trzy im plem entacje interfejsu Set: HashSet, LinkedHashSet oraz TreeSet. Precyzyjnie rzecz ujmując, klasa TreeSet stanowi implementa cję interfejsu SortedSet, uznałem jednak, że przedstawienie narzutu związanego z zacho waniem określonego uporządkowania elementów może być interesujące i przydatne. public class Sets { private Set hashSet; private Set linkedHashSet; private Set treeSet; private String probe; }
T esty
Konstruktor klasy testowej inicjalizuje każdy z trzech porównywanych zbiorów, zapisując w nim te same elementy, a dodatkowo określa próbkę, która później będzie używana do pomiaru czasu sprawdzania, czy element jest dostępny w zbiorze. W arto zwrócić uwagę, że podczas tworzenia każdego ze zbiorów podawana jest liczba okre ślająca jego pojem ność. Cała literatura związana z językiem Java bardzo m ocno pod kreśla, jak duże znaczenie ma początkowe określenie odpowiedniej liczby elementów listy. Niemniej jednak moje badania wykazały, że wyniki uzyskiwane w przypadku stoso wania zbioru, który m iał być pusty, nie różnią się o więcej niż 10% od wyników opera cji wykonywanych na zbiorze, którego wielkość została prawidłowo określona już w m om encie tworzenia. public Sets(in t size) { hashSet= new HashSet(size); linkedHashSet= new LinkedHashSet(size); treeSet= new TreeSet(); for (in t i= 0; i < size; i++) { String element= String.format("a%d", i ) ; hashSet.add(element); linkedHashSet.add(element); treeSet.add(element); } probe= String.format("a%d", size / 2); } Czas sprawdzania, czy dany element występuje w kolekcji, jest wyznaczany na podsta wie przeglądnięcia zbioru w poszukiwaniu wyznaczonego wcześniej elementu. Wszystkie im plem entacje zbiorów są sprawdzane dokładnie tak samo. public void hashSetContains() { hashSet.contains(probe); } M etoda mierząca czas wprowadzania zmian dodaje do zbioru nowy element, a na stępnie go usuwa i pozostawia zbiór w początkowym kształcie. public void hashSetModification() { hashSet.add("b"); hashSet.remove("b"); }
Porównywanie map Sprawdzanie wydajności działania map — im plem entacji interfejsu Map — jest w za sadzie takie samo jak podczas badania zbiorów. Także w tym przypadku sprawdzane są trzy im plem entacje dostępne w bibliotece języka Java: HashMap, LinkedHashMap oraz TreeMap. Uzyskiwane wyniki są analogiczne, gdyż zbiory są implementowane jako mapy. Oznacza to, że klasa HashSet przechowuje elementy, używając w tym celu obiektu HashMap itd.
159
160
D odatek A
P o m ia r y w y d a jn o ś c i
public class Maps { private Map hashMap; private Map linkedHashMap; private Map treeMap; private String probe; Inicjalizacja map wymaga korzystania z metody p u t(), a nie add(). Zdecydowałem się, by klucz i wartość były takie same, gdyż nie mają one żadnego znaczenia dla pom ia rów czasu wykonywania operacji. public Maps(int size) { hashMap= new HashMap(size); linkedHashMap= new LinkedHashMap(size); treeMap= new TreeMap(); for (in t i= 0; i < size; i++) { String element= String.format("a%d", i ) ; hashMap.put(element, element); linkedHashMap.put(element, element); treeMap.put(element, element); } probe= String.format("a%d", size / 2); } M etody pomiarowe używają operacji odpowiednich dla map — containsK ey() oraz pu t() — zamiast operacji co n ta in s() i pu t() charakterystycznych dla zbiorów. Są to jednak jedyne zmiany. public void hashMapContains() { hashMap.containsKey(probe); } public void hashMapModification() { hashMap.put("b", "b"); hashMap.remove("b"); }
Wnioski Zagadnienia dotyczące platformy oraz przykłady zamieszczone w tym rozdziale są po uczające pod kilkoma różnymi względami. Jednym z nich jest wartość pobierania danych. Okazuje się, że powszechnie uznawane przekonania, takie jak „W stępne alokowanie zbiorów poprawia w ydajność”, wymagają dokładnego przeanalizowania. Zanim za czniem y zwiększać złożoność programów, należy m ieć pewność, że da to jakieś korzy ści. Czasami jedynym sposobem, by przekonać się o tych zyskach, będzie próba ich zmierzenia. Kolejną lekcją, jaką można wyciągnąć z przykładu dotyczącego platformy pom ia rowej, jest poznanie znaczenia dostosowania stylu kodowania do kontekstu. Gdyby przedstawiona platforma była przeznaczona dla szerszego kręgu odbiorców lub gdy bym m iał wykonać więcej testów, to na pewno zaprojektowałbym i napisał ją zupełnie inaczej. Jednak dzięki przyjętym przeze mnie uproszczonym założeniom udało mi się
W n io s k i
ograniczyć nakład pracy niezbędny zarówno do stworzenia samej platform y testowej, ja k i napisania testów. Dogmatyczne rady, takie jak: „Zawsze należy implementować całą funkcjonalność, jaką można sobie wyobrazić” czy „Koduj z myślą o dniu dzisiej szym, nie myśl o przyszłości”, są całkowicie błędne. W końcu kod zamieszczony w tym rozdziale zawiera przykłady wielu wzorców im plem entacyjnych omówionych w książce. W zorce takie ja k kom pletny konstruktor, nazwa określająca przeznaczenie oraz inne można odnaleźć niemal w każdym wierszu ko du. Tylko czytelnik jest w stanie ocenić, czy podołały swojemu zadaniu, którym było wyrażenie m oich intencji. Jeśli nie, to warto spróbować określić własne wzorce, które mogłyby to zrobić lepiej. Dobrze jest też wspom nieć o jednej wielkiej nauce płynącej z całej tej książki: trzeba pamiętać, że zadaniem programisty jest kom unikacja z inny mi programistami, a nie jedynie z komputerem. Programowanie jest w końcu ludzkim zadaniem, które ludzie wykonują z myślą o innych ludziach. Programowanie nie musi oznaczać ucieczki od społeczeństwa. M oże ono stanowić sposób na połączenie się z n im ... i jednocześnie stwarzać okazję do tworzenia dobrego kodu.
161
162
Dodatek A Pomiary wydajności
Bibliografia
O to lista książek, które przydały mi się podczas nauki programowania.
Ogólne zagadnienia programistyczne Kent Beck, S m alltalk B est P ractice Patterns, Prentice Hall, 1997, ISBN: 013476904X . Książka przedstawia wzorce im plem entacyjne stosowane w języku Smalltalk. W iele z nich przypomina wzorce opisane w tej książce, choć występują między nim i zna czące różnice, gdyż same języki bardzo się od siebie różnią. Opisywanie wzorców zmusiło mnie, bym nieco zwolnił i przemyślał decyzje, które wcześniej podejm o wałem błyskawicznie. M artin Fowler, R efactoring: Im p rov in g the D esign o f E xisting C ode, Addison-W esley, 1999, ISBN: 0201485672. Łatwo powiedzieć, że wraz z upływem czasu projekt oprogramowania powinien się nieco zmieniać. Ta książka wyjaśnia, jak wprowadzać zmiany. Eric Freeman, Elisabeth Freeman, Bert Bates oraz Kathy Sierra, W zorce projektow e. Rusz głow ą, Helion, Gliwice 2010, ISBN: 978-83-246-2803-2. Kolejna książka o wzorcach projektowych, nastawiona na odbiór wzrokowy. Erich Gamma, Richard Helm, Ralph Johnson oraz John Vlissides, W z orce p ro jek to w e. E lem enty op rog ra m ow a n ia ob iektow eg o w ielokrotn eg o użytku, Helion, Gliwice 2010, ISBN: 978-83-246-2662-5. Książka poświęcona powtarzającym się w kodzie dużym strukturom, uznawana już za opracowanie klasyczne. D aniel Hoffm an i David W eiss, S oftw are F u n d am en tals: C ollected P ap ers by D av id L. P arn as, Addison-W esley, 2001, ISBN: 0201703696. Książka opisuje teoretyczne podstawy dobrego oprogramowania. 163
164
B ib l io g r a f ia
Andrew Hunt i David Thom as, T he P ragm atic P rog ram m er, Addison-W esley, 2000, ISBN: 020161622X . Ta książka wyraźnie pokazuje postawę profesjonalnego programisty: ciekawość, uczciwość i ciągłą chęć nauki. Brian Kernighan i Rob Pike, The P ractice o f P rogram m in g, Addison-W esley, 1999, ISBN: 020161586X . Kolejny przykład całkowicie profesjonalnych programistów przy pracy. Donald Knuth, The A rt o f C om puter Program m ing: V olum e 1, F un dam en tal A lgorithm s, 3 rd E dition, Addison-W esley, 1997, ISBN: 0201896834. Donald Knuth, The A rt o f C om puter Program m ing: V olum e 2, Sem inum erical Algorithm s, 3 rd E dition, Addison-W esley, 1997, ISBN: 0201896842. Donald Knuth, T he A rt o f C om p u ter P rogram m ing: V olu m e 3, S earch in g a n d Sorting, 2 n d E dition , Addison-W esley, 1998, ISBN: 0201896850. Profesor Knuth niezaprzeczalnie kocha programowanie i wyraża tę m iłość w swo ich publikacjach. Donald Knuth, Literate P rogram m ing (Center f o r the Study o f Language an d In form ation ), 1992, ISBN: 0937073806. Jedna z pierwszych książek koncentrujących się na potrzebie komunikowania się programistów z innymi programistami. Źródło jednego z moich ulubionych cytatów: „Program powinno się czytać jak książkę”. Nie zawsze warto dążyć do tego celu za wszelką cenę, niem niej jednak kierunek jest słuszny. Steve M cConnell, C ode C om plete: A P ractical H a n d b o o k o f S oftw are C onstruction, 2n d E dition , M icrosoft Press, 2004, ISBN: 0735619670. Książka opisuje techniki konieczne, by programować odpowiedzialnie. Diomidis Spinellis, C ode R eadin g, Addison-W esley, 2003, ISBN: 0201799405. Książka napisana z nieco odmiennego punktu widzenia: pokazuje, jak czytać kod. M ożna ją uznać za lustrzane odbicie tej książki, W zorców im plem en tacyjn ych, opi sujące, jak czytać kod, by go zrozumieć, a nie jak go pisać, by był zrozumiały. Edward Yourdon, T echn iques o f P rogram Structure an d Design, Prentice Hall, 1975, ISBN: 013901702X . Ta książka jako jedna z pierwszych wyjaśnia, co składa się na dobry program. Przed stawione w niej zasady wciąż są aktualne, choć prezentowane przykłady można uznać za nieco przestarzałe. Edward Yourdon i Larry Constantine, Structured D esign: F u n d am en tals o f a D iscipline o f C om p u ter P rogram an d System s D esign, Prentice Hall, 1979, ISBN: 0138544719. Ta książka przedstawia odpowiedniki praw fizyki w świecie projektowania opro gramowania i tworzy grunt do dyskusji na temat ekonom ii tworzenia programów.
F il o z o f ia
Filozofia C hristopher Alexander, N otes on the Synthesis o f F orm , Harvard University Press, 1964, ISBN: 0674627512. Książka wyjaśnia teorię leżącą u podstaw wzorców: powtarzające się decyzje wraz z powtarzającymi się wzorcami ograniczeń oraz podobne rozwiązania. Christopher Alexander, T he T im eless W ay o f B uilding, Oxford University Press, 1979, ISBN: 0195024028. Teoretyczny opis projektowania i konstruowania przy wykorzystaniu wzorców. Zagadnieniem, które często w tej książce powraca, są zalety projektowania niewiel kim i etapami i jednoczesnego korzystania z inform acji wynikających z wcześniej szych projektów, konstrukcji i zastosowań. Christopher Alexander, Sara Ishikawa, M urray Silverstein oraz M ax Jacobson, Ingrid Fiksdahl-King, Shlom o Angel, A P attern L an guage, Oxford University Press, 1977, ISBN: 0195019199. Przykład języka wzorców o szerokim zastosowaniu. M oże się także przydać pod czas projektowania przestrzeni roboczej oraz domów. Richard Gabriel, Patterns o f Software, Oxford University Press, 1996, ISBN: 019510269X . Książka stanowi zbiór esejów poświęconych wykorzystaniu myślenia opartego na wzorcach w projektowaniu oprogramowania. Robert Grudin, The G race o f G reat Things, Ticknor and Fields, 1990, ISBN: 0395588685. Książka opiewa wyjątkowo dobre projekty i zachęca do ich analizy. Leonard Koren, W ab i-S ab i f o r A rtists, D esigners, P oets, a n d P h ilosop h ers, Stone Bridge Press, 1994, ISBN: 1880656124. Efektywne projektowanie jest dążeniem nie do doskonałości, lecz do dostateczności. W abi-sabi jest japońską estetyką rzeczywistego piękna, czasami szorstką, ale zawsze funkcjonalną. D ’Arcy Thom pson, On G row th a n d F orm , Cambridge University Press, 1961, ISBN: 0521437768. Ta zazwyczaj trudna w odbiorze książka jest poświęcona sposobom powstawania i wyrażania złożoności w świecie natury. Edward Tufte, The V isu al D isplay o f Q u an titativ e In fo rm a tio n , Graphics Press, 1983, ISBN: 0961392142. Zajmujący przykład myślenia opartego na zasadach, umieszczony w kontekście pro jektowania grafiki.
165
166
B ib l io g r a f ia
Java Joshua Bloch, E ffective Ja v a P rog ram m in g L an g u ag e G uide, Addison-W esley, 2001, ISBN: 0201310058. W czesny opis korzystania z języka Java, zawierający stosunkowo dużo niejawnych inform acji o tym, dlaczego ten język jest taki, a nie inny. Bruce Eckel, Thinking in Java. E dycja p olska. W ydanie IV , Helion, Gliwice 2006, ISBN: 978-83-246-3176-6. Ta książka jest m oją biblią języka Java. Kiedy muszę się dowiedzieć, ja k coś w nim działa, sięgam po tę pozycję. Steven M etsker, Design P atterns Ja v a
W o rk b o o k , Addison-W esley, 2002, ISBN:
0201743973. Książka przedstawia wpływ języka Java na ogólne wzorce projektowe.
Spis szablonów
Anonimowa klasa wewnętrzna ................................................................................................... 53 Bezpieczna kopia ......................................................................................................................... 112 Delegacja........................................................................................................................................ 50 Dostęp .............................................................................................................................................57 Dostęp bezpośredni....................................................................................................................... 58 Dostęp pośredni............................................................................................................................. 59 Dwukrotne przydzielanie...............................................................................................................80 Fabryka wewnętrzna................................................................................................................... 106 Implementator...............................................................................................................................46 Inicjalizacja.....................................................................................................................................73 Inicjalizacja leniwa ........................................................................................................................ 74 Inicjalizacja wczesna ..................................................................................................................... 73 Interfejs...........................................................................................................................................38 Interfejs abstrakcyjny.................................................................................................................... 37 Interfejs wersjonowany................................................................................................................. 40 Klasa ................................................................................................................................................34 Klasa abstrakcyjna......................................................................................................................... 39 Klasa biblioteczna..........................................................................................................................53 Klasa pochodna.............................................................................................................................. 44 Klasa wewnętrzna.......................................................................................................................... 47 Klauzula strażnika.......................................................................................................................... 84 Komentarz do metody.................................................................................................................100 Kompletny konstruktor...............................................................................................................104 Komunikat dekomponujący (sekwencjonujący).........................................................................81 Komunikat odwracający.................................................................................................................82 Komunikat wybierający................................................................................................................. 80 Komunikat wyjaśniający................................................................................................................83 Komunikat zapraszający................................................................................................................ 83 Komunikat.......................................................................................................................................79 Konstrukcja warunkowa .............................................................................................................. 48 Konstruktor konwertujący..........................................................................................................103 Konwersja..................................................................................................................................... 102 Kwalifikowana nazwa klasy pochodnej....................................................................................... 36 Metoda dostępu do kolekcji........................................................................................................ 106 167
168
S p is s z a b l o n ó w
Metoda komunikatu informacyjnego ....................................................................................... 101 Metoda konwertująca..................................................................................................................102 Metoda określająca wartości logiczne ....................................................................................... 108 Metoda pobierająca..................................................................................................................... 110 Metoda pomocnicza.....................................................................................................................100 Metoda przeciążona...................................................................................................................... 98 Metoda przesłonięta...................................................................................................................... 98 Metoda równości......................................................................................................................... 109 Metoda ustawiająca..................................................................................................................... 111 Metoda wytwórcza...................................................................................................................... 105 Metoda zapytania........................................................................................................................ 108 Metoda złożona ............................................................................................................................. 92 Nazwa określająca przeznaczenie ................................................................................................ 93 Nazwa sugerująca znaczenie.........................................................................................................71 Obiekt metody............................................................................................................................... 96 Obiekt parametrów....................................................................................................................... 69 Obiekt wartościowy....................................................................................................................... 41 Parametr .........................................................................................................................................66 Parametr opcjonalny..................................................................................................................... 68 Parametr zbierający....................................................................................................................... 67 Pole ................................................................................................................................................. 65 Propagacja wyjątków..................................................................................................................... 87 Prosta nazwa klasy bazowej ..........................................................................................................35 Przepływ główny............................................................................................................................ 78 Przepływ sterowania...................................................................................................................... 78 Przepływ wyjątkowy...................................................................................................................... 84 Selektor dołączany.........................................................................................................................52 Specjalizacja....................................................................................................................................43 Stałe .................................................................................................................................................70 Stan..................................................................................................................................................56 Stan zewnętrzny............................................................................................................................. 62 Stan zmienny.................................................................................................................................. 60 Typ wynikowy metody................................................................................................................. 99 Utworzenie ................................................................................................................................... 103 Widoczność metody ..................................................................................................................... 94 Wspólny stan ................................................................................................................................. 60 Wyjątek............................................................................................................................................86 Wyjątki sprawdzane....................................................................................................................... 87 Zachowanie zależne od instancji................................................................................................. 48 Zadeklarowany ty p ........................................................................................................................ 72 Zmienna..........................................................................................................................................62 Zmienna lista argumentów...........................................................................................................68 Zmienna lokalna............................................................................................................................ 63
Skorowidz
A Abstract Class, 34 Abstract Interface, 33 abstrakcja, 138, 140 Access, 55 akcesory, 59 akcje wykonywane na większą odległość, 112 aktualizacja danych, 112 metody ustawiające, 112 komentarzy, 100 powiększenie bazy użytkowników, 136 algorytmy zaimplementowane w obiektach, 111 Anonymous Inner Class, 34 API, 150 klasy biblioteczne, 137 problemy, 137
B bezpieczna kopia, 112 metody ustawiające, 113 bibliografia, 163 biblioteka JUnit, 24 biblioteki stanu, 55 boolean, 145 Boolean Setting Method, 91
C całkowity czas wywołania metody, 153 Checked Exception, 78 Choosing Message, 77
Class, 33 Collecting Parameter, 56 Collection, 72 Collection Accessor Method, 91 Common State, 55 Comparator, 122 Complete Constructor, 91 Composed Method, 90 Conditional, 34 Constant, 56 Control Flow, 77 Conversion, 91 Conversion Constructor, 91 Conversion Method, 91 Creation, 91 czas istnienia zmiennych, 63
D dane a logika, 34, 44 pobieranie, 58 powiązane dostęp pośredni, 59 przeniesienie, 112 stałe w różnych miejscach kodu, 70 szybkość zmian, 29 typu ArrayList, 73 Collection, 73 HashSet, 73 zapisywanie, 58 zmienność, 34 kolekcje, 115 169
170
S k o r o w id z
Debug Print Method, 91 Declared Type, 56 Decomposing Message, 77 decyzje programistyczne, 17 projektowe, 37 dekompozycja fUnkcjonalna, 81 delegacje, 50 konstrukcje warunkowe, 49 odmiana, 51 przechowywanie, 51 Delegation, 34 Direct Access, 55 domyślna wartość parametru, 146 dostęp, 57 bezpośredni, 58 reguły używania, 59 w kodzie klientów, 59 w ramach klasy, 59 czytelność, 58 do elementów projektowych, 38 do kolekcji obiektu, 106 ogólny, 107 do pola, 91 do przechowywanej wartości, 57 do stanu, 59 obiektu, 110 ograniczenie zbioru typów, 63 pośredni, 59 dostosowywanie wysiłku do celu, 149 Double Dispatch, 77 dwukrotne przydzielanie, 80 powtórzenia, 81 dziedziczenie, 44 klasy wewnętrznej, 47 ograniczenia, 44, 46 po klasach kolekcji, 130 równoległe hierarchie klas, 44 wyważone stosowanie, 35 dzielenie logiki, 90
dostęp, 58 do zmiennej, 58 pośredni, 59 dwukrotne przydzielanie, 81 konstruktor bezargumentowy, 104 metoda dostępu do kolekcji, 106 strumień sformatowany, 99 obiekty w kolekcjach, 115 wytwórcze, 144 platformy, 140 sekwencja metod ustawiających, 104 udostępnianie informacji, 73 ujawnianie metod, 94 widoczność metod, 95 wielokrotne dziedziczenie, 38 wykorzystanie delegacji, 50 zapewnienie przekazu, 73 zastosowanie stałych, 154 Equality Method, 91 Exception, 78 Exception Propagation, 78 Exceptional Flow, 78 Explaining Message, 77 Extrinsic State, 55
F fabryka statyczna, 143 wewnętrzna, 106 Factory Method, 91 Field, 56 flaga, 44 bordered, 61 logiczna, 65 funkcja gromadzenie w bibliotece, 53 typ wynikowy, 99
G E Eager initialization, 56 Eclipse, 135 efektywność programowania obiektowego, 34 elastyczność, 21, 24, 38 a złożoność, 25
Getting Method, 91 grupowanie elementów przepływu sterowania, 78 powiązanych ze sobą obiektów, 115 złożony algorytm, 81 Guard Clause, 78
S k o r o w id z
H Helper Method, 90 hierarchia bez powtórzeń, 45 równoległa, 45 uproszczona, 52 horyzont zdarzeń, 104
I implementacja @RunWith, 140 gniazda, 46 interfejsu Collection, 124 List, 125 Map, 126 Set, 125 kolekcji, 123 niezmiennych, 129 nowych klas, 130 kompletnego konstruktora, 105 komunikat wyboru, 80 konwersji, 102 metoda ustawiająca, 111 platformy, 138 pomiarowej, 152 wielokrotna protokołu, 46 Implementor, 34 Indirect Access, 55 inicjalizacja, 73 deklaracja zmiennej, 73 leniwa, 74 fabryki wewnętrzne, 106 metody pobierające, 111 środowiska, 74 pól obiektu, 74 wczesna, 73 Initialization, 56 Inner Class, 34 Instance-Specific Behavior, 34 instancje interfejsu Command, 40 ReversibleCommand, 40 określanie sposobu działania, 48 tej samej klasy, 48 tworzenie zachowań, 51
instrukcja warunkowa for, 120 if/else, 48 switch, 48 instrukcje continue, 86 wykonywane sekwencyjne, 84 intencje a implementacja, 83 Intention-Revealing Name, 90 Interface, 33 interfejs, 37 a hierarchie klas, 40 a klasy abstrakcyjne, 39 a nazwy metod, 94 abstrakcje platfomry, 140 metody, 145 abstrakcyjny, 39 aktualizacja platformy, 140 anonimowe klasy wewnętrzne, 53 Collection, 119, 121 implementacje, 124 dodanie operacji, 40 elastyczność, 37 instanceof, 41 Iterable, 119, 120 jako klasa bez implementacji, 38 jako obiekt zaimplemetowany, 39 jako typ wynikowy metod, 99 klasy Array, 119, 120 Object, 101 kolekcji, 119 koszty stosowania, 37 List, 119, 121 implementacje, 125 Map, 119, 123 implementacje, 126 testowanie, 159 metoda ustawiająca, 111 wykorzystująca stałe, 70 modyfikowanie implementacji, 38 struktury, 41 nieprzewidywalność oprogramowania, 37 obiektu źródłowego i docelowego, 102 obliczeń alternatywny, 90 określanie nazw, 38
171
172
S k o r o w id z
interfejs operacje publiczne, 38 proceduralny, 42 Set, 119, 121 implementacje, 125 porównanie implementacji, 158 SortedSet, 119, 122, 125, 158 ustawienie wartości pola, 111 wersjonowany, 39, 140 zalety, 140 zarządcy układu, 140 zmienność komunikatów, 70 zmienny, 41 Internal Factory, 91 Inviting Message, 77 iterator, 94, 107 mapy, 123
J Java mechanizmy tworzenia interfejsów, 38 możliwość zmian interfejsu, 39 obiekt parametrów, 69 organizacja kodu, 78 typy podstawowe, 41 wartości parametrów, 68 jawność kodu, 80 język obiektowy, 57 proceduralny mechanizm ukrywania informacji, 79 Smalltalk, 83 JUnit, 133 wykonywanie testów, 152
K klarowność dostęp bezpośredni, 58 klasa abstrakcyjna a interfejsy, 39 dodawanie nowych operacji, 39 ograniczenia, 39 Socket, 46 Account, 147 ArrayList, 123, 124, 125, 143 bazowa, 35
abstrakcja platformy, 141 abstrakcyjna, 39 anonimowa, 53 dostęp klientów, 141 możliwość tworzenia instancji, 40 prosta nazwa, 35 rozdzielenie logiki, 45 testu, 156 biblioteczna, 53, 137 zastąpienie obiektami, 54 Bordered, 61 Brush,80 Cartesian, 103 Collection, 54, 118 CollectionFactory, 144 Collections, 137 funkcjonalności, 128 jednoelementowe kolekcje, 129 niezmienne kolekcje, 129 puste kolekcje, 129 sortowanie, 128 wyszukiwanie, 128 Comparator, 139 ComplexCalculator, 97 Customer, 94 deklaracja, 34 dobieranie nazwy, 35 Enumerator, 135 Figure, 35 File, 103 Handle, 36 HashMap, 126 HashSet, 123, 124, 125, 159 idea, 33 interfejsy, 38 jako element projektowy, 35 JUnitCore, 139 komunikacja, 33 Library, 54, 130 LinkedHashMap, 126 LinkedHashSet, 125 LinkedList, 125 List, 54 ListTest, 52 Map, 118 MethodsTimer, 150 MethodTimer wydajność, 152 Nothing, 151
S k o r o w id z
organizowanie w hierarchie, 35 pochodna, 35, 44 awaria, 98 konstrukcje warunkowe, 49 kwalifikowana nazwa, 36 różne testy, 52 usprawnienia, 106 Point, 103 Polar, 103 potomna wywołanie własnego kodu, 98 RectangleTool, 51 Set, 118 SetVsArrayList, 156 Shape, 80 String, 103 Train, 44 Transaction, 147 TreeMap, 126 TreeSet, 125, 138, 158 używana w jednym miejscu, 53 Vector, 135 Vehicle, 44 w pakiecie, 135 wewnętrzna, 47 anonimowa, 53 kopie obiektów, 47 niezależna, 48 Widget, 145 klauzula strażnika, 84 użyteczność, 85 wymagania wstępne obsługi żądania, 86 klucze, 123 kod a dane, 27 a zapewnienie wydajności, 118 abstrakcyjność, 105 aktualizacja, 135 automatyczna, 135 analiza metody konwersji, 103 metod przeciążonych, 99 anonimowej klasy wewnętrznej, 53 czytelność, 82, 93 deklaracje zmiennych, 73, 117 deklaratywny, 28 deklarowanie pól, 65 dostosowanie stylu do kontekstu, 160 fragmenty niezalecane, 135
informacje specjalnego przeznaczenia, 62 inicjalizacja leniwa, 75 instrukcje warunkowe, 48 intencje twórcy, 22 jakość, 12 kolekcje, 117 komentarze, 100 komunikaty, 79 wybierające, 80 komunikatywność, 22 konstrukcje warunkowe, 46 lokalne konsekwencje zmian, 26 mechanizmy optymalizacji, 149 metody określające wartości logiczne, 108 pomocniczej, 101 wytwórcze, 105 nadawanie struktury, 93 obiekty metody, 97 powtórzenia wierszy, 101 wytwórcze, 144 powtarzanie, 26 eliminacja, 28 prostota, 24 przejrzystość, 32 przeniesienie logiki, 109 rozwój platformy, 133 różne kombinacje dostępu, 63 skopiowany, 45 stan, 57 symetria, 27, 82 testy warunków wyjątkowych, 86 usprawnienie obliczeń, 83 w klasie bazowej, 45 wcięcia, 92 wrażliwość, 112 współużytkowanie delegacje, 51 wyjątki sprawdzane, 87 zabezpieczenie, 113 zgodne zmiany, 136 kolejka wiadomości lista, 121 kolekcje, 63, 115 aktualizowanie platform, 135 ArrayList, 157 dodawanie elementów, 116
173
174
S k o r o w id z
kolekcje elementy unikalność, 118 uporządkowanie, 122 usunięcie powtórzeń, 122 usuwanie, 120 znaczenie kolejności, 117 implementacje, 123 inicjalizacja klasy testy, 156 interfejsy, 119 i klasy, 124 jako obiekty, 116 jako zbiór, 121 jako zbiór obiektów, 117 jednoelementowe, 129 kluczy, 123 LinkedList, 157 metafory, 116 modyfikowanie zawartości, 120 niezmienne, 129 odwołanie do obiektów, 116 pojęcia ortogonalne, 117 pola reprezentujące, 116 porównywanie, 157 posiadające tożsamość, 116 przekazywanie, 68 puste, 129 rozszerzanie, 116, 130 równoległe architektury, 135 sortowanie, 128 typu Collection, 155 typu Map elementy, 123 typu Set duplikaty elementów, 121 przechowywanie elementów, 121 w kolekcji, 107 wartości, 123 wielkość, 117 wydajność, 118 pomiary, 149 wyrażanie intencji, 115 wyszukiwanie, 128 zabezpieczenie, 107, 120 zagadnienia, 117 komentarz do metody, 100 dokumentujący, 100
komparator, 122, 126 byFirstName, 139 komputery mainframe, 24 komunikat, 46, 79 a konstrukcje warunkowe, 49 dekomponujący, 81 display(), 80 logika, 49 metoda, 101 odwracający, 82 polimorficzny, 46, 80 sekwencjonujący, 81 wybierający, 80 kaskada, 80 wyjaśniający, 83 komentarz kodu, 84 zapraszający, 83 komunikatywność, 21, 22 a elastyczność, 25 a prostota, 24 określanie nazw klas, 36 podstawa ekonomiczna, 23 stan zmienny, 61 komunikowanie intencji kolekcje, 115 za pośrednictwem kodu, 13 konfiguracja platformy, 138 zastosowanie stałych, 154 konstrukcja if-then-else, 85 konstrukcje warunkowe, 48 konstruktor czteroargumentowy, 105 File(String name), 103 główny, 105 klasy MethodTimer, 152 ServerSocket, 68 wewnętrznej, 47 kompletny, 104 konwertujący, 103 przygotowanie obiektów, 104 StringReader(String contents), 103 tworzenie obiektów platformy, 143 URL(String spec), 103 zamiennik metoda statyczna, 96 zastosowanie metod pomocniczych, 101
S k o r o w id z
konwersja, 102 a zmiana klienta, 103 cel, 103 implementacja, 102 między obiektami podobnych typów, 102 obiektu źródłowego na docelowy, 102 koszty abstrakcji, 40 aktualizacji niezgodnych, 135 redukcja, 134 testów, 100 analizy i zrozumienia kodu, 32 kolekcja w kolekcji, 107 komentarzy, 100 modyfikowania programów obiekty, 104 napisania, 31 określania zmiennych, 74 oprogramowania, 31 selektora dołączanego, 53 tworzenia oprogramowania, 23 platform, 134 utrzymania, 31 widoczność metod, 95 wydajności, 118 wyjątki sprawdzane, 87 wywoływania obiektów, 95 zachowania zgodności, 134 zachowań zależnych od instancji, 48 zmiany kodu, 133
L Lazy initialization, 56 Library Class, 34 liczba iteracji, 153 potrzebnych konwesji, 102 licznik czasu, 150 dokładność, 151 narzut, 154 zewnętrzny interfejs, 150 List, 72 listy, 121 literate programming, 22 Local Variable, 56
logika a metoda pobierająca, 111 anonimowe klasy wewnętrzne, 53 grupowanie w klasy, 34 i dane, 27 zgrupowanie, 29 komunikaty polimorficzne, 115 konstrukcje warunkowe, 115 metody pomocniczej, 101 obiektu, 48 obsługi kliknięcia, 51 operująca na różnych danych, 44 na tych samych danych, 44 parametry obiektowe, 70 podział na metody, 89 przeniesienie, 112 w jednej klasie, 48 w metodach statycznych, 53 w różnych instancjach, 50 warunkowa zastąpienie komunikatami, 49 wyrażanie podobieństw i różnic, 44 przez komunikaty, 79 zmienna, 46 zmienność, 34 lokalne konsekwencje, 26
M Main Flow, 77 mapy, 123 porównywanie, 159 stan zmienny, 60 mechanizm odzwierciedlania, 52, 153 klasy wewnętrzne, 47 optymalizacji, 149 przekazywania zmiennej liczby argumentów, 69 przepływu sterowania komunikaty, 79 Message, 77 metafora nazywanie klas, 35 Method Comment, 90 Method Object, 90 Method Return Type, 90
175
176
S k o r o w id z
Method Visibility, 90 metoda, 89 abstrakcyjna, 83 dodawanie do klasy bazowej, 141 addAll(), 121 akcesorów, 59 argumenty opcjonalne, 69 calculateO, 97 chroniona, 95, 100 clear(), 130 Collections.binarySearch(list, element), 128 Collections.factoryO, 144 Collections.singleton(), 129 complexCalculation(), 97 compute(), 82 contains(), 117, 124 createArrayList(), 144 czas wykonania, 153 display(), 48 displayWith(), 81 długość, 92 do pomiaru czasu, 156 dostępna w pakiecie, 95 dostępu do kolekcji, 106, 113 equal(), 121 equals(), 91, 109 fabryki statycznej, 154 find(), 94 get(int), 157 getDeclaredMethods(), 152 getX(), 103 getY(), 103 hashCode(), 91, 109 hasNext(), 94 highlight(), 83 inactive(), 145 indexOf(), 128 inicjalizacja wartości pola, 74 instancji obiektu wytwórczego, 106 invisible(), 145 iterator(), 120 komentarze, 100 komunikat odwracający, 82 komunikatu informacyjnego, 101 konwertująca, 102 koszty aktualizacji, 136 mouseDown(), 51 nazwa określająca przeznaczenie, 93 next(), 94, 95
obiektu źródłowego, 102 ogólna struktura kodu, 92 określająca wartości logiczne, 108 overheadTimer(), 154 paragraph.centered(), 111 platformy, 138 pobierająca, 110, 141 parametry, 68 publiczna, 111 udostępnianie klientom, 145 wewnętrzna, 111 podobne listy parametrów, 54 pomiarowa, 150, 151 pomocnicza, 100 komunikaty wyjaśniające, 84 zmienna lokalna, 64 prywatna, 96, 100 przeciążona, 98 cel, 99 przekazywanie łańcucha znaków String, 98 parametrów, 68 przesłanianie, 102 przesłonięta, 98 publiczna, 95 zwracająca wartość pola, 111 remove(), 107, 120, 124 removeAll(), 121 report(), 150, 151 retainAll(), 121 reverse(), 83 reverse(list), 128 równości, 109 run(), 153 run(Class ...), 139 run(Classes ...), 146 runTest(), 52 search(), 150 setLoadedFlag(), 84 setVisibility(), 145 setVisibility(boolean), 145 setVisibility(State), 145 sfinalizowana, 96 shuffle(list), 128 size(), 117 sort(list) , 54, 129 sort(list, comparator), 129 statyczna, 96 przekształcenie w metody instancji, 54
S k o r o w id z
String.asFile(), 103 suit(), 28 toArray(), 130 toString(), 91, 101 typ wynikowy, 99 typu Collection, 99, 121 udostępnianie, 96 ustawiająca, 106, 108, 111 publiczna, 111 udostępnianie klientom, 145 wewnętrzna, 112 visible(), 145 w klasie bazowej, 98 wytwórcza, 103, 105 tworzenie obiektów, 144 wywołująca inne metody, 94 zabezpieczenie, 96 zapytania, 108 złożona, 92, 113 konsekwencje stosowania, 100 zwracająca iterator, 107 minikomputery, 24 minimalizacja powtórzeń, 26 modyfikator abstract, 39 final, 65, 96 package, 62 private, 62 protected, 62 public, 62 static, 48 modyfikowanie programów typy wynikowe, 99 motywacja, 31
N narzędzia refaktoryzujące, 100 narzut, 153 czasowy eliminacja, 154 dynamiczne wywoływanie metod, 154 nazwy interfejsów, 39 klas, 35 funkcje, 36 pochodnych, 36 wielopoziomowe hierarchie klas, 36 komunikatów dekomponujących, 81
metod, 80, 89, 93 przedrostek, 110, 111 obliczeń, 99 stałych, 70 sugerujące znaczenie, 71 zmiennych, 63, 71 użyteczność, 14 niezgodne aktualizacje, 134 społeczność klientów, 136 struktura, 136 numer seryjny, 109
O obiekt Account, 43 Comparator, 138 dane specjalnego przeznaczenia, 123 dołączany, 51 GraphicEditor, 51 GraphicsContext, 68 HashMap, 159 iterable, 120 iterator, 120 jako łańcuch znaków, 101 JUnitCore, 139 konstrukcje warunkowe, 48 konstruktory, 104 konwersja, 102, 103 na wiele obiektów docelowych, 103 metafora kolekcji, 116 MethodTimer, 151, 152 metody, 93, 96 statyczna, 91 niezmienny, 43, 113 numer seryjny, 109 o zmiennym stanie, 41 odpowiedzialność za dane, 112 parametr wywołania wielu metod, 65 parametrów, 69 zastępowanie jawną listą, 70 platformy, 137 podejmowanie decyzji, 108 pole, 65 pomocniczy, 44 operacja prywatna, 106 uproszczenie projektu, 61 Rectangle, 105 RectangleTool, 51
177
178
S k o r o w id z
obiekt reprezentujący kolekcje, 115 sprawdzenie równości, 109 stan programu, 55 wspólny, 60 zapewnienie dostępu, 110 zewnętrzny, 62 zmienny, 43 TestResult, 68, 146 Transaction, 42 tworzenie przez klientów, 142 sposób alternatywny, 105 typu Comparator, 126 umieszczenie w pamięci podręcznej, 105 unieważnienie stanu wewnętrznego, 107 utworzenie, 91 uzyskiwanie informacji, 101 w metodzie wytwórczej, 110 w pamięci podręcznej, 111 wartościowy argumenty przeciwko, 43 wartość skrótu, 109 wymagania wstępne, 104 wytwórczy, 106, 144 zabezpieczenie, 96 zachowanie i stan, 55 zależność od stanu innego obiektu, 108 zapisanie w polu, 65 zmiana w klasie pochodnej, 106 odczytywanie wartości logicznych, 91 odmienność delegacje, 50 logika warunkowa, 50 tworzenie obiektu, 46 wprowadzanie nowej, 45 zbiór, 44 złożona, 44 odwoływanie do elementów kolekcji, 118 ograniczenia klas bazowych, 141 stylu stosowania platform, 139 widoczność metod, 94 wydajności niewielkie metody, 92 określanie równości obiektów, 109
operacja contains(), 160 containsKey(), 160 put(), 160 oprogramowanie elastyczność, 37 koszty, 31 strategia redukcji, 31 wytworzenia, 31 nieprzewidywalność, 37 utrzymanie, 31 overloaded Method, 90 overrided Method, 90
P pakiety, 135 wewnętrzne, 142 Parameter, 56 Parameter Object, 56 parametr, 66 brush, 80 obiektowy, 69 opcjonalny, 68 powiązania, 66 powtórzenie, 67 skojarzenie z obiektem, 66 TestResult, 146 zastąpienie wskaźnikiem, 67 zbierający, 67 platformy abstrakcja, 140 aktualizacja, 134 idealne aktualizacje, 133 kodu, 135 warianty zgodności, 136 zgodne zmiany, 136 bez możliwości tworzenia obiektów, 143 deklarowanie pól, 134 ekonomiczne aspekty tworzenia, 134 funkcjonalność, 145 a rozwój, 137 implementacja, 139 skalowalność, 139 konfiguracja, 138 koszty, 146 łatwość użycia i modyfikacji, 145 ograniczenie możliwości zastosowania, 134 określanie pojęć, 147
S k o r o w id z
pomiarowe sposoby wykorzystania, 154 reprezentacja w formie obiektów, 137 rozwój, 146 równowaga, 137 style stosowania, 138 tworzenie obiektów, 138, 144 fabryki statyczne, 143 konstruktory, 143 obiekt wytwórczy, 144 wersja przejściowa, 135 widoczność pakiety, 142 zadania twórców, 147 zapewnienie wielkości, 147 Pluggable Selector, 34 podwyrażenia eliminacja, 101 podział na metody, 90 pole, 65 borderColor, 61 borderwidth, 61 dane pomocnicze, 65 deklaracja, 65 flaga, 65 kolekcji, 130 komponenty, 66 publiczne, 58 stan, 65 strategia, 65 polimorfizm, 61 powielanie, 156 powtórzenia kodu, 26 poziomy abstrakcji, 92 prefiks, 71 private, 63 problemy kopiowanie metod klasy bazowej, 98 rozszerzanie klas kolekcji, 130 wielokrotnego użycia, 89 wyjątki niskiego poziomu, 88 procedura podsekwencja kroków, 81 programowanie dane a logika, 44 dostęp bezpośredni, 58 elementy, 22 imperatywne, 28 obiektowe, 33 cel wprowadzenia, 58
piśmienne, 22 problemy, 17 przechowywanie danych, 59 teoria, 21 wartości, 21 współbieżne źródła, 15 zasady, 21 programy korzyści zwiększenia złożoności, 160 modyfikowanie, 104 odmienność, 43 ścieżki realizacji, 48 wyrażanie podobieństw i różnic, 43 projektowanie oprogramowania czynnik warunkujący, 31 możliwość zmiany strategii, 46 propagacja wyjątków, 87 prostota, 21, 23 przechwycenie wyjątku, 86, 87 niskiego poziomu, 87 przeciążanie, 99 przejrzystość dostęp pośredni, 59 głównego przepływu programu, 79 obliczeń metoda pomocnicza, 100 wspólny stan, 60 przekaz deklaratywny, 28 przekazywanie parametrów, 99 przez referencję odpowiednik, 116 przepływ główny, 78 wykonywanie innymi ścieżkami, 84 przeskoki, 86 sterowania, 78 alternatywny, 85 poprawne wyrażanie, 87 ważność, 85 wyjątki, 87 wyjątkowy, 84 przesłanianie metod, 35, 98 przeszukiwanie hierarchii, 153
Q Qualified Subclass Name, 33 Query Method, 91
179
180
S k o r o w id z
R refaktoryzacja tworzenie obiektu metody, 97 Reversing Message, 77 Role-Suggesting Name, 56 rozwijanie platform, 133 równoległe architektury, 135 hierarchie klas, 26 rzutowane w dół, 40
S Safe Copy, 91 scalanie wyników, 67 sekwencja instrukcji, 77 kontroli, 78 Setting Method, 91 Simple Superclass Name, 33 słowo kluczowe abstract, 39, 141 extends, 46 final, 142 implements, 46 socket, 46 Specialization, 34 specjalizacja dobór wielkości metod, 93 komunikat dekomponujący, 81 spis szablonów, 167 stałe, 70 ONE_SECOND, 154 znaczenie, 70 stan, 41, 55, 56 efektywne zarządzanie, 57 elementy podobne, 57 odwołania dostęp bezpośredni, 59 podział na fragmenty, 57 pole, 66 przekazywanie informacji między obiektami, 66 wspólny, 60 zewnętrzny, 62 kopiowanie, 62 mapy, 123
zmienny, 60 mapy, 123 przechowywanie, 60 wada, 61 State, 55 static, 63, 96 strategia implementacji, 31 w nazwach metod, 93 przyrostowego wprowadzania zmian, 135 redukcji kosztów ogólnych, 32 redukcji złożoności aplikacji, 134 zapewniania wydajności, 118 zwiększenia złożoności platformy, 134 struktura drzewiasta linearyzacja, 67 powiązania między węzłami, 66 strumień OutputStream, 99 styl programowania, 22 funkcyjny, 41 styl stosowania, 138 Subclass, 34 symetria czasowa, 29 kodu, 83 pojęciowa, 27 pól, 29 tempo zmian, 29 w kodzie, 27 sytuacja statyczna, 41
Ś ścieżka realizacji programu, 48 prawdopodobieństwo poprawności, 48 ścieżki wyjątkowe, 84
T tablice, 117, 119, 120 zamiana na kolekcję, 120 techniki kompresji, 35 tekstowe reprezentacje obiektu, 102 tempo zmian, 29 teoria programowania, 21 testowanie równości obiektów, 109 testy @After, 139 @Before, 139
S k o r o w id z
@RunWith, 140 @Test, 139 abstrakcyjna implementacja metody, 156 anonimowych klas wewnętrznych, 53 czas dodania i usunięcia elementu, 157 czas dostępu do elementów kolekcji, 158 czas przeglądania zawartości kolekcji, 156 czas przeszukiwania listy, 150 czas wykonania metody, 152, 153 czas wykonania operacji, 150 czas zmodyfikowania kolekcji, 156 implementacji interfejsu Map, 159 kolekcji ArrayList oraz LinkedList, 157 koszt aktualizacji, 100 modyfikacja zawartości zbiorów, 158 operacji, 150 pomiarowe, 155 porównywanie kolekcji, 155 selektor dołączany, 52 sprawdzenie występowania elementu zbioru, 158 umieszczanie w jednej klasie, 53 zautomatyzowane, 100 zbiorów, 158 tworzenie API, 137 biblioteki widżetów, 145 obiektów, 138 platformy, 138 wnioski, 144 wybór stylu, 142 platform prostota a możliwość rozwoju, 146 typ wyliczeniowy States, 145 typ wynikowy metody, 99 generalizacja, 99 konwertującej, 103 void, 99
U ujawnianie metod, 94 unikalność elementów, 118 unikanie powtórzeń, 26 uporządkowanie elementów, 117 utożsamianie nazw, 91, 112, 116 utworzenie, 103
V Value object, 34 Variable, 55 Variable State, 55 Versioned Interface, 33
W wartości, 22 mieszające, 155 oprogramowania, 23 pobierania danych, 160 skrótu, 109 węzeł rodzica, 66 widoczność metody, 94 ograniczenie, 96 system pakietów Javy, 142 widżet stan wspólny, 61 zmienny, 61 współbieżność a stan, 57 współużytkowanie implementacji, 44 wyciekanie informacji projektowych, 145 wydajność, 118 dostęp, 58 inicjalizacja, 73 kolekcji, 118 ArrayList, 124, 125 HashSet, 124, 125 implementacja, 123 LinkedHashSet, 125 LinkedList, 125 operacje sortowania, 129 porównanie, 125, 126 reprezentacja danych, 155 TreeSet, 126 map, 159 metody indexOf(), 128 niewielkie, 92 pomiary, 149 implementacja, 151 w konstruktorze, 153 skalowanie ilości danych, 151
181
182
S k o r o w id z
wydajność tablice, 120 wyszukiwanie binarne, 128 zbiorów, 158 wydzielanie odmienności, 50 wyjątek, 86 ClassCastException, 109 IllegalArgumentException, 109 sprawdzany, 87 UnsupportedOperationException, 130 wady stosowania, 86 wymiar zmienności komunikaty wybierające, 80 wyniki wywołań wielu metod, 67 wyrażanie intencji komunikat wyjaśniający, 83 parametry obiektowe, 69 proceduralne, 46 przy użyciu obiektu, 46 wyrażenie new ArrayList(), 143 new ServerSocket(), 138 wyszukiwanie binarne, 128 wywołanie obliczeń, 57 procedur, 79 super.metoda(), 98 wzorce, 17, 22 anonimowa klasa wewnętrzna, 34, 53 ograniczenia, 53 bezpieczna kopia, 91, 112 cechy programowania, 17 delegacja, 34, 50 dostęp, 55, 57 bezpośredni, 55, 58 pośredni, 55, 59 dwukrotne przydzielanie, 80 fabryka wewnętrzna, 91, 106 implementacyjne, 11 a koszty oprogramowania, 31 koncentracja na korzyściach, 32 lista zasad, 26 zalety stosowania, 32 zaspokajanie potrzeb, 32 związane z tworzeniem, 104 implementator, 34, 46 inicjalizacja, 56, 73 leniwa, 56, 74 wczesna, 56, 73
interfejs, 33, 38 abstrakcyjny, 33, 37 wersjonowany, 33, 40 klasa, 33, 34 abstrakcyjna, 34, 39 biblioteczna, 34, 53 Collections, 128 pochodna, 34, 44 wewnętrzna, 34, 47 klauzula strażnika, 78, 84 kolekcje, 115 implementacje, 123 interfejsy, 119 metafory, 116 rozszerzanie, 130 zagadnienia, 117 komentarz do metody, 90, 100 kompletny konstruktor, 91 komunikat, 77, 79 dekomponujący, 77, 81 odwracający, 77, 82 wybierający, 77, 80 wyjaśniający, 77, 83 zapraszający, 77, 83 konstrukcja warunkowa, 34, 48 konstruktor kompletny, 104 konwertujący, 91, 103 konwersja, 91, 102 kwalifikowana nazwa klasy pochodnej, 33, 36 lista założeń, 17 metoda dostępu do kolekcji, 91, 106 komunikatu informacyjnego, 91, 101 konwertująca, 91, 102 określająca wartości logiczne, 91, 108 pobierająca, 91, 110 pomocnicza, 90, 100 przeciążona, 90, 98 przesłonięta, 90, 98 równości, 91, 109 ustawiająca, 91 wytwórcza, 91, 105 zapytania, 91, 108 złożona, 90, 92 modyfikowanie platform bez zmian w aplikacjach, 133
S k o r o w id z
nazwa określająca przeznaczenie, 90, 93 sugerująca znaczenie, 56, 71 niezgodne aktualizacje, 134 obiekt metody, 90, 96 parametrów, 56, 69 wartościowy, 34, 41 parametr, 56, 66 opcjonalny, 68 teleskopowy, 68 zbierający, 56, 67 podwójne przydzielanie, 77 pole, 56, 65 projektowe, 14 związki między klasami, 33 propagacja wyjątków, 78, 87 prosta nazwa klasy bazowej, 33, 35 przepływ główny, 77, 78 sterowania, 77, 78 wyjątkowy, 78, 84 selektor dołączany, 34, 52 specjalizacja, 34, 43 sposoby prezentacji, 14, 18 sposoby wyrażania podobieństw i różnic, 44 stałe, 56, 70 stan, 55, 56 wspólny, 55, 60 zewnętrzny, 55, 62 zmienny, 55, 60 typ wynikowy metody, 90, 99 utworzenie, 91, 103 widoczność metody, 90, 94 współpraca, 18 wyjątek, 78, 86 sprawdzany, 78, 87 zachęcanie do wprowadzania zgodnych zmian, 136 zachowanie zależne od instancji, 34, 48 zadeklarowany typ, 56, 72 zalety stosowania, 18 zmienna, 55, 62 lista argumentów, 68 lokalna, 56, 63 zwiększone koszty, 25
Z zachowanie zależne od instancji anonimowe klasy wewnętrzne, 53 selektor dołączany, 52 zadeklarowany typ, 72 zakres pól określanie, 62 zależności między klasami konwersja, 102 zasady, 25 lokalne konsekwencje, 26, 118 minimalizacja powtórzeń, 26 połączenie logiki i danych, 27 programowania, 22 zalety stosowania, 21 przekaz deklaratywny, 28 symetria, 27 użyteczność danych dla kodu, 62 tempo zmian, 29 zalety poznawania, 25 zrozumienie, 26 zautomatyzowane testy, 100 zbiory, 121 obiektów jako kolekcja, 117 pojemność testowanie, 159 porównywanie, 158 posortowane, 122 zdarzenia SWT, 143 zgłoszenie wyjątku, 86 zgodne zmiany, 136 zgodność domyślna wartość parametru, 146 tworzenie platform, 134 w przód, 136 wstecz, 134, 136 zgrupowanie obliczeń, 47 złożoność, 23 aplikacji redukcja, 134 równoległe architektury, 135 zmienna, 62 count, 72 deklarowanie, 63, 74 typu, 72 each, 64, 72 element, 64
183
184
S k o r o w id z
zmienna gromadzenie, 63 inicjalizacja, 74 intencje, 63 licznik, 64 lokalna, 62, 63 deklarowanie, 63 przechowywanie elementu, 64 przeznaczenie, 63 members, 72 nazwa opisowa, 64 nazywanie, 71 nieprywatna powiązanie między klasami, 66 odczytywanie nazw, 72 odwołująca się do kolekcji, 116 pola, 62 przeznaczenie, 71 przypisywanie stanu, 73 result, 63, 72
results, 63 statyczna, 62 typy używane iDE, 71 w obiekcie czas istnienia, 61 wielokrotne wykorzystanie, 64 wyjaśnienie, 64 zadeklarowana jako iterable, 120 zakres, 62, 71 zapisywanie wartości, 58 zmienna liczba elementów, 115 zmienna lista argumentów, 68 zmienność kolekcje, 115 w procesie tworzenia obiektów, 144 znacznik czasu, 64
Ź źródła, 163