Mariusz Sieraczkiewicz

Wzorce implementacyjne

Wzorce implementacyjne cz. 1

Tworząc oprogramowanie tworzymy wiele tysięcy wierszy kodu. Część tego kodu jest naszym kreatywnym wysiłkiem w tworzeniu Nowego, a część to powtarzalne elementy, które pojawiają się na każdym kroku. Jako programiści nie lubimy powtarzać wiele razy tych samych czynności, a jednak to robimy, gdyż nie jesteśmy w stanie tego uniknąć. W takiej sytuacji warto nabyć pewnych nawyków, które pozwolą nam jednoznacznie i świadomie podejmować decyzje, doprowadzą do czytelnego i jasnego w przesłankach kodu.

Przytoczę za Kentem Beckiem zestaw praw, który dotyczy ogromnej części pisanego oprogramowania:

  • kod oprogramowania częściej jest czytany niż pisany (sic!),
  • w programowaniu nie istnieje stwierdzenie „zrobione” (sic!) - kod ulega bezustannemu rozwojowi,
  • sercem oprogramowania (niskopoziomowym) jest odpowiednie zarządzanie przepływem instrukcji oraz zarządzanie jej stanem (zmiennymi),
  • osoba czytająca kod musi być w stanie łatwo zrozumieć ideę, koncept zawarty w kodzie oraz łatwo dotrzeć do szczegółów implementacyjnych (kod powinien umożliwiać płynne przejście od ogólnej wizji do szczegółów i od szczegółów do wizji).

Wspomniane wcześniej nawyki, które możemy inaczej nazwać wzorcami implementacyjnymi, są w dużym stopniu subiektywną decyzją, choć prawdopodobnie większość z nich jest używana i akceptowana przez profesjonalnych programistów. Warto zatem podkreślić trzy podstawowe wartości, które stoją za przedstawianymi w tym i kolejnych artykułach wzorcami:

  • komunikacja (komunikatywność),
  • prostota,
  • elastyczność.

Koszt oprogramowania

Każdy programista, który brał udział w większym projekcie, wie że wytworzenie oprogramowania to tylko wierzchołek góry lodowej. System stworzony w wersji 1.0 to nie koniec podróży:

  • z czasem rozwija się – ulega zmianom,
  • należy naprawiać znalezione błędy.

Koszt całkowity jest więc dużo większy niż tylko oprogramowanie zasadniczych funkcjonalności. Co więcej, z przeprowadzonych badań statystycznych wynika, że koszt utrzymania systemu jest dużo większy niż jego wytworzenia (w średniej relacji 30%/70%).

Koszt całkowity oprogramowania

A co to takiego ten magiczny „koszt utrzymania”? Koszt zrozumienia, zmiany, przetestowania i wdrożenia. Ile razy zaglądaliśmy do własnego kodu (nie mówiąc już o czyimś kodzie), który został stworzony pół roku wcześniej i stwierdzaliśmy, że nie wiemy o co chodzi! Stosowanie wzorców – niskopoziomowych wzorców implementacyjnych, dotyczących strategii tworzenia kodu źródłowego, upraszcza nam życie – wiemy czego spodziewać się w kodzie i wiemy, że łatwo będziemy go mogli przeczytać.

Zatem o co chodzi? Co to za wzorce? Chciałbym przedstawić kilka a może kilkanaście strategii, które ułatwią podejmowanie decyzji podczas tworzenia oprogramowania. Dla uwiarygodnienia przedstawianych propozycji, podpierał się będę kodem źródłowym projektów:

  • Spring Framework,
  • Hibernate,
  • Struts2,
  • kod źródłowy JDK6.

Oczywiście nie sposób zawrzeć tychże strategii w jednym artykule, dlatego niniejszym rozpoczynam szereg artykułów poświęconych wzorcom implementacyjnym. Do dzieła!

Klasa

