Wzorce implementacyjne 978-83-246-8647-6 [PDF]

Sprawdzone rozwiązania Twoich problemów! Przy nauce programowania warto uczyć się na cudzych błędach. Programiści tworzą

140 74 2MB

Polish Pages 186 Year 2014

Report DMCA / Copyright

DOWNLOAD PDF FILE

Table of contents :
Spis treści......Page 7
Wstęp......Page 11
Podziękowania......Page 12
Rozdział 1. Wprowadzenie......Page 13
Przewodnik......Page 15
A teraz…......Page 16
Rozdział 2. Wzorce......Page 17
Rozdział 3. Teoria programowania......Page 21
Komunikatywność......Page 22
Prostota......Page 23
Elastyczność......Page 24
Zasady......Page 25
Minimalizacja powtórzeń......Page 26
Symetria......Page 27
Przekaz deklaratywny......Page 28
Tempo zmian......Page 29
Wnioski......Page 30
Rozdział 4. Motywacja......Page 31
Rozdział 5. Klasy......Page 33
Wniosek......Page 54
Rozdział 6. Stan......Page 55
Wniosek......Page 75
Rozdział 7. Zachowanie......Page 77
Wniosek......Page 88
Rozdział 8. Metody......Page 89
Wniosek......Page 113
Rozdział 9. Kolekcje......Page 115
Metafory......Page 116
Zagadnienia......Page 117
Interfejsy......Page 119
Interfejs Iterable......Page 120
Interfejs Set — zbiory......Page 121
Interfejs SortedSet — zbiory posortowane......Page 122
Implementacje......Page 123
Implementacje interfejsu Collection......Page 124
Implementacje interfejsu Set......Page 125
Implementacje interfejsu Map......Page 126
Sortowanie......Page 128
Kolekcje puste......Page 129
Rozszerzanie kolekcji......Page 130
Wniosek......Page 131
Modyfikowanie platform bez zmian w aplikacjach......Page 133
Niezgodne aktualizacje......Page 134
Zachęcanie do wprowadzania zgodnych zmian......Page 136
Obiekty......Page 137
Wnioski......Page 146
Dodatek A. Pomiary wydajności......Page 149
API......Page 150
Implementacja......Page 151
Klasa MethodTimer......Page 152
Testy......Page 154
Porównywanie kolekcji......Page 155
Porównywanie kolekcji ArrayList i LinkedList......Page 157
Porównywanie zbiorów......Page 158
Porównywanie map......Page 159
Wnioski......Page 160
Ogólne zagadnienia programistyczne......Page 163
Filozofia......Page 165
Java......Page 166
Spis szablonów......Page 167
Skorowidz......Page 169

Wzorce implementacyjne
 978-83-246-8647-6 [PDF]

  • 0 0 0
  • Gefällt Ihnen dieses papier und der download? Sie können Ihre eigene PDF-Datei in wenigen Minuten kostenlos online veröffentlichen! Anmelden
Datei wird geladen, bitte warten...
Zitiervorschau

Tytuł oryginału: Implementation Patterns Tłumaczenie: Piotr Rajca Projekt okładki: Maciej Pasek Materiały graficzne na okładce zostały wykorzystane za zgodą Shutterstock Images LLC. ISBN: 978-83-246-8647-6 Authorized translation from the English language edition, entitled: IMPLEMENTATION PATTERNS; ISBN 0321413091; by Kent Beck; published by Pearson Education, Inc, publishing as Addison Wesley. Copyright © 2008 by Pearson Education. All rights reserved. No part of this book may by reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc. Polish language edition published by HELION S.A. Copyright © 2014. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Wydawnictwo HELION dołożyło wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie bierze jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Wydawnictwo HELION nie ponosi również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/wzoimp_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Printed in Poland.

 Poleć książkę na Facebook.com

 Księgarnia internetowa

 Kup w wersji papierowej

 Lubię to! » Nasza społeczność

 Oceń książkę

Pochwały książki Wzorce implementacyjne „Kent jest mistrzem tworzenia bardzo komunikatywnego kodu, który można łatwo zrozumieć i który czyta się z przyjemnością. Każdy rozdział tej książki zawiera doskonałe wyjaśnienia i spostrzeżenia na temat niewielkich, lecz ważnych decyzji, które bezustannie musimy podejmować, tworząc kod i klasy wysokiej jakości”. — ERICH GAMMA, IBM Distinguished Engineer

„Wiele zespołów posiada głównego programistę, który przez cały dzień podejmuje szybkie i dobre decyzje. Ich kod można łatwo zrozumieć, szybko modyfikować, zapewnia poczucie bezpieczeństwa i komfort pracy. Kiedy zostaną zapytani, dlaczego zdecydowali się napisać kod w taki sposób, w jaki to zrobili, zawsze są w stanie podać dobry powód. Ta książka pomoże Ci stać się takim głównym programistą w swoim zespole. Zakres oraz poziom opisywanych zagadnień przyciągnie uwagę nawet bardzo doświadczonych programistów, którzy będą mogli poznać nowe sztuczki i poprawić stare nawyki, a jednocześnie poprawią czytelność swojego kodu, tak że będzie on zrozumiały nawet dla początkujących”. — RUSS RUFER, Silicon Valley Patterns Group

„Wiele osób nawet nie zdaje sobie sprawy z tego, jak czytelny może być kod i jak cenna jest łatwość jego analizy i zrozumienia. Kent nauczył mnie tak wiele; cieszę się, że niniejsza książka zapewnia innym możliwość uczenia się od niego”. — MARTIN FOWLER, chief scientist, ThoughtWorks

„Kod powinien być wart tego, by był czytany, i to nie tylko przez komputery, lecz także przez ludzi. Kent Beck wyróżnia i wskazuje pewne jego cechy, tworząc z nich spójną kolekcję wzorców implementacyjnych. Te drobne porady sprawią, że Twój kod będzie naprawdę wart czytania”. — GREGOR HOHPE, autor książki Enterprise Integration Patterns

„W tej książce Kent Beck pokazuje, jak wykorzystanie prostych zasad prowadzi do powstawania przejrzystego i czytelnego kodu. Książka Wzorce implementacyjne pomoże programistom pisać kod ujawniający ich intencje, który będzie łatwy do zrozumienia i zapewni elastyczność niezbędną do jego rozszerzania”. — SVEN GORTS

„Książka Wzorce implementacyjne stanowi pomost łączący projekt i tworzenie kodu. Beck przedstawia nowy sposób myślenia o programowaniu, koncentrując rozważania na wartościach i zasadach”. — DIOMIDIS SPINELLIS, autor książek Code Reading oraz Code Quality

Dla Cindee Dziękuję Ci za wsparcie, jedzenie, kojenie, irytację, edycję i herbatę. W porównaniu z tym, co mi dajesz, ta dedykacja jest jak ziarnko piasku w obliczu słonia.

Spis treści

Wstęp ...................................................................................................................................... 11 Podziękowania ....................................................................................................................... 12 Rozdział 1. Wprowadzenie ..................................................................................................... 13 Przewodnik ............................................................................................................................ 15 A teraz… ................................................................................................................................. 16 Rozdział 2. Wzorce ................................................................................................................. 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. Motywacja ............................................................................................................ 31 Rozdział 5. Klasy ..................................................................................................................... 33 Klasa ........................................................................................................................................ 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 Wniosek ...................................................................................................................................54 Rozdział 6. Stan ...................................................................................................................... 55 Stan .......................................................................................................................................... 56 Dostę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 Pole .......................................................................................................................................... 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 typ ................................................................................................................ 72 Inicjalizacja ............................................................................................................................. 73 Inicjalizacja wczesna ............................................................................................................. 73 Inicjalizacja leniwa ................................................................................................................ 74 Wniosek ...................................................................................................................................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. Metody ................................................................................................................. 89 Metoda złożona ..................................................................................................................... 92 Nazwa określająca przeznaczenie ....................................................................................... 93 Widoczność metody ............................................................................................................. 94 Obiekt metody ....................................................................................................................... 96 Metoda przesłonięta ............................................................................................................. 98 Metoda przeciążona .............................................................................................................. 98 Typ wynikowy metody ......................................................................................................... 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 kopia ................................................................................................................. 112 Wniosek ................................................................................................................................ 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 Set ....................................................................................... 125 Implementacje interfejsu Map .................................................................................... 126 Klasa Collections ................................................................................................................. 128 Wyszukiwanie ............................................................................................................... 128 Sortowanie ..................................................................................................................... 128 Kolekcje niezmienne .................................................................................................... 129 Kolekcje jednoelementowe .......................................................................................... 129 Kolekcje puste ............................................................................................................... 129 Rozszerzanie kolekcji .......................................................................................................... 130 Wniosek ................................................................................................................................ 131 Rozdział 10. Rozwijanie platform ........................................................................................ 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 API ......................................................................................................................................... 150 Implementacja ..................................................................................................................... 151 Klasa MethodTimer ............................................................................................................ 152 Eliminacja narzutów czasowych ....................................................................................... 154 Testy ...................................................................................................................................... 154 Porównywanie kolekcji ................................................................................................ 155 Porównywanie kolekcji ArrayList i LinkedList ........................................................ 157 Porównywanie zbiorów ............................................................................................... 158 Porównywanie map ...................................................................................................... 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, programowaniu w taki sposób, by inni programiś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 udostępnia dobre środki przekazu. Przedstawione w tej książce wzorce implementacyjne 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 pytanie: „Co komputer zrobi, gdy będzie wykonywał ten kod?”, lecz: „W jaki sposób przekazać 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 formie podstawowych konstrukcji. Programiści zazwyczaj sami muszą określać, jakie są pytania — jakie problemy rozwiązują konkretne konstrukcje języka. Jeśli odpowiedzią jest „Zadeklarować pole typu Set”, to pytaniem mogłoby być: „W jaki sposób pokazać innym programistom, że w kolekcji nie mogą występować powtórzenia?”. Wzorce implementacyjne stanowią katalog najczęściej występujących problemów programistycznych 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 pisania oprogramowania. Oto kilka informacji 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 zajmuje się decyzjami o mniejszej skali, które 11

12

WSTĘ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 szczegó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 znaczenie. Widział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 odniesienia 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ą zmieniać 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 odnosić 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 ekonomiczne 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 wiedzę, ż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 programista otrzymałeś, Czytelniku, czas, talent, pieniądze i sposobność. Co zrobisz, by sensownie wykorzystać te dary? W tej książce znajdziesz moją odpowiedź na to pytanie, a jest nią pisanie 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 otrzymał ten projekt wraz ze mną i podczas jego trwania wspierał mnie licznymi telefonami. Z Chrisem Guzikowskim, moim 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, Johnowi Fullerowi oraz Cynthii Kogut. Ilustracje do książki, łączące w sobie informacje i przejrzystość, stworzyła Jennifer Kohnke. Recenzentami książki, którzy szybko dostarczyli przejrzystych opinii o niej, byli: Erich Gamma, Steve Metsker, Diomidis Spinellis, Tom DeMarco, Michael 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 Martinowi oraz Arturo Flacowi za wyłapanie i poprawienie błędów typograficznych. Dziękuję też moim dzieciom, które pozostawał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śmy razem. Kupiłeś moją książkę (i teraz jest Twoja). 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średnictwem 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żliwości języka Java do pisania zrozumiałego kodu. Ostatni rozdział zawiera natomiast informacje 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 etapów. Przede wszystkim musiałem zacząć programować świadomie. Programowałem już wiele lat, zanim zacząłem stosować wzorce implementacyjne. 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 komunikacji 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ć komunikatywny kod, musiałem uwierzyć w to, że inni są równie istotni jak ja sam. Programowanie rzadko kiedy jest samoistnym obcowaniem jednego człowieka i jednego komputera. 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, wystawiają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

ROZDZIAŁ 1

WPROWADZENIE

Tę książkę można czytać wyłącznie w poszukiwaniu informacji technicznych — przydatnych sztuczek i wyjaśnień. Niemniej jednak uznałem, że uczciwość wymaga ostrzeżenia czytelnika, iż zawiera ona znacznie więcej; przynajmniej w mojej opinii. Te techniczne informacje można odnaleźć, przeglądając rozdziały poświęcone wzorcom. Jedną z efektywnych strategii przyswajania informacji zamieszczonych w tej książce jest czytanie jej, gdy pojawi się taka konieczność. Można by to nazwać „czytaniem na bieżąco”. W takim przypadku sugeruję, by przejść od razu do rozdziału 5., przejrzeć zawartość książki aż do końca i mieć ją pod ręką w trakcie programowania. Po zastosowaniu wielu różnych wzorców można wró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 zrozumieniem informacji 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. Wię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ć miliony zmiennych. Określając nazwę każdej z nich, nie musimy wymyślać za każdym razem zupełnie nowatorskiej metody. Ogólne uwarunkowania za każdym razem są takie same: nazwa musi przekazywać czytelnikom kodu informację o przeznaczeniu zmiennej, jej typie i długości istnienia; powinna także być czytelna oraz łatwa do zapisania i sformatowania. Do tych ogólnych uwarunkowań należy dodać specyfikę konkretnej zmiennej. Tak uzyskujemy użyteczną nazwę zmiennej. Sposób określania nazw zmiennych jest przykładem wzorca: decyzja oraz wpływające na nią czynniki powtarzają się, choć tworzona nazwa może być za każdym razem inna. Uważam, że poszczególne wzorce często wymagają różnego sposobu prezentacji. Czasami najlepszym wyjaśnieniem wzorca będzie szczegółowy esej, czasami diagram, czasami dydaktyczny przykład, a czasami przykładowy kod. Zamiast dostosowywać informacje dotyczące każdego wzorca do sztywnego formatu, przekazałem je w sposó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 pewnego aspektu tworzenia czytelnego kodu. Oprócz tego w tekście wspominam 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, codziennymi zadaniami programistycznymi, tak by ułatwić przyszłym czytelnikom zrozumienie przeznaczenia kodu. Tę pozycję można umiejscowić gdzieś między książką Wzorce projektowe a podręcznikiem programowania w języku Java. Wzorce projektowe opisują decyzje, które zapewne będziemy musieli podejmować kilka razy dziennie, pisząc kod, i które zazwyczaj będą związane z określaniem interakcji pomiędzy obiektami. Podczas tworzenia kodu ze wzorców implementacyjnych będziemy korzystali bardzo często. Chociaż podręczniki doskonale opisują, co można zrobić w Javie, to jednak nie wyjaśniają, dlaczego

PRZEWODNIK

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 obejmują zagadnień programowania współbieżnego. Nie wynika to z faktu, że są one mało ważne; przyczyną jest raczej to, że nie mam wiele do powiedzenia na ten temat. Moja 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ć. Dlatego osobom poszukującym praktycznego podejścia do zagadnień współbieżności polecam książkę Java Concurrency in Practice. 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 komunikacji 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 podejmuję 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 oprogramowania 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 komunikacji 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 klasy oraz w jaki sposób klasy implementują 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łania, a zwłaszcza zamiennych ścieżek.

15

16

ROZDZIAŁ 1

WPROWADZENIE

Rysunek 1.1. Przegląd zawartości książki

■ Metoda — ten rozdział przedstawia wzorce służące do tworzenia metod, przypomina nam, do jakich wniosków najprawdopodobniej dojdą inni programiś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źmy 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 moich wyjaśnień). Jeśli jednak czytelnik woli przejrzeć opisywane wzorce, to powinien przejść do rozdziału 5., na stronę 33. Udanego kodowania.

Rozdział 2

Wzorce

Wiele decyzji programistycznych ma unikalny charakter. Sposób tworzenia witryny WWW będzie się diametralnie różnił od sposobu tworzenia aplikacji dla biegaczy. Jednak gdy podejmowane decyzje zaczną mieć coraz bardziej techniczny charakter, pojawi się także wrażenie rozpoznawania znajomych elementów. Czy takiego kodu nie napisałem już wcześniej? Programowanie byłoby znacznie bardziej wydajne, gdyby programiś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. Wię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. Wzorce 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 jakiegoś czasu znane odpowiedzi, a my będziemy mogli skoncentrować się na zagadnieniach 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. Wymienione wcześniej ograniczenia lub siły odnoszą się do każdej pętli w pisanym programie. Siły te powtarzają się w sposób łatwy do przewidzenia, co zresztą sprawia, że wzorce są właśnie wzorcami: określają wzór występujących sił. 17

18

ROZDZIAŁ 2

WZORCE

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ć inna, 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ólnych 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żytkownika 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 sugestię rozwiązania problemu. W przypadku wzorca pętli operującej na kolekcji sugestia ta mogłaby być następująca: „Do wyrażenia iteracji można skorzystać z pętli for języka Java 5”. Wzorce stanowią pomost łączący abstrakcyjne zasady z praktycznymi rozwiązaniami. Pomagają nam w pisaniu kodu. Wzorce współpracują ze sobą. Na przykład wzorzec sugerujący wykorzystanie pętli for jest przyczyną pojawienia się kolejnego problemu: jak nazwać zmienną używaną w pętli. Zamiast gromadzić wszystkie te zagadnienia w ramach 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 przedstawiają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 znacznie 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ść. Dysponujemy 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 dostosowana do sytuacji, jednak ogólną procedurę możemy wykonać z pamięci, co pozwala 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ć opinie i wprowadzić nowy. Żaden zestaw wzorców nie będzie się nadawał do zastosowania we wszystkich możliwych sytuacjach. W dalszej części książki przedstawiłem wzorce, których używam i które — jak wynika z moich 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łasnych rozwiązań oraz przedstawienie ich innym członkom zespołu i przedyskutowanie.

WZORCE

Wzorce najlepiej zdają egzamin, gdy pomagają nam w podejmowaniu decyzji. Niektóre wzorce implementacyjne zostaną w końcu wykorzystane w językach programowania, tak jak strukturalne wykorzystanie funkcji setjmp() i longjmp() stało się podstawą 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 mniej energochłonnych sposobów rozwiązywania często powtarzających się problemów programistycznych. Stosowanie wzorców pomaga programistom tworzyć sensowne rozwiązania 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 wzorzec kojarzy popularny problem programistyczny z rozważaniami o czynnikach mających na niego wpływ oraz jest uzupełniony konkretną radą opisującą sposób szybkiego 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

WZORCE

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órymi możemy się zetknąć podczas programowania. Wcześ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 temat programowania są znacznie bardziej interesujące, kiedy obejmują zagadnienia zarówno teoretyczne, jak i praktyczne. Każdemu wzorcowi towarzyszą pewne rozważania teoretyczne. Niemniej 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: wartości oraz zasady. Wartości są uniwersalnymi myślami przewodnimi programowania. Kiedy pracuję dobrze, zwracam uwagę na znaczenie komunikacji 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 decyzjom, które podejmuję podczas tworzenia kodu. Z kolei zasady nie mają 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ą pomosty łą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 sytuacjach, w których nie można zastosować żadnych wzorców bądź też gdy można zastosować dwa wzajemnie się wykluczające. Zrozumienie zasad w obliczu niejednoznaczności pozwala mi na „wymyślenie czegoś”, co będzie spójne z moimi pozostałymi doświadczeniami i będzie miało spore szanse okazać się dobrym rozwiązaniem.

21

22

ROZDZIAŁ 3

TEORIA PROGRAMOWANIA

Te trzy elementy — wartości, zasady oraz wzorce — stanowią zrównoważony wyraz stylu programowania. Wzorce określają, co należy zrobić. Wartości tłumaczą motywacje. A zasady pomagają w przekształceniu pobudek na działania. Wartości, zasady oraz wzorce przedstawione w tej książce zostały określone na podstawie moich własnych doświadczeń, przemyśleń oraz dyskusji z innymi programistami. Wszyscy bazujemy na doświadczeniach wcześniejszych generacji programistó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 uzyskanie 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ąć tracenia czasu. Jeśli różnimy się w opiniach dotyczących zasad, to spory o to, gdzie umieścić nawiasy klamrowe, nie pomogą nam w uzgodnieniu poglądów.

Wartości Trzema wartościami współgrającymi z doskonałością w programowaniu są: komunikatywność, prostota oraz elastyczność. Choć czasami są one sprzeczne, to jednak znacznie częściej się uzupełniają. Najlepsze programy zapewniają możliwość przyszłej rozbudowy, nie zawierają żadnych elementów zewnętrznych oraz są łatwe do przeanalizowania 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 mi wiele korzyści. Wówczas tworzę bardziej przejrzysty kod, który można łatwiej zrozumieć, jest on bardziej efektywny pod względem kosztów wytworzenia, mam jaśniejsze myśli, uzyskuję świeży punkt widzenia, opada poziom stresu i zaspokajam niektóre z moich potrzeb 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 sposó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 komunikacji, było odkrycie tego, co Knuth określił jako programowanie piśmienne (ang. literate programming) — 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ć. Wybraliśmy jeden z najbardziej

WARTOŚ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łkowicie 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. Wymóg komunikacji za pośrednictwem kodu zmienił nasz punkt widzenia. Istnieje bardzo silna ekonomiczna podstawa przemawiająca za koncentrowaniem się na komunikacji podczas programowania. Przeważająca część kosztów tworzenia oprogramowania jest generowana przez prace wykonywane po początkowym napisaniu programu. Gdy rozmyślam na temat moich doświadczeń związanych z modyfikacją kodu, muszę przyznać, że znacznie więcej czasu poświęcam na czytanie istniejącego 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 komunikacji poprawia myślenie przez zmuszanie mnie do bycia większym realistą. Usprawnienia częściowo wynikają z faktu większego zaangaż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 egocentrycznego punktu widzenia i patrzę na problemy i ich rozwiązania z zupełnie nowej perspektywy. Kolejnym usprawnieniem jest mniejszy poziom stresu, wynikający ze świadomości, że dbam o biznes i robię wszystko jak należy. I w końcu, ponieważ jesteśmy 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 Display of Quantitative Information zamieścił przykład, w którym przedstawił wykres, a następnie zaczął z niego usuwać wszystkie znaczniki, które nie wnosiły żadnych informacji. Ostateczny wykres był nowatorski, a jednocześnie dużo łatwiejszy do zrozumienia niż oryginalny. Wyeliminowanie 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ń komplikacji samego problemu. Jednak niekiedy złożoność można sobie wyobrazić jako ślady paznokci pozostawione w trakcie naszej walki o samo uruchomienie programu. 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 programu, a w przyszłości utrudni wprowadzanie w nim modyfikacji. Jednym z elementó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