Klasa jest tak podstawowym elementem programowania obiektowego, że równie trudno określić oczywiste zasady, jakimi należy posługiwać się definiując i nazywając klasy. Istnieje wiele technik, które ułatwiają wyodrębnianie klas podczas analizy, takie jak karty CRC (http://en.wikipedia.org/wiki/Class-Responsibility-Collaboration_card)czy diagram klas analitycznych (Robustness Diagram) (http://www.agilemodeling.com/artifacts/robustnessDiagram.htm). Jednak ciągle to do nas jako projektantów lub programistów należy decyzja, jak stworzyć nową klasę. Jest to największa sztuka związana z programowanie obiektowym.

Pokrótce można powiedzieć, że

każda klasa powinna mieć dobrze określoną odpowiedzialność

Co oznacza dobrze? Klasa powinna być odpowiedzialna zazwyczaj za jedną rzecz w systemie.

Na przykład klasa Converter powinna dokonywać tylko i wyłącznie operacji konwersji i nie zajmować się niczym innym (np. zapisywaniem wyników konwersji do bazy danych). Po czym poznać, że klasa łamie tę zasadę? Główny objaw do duża ilość metod (choć to tylko objaw ilościowy), ostatecznie należy przeanalizować nazwy metod, a w zasadzie to, co takiego one robią. Jeśli metody nie są spójne ze sobą i wychodzą poza przeznaczenie klasy, to odpowiedzialnośc nie została dobrze określona.

Nazwa klasy musi być z jednej strony zwięzła, a z drugiej strony oddająca w pełni przeznaczenie klasy.

oddająca w pełni przeznaczenie klasy.

Nadanie nazwy klasie to próba pogodzenia dwóch skrajnych sił – z jednej strony nazwa powinna być jak najkrótsza i jak najprosztsza (łatwiej ją zapamiętać, skojarzyć), a z drugiej strony musi precyzyjnie odzwierciedlać przeznaczenie klasy (co czasem wymaga dłuższych nazw). Jedna z najważeniejszych funkcji nazwy klasy to komunikatywność. W praktyce nie warto od razu szukać idealnej nazwy, tylko na początku nadać taką, która nam przychodzi do głowy, a z czasem gdy odnajdziemy dużo lepszą, zmienić ją. W dobie zaawansowanych narzędzi refaktoryzacji, zmiana nazwy klasy nie powinna być zbyt wielkim problemem.

Warto w praktyce dążyć w pierwszej kolejności do nazw klas złożonych z jednego słowa (mam tu na myśli przede wszystkim klasy, które nie są dziedziczone z innych), gdyż łatwiej je zapamiętać. Ponadto nazwa klasy powinna być jak najbliższa świata klienta (słowem z jego dziedziny), jeśli klasa odzwierciedla element świata klienta.

Jest jeszcze jedna wskazówka, którą warto się kierować:

Im mniej klas w projekcie, tym lepiej

Oczywiście kierunek jej oddziaływania jest przeciwny do kierunku oddziaływania reguły, która nakazuje wyodrębnianie jednoznacznej odpowiedzialności, gdyż ta z kolei powoduje większe rozdrobnienie klas. Tutaj jednak musimy działać zgodnie z maksymą wypowiedzianą przez Einsteina: twórzmy rzeczy najprościej jak tak tylko możliwe, ale nie prościej.Te dwie siły trzeba w praktyce zrównoważyć. Z jednej strony tworzyć klasy od jasno określonej odpowiedzialności, z drugiej starać się ograniczać ilość powstających klas. Im więcej klas, tym więcej nazw do zapamiętania, miejsc do analizowania, debugowania, testowania itp. itd.

Podsumowując powyższe rozważania:

  • klasa musi mieć dobrze określoną odpowiedzialność (jednoznacznie) i wszystkie metody muszą być spójne z tą odpowiedzialnością,
  • nazwa klasy powinna być zwięzła, ale z drugiej strony jednoznacznie wyrażająca odpowiedzialność klasy,
  • w pierwszej kolejności należy szukać nazw złożonych z jednego słowa,
  • im mniej klas w projekcie tym lepiej.

Dobrym case study są projekty open source. Jeśli nie znasz omawianej biblioteki, nie ma to większego znaczenia, gdyż zazwyczaj nie będziemy analizować funkcjonalności tychże, jakże wspaniałych narzędzi, a przyjrzymy się bliżej strukturze ich klas.

Na początek spójrzmy na klasę org.hibernate.cfg.Configuration z projektu Hibernate 3. Nazwa Configuration jest łatwa, jednoznaczna, wydaje się świetnie oddawać odpowiedzialność obiektu – jest to klasa skupiająca w sobie informacje związaną z konfiguracją Hibernate'a. Jak się bliżej przyjrzymy zawartości klasy, to zobaczymy m. in. takie elementy:

    
        public class Configuration implements Serializable {

        private static Logger log = LoggerFactory.getLogger( Configuration.class );

        protected Map classes;

        protected Map imports;

        protected Map collections;

        .........

        public Configuration addFile(File xmlFile) throws MappingException {

        .........

        public Configuration addXML(String xml) throws MappingException {

        .........

        protected void parseMappingElement(Element subelement, String name) {

        ..........

        private void parseSecurity(Element secNode) {

        ..........
    

Oprócz metod do przechowywania i zarządzania konfiguracją, pojawiają się m. in. metody do parsowania pliku XML. Tutaj można poddać wątpliwość jednoznaczność odpowiedzialności tejże klasy, gdyż zaczyna być ona odpowiedzialna za parsowanie pliku XML. Choć można by szukać uzasadnienia tej decyzji w zasadzie im mniej klas, tym lepiej.

Inna klasa z tego samego projektu org.hibernate.cfg.Mapping, budzi mniejsze wątpliwości – do tej klasy wydzielono wszelkie elementy związane z mapowaniem ORM http://en.wikipedia.org/wiki/Object-relational_mapping Nazwa klasy jest prosta, jednoznaczna i ekspresyjna.

Spójrzmy jeszcze na podwórko jdk6. Klasa java.text.DateFormat ma bardzo ostro określoną odpowiedzialność – formatowanie dat w zależności od ustawień regionalnych (Locale). Jednoznaczna i prosta nazwa (potrzebne były dwa słowa do tego celu).

Takich przykładów znajdziemy tu bardzo dużo np.:

  • java.util.regex.Matcher – nazwa jasno wyraża odpowiedzialność – zbiór operacji związanych z dopasowywaniem wyrażeń regularnych,
  • java.net.Socket – nazwa jasno i jednoznacznie określa przeznaczenie klasy – reprezentacja gniazda TCP oraz zbiór operacji na tym gnieździe.
Analizując kod źródłowy projektów Hibernate i jdk6 wyraźnie widać preferencje, którymi zaznaczają się w tych projektach:

  • w Hibernate często zdarza się łamanie zasad odpowiedzialności na rzecz zmniejszenia ilości klas w projekcie (których i tak jest dużo), co często jednak prowadzi do powstawania trudnych do ogarnięcia klas (kilkanaście a nawet kilkadziesiąt metod),
  • w jdk6 (i wcześniejszych również) tendencja jest zgoła odwrotna, zdecydowanie nacisk kładzie się na ostrą, jednoznaczną odpowiedzialność, kosztem wytwarzania dużej ilości klas – np. żeby obsłużyć w pełni wyrażenie regularne potrzeba aż trzech klas (!).

Polecam analizę kodów źródłowych tychże projektów oraz pozostałych (np. spring framework), gdyż jest to jeden z najlepszych praktycznych nauczycieli naprawdę dobrego stylu pisania oprogramowania.

Już samo podjęcie decyzji jaką stworzyć klasę (jaka odpowiedzialność, jakie metody, jaka nazwa), jest nielada wyzwaniem. Dlatego warto poświęcić wieczór, aby przeanalizować za i przeciw, tak aby trafnie podejmować tę jedną z częściej podejmowanych decyzji.

Oczywiście to zaledwie początek i wierzchołek góry lodowej. Niebawem nieco rozważań na temat interfejsów.