ROZDZIAŁ 3

TEORIA PROGRAMOWANIA

programów. Stawianie przed odbiorcami 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 momentu pojawienia 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 JUnit jest znacznie prostsza niż narzędzia testowe, które zastąpiła. Łączy ona w sobie wiele podobnych rozwiązań, dodatków i nowych technik programowania i testowania. Jej najnowsza wersja, JUnit 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. Wymagania należy zmieniać, by wskazać te, które mają kluczowe znaczenie. Eliminacja 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ść komunikacji, 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 projektowych. 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ł przechowywany plik z tą wartością. Czemu miało służyć stosowanie tak skomplikowanego rozwiązania? Zapewniało elastyczność. Programy powinny być elastyczne, jednak tylko 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.

ZASADY

Ponieważ większość kosztów programu pojawia się po jego wdrożeniu, programy powinny zapewniać łatwość modyfikacji. Ta elastyczność, którą mam na myśli, będzie potrzebna w przyszłości, choć najprawdopodobniej nie będzie tym, czego będę potrzebował 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. Warto 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 cierpliwość. Warto trzymać je w odwodzie aż do momentu, gdy staną się naprawdę potrzebne. Wtedy 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 przykład opcje, które użytkownik może modyfikować, zapewnią elastyczność, jednak spowodują zwiększenie złożoności pliku konfiguracyjnego i pojawi się konieczność uwzględnienia 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 wyeliminowania opcji konfiguracyjnej bez utraty jej wartości, uzyskamy program, który w przyszłości będzie łatwiej zmodyfikować. Rozszerzanie możliwości komunikacji za pośrednictwem kodu poprawia także jego elastyczność. Im więcej będzie osób, które będą w stanie szybko przeczytać, zrozumieć 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 pomagają programistom w tworzeniu prostych, zrozumiałych aplikacji, które można łatwo modyfikować.

Zasady Wzorce implementacyjne nie są tym, czym są, bez przyczyny. Każdy z nich odzwierciedla co najmniej jedną zaletę, którą niosą ze sobą komunikacja za pośrednictwem kodu, prostota oraz elastyczność. Zasady są kolejnym poziomem ogólnych idei, bardziej charakterystycznym dla programowania niż wartości, i także one stanowią podstawę wzorców. Poznawanie zasad jest wartościowe z kilku powodów. Przejrzyste zasady mogą prowadzić do powstawania nowych wzorców, tak jak układ okresowy doprowadził do odkrycia 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

ROZDZIAŁ 3

TEORIA PROGRAMOWANIA

Na przykład kiedy stykam się z nowym językiem programowania, korzystam z mojej znajomoś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, kurczowo trzymać się stylu programowania stosowanego w innym języku (w każdym języku programowania można pisać kod typowy dla FORTRAN-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 mieć taką strukturę, by wprowadzane zmiany miały jedynie lokalne konsekwencje. Jeśli zmiana tutaj może spowodować problemy gdzie 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 komunikacji. Moż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 konsekwencji współdziała zasada unikania powtórzeń. Jeśli ten sam fragment kodu występuje w kilku miejscach, zmieniając jedno z jego wystąpień, będziemy musieli zdecydować, czy zmienić także wszystkie pozostałe. Taka zmiana 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 wprowadzenie zmiany pojęciowej wymaga zmiany dwóch lub większej liczby hierarchii klas, to konsekwencje 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 muszą 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 mniejszych 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 nimi 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.

ZASADY

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 wprowadzaniu 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 umieścić logikę i dane, by zastosować się do założeń tej zasady. Może się zdarzyć, że pisząc kod obiektu A, uzmysłowimy 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 musimy określić, co należy zrobić: przenieść kod do danych, dane do kodu czy umieścić je razem w jakimś nowym obiekcie pomocniczym; możemy także dojść do wniosku, że aktualnie nie jesteśmy 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, zrozumienie 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 wszystkich miejscach 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 zasadzie 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 input() i output() stanowią odzwierciedlenie intencji, natomiast nazwa incrementCount() jest

27

28

ROZDZIAŁ 3

TEORIA PROGRAMOWANIA

ściśle powiązana z implementacją. Poszukując symetrii, zastanowiłbym się, dlaczego inkrementuję zmienną count, co mogłoby doprowadzić do zmiany kodu na następujący: void process() { input(); tally();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 implementacyjnych 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 JUnit klasy mogły zawierać statyczną metodę suit(), która zwracała zestaw tekstów do wykonania. public static junit.framework.Test suite() { Test result= new TestSuite(); ... złożone działania ... return result; }

A teraz czas na zadanie prostego, częstego pytania: jakie teksty należy wykonać? W większości przypadków metoda suit() łączy testy z całej grupy klas. Niemniej jednak, ponieważ jest ona metodą ogólną, by mieć co do tego pewność, należy ją przeanalizować i zrozumieć. W JUnit 4 ten sam problem rozwiązano dzięki wykorzystaniu zasady przekazu deklaratywnego. Zamiast metody zwracającej zestaw testów stosowany jest specjalny mechanizm 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.

ZASADY

Jeśli wiadomo, że testy są grupowane w taki sposób, wystarczy spojrzeć na adnotację TestClasses, by dowiedzieć się, które z nich zostaną wykonane. Ponieważ zestaw testów został określony deklaratywnie, nie trzeba podejrzewać występowania jakichkolwiek podstępnych wyjątków. To rozwiązanie nie dysponuje możliwościami oryginalnej metody suit() ani nie ma porównywalnie ogólnego charakteru, jednak zastosowanie stylu deklaratywnego ułatwia zrozumienie kodu. (Adnotacja RunWith zapewnia jeszcze większą elastyczność wykonywania testów niż metoda suit(), 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 zmieniają się szybciej lub wolniej. To tempo zmian można potraktować jako formę symetrii czasowej. Niekiedy zasada tempa zmian odnosi się także do zmian wprowadzanych przez programistów. Na przykł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 zmieniają się jednocześnie, lecz w innym tempie niż pozostałe, najprawdopodobniej należałoby umieścić w obiekcie pomocniczym. Jeśli wartość oraz waluta jakiegoś instrumentu finansowego mogą się zmieniać jednocześnie, to można przypuszczać, że najlepiej byłoby je wyrazić w formie obiektu pomocniczego, takiego jak 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 currency, są symetryczne. Zmieniają się w tym samym czasie. Jednak nie są

29

30

ROZDZIAŁ 3

TEORIA PROGRAMOWANIA

symetryczne względem innych pól obiektu. Wyrażenie tej symetrii poprzez umieszczenie ich w odrębnym obiekcie stanowi jasny komunikat odnośnie do ich wzajemnej relacji i można sądzić, że w przyszłości pozwoli na dalszą eliminację powtórzeń i zachowanie lokalnego charakteru zmian.

Wnioski Ten rozdział stanowi wprowadzenie do teoretycznych podstaw wzorców implementacyjnych. Wartości, które niosą ze sobą komunikatywność, prostota oraz elastyczność, stanowią główną motywację opracowywania i stosowania wzorców. Zasady konsekwencji lokalnych, minimalizacji 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 ekonomiczne, które sprawiają, że komunikacja za pośrednictwem kodu jest warta uwagi.

Rozdział 4

Motywacja Trzydzieści lat temu Yourdon i Constantine w książce Structured Design wskazali, że to ekonomia jest czynnikiem warunkującym projektowanie oprogramowania. Oprogramowanie powinno być projektowane w taki sposób, by redukować jego koszty. Koszt oprogramowania składa się z kosztu początkowego oraz kosztów utrzymania: kosztcałkowity = kosztnapisania + kosztutrzymania

Kiedy przemysł nabrał doświadczenia w wytwarzaniu oprogramowania, dużym zaskoczeniem 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 stosować odmienne wzorce implementacyjne od tych przedstawionych w dalszej części książki). Utrzymanie oprogramowania jest kosztowne, gdyż zrozumienie 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ć. kosztutrzymania = kosztzrozumienia + kosztmodyfikacji + koszttestowania + kosztwdrożenia

Jedną ze strategii redukcji ogólnych kosztów oprogramowania jest zainwestowanie w jego początkowe wytworzenie w nadziei na zredukowanie lub całkowite wyeliminowanie 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 napisania 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 ekonomii: 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 przyniesie przyszłość, strategia implementacyjna powinna dążyć do natychmiastowych 31

32

ROZDZIAŁ 4

MOTYWACJA

korzyści, a nie mieć na uwadze zyski długoterminowe. Choć może to brzmieć jak sugestia, 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 przejrzystego kodu ułatwiającego wprowadzanie przyszłych modyfikacji. Moja strategia redukcji ogólnych kosztów polega na przekonywaniu wszystkich programistów, by zmniejszyli koszt analizy i zrozumienia kodu w fazie utrzymania oprogramowania poprzez skoncentrowanie uwagi na komunikacji, myśleniu o innych programistach. 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 programować szybciej i spada liczba zagadnień, które nas dekoncentrują. Kiedy zacząłem tworzyć swój pierwszy zestaw wzorców implementacyjnych (opisany w książce The Smalltalk Best Practice Patterns, 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 tygodniu 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 kodowania i nie dekoncentrowały mnie żadne wątpliwości. Wzorce 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 generacji programistów. To właśnie te wzorce były nawykami, które doprowadziły do powstania bardziej przejrzystego i zrozumiałego kodu, łatwiejszego w zrozumieniu, a poprzez ich opisanie byłem w stanie tworzyć kod szybciej i bardziej płynnie niż wcześniej. Mogł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 JDK 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 doprowadziłoby do powstania innych wzorców. Na przykład w ostatnim rozdziale, „Rozwijanie platform”, przedstawiłem wzorce implementacyjne przeznaczone do zastosowania przy tworzeniu platform. Bazują one na innych priorytetach i dlatego różnią się od mojego podstawowego stylu kodowania. Wzorce implementacyjne mają pomagać ludziom i umożliwiać osiągnięcie celów ekonomicznych. Kod jest tworzony przez ludzi i dla ludzi. Programiści mogą używać wzorców implementacyjnych, 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 temat 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 absolutnie 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 zachodnioeuropejskich filozofów, dzieląc programy na klasy, stanowiące ogólne opisy całych grup znajomych rzeczy, oraz obiekty, będące komputerowymi odpowiednikami tych rzeczy. Klasy są bardzo ważne dla komunikacji, gdyż potencjalnie mogą opisywać wiele konkretnych rzeczy. Spośród wszystkich wzorców implementacyjnych te związane z klasami mają największy zasięg. W odróżnieniu od nich wzorce projektowe ogólnie opisują 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. Simple Superclass Name) — określa nazwy klas bazowych całych hierarchii klas, wykorzystując w tym celu proste nazwy utworzone na podstawie tej samej metafory. ■ Kwalifikowana nazwa klasy pochodnej (ang. Qualified Subclass Name) — określa nazwy klas pochodnych, informując o podobieństwach i różnicach z klasami bazowymi. ■ Interfejs abstrakcyjny (ang. Abstract Interface) — oddziela interfejs od implementacji. ■ Interfejs (ang. Interface) — określa abstrakcyjny interfejs, który zazwyczaj nie różni się zbytnio od interfejsu w języku Java. ■ Interfejs wersjonowany (ang. Versioned Interface) — bezpiecznie rozszerza interfejsy poprzez wprowadzenie nowych interfejsów pochodnych.

33

34

ROZDZIAŁ 5

KLASY

■ Klasa abstrakcyjna (ang. Abstract Class) — określa interfejs abstrakcyjny, który najprawdopodobniej będzie się zmieniał wraz z klasą abstrakcyjną. ■ Obiekt wartościowy (ang. Value Object) — pozwala utworzyć obiekt zachowujący się jak wartość matematyczna. ■ Specjalizacja (ang. Specialization) — przejrzyście pokazuje podobieństwa powiązanych ze sobą obliczeń i różnice między nimi. ■ Klasa pochodna (ang. Subclass) — wyraża jednowymiarową odmienność za pomocą klasy pochodnej. ■ Implementator (ang. Implementor) — przesłania metodę, by wyrazić inny wariant obliczeń. ■ Klasa wewnętrzna (ang. Inner Class) — lokalnie grupuje użyteczny kod w formie klasy prywatnej. ■ Zachowanie zależne od instancji (ang. Instance-Specific Behavior) — modyfikuje logikę w zależności od instancji. ■ Konstrukcja warunkowa (ang. Conditional) — modyfikuje logikę na podstawie jawnie określonego warunku. ■ Delegacja (ang. Delegation) — modyfikuje logikę poprzez wybór jednego z kilku typów obiektów. ■ Selektor dołączany (ang. Pluggable Selector) — modyfikuje logikę poprzez przemyślany wybór wywoływanej metody. ■ Anonimowa klasa wewnętrzna (ang. Anonymous Inner Class) — modyfikuje logikę, przesłaniając jedną metodę lub kilka metod bezpośrednio w metodzie, wewnątrz której jest tworzony nowy obiekt. ■ Klasa biblioteczna (ang. Library 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 tworzenia 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 zmieniać w zależności od wartości danych; czasami 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 zmienności jest jednym z elementów efektywnego programowania obiektowego.

PROSTA NAZWA KLASY BAZOWEJ

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 zrozumienie 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 mogliby być programiści, gdyby rozważnie dobierali używane metafory?). Klasy są stosunkowo kosztownymi elementami projektowymi programów obiektowych. Klasa powinna realizować coś o dużym znaczeniu. Ograniczenie liczby klas używanych w systemie może być korzystne, o ile tylko pozostałe klasy nie staną się przeładowane i zbyt duże. Wzorce przedstawione w dalszej części rozdziału pokazują, w jaki sposób komunikować intencje poprzez odpowiednie deklarowanie klas.

Prosta nazwa klasy bazowej Znalezienie odpowiedniej nazwy jest jednym z najbardziej satysfakcjonujących momentów podczas pisania kodu. Konfrontujemy 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 Terminarz”, 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ęciami 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 wartoś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ładać z kilku słów. Sposobem rozwiązania tego dylematu jest wybór odpowiednio mocnej 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ę graficzną HotDraw, pierwszą nazwą, jaką nadałem obiektowi rysunku, było DrawingObject. Ward Cunningham zaproponował jednak skorzystanie z metafory związanej z typografią: rysunek można porównać do wydrukowanej strony, której zawartość została odpowiednio rozmieszczona. Graficzne elementy umieszczane na stronie są rysunkami, dlatego 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

ROZDZIAŁ 5

KLASY

Czasami odnalezienie dobrej nazwy zajmuje 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ę potencjalnych nazw, przemyśleć wszystko podczas spaceru. Czasami trzeba będzie zająć się kolejnymi sprawami, ufając, że czas, frustracja i podświadomość pozwolą w końcu znaleźć lepszą nazwę. Narzędziem, które nieodmiennie 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 wskazania nowych nazw. Warto 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. Mają przekazywać informację o tym, do której klasy dana klasa jest podobna oraz pod jakimi 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ą informacyjną nazwy. W odróżnieniu od klas bazowych hierarchii nazwy klasy pochodnych nie są używane w rozmowach równie często, a zatem mogą przekazywać nieco więcej informacji kosztem długości. Można je tworzyć, poprzedzając nazwę klasy bazowej jednym modyfikatorem lub kilkoma. Wyjątkiem od tej reguły jest sytuacja, gdy klasy pochodne są tworzone wyłącznie jako mechanizm udostępniania implementacji 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, reprezentują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ą bazową swojej własnej hierarchii klas, ważniejsze jest nadanie jej prostej nazwy klasy bazowej niż bardziej rozbudowanej nazwy klasy pochodnej. Kolejne problemy związane z określaniem nazw klas pochodnych występują w wielopoziomowych hierarchiach klas. Takie wielopoziomowe hierarchie są zazwyczaj tworzone z intencją wykorzystania delegacji, jednak wymagają użycia odpowiednich nazw. Dlatego zamiast bezmyślnie dodawać modyfikatory do nazwy bezpośredniej klasy bazowej, 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 pochodnej, warto skorzystać z nazwy klasy bazowej. Komunikacja z innymi osobami jest najważniejszym aspektem określania nazw klas. Z punktu widzenia komputera nazwa klasy równie dobrze mogłaby być liczbą.

INTERFEJS ABSTRAKCYJNY

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 zrozumieć i zapamię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żenia 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ę kolekcją, to później będę w stanie zmienić konkretną, używaną w nim klasę. Niemniej jednak kiedyś trzeba będzie wskazać konkretną klasę, by komputer mógł wykonać operacje. W tym przypadku, gdy używam terminu „interfejs”, mam na myśli „zbiór operacji pozbawionych konkretnych implementacji”. W języku Java można je przedstawić w postaci 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 nazwać. Maksymalizacja liczby interfejsów wcale nie prowadzi do minimalizacji kosztów stworzenia oprogramowania. Koszty stosowania interfejsów warto ponosić tylko wtedy, gdy potrzebujemy 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 wprowadzaniem ich wtedy, gdy okaże się to niezbędne. Choć 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 ekonomicznym 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ę przyczyn, 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 zmieniający zdanie itd. Jednak czynnikiem, którego na niej nie znalazłem, były uzasadnione zmiany oprogramowania. 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

ROZDZIAŁ 5

KLASY

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 wprowadzać 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 zmienić całego oprogramowania, które takiej zmiany wymaga, to koszty modyfikacji wzrastają jeszcze bardziej; zagadnienie to zostanie szerzej opisane 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 szczegół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 publicznoś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 implementować wiele różnych interfejsów. Interfejsy udostępniają jedynie operacje, a nie pola, dlatego też mogą efektywnie zabezpieczyć użytkowników przed zmianami ich implementacji. Choć interfejsy umożliwiają modyfikowanie implementacji, nie zaleca się zmieniania samych interfejsów. Jakakolwiek zmiana interfejsu — dodanie do niego jakiejś metody lub jej usunięcie — stwarza konieczność wprowadzenia zmian we wszystkich jego implementacjach. Jeśli nie mamy możliwości zmiany implementacji, to popularność 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 elementów projektowych nie jest szczególnym problemem w przypadkach, gdy są one przeznaczone do naszego prywatnego użytku, jednak jeśli będziemy udostępniać interfejs szerokiej 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 pojmujemy interfejsy. Interfejsy rozumiane jako klasy bez implementacji 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

KLASA ABSTRAKCYJNA

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 nazwie File implementująca go klasa musiałaby nosić taką nazwę jak ActualFile, ConcreteFile, a nawet (o zgrozo!) FileImpl (przy czym można by dodać albo przedrostek, albo końcówkę). Ogólnie rzecz biorąc, informacja, czy posługujemy się konkretnym obiektem, czy też obiektem abstrakcyjnym, ma duże znaczenie, natomiast znacznie mniej istotne jest to, czy obiekt abstrakcyjny został zaimplementowany jako klasa bazowa, 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 komunikacji niż ukrywanie faktu wykorzystania interfejsów. W takich przypadkach nazwy interfejsów poprzedzane są wielką literą „I”. Jeśli interfejs będzie nosił nazwę IFile, to implementującej go klasie można nadać prostą nazwę File.

Klasa abstrakcyjna Innym sposobem wyrażenia w języku Java rozróżnienia pomiędzy abstrakcyjnym interfejsem i konkretną implementacją jest użycie klasy bazowej. Klasa bazowa jest abstrakcyjna 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 modyfikator abstract, czy też nie. Wybór, czy zastosować klasy abstrakcyjne, czy interfejsy, zależy od dwóch czynnikó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: zmian implementacji oraz zmian samego interfejsu. Interfejsy języka Java kiepsko sobie radzą ze zmianami tego drugiego rodzaju. Jakakolwiek zmiana interfejsu wymaga bowiem odpowiedniego zmodyfikowania wszystkich implementacji. 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 interfejsó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 implementacjach, o ile tylko domyślna implementacja będzie konkretna. Jedynym ograniczeniem klas abstrakcyjnych jest to, że dana klasa może mieć tylko jedną klasę bazową. Jeśli zatem chcemy mieć możliwość zaprezentowania danej klasy w inny sposób, to musimy to zrobić, korzystając z interfejsów. Zastosowanie słowa kluczowego abstract 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

ROZDZIAŁ 5

KLASY

możliwości tworzenia instancji klasy bazowej zachęca do eliminacji abstrakcji, których zalety nigdy nie zrównoważą kosztów. Interfejsy oraz hierarchie klas nie są rozwiązaniami wzajemnie się wykluczającymi. Nic nie stoi na przeszkodzie, by stworzyć interfejs stwierdzający: „Oto, jak można skorzystać z następujących funkcjonalności” oraz klasę bazową deklarującą: „A to jest jeden ze sposobów implementacji tych funkcjonalności”. W takim przypadku w deklaracjach zmiennych należy korzystać z interfejsu, co zapewni możliwość wykorzystania nowej implementacji, jeśli w przyszłości będzie to konieczne.

Interfejs wersjonowany Co zrobić, kiedy musimy zmienić interfejs, którego zmienić nie można? Zazwyczaj taka sytuacja ma miejsce, gdy trzeba dodać do interfejsu jakąś operację. Ponieważ dodanie operacji spowoduje problemy we wszystkich istniejących implementacjach interfejsu, nie można tego zrobić. Niemniej jednak można zadeklarować interfejs rozszerzający ten istniejący i do niego dodać nową operację. Użytkownicy, którzy chcieliby skorzystać z nowych funkcjonalności, mogą użyć nowego interfejsu, natomiast wszyscy pozostali 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 sprawdzenie 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. Niemniej 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 nastę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= ...; if (recent instanceof ReversibleCommand) { ReversibleCommand downcasted= (ReversibleCommand) recent; downcasted.undo(); } ...

OBIEKT WARTOŚCIOWY

Ogólnie rzecz biorąc, zastosowanie interfejsu instanceof zmniejsza elastyczność kodu, gdyż wiąże go z konkretnymi typami. Niemniej jednak w tym przypadku to rozwiązanie może być usprawiedliwione, ponieważ daje możliwość modyfikacji używanych interfejsów. Gdyby w grę wchodziło kilka różnych interfejsów, to ich użytkownicy 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ć implementacje. Jednak interfejsy będą ulegać zmianom, podobnie jak wszystkie inne elementy projektu oprogramowania. Wszyscy uczymy się projektowania na podstawie implementacji 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 trudniejsze niż przy pisaniu aplikacji. Niemniej jednak, jeśli znajdziemy się w sytuacji, która zmusza nas do rozszerzenia interfejsu, to warto wiedzieć, jak to można zrobić.

Obiekt wartościowy Choć obiekty o zmiennym stanie są jedynym sensownym sposobem pojmowania obliczeń, 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żywane 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 zmiennej, 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 chcielibyś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 bardziej użyteczny? Na przykład rysowanie obrazka można przedstawić jako zmiany stanu jakiegoś medium 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

ROZDZIAŁ 5

KLASY

Rysunek 5.1. Grafika przedstawiona jako 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, ponadczasowe 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 podstawowe transakcje nie będą powodowały zmian wartości matematycznych.

Rysunek 5.2. Obiekty o zmiennym stanie odwołujące się do niezmiennych obiektów class Transaction { int value; Transaction(int value, Account credit, Account debit) { this.value= value; credit.addCredit(this); debit.addDebit(this); } int getValue() { return value; } }

Po utworzeniu obiektu Transaction nie można już zmienić ż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 pominięcia transakcji bądź zmianą wartości transakcji po jej przeprowadzeniu.

SPECJALIZACJA

Aby zaimplementować takie obiekty przypominające stałe (czyli obiekty działające jak liczby całkowite, a nie jak pojemniki przechowujące stan, który może się zmieniać), w pierwszej kolejności należy wyznaczyć granicę między światem stanu a światem 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. Operacje 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 zachowują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 argument ten często traci na znaczeniu, gdyż pod względem wydajności działania większość kodu programu nie stanowi wąskiego gardła. Innymi 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ą skomplikowane, 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 temat programowania przy użyciu trzech wymienionych wcześniej stylów — obiektów, funkcji i procedur — i dokładniej wyjaśnić, jak 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 obiektó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 zrozumieć istniejący kod, określić, czy jedna z istniejących jego odmian wyraża bieżące intencje, a jeśli nie, to jak najlepiej go odpowiednio zmodyfikować lub napisać od nowa. Najprostszymi rodzajami odmienności są te, w których różni się stan obiektów. Łańcuch znaków "abc" różni się od łańcucha "def". 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

ROZDZIAŁ 5

KLASY

Większość złożonych odmienności całkowicie różni się pod względem logiki. Procedura całkowania symbolicznego nie będzie miała nic wspólnego z procedurą matematycznego opisu czcionki, choć obie mogą mieć takie same dane wejściowe. Pomiędzy tymi dwoma skrajnościami — tą samą logiką operującą na różnych danych 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 identyczna, lecz różnić się w szczegółach. (Domyślam się, że procedury całkowania symbolicznego oraz matematycznego opisu czcionki nie mają 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 pomocniczy 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ą. Odmiennoś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ą podobne do tamtych, z wyjątkiem…”. Jeśli dysponujemy właściwymi klasami bazowymi, to tworzenie klas pochodnych może dawać ogromne możliwości przy tworzeniu oprogramowania. Jeśli możemy przesłonić odpowiednią metodę, to wystarczy kilka wierszy kodu, by utworzyć jego odmianę. W czasie gdy programowanie obiektowe stawało się popularne, tworzenie klas pochodnych wydawało się magicznym lekarstwem. Przede wszystkim klasy pochodne były używane do klasyfikacji, klasa Train była klasą pochodną klasy Vehicle, niezależnie od tego, czy obie miały jakiekolwiek wspólne fragmenty implementacji. Wraz z upływem czasu zauważono, że dziedziczenie pozwala na współużytkowanie implementacji, zatem 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 zmienić jego strukturę. Po drugie, zanim będzie można zrozumieć klasę pochodną, należy zrozumieć klasę bazową. Wraz 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 cechach ich implementacji. I w końcu wszystkie te problemy potęgują się wraz ze zwiększeniem 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 jednej hierarchii musi istnieć odpowiadająca jej klasa pochodna należąca do drugiej hierarchii.

KLASA POCHODNA

Jest to pewna forma powielania, która prowadzi do powstawania powiązań między obiema hierarchiami klas. W takich przypadkach, aby móc wprowadzić nową odmienność, konieczne jest zmodyfikowanie obu hierarchii. Choć osobiście często spotykam się z takimi hierarchiami równoległymi, w których sposób eliminacji 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 ubezpieczeniowy przedstawiony na rysunku 5.3. W tej hierarchii ewidentnie coś jest nie w porządku, gdyż InsuranceContract nie może odwoływać się do PensionProduct, a przeniesienie 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 wprowadzenie 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. Wymagało to utworzenia nowego obiektu, reprezentującego odpowiedni przepływ gotówki (i przedstawionego na rysunku 5.4).

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 chodziło. Jednym z kluczowych czynników warunkujących możliwość tworzenia użytecznych klas pochodnych jest uważne rozdzielenie logiki w klasie bazowej na metody wykonują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 skomplikowane, 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 zmienić kodu w klasie bazowej bez przeanalizowania i potencjalnego zmodyfikowania wszystkich miejsc, do których został on skopiowany.

45

46

ROZDZIAŁ 5

KLASY

Rysunek 5.5. Skopiowany i zmodyfikowany kod klasy pochodnej

Kiedy projektuję oprogramowanie, moim celem jest uzyskanie możliwości dowolnego 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żywana może przynieść korzyści? Można wykonać kilka kroków w tym kierunku i sprawdzić, 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ć, konieczne będzie wykorzystanie konstrukcji warunkowych lub delegacji.

Implementator Podstawowym sposobem wyrażania warunków w programach obiektowych jest komunikat 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 intencje 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ę implementacji gniazda (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 (komunikatu) jest znacznie bardziej przejrzysta — zapewnia możliwość oddzielenia wyrażenia intencji (na przykład zapisania kilku bajtów) od jej implementacji (wywołania stosu TCP/IP z odpowiednimi parametrami). 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 fantastyczne połączenie przejrzystości wyrazu oraz elastyczności jest głównym powodem, dla którego języki obiektowe stały się dominującym paradygmatem programowania.

KLASA WEWNĘTRZNA

To doskonałe rozwiązanie łatwo jednak zmarnować, tworząc w języku Java programy proceduralne. Wzorce przedstawione w tej książce mają pomagać w przejrzystym pisaniu kodu, zapewniającym dużą łatwość rozszerzania go.

Klasa wewnętrzna Czasami może się pojawić konieczność zgrupowania gdzieś pewnego fragmentu obliczeń, 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 Object. Niektóre klasy wewnętrzne dziedziczą po innych klasach bazowych, co może być użyteczne, jeśli zależy nam na wyrażeniu usprawnień wprowadzonych w stosunku do innych klas, interesują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 chcemy skorzystać z danych obiektu zewnętrznego bez jawnego określania ich wzajemnej relacji: public class InnerClassExample { private String field; public class Inner { public String example() { return field; // wykorzystanie pola z obiektu zewnętrznego } @Test public void passes() { field= "abc"; Inner bar= new Inner(); assertEquals("abc", bar.example()); } }

Niemniej jednak konstruktor klasy wewnętrznej takiej jak ta przedstawiona powyżej nie jest w rzeczywistości konstruktorem bezargumentowym, choć właśnie tak został 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

ROZDZIAŁ 5

KLASY

Inner.class.getConstructor(new Class[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 static.

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, sposób działania konkretnej instancji należy określać podczas tworzenia obiektu i już potem go nie zmieniać.

Konstrukcja warunkowa Najprostszymi formami zachowania zależnego od instancji są instrukcje warunkowe if/else oraz switch. Korzystając z nich, różne obiekty będą wykonywały różną logikę, zależnie od danych, jakimi dysponują. Konstrukcje warunkowe pojmowane jako forma przekazu mają tę zaletę, że pozwalają, by cała logika była umieszczona w jednej klasie. Osoby przeglądające kod nie muszą zmieniać plików, by przeanalizować różne możliwe ścieżki wykonywania obliczeń. Jednak konstrukcje warunkowe mają 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. Choć 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. Wzrost liczby konstrukcji warunkowych prowadzi do zmniejszenia niezawodności kodu. Problem ten staje się jeszcze poważniejszy w chwili powielania konstrukcji warunkowych. Przeanalizujmy prosty program graficzny. Wszystkie rysowane kształty będą wymagały metody display(): public void display() { switch (getType()) { case RECTANGLE :

KONSTRUKCJA WARUNKOWA //... 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 chcemy dodać do programu nową figurę. W pierwszej kolejności trzeba dodać odpowiednią klauzulę do każdej instrukcji switch. Oprócz tego, by wprowadzić 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. Wszystkie te problemy można wyeliminować, zastępując logikę warunkową komunikatami 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 wyrażać w formie komunikatów niż w postaci jawnej. Oprócz tego w postaci komunikatów lepiej jest wyrażać logikę warunkową, która podlega częstym zmianom, gdyż pozwala to na uproszczenie wprowadzania zmian w wybranej gałęzi i minimalizację wpływu tych zmian na pozostałe gałęzie logiki. Mówiąc krótko, mocne strony konstrukcji warunkowych — ich prostota oraz lokalny charakter — stają się problematyczne, jeśli będą wykorzystywane zbyt często.

49

50

ROZDZIAŁ 5

KLASY

Rysunek 5.6. Logika warunkowa wyrażona w formie klas pochodnych i delegacji

Delegacja Innym sposobem wykonywania odmiennej logiki w różnych instancjach klasy jest przekazywanie 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 wykonania, natomiast odmienności zostają zlokalizowane w samych delegacjach. Przykładem wykorzystania delegacji do wydzielania odmienności jest obsługa danych 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 odmienności między narzędziami jest wykorzystanie 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. Odpowiednią elastyczność zapewnia wykorzystanie delegacji. public void mouseDown() { getTool().mouseDown(); }

DELEGACJA

W takim przypadku kod umieszczony wcześniej w poszczególnych klauzulach instrukcji switch może zostać przeniesiony do różnych narzędzi. Można także wprowadzać 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 tradycyjnie, tworzona jest jedna delegacja, jeśli natomiast 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 wykonanie operacji do klasy Stream może wykazywać działanie charakterystyczne dla instancji, o ile obiekt Stream może się zmieniać w trakcie działania programu bądź też może współdzielić implementację typu Stream z innymi użytkownikami. Często stosowaną odmianą delegacji jest przekazywanie obiektu delegującego jako parametru metody mającej wykonać zadanie. GraphicEditor public void mouseDown() { tool.mouseDown(this); } RectangleTool public void mouseDown(GraphicEditor editor) { editor.add(new RectangleFigure()); }

Jeśli delegacja musi przekazać komunikat sama do siebie, to mogą się pojawić wątpliwoś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 RectangleTool dodaje nową figurę, lecz nie do siebie, a do obiektu GraphicEditor. 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 referencji w obiekcie RectangleTool. Przekazywanie obiektu GraphicEditor 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 referencji może być prostszym rozwiązaniem. GraphicEditor public void mouseDown() { tool.mouseDown(); }

51

52

ROZDZIAŁ 5

KLASY

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 JUnit 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 takie rozwiązanie nie było szczególnie eleganckie.

Rysunek 5.7. Bardzo proste klasy pochodne reprezentujące różne testy

Przy implementacji ogólnej metody runTest()poszczególne klasy pochodne klasy ListTest dają możliwość wykonywania różnych metod testowych. Zakładamy, że nazwa testu jest także nazwą metody, którą należy pobrać i wywołać podczas testu. Oto prosta wersja kodu stanowiącego implementację 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 Object[0]); }

Uproszczona hierarchia klas pozwala na wykorzystanie tylko jednej klasy (przedstawionej na rysunku 5.8). Jak we wszystkich technikach kompresji tak zmodyfikowany kod będzie łatwiejszy do przeanalizowania wyłącznie w przypadku, gdy rozumiemy zastosowaną „sztuczkę”. Początkowo, gdy wzorzec selektora dołączanego zdobył popularność, programiści mieli tendencję, by go nadużywać. W tamtym czasie było prawdopodobne, że analizując kod, mogliśmy dojść do wniosku, iż najpewniej nie mógł on zostać wywołany,

ANONIMOWA KLASA WEWNĘTRZNA

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 zamiennik dla zachowań charakterystycznych dla instancji; 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 pomocą nazwy. Efektywne wykorzystanie anonimowych klas wewnętrznych bazuje na stosowaniu niezwykle prostych interfejsów programowania — takich jak implementacja interfejsu Runnable z jego jedyną metodą run() — bądź też na wykorzystaniu klasy bazowej, która udostępni większość niezbędnych implementacji, dzięki czemu anonimowa klasa bazowa 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 umieszczany 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ć trudne, 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 systemie? Jednym z rozwiązań może być utworzenie odrębnej klasy, która będzie zawierał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ą dobrych możliwości skalowania. Umieszczenie logiki w metodach statycznych przekreśla

53

54

ROZDZIAŁ 5

KLASY

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 normalnymi obiektami. Czasami sprowadza się to jedynie do odnalezienia lepszego miejsca, w którym można umieścić metodę. Na przykład klasa biblioteczna Collection udostępnia metodę sort(List). Tak konkretnie określony parametr może stanowić podpowiedź sugerującą, że metoda ta powinna raczej należeć do klasy List. Inkrementalny sposób konwersji klasy bibliotecznej na obiekt polega na przekształ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 static void method(... parametry ...) { ... jakaś logika ... }

można zmienić następująco: public static 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 najprawdopodobniej powinny należeć do innych klas), to parametry metody można przekształcić w parametry konstruktora: public static void method(... parametry ...) { new Library(... parametry ...).instanceMethod(); } private void instanceMethod() { ... jakaś logika ... }

Następnie pozostaje już jedynie zmienić 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 rozdziale zostały przedstawione wzorce informujące o decyzjach związanych ze stanem.

Rozdział 6

Stan

Wzorce przedstawione w tym rozdziale opisują, jak można komunikować sposoby wykorzystania stanu. Obiekty stanowią wygodne połączenie zachowań udostępnianych ś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 obiektó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 zmieniają się wraz z upływem czasu. ■ Dostęp (ang. Access) — zapewnia elastyczność poprzez ograniczenie dostępu do stanu. ■ Dostęp bezpośrednio (ang. Direct Access) — zapewnia bezpośredni dostęp do stanu obiektu. ■ Dostęp pośredni (ang. Indirect Access) — opisuje dostęp do stanu przy wykorzystaniu metody, zapewniając tym samym większą elastyczność. ■ Wspólny stan (ang. Common State) — pozwala zapisywać stan wspólny wszystkim obiektom klasy w jej polu. ■ Stan zmienny (ang. Variable 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. Variable) — zmienne stanowią przestrzeń nazw dla korzystania ze stanu. 55

56

ROZDZIAŁ 6

STAN

■ Zmienna lokalna (ang. Local Variable) — zmienna lokalna przechowuje stan przeznaczony do użycia w jednym zakresie. ■ Pole (ang. Field) — pola przechowują stan przez cały okres istnienia obiektu. ■ Parametr (ang. Parameter) — parametry informują o stanie podczas aktywacji konkretnej metody. ■ Parametr zbierający (ang. Collecting Parameter) — reprezentuje przekazanie parametru służącego do gromadzenia złożonych wyników zwracanych przez wiele metod. ■ Obiekt parametru (ang. Parameter Object) — grupuje często używane, długie listy parametrów w formie jednego obiektu. ■ Stała (ang. Constant) — zapisuje w formie stałej stan, który się nie zmienia. ■ Nazwa sugerująca znaczenie (ang. Role-Suggesting Name) — zaleca nadawanie zmiennym nazw określanych na podstawie funkcji, jaką pełnią w obliczeniach. ■ Zadeklarowany typ (ang. Declared Type) — pozwala zadeklarować ogólny typ zmiennych. ■ Inicjalizacja (ang. Initialization) — zaleca, by w możliwie jak największym stopniu inicjalizować zmienne deklaratywnie. ■ Inicjalizacja wczesna (ang. Eager initialization) — inicjalizuje pola w momencie tworzenia instancji. ■ Inicjalizacja leniwa (ang. Lazy 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, znajomości szybkości obrotowej Ziemi 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 zmianom jest bardzo użyteczne. Rodowici Amerykanie w okolicach, w których mieszkam, 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 wieloma strategiami posługiwania się stanem, zarówno wrodzonymi, jak i wyuczonymi.

DOSTĘP

Niemniej 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. Wiele problemów, których przysparza programowanie równoległe, znika, 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 zaprogramowane do posługiwania się pojęciem zmiennego stanu. Sposoby programowania bazujące na pojedynczym przypisaniu lub całkowicie pozbawione zmiennych zmuszają 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żliwość uniknięcia problemu zmieniania stanu „za naszymi plecami”, gdyż pozwalają podzielić stan systemu na wiele fragmentów, z których każdy dysponuje ściśle ograniczonym dostępem do innych. Znacznie łatwiej jest zarządzać kilkoma bajtami niż całymi mega- lub gigabajtami danych. Choć problem niewłaściwego określenia jakiegoś stanu wciąż występuje, to jednak dzięki wykorzystaniu obiektów mamy możliwość szybkiego 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 przechowywanie 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 wykonywana, a nie zwracana bezpośrednio. Niemniej jednak używane przez nas języki programowania 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

ROZDZIAŁ 6

STAN

Decyzje dotyczące tego, co zapisać, a co obliczyć, mają wpływ na czytelność, elastyczność 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 potrzeba 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 informacji. Każdy obiekt działa jak niewielki komputer, dysponują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 zastosowanie bezpośredniego dostępu do zmiennej: 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 zmiennej, to jest to jedyna rzecz, jaką mogę z nią zrobić. Gdybym zapisywał w tej zmiennej wartości z wielu miejsc programu, to aby go zmodyfikować, musiałbym wprowadzić zmiany w każdej z tych części. Kolejną wadą dostępu bezpośredniego jest to, że stanowi on szczegół implementacyjny, znajdujący się wśród zagadnień, na które nie zwracamy uwagi podczas programowania. Przypisanie pewnej zmiennej wartości 1 może powodować otworzenie bramy do garażu, jednak kod z tym szczegółem implementacyjnym niezbyt dobrze komunikuje swoje intencje. Porównajmy 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 przechowywaniu informacji. 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 dotyczące przechowywania danych mają różne znaczenie dla różnych programistów, dlatego też nie można wskazać jednej metody wykorzystania dostępu bezpośredniego, która wszystkim by pasowała. Wciąż 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. Programiści cały czas muszą myśleć, komunikować intencje 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, nazywane 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 wymuszania konieczności wprowadzania zmian w kodzie klientów. Moja 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ść odwołań do stanu obiektu pochodzi spoza niego, może to sugerować występowanie jakiegoś 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 umieścić konkretne obliczenia, wygodniej jest zaimplementować je gdziekolwiek i użyć odpowiednich metod, by uzyskać informacje 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 natomiast jest mniej bezpośrednie i wykorzystuje odbiorcę zdarzeń: Widget void setBorder(int width) { this.width= width; notifyListeners(); }

59

60

ROZDZIAŁ 6

STAN

Takie powiązanie nie jest atrakcyjne (łatwo można bowiem zapomnieć o zachowaniu sugerowanych ograniczeń), jednak może być najlepszą z dostępnych opcji. W takim przypadku dostęp bezpośredni będzie najlepszym rozwiązaniem.

Wspólny stan Wiele obliczeń wykorzystuje te same elementy danych, chociaż ich wartoś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ładzie 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, najbardziej zrozumiałym sposobem ich wyrażenia będzie użycie pól: class Point { int x; int y; }

Warto 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ą niezbę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 komunikuje 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 takich przypadkach zawsze mogę poprawić kod, umieszczając dane w jakimś innym miejscu, 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 danych w zależności od sposobu, w jaki jest używany. Nie chodzi tu jedynie o inne wartości danych, lecz o zupełnie inne elementy danych przechowywane w obiektach tej samej klasy. Stan zmienny jest często przechowywany w formie mapy, której klucze są nazwami elementów (wyrażonymi jako łańcuchy znaków lub wartości jakiegoś typu wyliczeniowego), a wartości — przechowywanymi wartościami danych. class FlexibleObject { Map properties= new HashMap(); Object getProperty(String key) { return properties.get(key); }

STAN ZMIENNY void setProperty(String key, Object value) { properties.set(key, value); } }

Stan zmienny zazwyczaj jest znacznie bardziej elastyczny do stanu wspólnego. Jego podstawową wadą jest natomiast to, że nie pozwala na sprawne komunikowanie intencji. 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. Miałem okazję analizować kod, w którym programiści nadużywali stosowania stanu 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 same informacje zostały zadeklarowane w formie pól. Do 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ść true, to jednocześnie musiałbym określić wartości pól borderWidth oraz borderColor. O takiej sytuacji można by poinformować przy wykorzystaniu stanu zmiennego, takiego jak ten przedstawiony w górnej części rysunku 6.1.

Rysunek 6.1. Obramowanie widżetu wyrażone w formie 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. Polimorfizm wyjaśnia tę sytuację. Jedna klasa może reprezentować stan, w którym widżet nie ma obramowania, a druga stan, w którym obramowanie jest używane. W takim przypadku klasa Bordered będzie miała wspólny stan reprezentujący parametry obramowania.

Rysunek 6.2. Obiekt pomocniczy upraszcza projekt

61

62

ROZDZIAŁ 6

STAN

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 skojarzonego z pewnym obiektem, który nie ma żadnego znaczenia dla całej reszty systemu. Na przykład informacje o tym, gdzie obiekt jest zapisany na dysku, mogą być ważne dla mechanizmu zapewniania trwałości obiektów, lecz nie dla pozostałych fragmentó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 miejscach kodu, w których są używane, a nie w obiekcie. W powyższym przykładzie mechanizm zapewniania trwałości obiektów może tworzyć mapę IdentityMap, której kluczami będą zapisywane obiekty, a wartościami — informacje 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ętrznego. 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 analizujący kod muszą znać zakres, czas istnienia, przeznaczenie oraz typ zmiennych. Choć zostały wypracowane rozwinięte schematy umieszczania wszystkich tych informacji w nazwach zmiennych, to jednak preferowane jest upraszczanie kodu poprzez stosowanie prostych nazw. Moż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 wszystkich obiektach danej klasy. Zakres pól można dodatkowo określać za pomocą modyfikatorów public, package (odpowiada on domyślnemu zakresowi, co zresztą jest nieco dziwne, gdyż jednocześnie jest on najrzadziej używany), protected oraz private.

ZMIENNA LOKALNA

Jeśli swobodnie korzystamy ze wszystkich kombinacji dostępu, to z punktu widzenia 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 statycznych (static) oraz prywatnych (private). Dzięki wykorzystaniu takiego ograniczonego zbioru typów dostępu już sam kontekst wystarczy, by przekazać informację o tym, czy dana nazwa jest zmienną 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 informacji o zakresie. A to sprawia, że stosowane w kodzie nazwy zmiennych będą jednolite i łatwe. Wszystkie te rozważania zakładają, że dysponujemy możliwością podzielenia kodu na niewielkie fragmenty; można ją uzyskać, stosując inne wzorce implementacyjne, 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 rozwiązanie byłoby okropne. Należy dołożyć wszelkich starań, by czas istnienia zmiennych 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 informacji o typie zmiennej 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ć innymi sposobami, ich nazwy można wykorzystać do przekazania informacji o roli, jaką zmienne odgrywają w obliczeniach. Dzięki jak największemu ograniczeniu informacji przekazywanych przy użyciu nazw zmiennych mogą one być proste i czytelne.

Zmienna lokalna Zmienne lokalne są dostępne wyłącznie w miejscu, 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życiem i to w możliwie najmniejszym zakresie. Można wskazać kilka najczęściej występujących ról, odpowiadających przyczynom i przeznaczeniu zmiennych lokalnych: ■ Gromadzenie — zmienna gromadzi informacje przeznaczone do późniejszego wykorzystania. Często zdarza się, że wartość takiej zmiennej jest zwracana jako wynik wywołania funkcji. W takich przypadkach warto nadać zmiennej nazwę result lub results.

63

64

ROZDZIAŁ 6

STAN

■ Licznik — są to zmienne służące do gromadzenia informacji o liczbie innych obiektów. ■ Wyjaśnienie — jeśli używamy jakiegoś skomplikowanego wyrażenia, to zapisując jego cząstkowe wyniki w zmiennych lokalnych, możemy pomóc innym zrozumieć jego przeznaczenie: int top= ...; int left= ... int height= ...; int bottom= ...; return new Rectangle(top, left, height, width);

Choć z punktu widzenia obliczeniowego nie jest to niezbędne, to jednak opisowe 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 stanowi pierwszy krok na drodze do tworzenia metod pomocniczych. W takich przypadkach wyrażenie przypisywane zmiennej staje się zazwyczaj ciałem metody, a nazwa zmiennej sugeruje, jaką nazwę należy nadać metodzie. Czasami takie metody pomocnicze są tworzone w celu uproszczenia metod, w których początkowo były umieszczone wyrażenia; w innych przypadkach mogą one służyć do eliminacji powtórzeń. ■ Wielokrotne 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 zmiennej lokalnej, która następnie będzie użyta do każdego z obiektów: long now= System.currentTimeMillis(); for (Clock each: getClocks()) each.setTime(now);

■ Element — kolejnym popularnym zastosowaniem zmiennych lokalnych jest przechowywanie aktualnie przetwarzanego elementu kolekcji. W powyższym przykładzie each stanowi czytelną i zrozumiałą nazwę zmiennej lokalnej. Gdybyśmy chcieli zapytać, 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 przechowujących poszczególne przetwarzane elementy można dodać do nich nazwę kolekcji: broadcast() { for (Source eachSender: getSenders()) for (Destination eachReceiver: getReceivers()) ...; }

POLE

Pole Zakres oraz czas istnienia pola odpowiadają zakresowi oraz czasowi istnienia obiektu, do którego to pole należy. Pola są przede wszystkim elementami 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 implementacji”. 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ę zaczynać 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 final. Informuje 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 final, by jawnie to deklarować. Uważam, że uzyskiwana w ten sposób przejrzystość przekazu nie jest warta wzrostu złożoności kodu. Niemniej jednak gdybym wiedział, że piszę kod, który po pewnym czasie będzie modyfikowany 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 mniej; kilka najczęściej stosowanych zostało przedstawionych na poniższej liście: ■ Dane pomocnicze — takie pole zawiera referencję do obiektów używanych przez wiele metod obiektu. Jeśli obiekt jest przekazywany jako parametr 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 komunikat: „Ten obiekt może działać na dwa różne sposoby”. Jeśli wartość flagi jest ustawiana przy użyciu metody, stanowi ona dodatkową informację: „…a jego działanie może się zmieniać w trakcie istnienia obiektu”. Pola pełniące taką funkcję można z powodzeniem stosować, jeśli są używane wyłącznie w kilku warunkach. Jeśli kod podejmujący decyzję 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ć modyfikacjom w trakcie istnienia obiektu, to warto go określać w kompletnym konstruktorze. 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 nimi polega na tym, że pola stanu, kiedy już zostaną uaktywnione,

65

66

ROZDZIAŁ 6

STAN

same określają stan. Z kolei jeśli modyfikowane są pola strategii, zmiany takie wprowadzają inne obiekty. Analiza maszyn stanu implementowanych w taki sposób może być trudna, gdyż zarówno poszczególne stany, jak i przejścia pomiędzy nimi nie są umieszczone w jednym miejscu. ■ Komponenty — te pola zawierają obiekty lub dane używane przez dany obiekt.

Parametr Pomijając zmienne inne niż prywatne (pola i pola statyczne), jedynym sposobem przekazywania między obiektami informacji o stanie jest wykorzystanie parametrów. Ponieważ zmienne nieprywatne wprowadzają silne powiązanie między klasami oraz ponieważ 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 obliczenia 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 przekazując ją do metody, która jej wymaga, w postaci parametru, można zmniejszyć 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 tego 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 utracimy możliwość wprowadzania w obiekcie jakichkolwiek zmian. Rysunek 6.4 przedstawia pojedynczy parametr.

Rysunek 6.4. Pojedynczy parametr stanowi bardzo słabe powiązanie

PARAMETR ZBIERAJĄCY

Temu schematowi odpowiada następujący fragment kodu: Server s= new Server(); s.a(this);

Jednak pięciokrotne powtórzenie tego samego parametru znacząco zwiększy powiązanie między obiektami: Server s= new Server(); s.a(this); s.b(this); s.c(this); s.d(this); s.e(this);

Rysunek 6.5. Powtórzenie parametru zwiększa powiązanie między obiektami

W takim przypadku oba obiekty będą mogły lepiej działać niezależnie, jeśli parametr zostanie zastąpiony wskaźnikiem: Server s= new Server(this); s.a(); s.b(); s.c(); s.d(); s.e();

Rysunek 6.6. Odwołanie osłabia powiązania między obiektami

Parametr zbierający Obliczenia gromadzące wyniki wywołań wielu metod potrzebują jakiegoś sposobu scalenia 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 result; }

Jeśli jednak scalanie wyników jest bardziej skomplikowane od zwyczajnego dodawania, 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 asList() { List results= new ArrayList();

67

68

ROZDZIAŁ 6

STAN

addTo(results); return results; } 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 sult, 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 parametrami opcjonalnymi. 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 Socket. Najprostszy z nich nie wymaga podawania żadnych argumentów, dostępne są jednak inne wersje konstruktorów, z których jedna ma jeden parametr opcjonalny służący do określania numeru portu, a druga dwa parametry określające odpowiednio numer 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 wartoś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 niektó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 konkretnego typu. Najprostszym rozwiązaniem zapewniającym taką możliwość jest przekazywanie parametru będącego kolekcją. Niemniej jednak w razie wykorzystania takiego rozwiązania kod wywołujący jest niepotrzebnie komplikowany poprzez wprowadzenie obiektu pośredniego — przekazywanej kolekcji:

OBIEKT PARAMETRÓ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 argumentów. Jeśli metoda z powyższego przykładu zostanie zadeklarowana przy użyciu konstrukcji: metoda(Class... klasy), to w jej wywołaniu będzie można umieścić dowolną liczbę argumentów odpowiedniego typu: object.index(key1, key2);

Trzeba przy tym pamiętać, że takie określenie zmiennej liczby argumentów musi być podane jako ostatni parametr 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 parametrom i zastąpieniem nim dotychczasowej grupy parametrów. Po zastąpieniu listy parametró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 parametró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 trudniejszy 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 wszystkich wymagających tego miejscach 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). Wiele obiektów o bardzo dużych możliwościach powstało właśnie jako obiekty parametrów. Choć podstawową przyczyną wprowadzania parametrów obiektowych jest poprawienie czytelności kodu, to dodatkowo stanowią one ważne miejsce do umieszczania

69

70

ROZDZIAŁ 6

STAN

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 zajmuje 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 zrezygnować 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 przetestowany; 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 miejscach kodu, lecz których wartości nie zmieniają się. Jeśli wartości takich danych są znane w czasie kompilacji, można je zapisać w zmiennych zadeklarowanych jako statyczne i sfinalizowane (static final) 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 wielkimi literami, co podkreśla, że nie są to zwyczajne zmienne. Jednym z czynników, które sprawiają, że stosowanie stałych ma tak wielkie znaczenie, jest to, że dzięki nim można uniknąć całej grupy problemów. Jeśli w kodzie używamy wartości 5, a później zdecydujemy się zmienić ją na 6, to łatwo można pominąć 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 potwierdzenia”, to w takiej sytuacji próby zmiany wartości będą jeszcze bardziej podatne na występowanie błędów. Jednak najważniejszym powodem przemawiającym za stosowaniem stałych jest możliwość wykorzystania ich nazw w celu poinformowania o znaczeniu wartości. Osoby analizujące kod znacznie łatwiej zrozumieją stałą Color.WHITE niż wartość 0xFFFFFF. Co więcej, jeśli zmieni się sposób zapisu wartości koloru, to w kodzie używającym stałych nie trzeba będzie wprowadzać żadnych zmian. Częstym zastosowaniem stałych jest wyrażanie zmiennoś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 setJustification(Justification.CENTERED). 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 komunikató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 komunikat mógłby mieć następującą postać: justifyCentered(). Interfejs, w którym wszystkie wywołania metod wykorzystują stałe, można poprawić, tworząc oddzielne metody odpowiadające każdej stałej.

NAZWA SUGERUJĄCA ZNACZENIE

Nazwa sugerująca znaczenie W jaki sposób określamy, jaką nazwę nadać zmiennej? 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. Niemniej jednak nazwy powinny odzwierciedlać sposób wykorzystania danych przechowywanych w zmiennych oraz ich rolę w wykonywanych obliczeniach. By zrozumieć przeznaczenie zmiennej, potrzebuję kilku informacji. Jakie jest przeznaczenie wykonywanych obliczeń? W jaki sposób jest używany obiekt, do którego odwołuje się zmienna? Jakie są zakres oraz czas istnienia zmiennej? Jak często zmienna jest używana w kodzie? Wiele schematów nazewniczych pozwala na umieszczanie tych informacji w nazwach zmiennych. Jednak ja ich nie stosuję. Niby po co mam bezustannie informować kompilator o typie zmiennej? Do tego przecież sprowadza się umieszczanie informacji o typie w jej nazwie. Mogę sobie wyobrazić celowość takiego rozwiązania w językach, które w znacznie mniejszym stopniu zapobiegają błędom związanym z nieprawidłowym stosowaniem typów, takich jak język C. Jednak Java jest wyposażona w dostatecznie dobre mechanizmy pozwalające na unikanie tego rodzaju błędów. Jeśli chcę poznać typ jednej ze zmiennych zawartych w kodzie, używane IDE szybko dostarczy mi takiej informacji. Stosowanie krótkich, zwartych metod także ułatwia zorientowanie się, jakie jest przeznaczenie najczęściej stosowanych pól, zmiennych lokalnych 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 informacji o zakresie w formie prefiksu, na przykład fCount oznacza pole1, a lCount — zmienną lokalną2. Jednak przekonałem się, że także w tym przypadku, pisząc odpowiednio 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ć konieczność stosowania większości pól statycznych). Dzięki temu nazwa zmiennej może służyć głównie do przekazania informacji o roli, jaką ta zmienna odgrywa w kodzie. To z kolei sprawia, że nazwy te mogą być krótkie i bardziej przejrzyste. Jeśli określenie nazwy zmiennej przysparza mi większych problemów, to dzieje się tak głównie dlatego, że niezbyt dobrze rozumiem wykonywane obliczenia.

1 2

Litera „f” pochodzi od angielskiego słowa field oznaczającego pole — przyp. tłum. Litera „l” pochodzi od angielskiego słowa local oznaczającego zmienną lokalną — przyp. tłum.

71

72

ROZDZIAŁ 6

STAN

W kodzie, który piszę, często pojawia się kilka nazw zmiennych: ■ result — 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: for (Node child: getChildren()); ■ 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 zmiennych 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 takich przypadkach staram się analizować kontekst, w którym zmienna jest umieszczona. Zastanawiam 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 informacji o jej roli. Wszystkie inne istotne informacje dotyczące zmiennej — 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 informacji. Ten deklarowany typ można zatem dobrać tak, by dawał informacje o sposobie wykorzystania zmiennej, a nie tylko o jej implementacji. Deklaracja o postaci List members= new ArrayList() informuje mnie, że zmienna members będzie używana jako lista. Mogę zatem oczekiwać, że w kodzie pojawią się operacje jak get() oraz set(), gdyż czynnikiem odróżniającym listy (List) od kolekcji (Collection) 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. Okazało się, że dodatkowy wysiłek związany z próbami uogólnienia wszystkich typów nie był wart zachodu. Zdarzało się, że używana zmienna była typu List, a w jakimś miejscu kodu próbowałem

INICJALIZACJA

przekazać ją do wywołania metody wymagającej danej typu Collection. 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 List 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 kompromisem. 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 ArrayList, to nie będę mógł później zmienić go na HashSet równie łatwo, jak po użyciu typu Collection. Ogólnie rzecz ujmując, im dalej jest przekazywana pewna decyzja, tym mniejsze będą elastyczność i możliwości późniejszego wprowadzania zmian. Aby zachować elastyczność, należy starać się udostępniać jak najmniej informacji, i to w możliwie jak najmniejszym zakresie. Stwierdzenie: „Zmienna members zawiera daną typu ArrayList” przekazuje więcej informacji niż: „Zmienna members zawiera daną typu Collection”. 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 zmienna zawiera daną typu Collection, wyrażam się precyzyjnie. Odpowiedni przekaz zapewnia największą elastyczność.

Inicjalizacja Przed rozpoczęciem pisania kodu należy zastanowić się, na co można liczyć. Możliwość zrobienia precyzyjnych założeń umożliwia i ułatwia skoncentrowanie uwagi na określeniu tego, o czym trzeba wiedzieć. Stan zmiennych jest jednym z tych czynników, w przypadku których możliwość robienia założeń jest bardzo przydatna. Inicjalizacja jest procesem przypisywania zmiennym określonego stanu, zanim program zacznie z nich korzystać. Z inicjalizacją zmiennych wiąże się kilka zagadnień. Jednym z nich jest chęć zapewnienia, by inicjalizacja miała jak najbardziej deklaratywny charakter. Jeśli inicjalizacja i deklaracja zmiennej występują razem, to wszelkich informacji na jej temat 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 inicjować jakiś czas po ich utworzeniu. Na przykład w zintegrowanym środowisku programistycznym Eclipse w celu możliwie największego skrócenia czasu jego 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 momencie ich tworzenia — podczas deklarowania zmiennej bądź tworzenia obiektu

73

74

ROZDZIAŁ 6

STAN

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 zmiennej 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ą. Niemniej jednak nawet zastosowanie obu tych stylów inicjalizacji nie powoduje większego zamieszania, o ile tylko obiekty nie są zbyt duże.

Inicjalizacja leniwa Opisana wcześniej inicjalizacja leniwa zdaje egzamin, kiedy nie zwracamy uwagi na koszty związane z określaniem wartości zmiennych podczas ich tworzenia. Jeśli obliczenia takie są kosztowne i nie chcemy tych kosztów ponosić (na przykład dlatego, że zmienna 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 momencie pierwszego wywołania: Library.Collection getMembers() { if (members == null) members= new ArrayList(); return members; }

Inicjalizacja leniwa była niegdyś techniką stosowaną znacznie częściej. Wynikało to z faktu, że wcześniej ograniczenie mocy przetwarzania znacznie częściej było problemem. Ten sposób inicjalizacji nabiera znaczenia, gdy moc obliczeniowa komputera jest zasobem podlegającym ograniczeniom. Środowiska, w których zasoby są ograniczone, takie jak Eclipse, korzystają z inicjalizacji leniwej, by unikać wczytywania wtyczek aż do momentu, gdy będą one potrzebne.

INICJALIZACJA LENIWA

Analiza pola inicjalizowanego w taki sposób jest nieco trudniejsza niż pól, których wartoś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 implementacji. Tworząc kod programu, zapisujemy informacje 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 odpowiedzieć, używając jedynie kilku technik. Wykorzystanie inicjalizacji leniwej jest komunikatem: „W tym przypadku najważniejsza jest wydajność działania”.

Wniosek Wzorce stanu łączą się ze sposobami przekazywania informacji o decyzjach związanych z reprezentacją stanu programu. Wzorce opisane w następnym rozdziale stanowią 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 rozdział jest poświęcony sposobom przekazywania informacji o zachowaniu programów. Zostały w nim opisane następujące wzorce: ■ Przepływ sterowania (ang. Control Flow) — wyraża obliczenia jako sekwencję czynności. ■ Przepływ główny (ang. Main Flow) — precyzyjnie wyraża główny przepływ sterowania. ■ Komunikat (ang. Message) — wyraża przepływ sterowania w formie wysyłania komunikatów. ■ Komunikat wybierający (ang. Choosing Message) — zmienia implementujące komunikaty w celu wyrażenia wyboru. ■ Podwójne przydzielanie (ang. Double Dispatch) — zmienia elementy implementujące komunikat względem dwóch osi, wyrażając w ten sposób wybór kaskadowy. ■ Komunikat dekomponujący (ang. Decomposing Message) — dzieli złożone obliczenia na spójne fragmenty. ■ Komunikat odwracający (ang. Reversing Message) — zapewnia symetrię przepływu sterowania poprzez wysyłanie sekwencji komunikatów do tego samego odbiorcy. ■ Komunikat zapraszający (ang. Inviting Message) — zachęca do wprowadzania przyszłych zmian poprzez wysyłanie komunikatu, który może być implementowany na różne sposoby. ■ Komunikat wyjaśniający (ang. Explaining Message) — wysyła komunikat wyjaśniający przeznaczenie pewnego fragmentu logiki. 77

78

ROZDZIAŁ 7

ZACHOWANIE

■ Przepływ wyjątkowy (ang. Exceptional Flow) — jak najbardziej precyzyjnie wyraża wyjątkowy przepływ sterowania, bez zakłócania głównego przepływu sterowania. ■ Klauzula strażnika (ang. Guard Clause) — wyraża lokalny przepływ wyjątkowy poprzez wcześniejsze zakończenie wywołania. ■ Wyjątek (ang. Exception) — wyraża nielokalne przepływy wyjątkowe, używając w tym celu wyjątków. ■ Wyjątek sprawdzany (ang. Checked Exception) — wymusza przechwytywanie wyjątków poprzez ich jawne deklarowanie. ■ Propagacja wyjątków (ang. Exception Propagation) — przekazuje wyjątki, przekształcając je w razie konieczności tak, by dostępne w nich informacje były odpowiednie 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 sterowania. W programach pisanych w takich językach wszystkie elementy logiki są wymieszane 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. Wysyłanie komunikatów powoduje aktywację jednej z kilku podprocedur. Wyjątki umożliwiają przeskakiwanie do kodu położonego w górze stosu. Wszystkie te mechanizmy sumują się, tworząc wspólnie bogate medium służące do wyrażania sposobu działania obliczeń. Jako autorzy (programiści) decydujemy, czy przepływ sterowania, który sobie wyobrażamy, zostanie wyrażony jako jeden przepływ główny, wiele przepływów dodatkowych, z których każdy ma równie duże znaczenie, czy też jako pewna kombinacja obu powyższych rozwiązań. Grupujemy poszczegó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 szczegółów dla tych, którzy będą chcieli je zrozumieć dokładniej. Grupowanie to czasami przybiera postać procedur umieszczanych w klasach, a czasami przekazywania sterowania do innych obiektów.

Przepływ główny Programiści zazwyczaj wiedzą, jak ma wyglądać główny przepływ sterowania w tworzonych 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

niemniej jednak obliczenia mają ściśle wytyczoną ścieżkę, po której przebiega ich realizacja. 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ć niezawodnie w niesprzyjających okolicznościach, przepływ główny nie jest widoczny. Jednak takie programy spotyka się raczej sporadycznie. Wykorzystanie dużych możliwości wyrazu używanego języka programowania w celu informowania o rzadko wykonywanych i modyfikowanych faktach dotyczących naszego programu sprawia, że mniej zauważalne stają się jego ważniejsze fragmenty: te, które będą często analizowane, rozumiane 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żaniu 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 mechanizmem służącym do ukrywania informacji są wywołania procedur: compute() { input(); process(); output(); }

Powyższa procedura przekazuje następujące informacje: „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 programowania 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 niezmienny fragment obliczeń, nie trzeba zwracać uwagi na wszystkie szczegóły ewentualnych 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 potencjalnym 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 komunikują: „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 elastyczności, jasne i bezpośrednie wyrażanie logiki zawsze, gdy to jest możliwe, i odpowiednie 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

ROZDZIAŁ 7

ZACHOWANIE

Komunikat wybierający Czasami wysyłam komunikat służący do wyboru implementacji, 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 komunikatu polimorficznego. public void displayShape(Shape subject, Brush brush) { brush.display(subject); }

Komunikat display() wybiera implementację na podstawie faktycznego typu, który w trakcie działania programu przyjmie parametr brush. Dzięki temu później będzie moż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, konieczne będzie wprowadzenie jawnych modyfikacji. Analiza kodu, który w dużym stopniu korzysta z komunikatów wybierających, wymaga nabycia doświadczenia i umiejętności. Jedną z wad stosowania takich komunikató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 metodom nazwy informują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 wykonywanych 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 Komunikaty wybierające dobrze nadają się do wyrażania jednego wymiaru zmiennoś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 zmienności, to można to osiągnąć, tworząc kaskadę składającą się z dwóch komunikatów wybierających. Załóżmy 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. Wydaje się, że podstawowe obliczenia należą do klasy Brush, dlatego też komunikat 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 displayWith() 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(this); } Rectangle.displayWith(Brush brush) { brush.displayRectangle(this); }

Dzięki temu do poszczególnych typów pochodnych klasy Brush przekazywane są informacje niezbędne do prawidłowego działania: PostscriptBrush.displayRectangle(Rectangle subject) { writer print(subject.left() +" "+...+ rect); }

Takie rozwiązanie wprowadza pewne powtórzenia, którym towarzyszy utrata elastyczności. Nazwy typów, do których są przekazywane pierwsze komunikaty wybierające, zostają bowiem podane w kodzie metod obsługujących drugi komunikat 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 pochodnych klasy Brush. Jeśli wiadomo, że prawdopodobieństwo zmienienia się jednego wymiaru 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 zrozumiał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 jeden komunikat. Taki komunikat jest tworzony w celu zapewnienia możliwości wprowadzania specjalizacji lub jakichkolwiek bardziej wyszukanych rozwiązań. Stanowi on odpowiednik staromodnej dekompozycji funkcjonalnej. Komunikat jest w tym przypadku stosowany tylko po to, by wywołać podsekwencję kroków umieszczoną w procedurze. Komunikat dekomponujący musi nosić odpowiednią, opisową nazwę. Większość osób analizujących kod powinna uzyskać wszystkie informacje konieczne do określenia przeznaczenia procedury wyłącznie na podstawie jej nazwy. Konieczność przeanalizowania całego kodu wywoływanego przez ten komunikat powinna dotyczyć jedynie osób, które chcą poznać szczegóły implementacyjne.

81

82

ROZDZIAŁ 7

ZACHOWANIE

Problemy 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ługa lista parametrów. Widząc takie sygnały, zastępuję komunikat dekomponujący wywoływanymi w nim metodami i staram się zastosować inny wzorzec (taki jak 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ę pomocniczą, której użycie zapewniłoby symetrię. Analizując zmodyfikowaną wersję metody compute(), nie trzeba już śledzić, do kogo jest wysyłany komunikat — wszystkie komunikaty są bowiem kierowane do bieżącego obiektu (this). 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 komunikat odwracający. Może się także zdarzyć, że przesadne stosowanie komunikatów odwracających skutecznie ukryje potrzebę przeniesienia funkcjonalności. Gdyby 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 zaspokoić „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ą informacją o jego jakości. Te odczucia wydostają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 programiści będą chcieli zmodyfikować fragment obliczeń przez wprowadzenie klasy pochodnej. Możliwość wprowadzenia takich późniejszych usprawnień da się wyrazić poprzez wysłanie komunikatu o odpowiednio dobranej nazwie. Taki komunikat zaprasza innych programistów do usprawnienia obliczeń wedle własnych potrzeb. Jeśli dostępna jest jakaś domyślna implementacja logiki, to należy użyć jej jako implementacji komunikatu. Jeśli jednak takiej domyślnej implementacji 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 zrozumieć wykonywane obliczenia, a później, w razie konieczności, poznać ich szczegóły. Rozróżnienie to można utworzyć, korzystają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 komunikat 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, moją 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ływać metody reverse() bezpośrednio, tylko przy użyciu dodatkowej metody highlight()?”. Jednak po przemyśleniu zagadnienia zrozumiałem, że choć metoda highlight() 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

ROZDZIAŁ 7

ZACHOWANIE

Można zastanowić się nad wykorzystaniem komunikatu wyjaśniającego w sytuacjach, 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ć implementacja metody setLoadedFlag() jest trywialnie prosta, została ona jednak utworzona w celu wyrażenia intencji. void setLoadedFlag() { flags|= LOADED_BIT; }

Zdarza się, że metody pomocnicze wywoływane przez komunikaty wyjaśniające stają się cennymi miejscami dalszego rozwijania kodu. Miło jest móc skorzystać z możliwości, kiedy się ona nadarzy. Niemniej jednak podstawowym celem, w którym stosuję komunikaty wyjaśniające, jest bardziej precyzyjne wyrażenie moich intencji.

Przepływ wyjątkowy Oprócz przepływu głównego programy mają także co najmniej jeden przepływ wyjątkowy. Stanowią one ścieżki obliczeń, których wyrażanie nie jest tak ważne, gdyż są rzadziej wykonywane, rzadziej zmieniane, a czasami także mniej istotne od przepływu głównego z koncepcyjnego punktu widzenia. Przepływ główny należy wyrażać w sposób przejrzysty, natomiast ścieżki wyjątkowe — w możliwie jak najbardziej przejrzysty, lecz jednocześnie taki, by nie zaciemniał 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ą wykonywane sekwencyjnie, jedna po drugiej. W takich przypadkach, aby zrozumieć intencję programu, osoby przeglądające kod mogą skorzystać z wygodnych i dobrze znanych umiejętności czytania prozy. Czasami jednak może się zdarzyć, że program będzie miał 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 znalezienie 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 konsekwencje. Porównajmy dwa przedstawione poniżej fragmenty kodu:

KLAUZULA STRAŻNIKA void initialize() { if (!isInitialized()) { ... } }

oraz: void initialize() { if (isInitialized()) return; ... }

W pierwszym z nich, kiedy zaczynam analizować pierwszą klauzulę instrukcji warunkowej, 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ładzie pierwsze dwa wiersze metody informują mnie jedynie o fakcie — odbiorca nie został zainicjowany. Konstrukcja if-then-else 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 przedstawionym 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 tego, 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 podprogram miał tylko jeden punkt wejścia i jeden punkt wyjścia. Sformułowano go, by zapobiec 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 opuszczać. 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 danych lokalnych, takie podejście jest niepotrzebnie konserwatywne. Niemniej 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(); if (server != null) { Client client= server.getClient(); if (client != null) { Request current= client.getRequest(); if (current != null)

85

86

ROZDZIAŁ 7

ZACHOWANIE processRequest(current);

} } }

Zagnieżdżone warunki reprezentują jakieś problemy. W razie wykorzystania klauzul 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(); if (server == null) return; Client client= server.getClient(); if (client == null) return; Request current= client.getRequest(); if (current == null) return; processRequest(current); }

Pewnym wariantem wzorca klauzuli strażnika jest instrukcja continue umieszczana w pętlach. Jej użycie stanowi komunikat: „Nie przejmuj się tym elementem i zajmij się następnym”. while (line = reader.readline()) { if (line.startsWith('#') || 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 Wyjątki są przydatne do wyrażania przeskoków w przepływie programu, przekraczających wiele poziomów wywołań. Jeśli w momencie wystąpienia problemu, takiego jak zapełnienie nośnika danych lub utrata połączenia sieciowego, na stosie będzie się znajdował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 momencie wykrycia problemu oraz przechwycenie go w miejscu, gdzie problem ten można obsłużyć, jest znacznie lepszym rozwiązaniem niż umieszczanie w kodzie jawnych testów sprawdzających zaistnienie wszelkich możliwych warunków wyjątkowych, 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 programu 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łoszenia wyjątku aż do dotarcia do metody, w której zostanie on przechwycony. Wyjątki

WYJĄTKI SPRAWDZANE

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, komunikatów, iteracji oraz konstrukcji warunkowych (w takiej kolejności). Wyją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 wyjątek nigdy nie zostanie przechwycony. Takie sytuacje mają tylko jeden koniec — przerwanie działania programu. Można jednak przypuszczać, że twórca programu chciałby mieć kontrolę nad tym, kiedy program zostanie nieoczekiwanie przerwany, móc wyświetlić wówczas informacje niezbędne do zdiagnozowania problemu i poinformować 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 chybiony 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 sprawdzane. Są one jawnie deklarowane przez programistę oraz sprawdzane przez kompilator. Kod, w którym może się pojawić taki wyjątek, musi go przechwycić lub przekazać 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 momentem zgłoszenia i obsługi. Poza tym wyjątki sprawdzane znacząco utrudniają modyfikowanie kodu. Refaktoryzacja kodu korzystającego z takich wyjątków jest znacznie trudniejsza 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 Wyjątki pojawiają się na różnych poziomach abstrakcji. Przechwytywanie i raportowanie o wyjątkach niskiego poziomu może być kłopotliwe dla osób, które się ich nie spodziewały. Kiedy serwer WWW wyświetla stronę błędu z obrazem stosu rozpoczynającym się od NullPointerException, to nie wiem, co mam zrobić z tą informacją. Wolałbym zobaczyć komunikat taki jak: „Programista nie przewidział zaistniałego scenariusza”. 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

ROZDZIAŁ 7

ZACHOWANIE

aby ułatwić mu zdiagnozowanie problemu; natomiast wyświetlanie niewyjaśnionych w żaden sposób technicznych szczegółów nie jest ani użyteczne, ani pomocne. Wyjątki niskiego poziomu często zawierają cenne informacje, przydatne do diagnozowania zaistniałych problemów. Takie wyjątki można umieszczać wewnątrz wyjątkó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 rozwiązania problemu.

Wniosek W programach obiektowych sterowanie jest przekazywane pomiędzy metodami. W następnym rozdziale zostało opisane wykorzystanie metod w celu wyrażenia koncepcji występujących w obliczeniach.

Rozdział 8

Metody 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ą metodę? Jaki w ogóle jest sens stosowania metod? Teoretycznie można by napisać program w formie jednej gigantycznej procedury, w której sterowanie byłoby przekazywane pomiędzy jej różnymi miejscami. Choć 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 utrudnienie 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ć. Kolejny problem wynika z faktu, że większość problemów napotykanych podczas programowania nie jest unikalna. Zamiast za każdym razem implementować wszystko od początku, wygodniej (i produktywniej) byłoby, gdyby istniała możliwość wywołania przygotowanego wcześniej rozwiązania. Jednak w takiej jednej gigantycznej procedurze nie istnieje żaden 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 stanowi 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ą bezpośrednio związane. Można je analizować i zrozumieć niezależnie. Co więcej, nazwy nadawane metodom pozwalają przekazać czytelnikom informacje o przeznaczeniu umieszczonego w nich kodu, i to niezależnie od jego implementacji. Często zdarza się, że czytelnicy mogą uzyskać potrzebne informacje, przeglądając same nazwy metod. Opró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

ROZDZIAŁ 8

METODY

Pod względem koncepcyjnym dzielenie dużych obliczeń na metody jest stosunkowo 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 wydaje 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ć sumaryczny nakład pracy. Umiejętność określania, które podziały będą najlepsze, przychodzi wraz z doświadczeniem. Poniżej podałem sugestie wynikające z moich doświadczeń. Częste problemy z podziałem programu na metody wiążą się z ich wielkością, przeznaczeniem oraz nazewnictwem. W razie utworzenia zbyt wielu małych metod osoby analizujące program będą miały kłopoty ze śledzeniem i zrozumieniem idei wyrażonych w formie wielu elementów. Poza tym zbyt mała liczba metod prowadzi do powielania 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. Dobieranie 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: ■ Metoda złożona (ang. Composed Method) — metody można tworzyć, grupując wywołania innych metod. ■ Nazwa określająca przeznaczenie (ang. Intention-Revealing Name) — nazwy metod należy dobierać tak, by oddawały ich przeznaczenie. ■ Widoczność metody (ang. Method Visibility) — metody powinny być prywatne. ■ Obiekt metody (ang. Method Object) — złożone metody można przekształcać w niezależne obiekty. ■ Metoda przesłonięta (ang. Overrided Method) — metody można przesłaniać w celu wyrażenia specjalizacji. ■ Metoda przeciążona (ang. Overloaded Method) — zapewnia alternatywny interfejs tych samych obliczeń. ■ Typ wynikowy metody (ang. Method Return Type) — deklarowany typ wyniku metody powinien być możliwie jak najbardziej ogólny. ■ Komentarz do metody (ang. Method Comment) — do metod należy dodawać komentarze, aby przekazać informacje, których nie można łatwo uzyskać na podstawie analizy kodu. ■ Metoda pomocnicza (ang. Helper Method) — tworząc niewielkie, prywatne metody, można zwięźlej wyrażać główne obliczenia.

METODY

■ Metoda komunikatu informacyjnego (ang. Debug Print Method) — używa metody toString(), by wyświetlać informacje, które mogą się przydać podczas debugowania. ■ Konwersja (ang. Conversion) — wyraża konwersję obiektu jednego typu na obiekt innego typu. ■ Metoda konwertująca (ang. Conversion Method) — w przypadku prostych konwersji zaleca tworzenie w obiekcie źródłowym metody zwracającej skonwertowany obiekt. ■ Konstruktor konwertujący (ang. Conversion Constructor) — przy większości konwersji zaleca tworzenie w klasie obiektu skonwertowanego metody pobierającej obiekt źródłowy jako parametr. ■ Utworzenie (ang. Creation) — pozwala jasno wyrazić utworzenie obiektu. ■ Kompletny konstruktor (ang. Complete Constructor) — tworzone konstruktory powinny zwracać w pełni ukształtowane obiekty. ■ Metoda wytwórcza (ang. Factory Method) — pozwala wyrazić bardziej złożony proces tworzenia obiektu w formie metody statycznej, a nie konstruktora. ■ Fabryka wewnętrzna (ang. Internal Factory) — hermetyzuje w metodzie pomocniczej proces tworzenia obiektu, który w przyszłości możne wymagać wyjaśnienia lub usprawnienia. ■ Metoda dostępu do kolekcji (ang. Collection Accessor Method) — tworzy metodę zapewniającą ograniczony dostęp do kolekcji. ■ Metoda określająca wartości logiczne (ang. Boolean Setting Method) — jeśli to pomoże w komunikacji, można stworzyć dwie metody określające dwa możliwe stany danej logicznej. ■ Metoda zapytania (ang. Query Method) — pozwala odczytywać wartości logiczne, wykorzystując w tym celu metody nazywane według schematu asXXX. ■ Metoda równości (ang. Equality Method) — zaleca, by definiować obie metody: equals() oraz hashCode(). ■ Metoda pobierająca (ang. Getting Method) — czasami dostęp do pola można zapewnić, udostępniając metodę zwracającą jego wartość. ■ Metoda ustawiająca (ang. Setting Method) — nieco bardziej sporadycznie można tworzyć metody dające możliwość określania wartości pola. ■ Bezpieczna kopia (ang. Safe 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

ROZDZIAŁ 8

METODY

Metoda złożona Metody 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(); flags|= 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ć pytanie, 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 ramach prac nad tym rozdziałem książki napisałem niewielki program pomiarowy, który porównywał pętlę o milionie powtórzeń z wygenerowaniem miliona 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 mierzonych liczbą wierszy kodu, stwierdzając na przykład, że długość metod powinna wahać się w granicach od 5 do 15 wierszy. Choć być może prawdą jest, że metody mieszczące się w tych granicach są najbardziej czytelne, to jednak trudno nie zadać pytania, dlaczego te limity 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ą mieć 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 fragmentu jest dosyć cenna. Odpowiednie zastosowanie wcięć w kodzie stanowi doskonałą 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 struktura kodu, stanie się jednak utrapieniem przy próbach szczegółowego zrozumienia sposobu jego działania. Mój umysł jest w stanie objąć tylko określoną ilość szczegółowych informacji w danej chwili, a w metodzie o długości tysiąca wierszy jest ich zdecydowanie zbyt dużo. Próbując dokładnie zrozumieć tajniki działania kodu, chciałbym,

NAZWA OKREŚLAJĄCA PRZEZNACZENIE

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 programiś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 (przynajmniej wedle standardó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 jednocześ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łaniania dwóch metod w celu wprowadzenia jednej zmiany koncepcyjnej. Metody należy konstruować, opierając się na faktach, a nie spekulacjach. Kod należ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 odpowiedniej struktury kodu, to tę samą pracę będziemy musieli wykonać jeszcze raz, kiedy później, na etapie implementacji, dowiemy się czegoś, co zmusi nas do wprowadzenia 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 ponowne 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 informacje, które możemy chcieć przekazywać w nazwach metod; jedną z nich jest na przykład zastosowana strategia implementacyjna. Niemniej jednak nazwa metody powinna służyć do wyrażenia jej intencji, a wszelkie dodatkowe informacje o metodzie można przekazywać inaczej. Strategia implementacyjna jest informacją uboczną, która najczęściej jest dodawana do nazwy metody. Oto przykład: Customer.linearCustomerSearch(String id)

Taka nazwa wydaje się znacznie lepsza do poniższej: Customer.find(String id)

gdyż przekazuje więcej informacji o metodzie. Jednakże naszym celem jako twórców oprogramowania nie jest przekazywanie wszystkich możliwych informacji na temat

93

94

ROZDZIAŁ 8

METODY

kodu tak szybko, jak to tylko możliwe. Czasami należy zachować powściągliwość. Jeśli strategia implementacyjna nie ma większego znaczenia dla użytkowników, to informacji o niej nie warto umieszczać w nazwie metody. Osoby zainteresowane sposobem implementacji 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 nimi najlepiej będzie wyrazić poprzez odpowiedni dobór nazw: Customer.find(String id) Customer.fastFind(String id)

(Właściwie w tym przypadku lepiej byłoby udostępnić tylko jedną metodę find(), 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 find() będzie zaimplementowana 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 kodzie 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. Metoda wywołująca inne metody powinna opowiadać pewną historię. Dlatego nazwy metod należy dobierać tak, by pomagały ją opowiadać. Jeśli implementacja metod stanowi analogię do istniejącego interfejsu, ich nazwy powinny być takie same jak te zastosowane w interfejsie. Jeśli tworzymy specjalny rodzaj iteratora, to będzie on definiował metody hasNext() oraz next(), choć formalnie nie musimy przy tym implementować interfejsu Iterator. Jeśli jednak tworzone metody 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 widoczności — publiczny, pakietu, chroniony oraz prywatny — przekazuje pewne informacje 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 funkcjonalności zewnętrznym użytkownikom, a drugim potrzeba zachowania elastyczności na wypadek przyszłych zmian. Im więcej metod zostanie ujawnionych, tym trudniej będzie zmienić interfejs obiektu, jeśli w przyszłości pojawi się taka konieczność. Podczas tworzenia JUnit często spieraliśmy się z Erichem Gammą o widoczność metod. Moje 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 najmniejszej liczby metod. Powoli także zaczynam zgadzać się z tym punktem widzenia.

WIDOCZNOŚĆ METODY

Przy określaniu widoczności metod konieczne jest zachowanie równowagi między dwoma potencjalnymi kosztami. Pierwszym z nich jest koszt zapewnienia elastyczności na przyszłość. Niewielki interfejs sprawi, że wprowadzanie zmian będzie dużo łatwiejsze. Drugim kosztem, który należy uwzględnić, jest koszt wywoływania obiektów. Zbyt mały interfejs sprawi, że korzystanie z obiektów będzie wymagało większego nakł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 zaczynają być wywoływane przez kod, nad którym nie mamy kontroli. Wówczas trzeba spekulować i na podstawie tych spekulacji określać, które metody mają 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. ■ Metody publiczne — deklarując metody jako publiczne, wyrażamy przekonanie, że mogą one być użyteczne poza pakietem, w którym zostały zadeklarowane. Oznacza to także, że przyjmujemy odpowiedzialność za ich utrzymanie, bądź to przez powstrzymanie się od ich modyfikacji, bądź też poprzez wprowadzenie odpowiednich zmian w całym kodzie, który je wywołuje, a w najgorszym razie poprzez poinformowanie 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 next() będzie widoczna dla klientów. ■ Metody dostępne w pakiecie — tworzenie metod dostępnych w pakiecie stanowi komunikat, że przydadzą się obiektom wchodzącym w skład tego pakietu, a jednocześnie że nie mamy zamiaru udostępniać ich obiektom spoza pakietu. Ten komunikat jest dosyć dziwny — takie metody mogą być potrzebne innym obiektom, lecz nie wszystkim, a jedynie napisanym przez twórcę metody. Występowanie tego rodzaju widoczności należy potraktować jako informację bądź to o konieczności przeniesienia 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. ■ Metody 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

ROZDZIAŁ 8

METODY

■ Metody prywatne — metody prywatne są najlepsze w celu zachowania elastyczności, gdyż dają pewność, że będzie można odszukać i zmodyfikować wszystkie miejsca kodu, w których są one wywoływane, i to niezależnie od tego, czy nasz kod został 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. Metody należy udostępniać powoli, zaczynając od najbardziej restrykcyjnego dostępu, jaki można zastosować dla danej metody, i zwiększając ich widoczność 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 widocznoś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 (final) przypomina nieco określanie ich widoczności. Stanowi sygnał, że choć nie mamy nic przeciwko temu, by inni programiści używali naszych metod, to jednak nie chcemy, by ktokolwiek je zmieniał. Jeśli niezmienniki 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 programistom 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 final, 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 (static) 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). Metody statyczne są w pewnym stopniu ograniczone, gdyż nie mogą korzystać ze stanu obiektów, a zatem nie za dobrze nadają się do implementacji jakiejś złożonej logiki. Metody 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 zastosowań metod statycznych jest wykorzystanie ich jako zamiennika konstruktorów.

Obiekt metody To jeden z moich ulubionych wzorców, zapewne dlatego, że używam go bardzo rzadko, 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ś nieprawdopodobnie dużej metodzie w czytelny i przejrzysty kod, który stopniowo będzie ujawniał

OBIEKT METODY

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 parametrów i zmiennych tymczasowych. Próby wydzielenia jakiegokolwiek fragmentu takiej metody zakończyłyby się zapewne powstaniem kolejnej metody z długą listą parametró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ż jak na razie nie ma narzędzi, które byłyby w stanie przeprowadzić taką refaktoryzację automatycznie): 1. Utworzyć klasę o nazwie zgodnej z nazwą metody. Na przykład metoda complex Calculation() 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 parametrami będą parametry wywołania oryginalnej metody oraz pola używanych w niej oryginalnych obiektów. 4. Skopiować kod oryginalnej metody i umieścić go w jakiejś metodzie (na przykład: calculate()) nowej klasy. Parametry, zmienne lokalne oraz pola używane w oryginalnej 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ę calculate(). 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 wywołaniu metody calculate(): 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. Można go dzielić na mniejsze metody, i to bez konieczności przekazywania do nich jakichkolwiek parametró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 przekształ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

METODY

trudno wydzielić, teraz staną się użytecznymi metodami pomocniczymi 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ływania 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 wykonywanych obliczeń, niemniej jednak wszystkie metody z wyjątkiem sfinalizowanych dają możliwość wyrażenia odmienności od istniejących obliczeń. Odpowiednie 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ą wykluczającą. Można wywołać metodę zdefiniowaną w klasie pochodnej, jak również jej wersję z klasy bazowej — wystarczy użyć wywołania o postaci super.metoda(). W ten sposób można wywoływać tylko metodę o tej samej nazwie. Jeśli klasa potomna jawnie 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 konieczność 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 nami pewien dylemat: czy kopiować ich kod do klasy pochodnej i modyfikować go, czy też spróbować odnaleźć inny sposób wyrażenia odmienności? Problem z kopiowaniem polega na tym, że w przyszł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, przekazujemy 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 (String)

TYP WYNIKOWY METODY

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 przekazać 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 konwertowania 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ę metodę?”, 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. Wszystkie 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 metodach 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 mieć różne nazwy.

Typ wynikowy metody Typ wynikowy metody przede wszystkim informuje, czy dana metoda działa jako procedura 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 potrzebne ż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 umożliwia 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 Collection zamiast List 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. Moż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 powinny mieć pewien wspólny interfejs. Wówczas dzięki zadeklarowaniu wspólnego interfejsu (o ile będzie to konieczne) i zastosowaniu go jako typu wynikowego wszystkich metod możemy pomóc czytelnikom zrozumieć podobieństwo między metodami.

99

100

ROZDZIAŁ 8

METODY

Komentarz do metody Warto starać się przekazywać możliwie dużo informacji poprzez wybierane nazwy oraz strukturę kodu. Z kolei komentarze należy stosować, by wyrażać informacje, 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ć komentarze dokumentujące języka Java. W kodzie napisanym z myślą o komunikacji wiele komentarzy jest nadmiarowych. W takich przypadkach korzyści, jakie daje komentarz, nie równoważą kosztów jego napisania oraz zapewnienia jego zgodności z kodem. Większość komentarzy 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 umieścić komentarz? Komentarze trzeba aktualizować niezależnie od kodu, a oprócz tego nie ma żadnych jawnych sygnałów, że komentarze przestają być aktualne. Informacje, których nie można po prostu umieszczać w komentarzach, 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łaszcza jeśli będziemy je pisać jeszcze przed rozpoczęciem implementacji. Jeśli testy zostaną 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. Niemniej jednak najważniejszą wartością w tych wszystkich wzorcach implementacyjnych jest komunikacja. Jeśli komentarz do metody jest najlepszym medium w komunikacji, to taki dobry komentarz 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ą metodami pomocniczymi. Ich przeznaczeniem jest poprawienie przejrzystości złożonych obliczeń poprzez ukrycie chwilowo nieistotnych szczegółów i zapewnienie programiście możliwości wyrażenia swoich intencji w nazwie metody. Metody pomocnicze 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ę pomocniczą, 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 zastosowanie 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

Metody pomocnicze zazwyczaj są krótkie, choć może się zdarzyć, że będą zbyt krótkie. Na przykład dziś usunąłem z kodu metodę pomocniczą, której jedynym zadaniem było zwracanie nowego obiektu konkretnej klasy. Uważ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 potomnych był przeciążany sposób wyznaczania konstruktora. Metody pomocnicze należy eliminować (przynajmniej tymczasowo), jeśli ich logika staje się mało czytelna. W takim przypadku cały kod metody pomocniczej należy przenieść z powrotem w miejsce, w którym jest ona wywoływana, od nowa przeanalizować logikę i podzielić ją na metody inaczej, bardziej sensownie. Ostatnim powodem tworzenia metod pomocniczych jest eliminacja często powtarzających się podwyrażeń. Jeśli metoda pomocnicza jest wywoływana we wszystkich miejscach klasy, w których potrzebne jest wykonanie pewnych obliczeń, to modyfikacja używanego wyrażenia jest całkiem łatwa. Jeśli ten sam wiersz (lub grupa wierszy) powtarza się w kodzie obiektu, to tracimy możliwość poinformowania o jego przeznaczeniu 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 Object jest stosunkowo niewielki — składa się jedynie z jedenastu metod. Jedna z nich — toString() — 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ń jednocześnie. Jednak takie kompromisy rzadko kiedy są skuteczne. To, co na temat obiektu będą chcieli wiedzieć makler zajmujący się obrotem obligacjami, programista oraz baza danych, w każdym z tych przypadków będzie czymś innym. Wyświetlanie odpowiednio dobranych informacji może być bardzo użyteczne. Uzyskanie ważnych wewnętrznych informacji o obiekcie może wymagać pół minuty klikania. Wystarczy jednak wyświetlić te same szczegóły w metodzie toString(), a okaże się, że identyczne informacje 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. Podczas intensywnych sesji debugowania zachowanie koncentracji może zaoszczędzić minuty, a nawet godziny. Ponieważ metoda toString() jest publiczna, może być nadużywana. Zdarza się, że jeśli obiekt nie udostępnia potrzebnego protokołu, to użyteczne informacje na jego temat są pobierane w trakcie analizy treści generowanych przez metodę toString().

101

102

ROZDZIAŁ 8

METODY

Jednak takie rozwiązanie jest w dużym stopniu narażone na błędy, gdyż programiś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ę toString() warto przesłaniać, kiedy z myślą o programistach chcemy zapewnić możliwość wyświetlania informacji o obiekcie. Generację innych tekstowych reprezentacji obiektu można implementować w formie 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żenie intencji programisty. Niemniej jednak istnieją pewne techniczne czynniki, które mają wpływ na to, co może być najbardziej efektywnym sposobem wyrażenia konwersji. 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 rozwią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 implementacja konwersji. Czasami może ona polegać na utworzeniu nowego obiektu innego typu i skopiowaniu do niego informacji 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 obiekcie źródłowym i docelowym i korzystanie z niego w kodzie.

Metoda konwertująca Jeśli istnieje konieczność wyrażenia konwersji między obiektami 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 biegunowe. W celu zaimplementowania metody konwertującej można by zdefiniować następującą metodę: class Polar { Cartesian asCartesian() { ... } }

KONSTRUKTOR KONWERTUJĄCY

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 uzyskanie 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. Metody 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 mieć 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 konwersji stają się niewygodne, jeśli liczba potencjalnych konwersji jest bardzo duża. Klasa z 20 różnymi metodami typu asTo() i asTamto() staje się mało czytelna. Alternatywnym 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 konwertujących, a robię to wyłącznie w sytuacjach, gdy konwertowane są obiekty podobnych typów. We wszystkich pozostałych sytuacjach wyrażam konwersję przy użyciu konstruktora 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 File 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, zapisywania oraz usuwania plików. Choć zapewne całkiem wygodnie byłoby móc skorzystać z metody String.asFile(), to jednak liczba takich konwersji mogłaby być ogromna; dlatego też lepszym rozwiązaniem jest tworzenie konstruktorów konwertujących, takich jak: File(String name), URL(String spec) czy StringReader(String contents). Gdyby nie one, w klasie String mogłaby się pojawić niczym nieograniczona liczba metod konwertujących. Jeśli potrzebna jest nieskrępowana możliwość implementacji konwersji poprzez zwracanie czegoś innego niż obiekt konkretnej klasy, to można wyrazić konstruktor konwertują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 danych, które trudno było od siebie odróżnić. Sterowanie mogło być przekazywane pomiędzy dowolnymi miejscami tych programów. Dostęp do danych był możliwy z każdego

103

104

ROZDZIAŁ 8

METODY

ich miejsca. Obliczenia, stanowiące pierwszy powód opracowania komputerów, były wykonywane z ogromną szybkością (oczywiście relatywnie) oraz perfekcyjną dokładnością. Jednak zauważono pewien dziwny fakt: programy były pisane zarówno po to, by z nich korzystać, jak i po to, by je modyfikować. Wszystkie 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 gdzie 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 komputerów (obiektów) wykonujących małe programy. Obiekty ułatwiały przyszłe zmiany, dostarczając horyzontu zdarzeń, po którego przekroczeniu modyfikowanie programu 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. Komputery działają tak samo, niezależnie od tego, czy program stanowi jedną ogromną 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 momencie 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. Wzorce implementacyjne związane z tworzeniem reprezentują techniki pozwalające na wyrażenie zmienności w tworzeniu obiektów.

Kompletny konstruktor Obiekty do działania potrzebują określonych informacji. Te wymagania wstępne obiektó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 obiektó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);

Większą elastyczność zapewnia jednak wykorzystanie konstruktora bezargumentowego oraz odpowiedniej sekwencji metod ustawiających. Takie rozwiązanie pozwala też poprawnie wyrazić, jaka kombinacja parametrów jest niezbędna do prawidłowego działania obiektu. Rectangle box= new Rectangle(); box.setLeft(0); box.setWidth(50);

METODA WYTWÓRCZA 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órczej. Jednak nawet w takim przypadku warto udostępnić kompletny konstruktor, tak by zainteresowani czytelnicy mogli się szybko zorientować, jakie parametry są niezbędne do utworzenia obiektu. Gdy implementujemy kompletny konstruktor, wszystkie konstruktory powinny odwoływać się do jednego konstruktora głównego, który będzie odpowiadał za zainicjowanie 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ą zmodyfikować klasę.

Metoda wytwórcza Alternatywnym sposobem utworzenia obiektu jest utworzenie w klasie odpowiedniej metody statycznej. W porównaniu z konstruktorami 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 implementację jakiegoś interfejsu), a oprócz tego mogą mieć nazwy wyrażające intencje, a nie odpowiadające nazwie klasy. Niemniej jednak wprowadzenie metod wytwórczych powoduje wzrost złożoności kodu, dlatego warto je stosować, kiedy faktycznie dają konkretne korzyści, a nie jedynie dla zasady. Utworzenie obiektu Rectangle przekształcone do postaci metody wytwórczej mogł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 umieszczenie 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 moją 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 metody wytwórczej, by zwrócić na ten fakt uwagę czytelników.

105

106

ROZDZIAŁ 8

METODY

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óżnymi obiektami wytwórczymi tworzącymi obiekty służące do wykonywania wywołań systemowych.

Fabryka wewnętrzna Co można zrobić w sytuacji, gdy utworzenie obiektu pomocniczego jest operacją prywatną, a jednocześnie jest problemem złożonym lub może się zmienić w klasie pochodnej? 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() { if (x == null) x= ...; return x; }

Jak na jedną metodę podaje ona całkiem sporo informacji. Jeśli obliczenie zmiennej x jest bardzo skomplikowane, to przeniesienie go do fabryki wewnętrznej może być sensownym i opłacalnym rozwiązaniem: getX() { if (x == null) x= computeX(); return x; }

Oprócz tego fabryki wewnętrzne zapewniają możliwość wprowadzania usprawnień w klasach pochodnych. Można ich używać także do wyrażania obliczeń, które korzystają 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 zapewnić 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. Może doprowadzić do unieważnienia wewnętrznego stanu

METODA DOSTĘPU DO KOLEKCJI

obiektu, zależnego od zawartości tej kolekcji. Oprócz tego zapewnienie ogólnego dostępu do kolekcji przekreśla okazję do stworzenia w obiektach bogatego i zrozumiałego 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ł kolekcję 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 dostęp do informacji 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: Iterator getBooks() { return books.iterator(); }

Takie rozwiązanie w zasadzie uniemożliwia wprowadzanie zmian w zawartości kolekcji, choć pozostawia możliwość skorzystania z nieznośnej metody remove() interfejsu Iterator. 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 informacje 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

ROZDZIAŁ 8

METODY

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 zadań 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 przechowywanego w formie wartości logicznych? Najprostszym rozwiązaniem będzie udostę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 false, to istnieje możliwość udostępnienia lepszego, bardziej ekspresyjnego interfejsu — polega ona na zdefiniowaniu dwóch metod, z których każda będzie służyć do zapisywania jednej wartoś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: ... if (...wyrażenie_logiczne...) cache.valid(); else cache.invalid();

to dwie metody — valid() oraz invalid() — należy zastąpić jedną metodą setValidity (boolean).

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: jest lub był bądź w wersji angielskiej is lub was) lub mieć. 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:

METODA RÓWNOŚCI if (widget.isVisible()) 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 zazwyczaj 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 dlatego, ż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 podczas określania równości. Na przykład podczas pisania oprogramowania finansowego może się pojawić konieczność operowania na instrumentach finansowych dysponujących numerami seryjnymi. W takim programie metoda używana do testowania równości mogłaby przyjąć postać: Instrument public boolean equals(Object other) { if (! other instanceof Instrument) return false; Instrument instrument= (Instrument) other; return getSerialNumber().equals(instrument.getSerialNumber()); }

Warto 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 zakończy się błędem, taką instrukcję warunkową można usunąć i pozwolić, by w razie czego został zgłoszony wyjątek ClassCastException. Ewentualnie wewnątrz instrukcji warunkowej można zgłaszać wyjątek IllegalArgumentException. Ponieważ numer seryjny jest jedyną informacją używaną podczas sprawdzania równości obiektów, jest to także jedyna informacja, która powinna być wykorzystywana do wyznaczania wartości skrótu:

109

110

ROZDZIAŁ 8

METODY

Instrument public int hashCode() { return getSerialNumber.hashCode(); }

Warto zauważyć, że w przypadku niewielkich zbiorów danych także 0 stanowi akceptowalną 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 tamtych czasów przedstawiał dwie osoby siedzące przy kontuarze. Pierwsza z nich mówi do serwera: „Chcę mieć to, co on”, a w odpowiedzi serwer chwyta talerz drugiej osoby i stawia przed pierwszą. Stosowane obecnie metody equals() oraz hashCode() są szczątkowymi pozostałościami po tamtych sposobach określania równości. Aby ich używać, trzeba się stosować 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 wykorzystanie takiego rozwiązania pozwala na przykład tworzenie obiektów w metodzie wytwórczej: Instrument static Instrument create(String serialNumber) { if (cache.containsKey(serialNumber)) return cache.get(serialNumber); Instrument result= new Instrument(serialNumber); cache.put(serialNumber, result); return result; }

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 konwencja jest pewną formą metadanych. Niegdyś starałem się nadawać metodom pobierającym nazwy odpowiadające zmiennym, których wartości metody te zwracał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.

METODA USTAWIAJĄCA

kod uważają, że nazwa „getX” jest łatwiejsza do zrozumienia niż „x”, to uznałem, że niezależnie od moich osobistych preferencji lepiej będzie pisać kod zgodny z ich oczekiwaniami. Sposób, w jaki należy implementować metody pobierające, nie jest ani równie interesują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 pakietu 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 mojej 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 implementacja zwraca wartość pola. I w końcu jeśli metody pobierające będą używane przez jakieś narzędzia, to zapewne także będą musiały być zadeklarowane jako publiczne. Wewnę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 usprawnienia 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 mnie chodzi, jestem jeszcze większym przeciwnikiem tworzenia publicznie dostępnych metod ustawiających niż metod pobierających. Ich nazwy są określane na podstawie implementacji, a nie intencji. Jeśli jakiś użyteczny fragment interfejsu najlepiej jest zaimplementować poprzez ustawienie wartości pola, to nic w tym złego, niemniej jednak nazwa takiej operacji powinna być określana z perspektywy kodu klienta. 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 rozpowszechnienia informacji o implementacji: paragraph.setJustification(Paragraph.CENTERED);

Natomiast stosowanie w interfejsie nazw odpowiadających przeznaczeniu metod pozwala na komunikowanie intencji: paragraph.centered();

111

112

ROZDZIAŁ 8

METODY

choć sama implementacja metody centered() może korzystać z metody ustawiającej: Paragraph:centered() { setJustification(CENTERED); }

Metody ustawiające stosowane wewnętrznie (czyli prywatne lub chronione) mogą być bardzo użyteczne, na przykład kiedy trzeba zaktualizować jakieś dane zależne. Kontynuują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 setJustification(...) { ... redisplay(); }

Takie zastosowanie metod ustawiających można uznać za prosty mechanizm zależności, który zapewnia, że jeśli te dane się zmienią, to tamte zależne od nich informacje zostaną odpowiednio zaktualizowane (w podanym przykładzie będą to wewnętrzne ustawienia akapitu oraz informacje 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 wewnętrznej reprezentacji obiektu B, to zmiana kodu obiektu B spowoduje konieczność zmiany kodu obiektu A, i to nie dlatego, że obiekt ten zmienił się w jakiś kluczowy sposób, lecz jedynie dlatego, że zmieniły się założenia, na podstawie których był tworzony. 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 pobierających. Jednak pisząc kod z myślą o innych programistach, warto zapewnić bardziej komunikatywny i modularny interfejs.

Bezpieczna kopia Korzystając z metod pobierających i ustawiających, potencjalnie narażamy się na problemy z utożsamianiem nazw, polegające na tym, że dwa obiekty mogą przyjąć, iż dysponują 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 takimi problemami 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 result; }

BEZPIECZNA KOPIA

W tym przypadku lepszym rozwiązaniem byłoby pewnie udostępnienie metod dostępu do kolekcji. Niemniej 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 zaimplementowana 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 implementacji. Znacznie prostszym, bardziej komunikatywnym, a jednocześnie mniej 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

114

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 dokumentację API — typy i operacje. Sama idea jest bowiem bardzo prosta: kolekcja odróżnia obiekty, które są w niej umieszczone, od tych, których w niej nie ma. Trudno powiedzieć na ten temat 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 korzystania 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 opanowanie posługiwania się kolekcjami jest ważnym elementem umiejętności dobrego komunikowania się przy wykorzystaniu kodu. Niegdyś zachowania przypominające kolekcje były implementowane poprzez udostępnianie połączeń wewnątrz samej struktury danych: każda strona dokumentu zawierała połączenia z innymi stronami — poprzednią i następną. Ostatnio moda się zmienił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 kolekcjach, bez konieczności modyfikowania jego zawartości. Kolekcje są ważne, gdyż stanowią sposób wyrażania jednej z podstawowych zmienności występujących w programowaniu: zmiennej liczby elementów. Zmienność logiki jest wyrażana przy użyciu konstrukcji warunkowych oraz komunikatów polimorficznych. Zmienność ilości danych jest natomiast wyrażana poprzez umieszczanie ich w kolekcjach. Konkretne szczegóły działania kolekcji ujawniają bardzo wiele informacji, jakie jej twórca miał dla czytelników. Istnieje stare (w kategoriach informatyki) powiedzenie, że jedynymi interesującymi liczbami są zero, jeden oraz wiele (zapewne nie wymyślił go żaden matematyk 115

116

ROZDZIAŁ 9

KOLEKCJE

analityk). Jeśli brak pola potraktujemy jako reprezentację „zera”, a istnienie pola jako reprezentację „jedynki”, to pole zawierające kolekcję będzie reprezentowało trzecią możliwość — „wiele”. Niemniej jednak kolekcje egzystują w dziwnym świecie, gdzieś pomiędzy konstrukcjami 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 napisać coś takiego: plural unique Book books;. Jednak póki kolekcje nie staną się pełnoprawnymi elementami języka, ważne jest, by wiedzieć, jak korzystać z istniejących bibliotek 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, implementacje kolekcji oraz to, co one wyrażają, przegląd funkcji klasy Collection 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. Można na to spojrzeć tak, że przestajemy traktować kolekcję jako niezależny obiekt. Tożsamość kolekcji nie ma znaczenia — liczą się tylko obiekty, do których kolekcja się odwołuje. Podobnie jak w przypadku wszystkich innych zmiennych, do takiej zmiennej mogącej zawierać wiele wartości można przypisać inną zmienną (czyli dodawać lub usuwać elementy kolekcji), można pobierać jej wartość oraz przesyłać do niej komunikaty (w pętli for). W języku Java metafora 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ą metaforą kolekcji są obiekty — kolekcja jest obiektem. Można pobrać kolekcję, 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 implementacji. A zatem kolekcje tak samo „są” zmiennymi mogącymi zawierać wiele wartości, jak i obiektami. Połączenie tych dwóch metafor daje dosyć dziwne efekty. Ponieważ kolekcje są implementowane jako obiekty, można je przekazywać; uzyskujemy w ten sposób odpowiednik 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

ZAGADNIENIA

referencję przestało być modne jakiś czas temu ze względu na wiążące się z nim ryzyko występowania niezamierzonych konsekwencji. Trudno jest debugować programy, kiedy nie można wskazać wszystkich miejsc, w których zmienna może się zmienić. Opracowano pewne konwencje programowania z wykorzystaniem kolekcji, pozwalające unikać sytuacji utrudniających analizę kodu oraz określanie miejsc, w których kolekcje mogą być modyfikowane. Następną metaforą pomocną 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 size() kolekcji) oraz sprawdzanie, czy zbiór zawiera określony element (której odpowiada metoda contains() kolekcji). W odniesieniu do kolekcji metafora zbioru matematycznego jest jedynie pewnym przybliżeniem. Kolekcje nie udostępniają bowiem bezpośrednio innych podstawowych 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ć temat 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ółowego 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 JUnit i uogólniłem wszystkie deklaracje zmiennych. W rezultacie powstał spory bałagan, gdyż w kodzie zabrakło jednorodności. Zamieszanie wynikające z tego, że ten sam obiekt był w jednym miejscu zadeklarowany jako Iterable, w innym jako Collection, a w jeszcze innym jako List, 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 List. Pierwszym pojęciem wyrażanym przez kolekcje jest ich wielkość. Tablice (będące prymitywnymi kolekcjami) mają konkretną wielkość określaną w momencie ich tworzenia. Większość kolekcji pozwala na zmienianie wielkości po utworzeniu. Drugim 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żytkownikó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

ROZDZIAŁ 9

KOLEKCJE

elementy były dodawane do kolekcji, może też być określane przez jakieś czynniki zewnę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 takie, 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 podstawie pewnego klucza. I w końcu wybór takiej, a nie innej kolekcji może być środkiem wyrazu informującym o uwarunkowaniach związanych z wydajnością działania. Jeśli wyszukiwanie liniowe sprosta naszym wymaganiom, to z powodzeniem będziemy mogli skorzystać z ogólnej klasy Collection. Jeśli jednak kolekcja będzie dostatecznie duża, to większego znaczenia nabierze możliwość sprawdzania istnienia elementów oraz pobierania ich przy użyciu klucza, to z kolei będzie sugerowało zastosowanie klas Set lub Map. A zatem przemyślany wybór kolekcji pozwala zoptymalizować zarówno szybkość korzystania 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 poprawianie wydajności działania było codziennym zajęciem. Niemniej jednak zasoby dostępne dla programów nie są nieograniczone. Kiedy doświadczenie pokaże konieczność poprawienia wydajności, a pomiary wykażą występowanie wąskich gardeł, to bardzo dużego znaczenia nabierze zrozumiałe wyraż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 miar jakości kodu, na przykład jego czytelność lub elastyczność. Waż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 moż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 ArrayList w innym miejscu programu może doprowadzić do poważnego spadku jej wydajności. Pojawianie się konsekwencji w odległych miejscach kodu jest kolejnym argumentem, by w przypadkach, gdy wydajność ma kluczowe znaczenie, zachować dużą ostrożność podczas tworzenia kodu.

INTERFEJSY

Zagadnienie wydajności wiąże się z kolekcjami, 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 milionów znaków. Chciałbym, żeby wpisanie milionowego znaku było równie szybkie jak pierwszego. Moja ogólna strategia zapewniania wydajności w przypadku korzystania z kolekcji polega na tym, by początkowo wybierać możliwie jak najprostszą implementację, a ewentualnie później, kiedy stanie się to niezbędne, sięgać po bardziej wyspecjalizowane klasy. Podejmując decyzje związane z wydajnoś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 dostatecznie dobra, przerywam usprawnianie kodu.

Interfejsy Patrząc na interfejsy zastosowane w deklaracjach zmiennych oraz wybierane implementacje, osoby analizujące kod korzystający z kolekcji będą poszukiwały odpowiedzi na różne pytania. Użyte deklaracje interfejsów przekazują informacje na temat kolekcji: czy uwzględnia ona kolejność elementów, czy mogą w niej występować powtarzające się elementy oraz czy istnieje możliwość pobrania elementu kolekcji na podstawie klucza, czy też trzeba pobierać wszystkie elementy w pętli. Oto dostępne interfejsy kolekcji: ■ Klasa Array — tablice są najprostszymi i najmniej elastycznymi kolekcjami, posiadają ściśle określoną liczbę elementów, prostą składnię odwołań i dużą szybkość działania. ■ Iterable — to prosty interfejs kolekcji, pozwalający na dostęp do ich zawartości wyłącznie poprzez wyliczanie. ■ Collection — ten interfejs zapewnia możliwość dodawania i usuwania elementów oraz sprawdzania, czy podany element jest dostępny w kolekcji. ■ List — 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 (innymi słowy, można stwierdzić: „Proszę o trzeci element kolekcji”). ■ Set — reprezentuje kolekcje, których elementy nie mogą się powtarzać. ■ SortedSet — 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

ROZDZIAŁ 9

KOLEKCJE

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 tworzenia. Odróżnia je także to, że są wbudowanym elementem języka, a nie elementem biblioteki klas. Jeśli chodzi o proste operacje, to tablice są bardziej efektywne niż inne rodzaje kolekcji, 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 elements[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óżnicami 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 Iterable 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 Iterable może być zastosowany w pętli for. Implementacja tego rozwiązania w niedostrzegalny sposób bazuje na wywoływaniu metody iterator(). 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 Iterable, ani wspomagający go interfejs Iterator nie zapewniają deklaratywnego przekazania informacji, czy kolekcja nie powinna być modyfikowana. Jeśli dysponujemy obiektem Iterable i użyjemy go do pobrania obiektu Iterator, to bez przeszkód będziemy mogli wywołać metodę remove(), by usuwać elementy z kolekcji. Choć interfejs Iterable 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 powiadomiony o żadnych zmianach w jej zawartości. Zgodnie z informacjami 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.

INTERFEJSY

Interfejs Iterable jest prosty — nie dopuszcza nawet możliwości sprawdzenia liczby elementów w kolekcji, jedyne, na co pozwala, to pobieranie jej kolejnych elementów. Bardziej użyteczne możliwości dają natomiast jego interfejsy pochodne.

Interfejs Collection — kolekcje Interfejs Collection dziedziczy po Iterable, wzbogacając jego możliwości o operacje dodawania, usuwania, wyszukiwania elementów oraz określania ich liczby. Zadeklarowanie zmiennej lub metody typu Collection daje bogate możliwości wykorzystania wielu klas implementujących. Stosując możliwie najbardziej ogólną deklarację, zapewniamy sobie możliwość późniejszej zmiany użytej klasy implementującej, bez konieczności ponoszenia konsekwencji tej zmiany w innych miejscach kodu. Kolekcje przypominają nieco matematyczne pojęcie zbioru, z tą różnicą, że operacje stanowiące odpowiedniki działań na zbiorach — sumy, przecięcia oraz różnicy (addAll(), retainAll() oraz removeAll()) — zmieniają istniejący obiekt kolekcji, zamiast zwracać nową kolekcję.

Interfejs List — listy Interfejs List dodaje do możliwości interfejsu Collection ideę stabilnego uporządkowania elementów. Element można pobrać na podstawie jego indeksu w kolekcji. Stabilne uporządkowanie jest ważne, gdy między poszczególnymi elementami kolekcji występują jakieś interakcje. Na przykład kolejka wiadomości, które należy przetwarzać w kolejności ich odbierania, powinna zostać zaimplementowana 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 equal() są identyczne). Takie kolekcje dosyć dobrze odpowiadają matematycznemu pojęciu zbioru, choć metafora nie jest najlepsza, gdy dodawanie elementu do kolekcji tego typu modyfikuje samą kolekcję, a nie powoduje zwrócenia nowej, zawierającej dodawany element. Kolekcje typu Set całkowicie ignorują pewną informację, przechowywaną przez inne rodzaje kolekcji — liczbę elementów dostępnych w kolekcji. Nie stanowi to większego problemu, gdy interesujący jest sam fakt występowania lub braku elementu, a nie ile razy dany element występuje w kolekcji. Na przykład gdyby interesowały mnie nazwiska wszystkich autorów, których książki znajdują się w bibliotece, to informacje 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 Set nie są uporządkowane. Fakt, że podczas ich pobierania są zwracane w określonej kolejności, nie oznacza wcale, że następnym razem kolejność ta będzie identyczna. Brak przewidywalnego uporządkowania

121

122

ROZDZIAŁ 9

KOLEKCJE

zawartości kolekcji nie jest problemem 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 Set 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ę cechami kolekcji. Może się zdarzyć, że będziemy chcieli zachować uporządkowanie kolekcji, 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 List, które bazuje na kolejności dodawania elementów lub jawnie określonych indeksach przekazanych w wywołaniu metody add(int, Object), uporządkowanie oferowane przez interfejs SortedSet bazuje na komparatorach (Comparator). W razie braku jawnego uporządkowania stosowany jest „naturalny porządek” elementów. Na przykład łańcuchy znaków będą zapisywane w kolejności leksykograficznej. Oto, w jaki sposób można użyć kolekcji typu SortedSet w celu wyznaczenia autoró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 results; }

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 jakiegoś innego typu niż String, to powyższy kod musiałby wyglądać tak: public Collection getAlphabeticalAuthors() { Comparator sorter= new Comparator() { public int compare(Author o1, Author o2) { if (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 results; }

IMPLEMENTACJE

Interfejs Map — mapy Ostatnim interfejsem kolekcji jest Map. Stanowi on hybrydę łączącą pozostałe interfejsy. Mapy przechowują wartości skojarzone z kluczami, lecz inaczej niż w interfejsie List 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 jak w zbiorach. Ponieważ interfejs Map nie do końca przypomina którykolwiek z pozostałych interfejsó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ść. Mapy przydają się podczas implementacji 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 sposobów implementacji 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 zaimplementować takie rozwiązanie, obiekt może zawierać mapę, która będzie odwzorowywać łańcuchy znaków (reprezentujące nazwy wirtualnych pól) na wartości.

Implementacje Wybór jednej z dostępnych implementacji kolekcji jest uzależniony przede wszystkim od wydajności. A jak to zwykle bywa w przypadkach związanych z wydajnością, najlepiej jest zacząć od wybrania prostej implementacji, a dopiero potem zmieniać ją na podstawie zebranych doświadczeń. Każdy z interfejsów przedstawionych w tej części rozdziału posiada jeszcze inne implementacje. Ponieważ dobór klas implementujących kolekcje jest kwestią wydajnoś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 ArrayList, choć niewielka ich część korzysta z klasy HashSet (w Eclipse i JDK występuje około 3400 odwołań do klasy ArrayList 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 wskazuje, że wydajność ma duże znaczenie, można wybrać jedną z możliwych implementacji, 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 informacje o wydajności działania były zbierane na kolekcjach liczących 100 tysięcy

123

124

ROZDZIAŁ 9

KOLEKCJE

Rysunek 9.1. Interfejsy i klasy kolekcji

elementów. Jeśli kolekcja ma zawierać jedynie jeden element lub dwa, to zapewne wybrana zostanie inna klasa niż wtedy, gdy oczekujemy, że może się w niej pojawić milion elementów. Niemniej jednak korzyści, które można uzyskać, zmieniając używaną implementację kolekcji, stosunkowo często są ograniczone i jeśli konieczne jest uzyskanie bardziej znaczącej poprawy wydajności, niezbędne może się okazać wprowadzenie poważniejszych zmian w zastosowanym algorytmie.

Implementacje interfejsu Collection Domyślną klasą używaną jako implementacja interfejsu Collection jest ArrayList. Stosowanie jej wiąże się jednak z pewnym problemem z wydajnością. Polega on na tym, że czas wykonania metody contains(Object) oraz wszelkich innych metod, które na niej bazują, takich jak remove(Object), rośnie proporcjonalnie wraz z powiększaniem się zawartości kolekcji. Jeśli profil wydajnościowy pokazuje, że metody te stanowią wąskie gardło, to warto zastanowić się nad zastąpieniem klasy ArrayList klasą HashSet. Jednak przed podjęciem takiej decyzji trzeba się upewnić, że używanemu algorytmowi 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 ArrayList oraz HashSet. (Więcej informacji na temat sposobu, w jaki uzyskałem te dane, można znaleźć w dodatku A).

IMPLEMENTACJE

Rysunek 9.2. Porównanie kolekcji ArrayList oraz HashSet jako implementacji interfejsu Collection

Implementacje interfejsu List W stosunku do interfejsu Collection interfejs List został wzbogacony o ideę stabilnego porządku, w jakim występują elementy kolekcji. Dwoma najczęściej używanymi implementacjami interfejsu List są ArrayList oraz LinkedList. Profile wydajnościowe obu tych klas są niemalże lustrzanymi odbiciami. Klasa ArrayList 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 ArrayList.

Implementacje interfejsu Set Najczęściej używanymi implementacjami interfejsu Set są HashSet, LinkedHashSet oraz TreeSet (przy czym ta klasa implementuje właściwie interfejs SortedSet). 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

ROZDZIAŁ 9

KOLEKCJE

Rysunek 9.3. Porównanie wydajności kolekcji ArrayList oraz LinkedList

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 komparator (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 elementu — w przypadku klasy TreeSet 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żliwość pobierania elementów zawsze w tej samej kolejności.

Implementacje interfejsu Map Implementacje interfejsu Map są analogiczne do implementacji interfejsu Set. Najszybsze 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 implementację interfejsu SortedMap) zwraca elementy w kolejności odpowiadającej uporządkowaniu kluczy, jednak koszt dodawania elementów oraz sprawdzania, czy dany element występuje w kolekcji, jest rzędu log n. Porównanie wydajności działania poszczególnych implementacji interfejsu Map zostało przedstawione na rysunku 9.5.

IMPLEMENTACJE

Rysunek 9.4. Porównanie implementacji interfejsu Set

Rysunek 9.5. Porównanie implementacji interfejsu Map

127

128

ROZDZIAŁ 9

KOLEKCJE

Klasa Collections Klasa Collections 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 indexOf() jest wprost proporcjonalny do wielkości listy. Niemniej jednak, kiedy zawartość listy zostanie posortowana, dzięki skorzystaniu z wyszukiwania binarnego możliwe jest uzyskanie czasu wyszukiwania rzędu log2 n. Wywołanie metody Collections.binarySearch(list, element) zwraca indeks elementu listy. Jeśli element nie występuje na liście, metoda ta zwraca wartość mniejszą od zera, jeśli natomiast spróbujemy jej użyć na liście nieposortowanej, to jej wynik będzie nieprzewidywalny. 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 ArrayList (patrz rysunek 9.6).

Rysunek 9.6. Porównanie wydajności metody indexOf() oraz wyszukiwania binarnego

Sortowanie Klasa Collections udostępnia także operacje służące do modyfikowania kolejności elementów na liście. Metoda reverse(list) zmienia kolejność elementów na liście na odwrotną, metoda shuffle(list) zapisuje elementy listy w losowej kolejności, natomiast

KLASA COLLECTIONS

metody sort(list) oraz sort(list, comparator) porządkują elementy listy w kolejności rosnącej. W odróżnieniu od wyszukiwania binarnego wydajność operacji sortowania dla kolekcji ArrayList oraz LinkedList jest mniej więcej taka sama. Wynika 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 danych (wyniki te można sprawdzić, wykonując test Sorting przedstawiony w Dodatku A).

Kolekcje niezmienne Zgodnie z informacjami podanymi w punkcie poświęconym interfejsowi Iterable nawet najprostsze interfejsy kolekcji pozwalają na modyfikowanie zawartości kolekcji. Jednak w przypadku przekazywania kolekcji do kodu, któremu nie można zaufać, można zabezpieczyć się przed jej zmodyfikowaniem poprzez umieszczenie jej w specjalnej implementacji kolekcji, która w razie próby modyfikacji zawartości zgłasza wyjątek. Dostępne są takie klasy implementujące interfejsy Collection, List, Set oraz Map. @Test(expected=UnsupportedOperationException.class) public void unmodifiableCollectionsThrowExceptions() { List l= new ArrayList(); l.add("a"); Collection unmodifiable= Collections.unmodifiableCollection(l); Iterator all= unmodifiable.iterator(); all.next(); all.remove(); }

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 jednoelementową kolekcję, wywołując metodę Collections.singleton(), która zwraca kolekcję typu Set. Dostępne są także inne wersje tej metody, zwracające kolekcje typów List 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= Collections.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 umieścić, klasa Collections udostępnia metody zwracające puste kolekcje, których zawartości nie można modyfikować.

129

130

ROZDZIAŁ 9

KOLEKCJE

@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 ArrayList: class Library extends ArrayList {...}

Ta deklaracja zawiera implementacje metod add() oraz remove(), iteracji oraz innych 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ępnianych 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 podany wcześniej przykład, wywołać metody clear() klasy Library) ani konwertować jej na postać tablicy przy użyciu metody toArray(). W najlepszym razie metafory zostaną pomieszane i staną się mylące, a w najgorszym — trzeba będzie uniemożliwić korzystanie ze wszystkich odziedziczonych metod poprzez zaimplementowanie ich i zgłaszanie 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 problemem 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 tracimy 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 odpowiednio dobrane nazwy. Nic także nie stoi na przeszkodzie, by korzystając z dziedziczenia, użyć takiej implementacji 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 implementujemy nowe klasy kolekcji ogólnego przeznaczenia — klasy, które można by dodać do pakietu java.util. We wszystkich innych przypadkach lepiej będzie utworzyć w klasie pole kolekcji i w niej przechowywać elementy.

WNIOSEK

Wniosek W tym rozdziale zostały przedstawione wzorce związane ze stosowaniem klas kolekcji. Tym samym opisane zostały wszystkie wzorce implementacyjne odnoszące się do języka Java oraz dostępnych w nim kolekcji. Wszystkie te wzorce zaprezentowano pod kątem wykorzystania ich do tworzenia aplikacji, kiedy prostota oraz łatwość komunikacji powodują obniżenie kosztów, lecz jednocześnie kiedy możliwa jest zmiana całego 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 platformy bez konieczności wprowadzania zmian w kodzie aplikacji.

131

132

ROZDZIAŁ 9

KOLEKCJE

Rozdział 10

Rozwijanie platform

Przedstawione wcześniej wzorce implementacyjne zakładały, że zmiana kodu nie jest kosztowna, w odróżnieniu od jego zrozumienia oraz wyrażenia jego intencji. Takie założenie było prawdziwe w przeważającej większości projektów programistycznych, nad którymi pracowałem. Niemniej jednak nie jest ono słuszne w przypadku tworzenia i rozwijania platform, czyli wtedy, gdy kod klienta nie może być modyfikowany przez programistów zajmujących się rozwojem platformy. Na przykład wprowadzenie zmian w JUnit 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 zmieniać swoje kody. Niezgodne aktualizacje są tak kosztowne, że o ile to tylko możliwe, zawsze staramy się ich unikać. Kiedy ostatnio udostępnialiśmy JUnit 4, niemal połowę budżetu projektowego poświęciliśmy na minimalizację kosztów wdrożenia nowej wersji biblioteki przez naszych 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. Staraliśmy się także zagwarantować, że w przyszłości będziemy posiadali możliwość wprowadzania zmian w JUnit, tak aby nie wywołać przy tym błędów w kodzie klientów. Ten rozdział ogólnie pokazuje, jak zmieniają się wzorce implementacyjne w przypadku tworzenia platform. Mowa w nim o zmianach metod tworzenia platform, sposobach minimalizacji wpływu niezgodnych aktualizacji oraz projektowania platform tak, by unikać występowania niezgodnych aktualizacji. Rozwijanie platform bez wywoływania 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 konieczność 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

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

nowe możliwości bez wprowadzania jakichkolwiek zmian 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 momencie 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 minimum jest cenną strategią, pozwalającą zapewnić to, że kod będzie łatwy do zrozumienia. Jednak podczas tworzenia platformy bardziej, z ekonomicznego punktu widzenia, opłaca się zwiększyć jej złożoność, aby umożliwić dalszy rozwój bez wywoływania błędów w kodzie klientów. Chociaż przy tworzeniu platform zgodność odgrywa znacznie większą rolę, to jednak 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ększać 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 implementacyjnych 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 chronionych, 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 rozwijać, a równocześnie na tyle proste, by były użyteczne oraz odpowiednie do zastosowań 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 przygotowane 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,

NIEZGODNE AKTUALIZACJE

co ich czeka, i pozwala im podjąć decyzję o wyborze momentu, w którym zaczną aktualizować 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ępnianiu dwóch różnych architektur służących do rozwiązywania tego samego problemu. Takie równoległe architektury zwiększają złożoność, lecz jednocześnie zmniejszają chaos związany z aktualizacją kodu. Kolekcje dostępne w języku Java są przykładem klas posiadających takie równoległe architektury. W momencie wprowadzenia nowych klas implementujących interfejs Collection starym klasom Vector 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 org.junit.Assert została zaktualizowana poprzez udostępnienie nowej klasy org.junit.newandimproved. Assert, to klienci mogliby z niej skorzystać, zmieniając wyłącznie deklarację importowanych pakietów. Zmienianie samych deklaracji importowanych pakietów jest znacznie mniej inwazyjne i ryzykowne niż zmienianie kodu. Kolejną strategią przyrostowego wprowadzania zmian jest zmienianie API lub implementacji, 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 wszystkim — zarówno klientom, jak i twórcom platformy — przyzwyczaić się do kierunku wprowadzanych zmian. Dzięki temu wszyscy będą mieć 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órcami platformy a korzystającymi z niej klientami 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 gwarantują zachowanie zgodności wyłącznie w ramach wersji o tym samym numerze głównym. Widać 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 szybkiego 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 zapewnienie, że większość wtyczek przeznaczonych dla wersji 2.x będzie działać także w wersji 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

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

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ępowania 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; niemniej jednak na razie nasze opcje ograniczają się do minimalizacji kosztów. Kolejnymi 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 platform. 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 pozwalają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 najmniejszym stopniu zależeć od szczegółów platformy. Jednak musi on zależeć od jakichś szczegółów, gdyż w przeciwnym razie w ogóle nie istniałby powód, by z platformy 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. Można jednak zwiększyć swoje szanse, zmniejszając liczbę widocznych szczegółów, udostępniając szczegóły, których prawdopodobieństwo zmiany jest mniejsze, i użyteczne funkcjonalności przy zachowaniu możliwości zmiany projektu. Jedną z decyzji, jakie należy podjąć, jest to, jakie warianty zgodności chcemy udostę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,

ZACHĘCANIE DO WPROWADZANIA ZGODNYCH ZMIAN

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 decyzji 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 wykorzystania nowej aktualizacji. Niemniej jednak zachowanie zgodności i wstecz, i w przód miało zaskakujące konsekwencje. Większość platform w języku Java ma postać obiektów, które są tworzone, używane i aktualizowane przez klientów. Ta część rozdziału przedstawia, jak można reprezentować platformy, by klienci mogli korzystać z potrzebnych funkcjonalności, a jednocześ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 odseparowania 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 formie nowych metod lub wariantów metod już istniejących. Przykładem API udostępnianego w formie klasy bibliotecznej jest klasa Collections. Klienci korzystają z niej, wywołując metody statyczne, a nie tworząc instancje tej klasy. Nowe wersje tej klasy dodają do niej nowe metody statyczne, a dotychczasowe funkcjonalności pozostawiają w niezmienionej postaci. Podstawowym problemem związanym z reprezentacją API w formie klasy bibliotecznej 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 doprowadzić do gwałtownego wzrostu liczby metod. Co więcej, klienci mogą zmieniać 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órcami 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

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

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 formie 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ź implementację 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 jednocześnie pozwalały na wprowadzanie zmian? Styl stosowania Platformy mogą obsługiwać trzy podstawowe style stosowania: tworzenie obiektów, konfigurację oraz implementację. Każdy z nich zapewnia inną kombinację użyteczności, elastyczności oraz stabilności. Można także łączyć te style w ramach jednej platformy, aby uzyskać lepszą kombinację 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 ServerSocket(). 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 zmienność 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 TreeSet pozwala na przekazanie własnego obiektu Comparator, co umożliwia dowolne sortowanie elementów. Comparator byFirstName= new Comparator() { 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 wprowadzanie zmian nie tylko w danych, lecz także w logice. Daje jednak mniejszą swobodę projektantom platformy, ponieważ kiedy zacznie już korzystać z obiektów przekazywanych przez klienta, będzie musiała to robić dalej w ten sam sposób i w tych

ZACHĘCANIE DO WPROWADZANIA ZGODNYCH ZMIAN

samych momentach, gdyż w przeciwnym razie zaistnieje ryzyko, że kod klienta przestanie działać. Kolejne ograniczenie tego stylu stosowania platform wiąże się z faktem, że udostępnia on jedynie kilka wymiarów wprowadzania zmienności. Dany 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ż dostarcza 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 implementuje 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 implementacja może w największym stopniu ograniczać swobodę wprowadzania zmian. Każdy szczegół klasy bazowej 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 implementacji. Komparator byFirstName stanowi implementację abstrakcji komparatora biblioteki kolekcji (która w jego przypadku przybiera postać klasy). Przedstawiony przykład jest nieskomplikowany, gdyż platforma wymaga przekazania tylko jednego fragmentu logiki, i to na tyle prostego, by można go umieścić bezpośrednio w miejscu wykorzystania. Jednak takie implementacje 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 implementacji zapewnia znacznie lepszą skalowalność niż opisywany wcześniej styl oparty na konfiguracji. Dzieje się tak dlatego, że pozwala na stosowanie dowolnie dużej liczby niezależnych odmian, z których każda będzie reprezentowana przez metodę zdefiniowaną przez platformę. JUnit wykorzystuje każdy z tych czterech stylów: ■ JUnitCore jest klasą biblioteczną udostępniającą statyczną metodę run(Class ...), 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 testami oraz generowanymi powiadomieniami. ■ Adnotacje @Test, @Before oraz @After stanowią pewną formę konfiguracji, zapewniającą twórcom testów możliwość wskazywania kodu, który należy wykonać w odpowiednich sytuacjach.

139

140

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

■ Adnotacja @RunWith stanowi formę implementacji, pozwalającą zaimplementować własny mechanizm uruchamiania testów, z którego mogą skorzystać osoby potrzebujące niestandardowych sposobów wykonywania testów. Abstrakcja W przypadku użycia ostatniego z przedstawionych stylów stosowania platform — implementacji — powstaje pytanie, czy abstrakcyjne byty należy reprezentować jako interfejsy, 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. Warto zauważyć, że rozwiązania te wzajemnie się nie wykluczają. Platforma może udostępniać zarówno interfejs, jak i domyślną implementację tego interfejsu. Interfejs Ogromną zaletą udostępniania interfejsów jest to, że podają one tak niewiele szczegółó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ę. Dopóki interfejsy pozostaną niezmienione, wszystko będzie w porządku, niemniej jednak wprowadzenie choćby jednej nowej metody sprawi, że kod klienta przestanie działać. Jeśli będziemy w stanie sprawić, że klienci będą jedynie korzystali z interfejsó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żliwość 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 implementuje kilka całkowicie niezależnych interfejsów, to najprawdopodobniej najlepiej będzie ją podzielić, by w odpowiednio przejrzysty sposób wyrazić to, co chcemy za jej pomocą 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. Niemniej jednak nic nie stoi na przeszkodzie, by utworzyć nowy interfejs pochodny i w nim umieś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 AWT dostępne są dwie wersje interfejsu zarządcy układu. W kodzie AWT w kilkunastu miejscach można zobaczyć takie wiersze:

ZACHĘCANIE DO WPROWADZANIA ZGODNYCH ZMIAN ... if (layout instanceof LayoutManager2) { LayoutManager2 layout2= (LayoutManager2) layout; layout2.newOperation(); } ...

Interfejsy wersjonowane są rozsądnym kompromisem 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 problemó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ązaniem w przypadkach abstrakcji, które często się zmieniają. Właściwym sposobem reprezentacji takich abstrakcji jest zaimplementowanie ich w formie klas bazowych. Klasy bazowe Jedną z innych metod definiowania abstrakcji przy wykorzystaniu interfejsów jest poproszenie użytkowników, by przekazywali instancję jakiejś klasy lub jej klas pochodnych. Zalety oraz wady takiego stylu korzystania z platform są dokładnie odwrotne niż interfejsów: klasy udostępniają więcej szczegółów niż interfejsy, natomiast dodanie operacji do klasy bazowej nie wywołuje błędów w już istniejącym kodzie. Jednak inaczej niż w przypadku interfejsów klasy klientów mogą dziedziczyć tylko po jednej klasie 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 udostę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 podczas korzystania z interfejsów. Pola w klasach należących do platformy zawsze powinny być deklarowane jako prywatne. Jeśli klienci muszą mieć dostęp do danych przechowywanych 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 bazowe udostępniające jedynie niewiele więcej szczegółów niż odpowiadające im interfejsy, a jednocześnie daje klientom znacznie większe możliwości stosowania własnej logiki. Słowo kluczowe abstract informuje klientów o tym, gdzie powinni udostępnić własną logikę. Dostarczenie sensownych domyślnych implementacji 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

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

Jeśli w definicji klasy zostanie umieszczone słowo kluczowe final, 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 platformy założyć, że będzie wykonywany konkretny kod, i to nawet w metodzie widocznej dla klientów. Jeśli o mnie chodzi, choć respektuję prawo twórców platformy do upraszczania sobie życia, zdarzało mi się także popadać we frustrację wynikającą ze stosowania sfinalizowanych klas i metod. Kiedyś spędziłem dwa dni na bezowocnych próbach programowego tworzenia zdarzeń SWT w celu przeprowadzenia niezbędnych testów. To właśnie sfinalizowana (w mojej 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ń SWT; dopiero to pozwoliło mi przetestować kod bez tworzenia faktycznego graficznego interfejsu użytkownika. Zachowanie słowa kluczowego final na te okazje, kiedy jego użycie faktycznie się nam opłaci, a przy tym nie przysporzy 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 umieszczana w kilku pakietach, wymagają zastosowania widoczności rozumianej jako: „widoczny wewnątrz platformy, lecz nie dla klientów”. Jednym z rozwiązań tego problemu 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 org.eclipse.jdt... oraz org.eclipse.jdt.internal.... Takie wewnętrzne pakiety stanowią kompromis między ujawnianiem i ukrywaniem szczegółów platformy. Klienci sami mogą zdecydować, jak dużą odpowiedzialność za korzystanie z niestabilnych elementów platformy chcą przyjąć na swoje barki. Czasami zdarza się, że funkcjonalność, której potrzebują klienci, jest dostępna w platformie, 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ć kompromis między ogólnością, złożonością, łatwością nauki i wprowadzania przyszłych modyfikacji. Cztery style opisane poniżej to odpowiednio: pominię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 kombinację, można także używać różnych stylów programowania w różnych fragmentach kodu platformy.

ZACHĘCANIE DO WPROWADZANIA ZGODNYCH ZMIAN

Bez możliwości tworzenia Najprostszym rozwiązaniem, dającym jednocześnie najmniejsze 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 SWT, 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żonych problemów programistycznych, w których eliminacja wszelkich ewentualnych złożoności jest pożądana, takie uniemożliwienie klientom tworzenia obiektów platformy może być dobrym rozwiązaniem. Wartość platformy może czasami polegać na czymś innym, niż początkowo zakładali jej twórcy. Wyeliminowanie nieprzewidzianych 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ą. Większość bibliotek języka Java udostępnia możliwość tworzenia obiektów przy użyciu konstruktorów. Kiedy twórcy Javy opublikowali informację, że listy są tworzone przy użyciu wyrażenia new ArrayList(), zobowiązali się do zachowania klasy ArrayList w pakiecie java.util i zachowania konkretnej klasy, której obiekty będą zwracane 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 zrozumiałe dla klientów. Jeśli klient potrzebuje prostego interfejsu pozwalającego na tworzenie obiektów, a nam nie zależy na zachowaniu możliwości zmiany nazwy klasy, pakietu 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 obiektów, jednak zapewniają twórcom platformy większe możliwości wprowadzania zmian projektowych. Jeśli klient tworzy listę, używając wywołania ArrayList.create(), a nie

143

144

ROZDZIAŁ 10

ROZWIJANIE PLATFORM

konstruktora, to zarówno nazwa, jak i pakiet oraz nazwa konkretnej klasy, której instancje są zwracane, może się zmienić bez konieczności wprowadzania jakichkolwiek zmian w kodzie klienta. Kolejnym krokiem mogłoby być umieszczenie metody wytwórczej w klasie bibliotecznej Collections.createArrayList(). W razie zastosowania takiego rozwiązania jedyną klasą, która musiałaby pozostać w początkowym pakiecie java.util, byłaby klasa biblioteczna. Wszystkie inne klasy można by przenieść, gdyby zaistniała taka konieczność. Jednakże im bardziej abstrakcyjny jest sposób tworzenia obiektów, tym trudniej jest użytkownikom platformy określić, gdzie te obiekty są tworzone. Kolejną zaletą stosowania metod wytwórczych jest to, że pozwalają precyzyjnie wyrazić znaczenie zmiennoś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 tworzenia 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 CollectionFactory mogłaby udostępniać metody służące do tworzenia różnych rodzajów kolekcji. Można by ich używać następująco: Collections.factory().createArrayList(). Obiekty 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 śledzić proces jego wykonywania, aby zorientować się, gdzie są tworzone określone klasy. Jeśli obiekt wytwórczy jest używany wyłącznie na poziomie globalnym, to nie zapewnia większej elastyczności niż statyczne metody wytwórcze. Prawdziwą moc obiektów wytwórczych można zauważyć dopiero, gdy są stosowane lokalnie. Na przykład gdybyś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 inicjować obiekty wymagające utworzenia kolekcji, używając do tego celu specjalnych wersji obiektów wytwórczych, gdyby kod był wykonywany na jakimś urządzeniu przenośnym, oraz normalnych obiektów wytwórczych, gdyby działał na serwerze. Obiekty wytwórcze mogą być użyteczne w przypadkach, gdy konieczne jest tworzenie 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, jak ł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-

ZACHĘCANIE DO WPROWADZANIA ZGODNYCH ZMIAN

dobieństwo zmian jest wysokie, oraz konstruktorów w klasach, które są stabilne. Niemniej jednak cenne jest także stosowanie spójnej strategii tworzenia obiektów, polegającej na wykorzystaniu bądź to metod wytwórczych, bądź obiektów wytwórczych. Metody Na łatwość użycia oraz modyfikacji platform mają także wpływ inne metody, a nie tylko te związane z tworzeniem obiektów. Ogólna strategia pozostaje taka sama: należy ujawniać możliwie jak najmniej szczegółów, starając się przy tym pomagać klientom w rozwiązywaniu ich problemów. Stosowanie dostępnych dla klientów metod pobierających i ustawiających jest prawidłowym rozwiązaniem wyłącznie w przypadkach, gdy struktury danych są stabilne. Zachę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ć alternatywny sposób wyliczania wartości przechowywanej w polu. Warto spróbować zrozumieć, 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ązywanemu problemowi, a nie będzie stanowiła odzwierciedlenia implementacji. Na przykład, tworząc bibliotekę graficznych widżetów, można udostępnić w należącej do niej klasie Widget metodę ustawiająca setVisibility(boolean). 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 intencje, takie jak visible() oraz invisible(). Właśnie takie znaczenie ma dla klientów metoda setVisibility(). W przypadku udostępnienia takich metod dodanie do klasy bazowej trzeciej metody — inactive() — 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 inactive() do interfejsu uszkodziłoby wszystkie implementacje klasy Widget stworzone przez klientów. Zamiast tego lepiej będzie zdefiniować typ wyliczeniowy States określający wszystkie dostępne stany widżetów i publikować metodę setVisibility(State). Wersja tej metody pobierająca argument typu boolean jest przykładem wyciekania do klientów informacji 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 zaimplementowana jakaś ważna funkcjonalność platformy, to należy udostępnić odpowiednią metodę pobierającą lub ustawiającą. Niemniej 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 dodawanych do udostępnionych metod. Jeśli do jakiejś metody zostanie dodany nowy

145

146

ROZDZIAŁ 10

ROZWIJANIE 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żliwość 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 TestResult do metody zwracającej testy dostępne w klasach. Wystarczyłoby zmodyfikować metodę przez dodanie do niej parametru. public TestResult run(Class... classes) { ...wykonuje testy w klasach... } public void run(TestResult result, Class... classes) { ...wykonuje testy w klasach... }

W takim przypadku każdy, kto chciałby wywołać metodę run(Classes ...), musiałby zmienić jej wywołanie, dodając do niego parametr TestResult. Niemniej jednak można zmodyfikować oryginalną metodę tak, by obsługiwała dodatkowy parametr: public TestResult run(Class... classes) { TestResult result= new TestResult(); run(result, classes); return result; }

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 dominującym jest tu nie koszt zrozumienia kodu, a koszt aktualizacji kodu klientów, wymaga znaczących modyfikacji zarówno stosowanych praktyk, jak i uwzględnianych wartości. W przypadku tworzenia platform prostota, która była główną wytyczną tworzenia aplikacji, ma mniejszy priorytet 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 platformy będzie wymagało ponownego przemyślenia wielu spośród decyzji projektowych podejmowanych podczas pisania aplikacji. Platformy rozwijają się na wiele różnych sposobów. Czasami konieczne jest usprawnienie obliczeń wykonywanych przez istniejące metody. W innych przypadkach dotychczas wykonywane obliczenia muszą być wykonywane przy użyciu nowych parametró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 implementacyjnych platformy.

WNIOSKI

Metaforą, która okazała się niezwykle użyteczna podczas tworzenia JUnit, było spojrzenie na platformę jako część wspólną wszystkich użytecznych funkcjonalności domeny, a nie ich sumę. Zadaniem twórców platformy jest zapewnienie klientom moż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 platformy rozwiązującej szerszą gamę problemów. Trudność jednak polega na tym, że ta dodatkowa funkcjonalność ogromnie 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 zaspokoić 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órcy platformy jest zaspokojenie wspólnych potrzeb użytkowników, lecz już niekoniecznie ich unikalnych wymagań. Gdyby wszyscy użytkownicy musieli dodawać tę samą funkcjonalność, to powinna ona należeć do platformy, niemniej jednak unikalne wymagania 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 problemy 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 platformie należy określać na podstawie co najmniej jednej zrozumiałej i spójnej metafory. Na przykład jeśli zasada podwójnego zapisu jest metaforą 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 metafor oraz komunikowanie ich klientom można doprowadzić do tego, że tworzone platformy będą łatwiejsze do poznania, stosowania i rozwijania. Wdrożenie platformy wcale nie musi oznaczać końca jej ewolucji i rozwoju. Rozwaga wykazana podczas projektowania platformy może wspomóc utworzenie stabilnej bazy dla aplikacji użytkowników oraz dynamicznej podstawy do dalszego rozwoju samej platformy.

147

148

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 prosty — 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 zajmuje wykonanie operacji. Przedstawiony tu mechanizm pomiarowy rozwiązuje ten problem, wykonując te same operacje wiele razy. Dostosowuje się do dokładności licznika czasu poprzez odpowiednie dobieranie faktycznego okresu, przez jaki są wykonywane pomiary poszczególnych operacji. Ze względu na mechanizmy optymalizacji wykorzystywane w języku Java wykonywanie dokładnych pomiarów wydajności operacji wymaga znacznie większej wiedzy, niż przedstawiłem w tym rozdziale, dotyczy to zarówno platformy, jak i samych testów. Aby uzyskać dokładne wyniki, trzeba wiedzieć, co mechanizmy optymalizacji najprawdopodobniej zrobią z naszym kodem, gdyż jest to konieczne, by uniknąć sytuacji, 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 temat pomiarów wydajności bądź kodu, którego wydajność chcemy 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ólnie. Na przykład parametry pomiarowe są reprezentowane przez stałe, a nie przez zmienne, nie ma interfejsu pozwalającego na wykonywanie testów z poziomu wiersza poleceń, a wyniki są stosunkowo proste i wyświetlane w oknie konsoli. Jedną z bardzo ważnych umiejętności programistycznych jest dostosowywanie wysiłku do celu, który chcemy osiągnąć. Naukę wzorców implementacyjnych należy uzupełnić przez poznanie, kiedy ich używać, a kiedy lepiej z nich nie korzystać.

149

150

DODATEK A POMIARY WYDAJNOŚCI

Przykład Licznik czasu powinien być w stanie mierzyć operacje zapisane w możliwie jak najprostszy sposób. Biorąc za wzór JUnit, testowane operacje można przedstawić w formie 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 (int 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 poznamy 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. Podczas 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ę report() klasy MethodsTimer. Na przykład aby zmierzyć czas wykonania operacji dostępnych w przedstawionej wcześniej klasie ListSearch, trzeba użyć następującej metody: public static void main(String[] args) throws Exception { MethodsTimer tester= new MethodsTimer(ListSearch.class.getDeclaredMethods()); tester.report(); }

IMPLEMENTACJA

Wykonanie tej metody spowoduje wyświetlenie w oknie konsoli wyników, podobnych 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 (przynajmniej na moim komputerze), 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 programista 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 wydajności podczas skalowania ilości danych, na których metoda operuje. Metoda report() stanowi pętlę wewnętrzną, umieszczoną wewnątrz pętli zewnętrznej operującej na wszystkich mierzonych metodach. Metoda ta operuje na szeregu wartości — 1, 10, …, 100 000. private static final int MAXIMUM_SIZE= 100000; public void report() throws Exception { for (Method each : methods) { System.out.print(each.getName() + "\t"); for (int size= 1; size