Zasady projektowania OOD (Object-Oriented Design)
Programowanie obiektowe (OOP) jest metodyką modelowania i projektowania oprogramowania, które obejmuje podstawowe pojęcia hermetyzacji, abstrakcji, dziedziczenia i polimorfizmu. Ta metodyka kieruje się zestawem zasad zwanych zasadami projektowania. Zasady te pokazują właściwy kierunek projektowania i pozwalają uniknąć kosztownych pomyłek na etapie projektowania.
Stosowanie zasad projektowania jest ważne nie tylko z punktu widzenia projektowania oprogramowania, ale również z punktu widzenia biznesowego, gdyż umożliwiają one tworzenie elastycznych projektów, które mogą ewaluować wraz z wymaganiami biznesowymi przy minimalnym wysiłku, czasie i kosztach.
Poniżej zamieszczony został opis dotyczący niektórych zasad.
Open Close Principle (OCP)
Zasada ta stanowi najbardziej podstawową regułę uelastycznienia oprogramowania. Wszystkie inne zasady, metodyki i konwencje OOP koncentrują się wokół tej zasady. OCP określa co następuje:
Elementy oprogramowania powinny być otwarte na rozszerzania (extensions) natomiast zamknięte na modyfikacje (modifications).
Na pierwszy rzut oka stwierdzenie powyższe wydaje się to wewnętrznie sprzeczne. W celu wyjaśnienia zdefiniujmy następujące terminy:
- Encje oprogramowania w projekcie to funkcje, metody, moduły, klasy.
- Otwarty na rozszerzanie oznacza, że jego zachowanie może zostać rozszerzone lub zmienione w zależności od potrzeb
- Zamknięte na modyfikacje oznacza ograniczenie zmian kodu źródłowego modułu do rozszerzenia lub zmiany jego zachowania.
void log() { prepareMessage(); writeMessageToFile(); }Biznes teraz wymaga od nas, by logowanie było możliwe nie tylko do pliku ale również do bazy danych. Tak więc robimy modyfikację w kodzie źródłowym:
void log() { prepareMessage(); if (target is file) { writeMessageToFile(); } else { writeMessageToDatabase(); } }Wszystko działa, natomiast problem polega na tym, że w przyszłości, gdy wymagania się znowu zmienią np. logowanie do pliku XML, będzie musiał być również znowu zmieniony kod źródłowy. Natomiast każda modyfikacja w kodzie źródłowym wprowadza potencjalne błędy, jak również prowadzi do zwiększenia ilości testów w celu zapewnienia poprawności działania i sprawdzenia czy przy okazji modyfikacji coś co działało poprawnie nie zostało zepsute. W szczególności, gdy zmiana została wykonana w części core-owej projektu, będzie musiał być wykonany cały zestaw testów.
Open-Closed Principle zaleca realizację elastycznego oprogramowania poprzez podejście polegające na inkrementacji kodu. Innymi słowy raczej dodawanie nowego kodu niż zmiana w kodzie istniejącym i działającym.
Powracając do przykładu z logowaniem, dobry projekt z punktu widzenia OCP powinien być zrealizowany poprzez przygotowanie interfejsu oraz klas specyficznych w odniesieniu do specyficznej realizacji logowania. Patrz diagram poniżej:
Główna klasa logująca nie musi być zmieniana w celu dodania nowego punktu przeznaczenia. Jest to przykład otwartości na rozszerzenie jednak zamknięcia na modyfikacje.
Liskov Substitution Principle (LSP)
Zasada OCP wymieniona wcześniej jest główną zasadą OOD i można powiedzieć o niej, że jest sercem OOD. Tak jak zostało wcześniej opisane, zaleca ona stosowanie abstrakcji w celu umożliwienia rozszerzalności obiektów, która typowo implementowana jest wykorzystując mechanizm dziedziczenia. Dziedziczenie samo w sobie wprowadza wiele możliwości, z których część może prowadzić do błędnego projektu. Zasada Liskov Substitution Principle (LSP) wprowadza wskazówki dotyczące projektowania używając dziedziczenia. Została sformułaowana przez Barbarę Liskov i w wolnym tłumaczeniu brzmi następująco:
Jeżeli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T, taki że dla wszystkich programów P zdefiniowanych używając T, zachowanie P pozostaje niezmienne po zamianie o1 na o2 to typ S jest podtypem typu T.
Powyższe stwierdzenie dostarcza definicji podtypu lub klasy pochodnej. Z punktu widzenia programowania, można to zinterpretować tak, że zachowanie funkcji lub metody używającej obiektu klasy bazowej powinno pozostać niezmienne po zmianie na obiekt klasy pochodnej wywodzącej się z tej klasy bazowej.
Co z tego wynika możemy prześledzić na przykładzie. Załóżmy, że mamy zdefiniowaną klasę Rectangle która definiuje prostokąt. Następnie chcielibyśmy przygotować klasę Square definiującą typ kwadrat. Wiemy, że kwadrat to prostokąt o równych bokach, tak więc naturalnym podejściem jest zaimplementowanie dziedziczenia klasy Square z klasy Rectangle. Podejście jest jak najbardziej słuszne, jednak jak zostanie to pokazane dalej może prowadzić do niespełnienia zasady LSP. Poniżej został zaprezentowany diagram klas dla opisywanego przypadku:
Jak wynika z diagramu prostokąt ma 4 metody 2 do pobierania długości boków oraz 2 do ustawiania długości boków. Ponieważ kwadrat dziedziczy z prostokąta, dziedziczy również te 4 metody. Teraz, aby zadbać o wewnętrzną spójność kwadratu możemy dodać kod ustawiający długość dla obydwu boków przy ustawieniu długości któregokolwiek z nich, tak jak poniżej:
public void setLengthA(int lengthA) { this.lengthA = lengthA; this.lengthB = lengthA; } public void setLengthB(int lengthB) { this.lengthB = lengthB; this.lengthA = lengthB; }Wszystko jest niby OK, ale zasada LSP nie jest spełniona. Można by zapytać w czym to przeszkadza. Otóż, załóżmy że w celu testu zostanie przygotowany następujący kod:
public void test(Rectangle rect) { rect.setLengthA(2); rect.setLengthB(3); asert(6 == rect.getLengthA() * rect.getLengthB()); }Test w przypadku wykonania na obiekcie klasy Rectangle działa OK, natomiast gdy to metody test() przekażemy obiekt klasy Square niestety test nie powiedzie się. W tym przypadku oczekiwanie jak najbardziej słuszne osoby piszącej test, nie jest spełnione. W tym przypadku można powiedzieć, że klasa Square nie spełnia zasady LSP.
Design by Contract (DBC)
Zasada DBC jest skojarzona z zasadą LSP. DBC określa, że każda encja oprogramowania jest zobowiązana do ciągłego dostarczania usługi innym encjom. Umowa (contract) jest definiowana przez warunki początkowe (preconditions) oraz warunki końcowe (postconditions), które w programowaniu przenoszą się na sygnaturę funkcji czy metody. Element wywołujący zapewnia spełnienie określonych warunków początkowych na podstawie, których element wywoływany zapewnia spełnienie warunków końcowych. DBC określa:
W przypadku przedefiniowania procedury [w elemencie pochodnym] można jedynie zmienić jej warunki początkowe na słabsze oraz jej warunki końcowe na mocniejsze.
W przykładzie opisywanym w LSP dotyczącym prostokątów i kwadratów, załóżmy, że klasa Rectangle ma metodę setDimension(lenghtA, lengthB), która w klasie Square może być przetransformowana do setDimension(length). W tym przypadku klasa Square zamienia warunki początkowe zdefiniowane w klasie Rectangle na mocniejsze powodując złamanie zasady DBC.
Dependency Inversion Principle (DIP)
Projekt oprogramowania definiuje obiekty oraz komunikację pomiędzy nimi. Ostatecznie dostajemy sieć wewnętrznych połączeń pomiędzy tymi obiektami, która w połączeniu z ograniczeniami biznesowymi takimi jak czas czy budżet, prowadzi do złego projektu oprogramowania. Robert Martin zdefiniował zły projekt oprogramowania trzema podstawowymi atrybutami przedstawionymi poniżej.
Część oprogramowania spełniająca swoje wymagania i wykazująca jeszcze którąkolwiek lub wszystkie z następujących trzech cech posiada zły projekt (design):
- Jest bardzo trudna do zmiany, ponieważ każda zmiana ma wpływ na wiele części systemu - rigidity.
- W przypadku wprowadzenia zmiany, nieoczekiwane części systemu ulegają zepsuciu - fragality.
- Jest bardzo ciężko ją użyć ponownie w innej aplikacji, ponieważ nie może być wyciągnięta z aktualnej aplikacji - immobility.
- sformułowanie komunikatu do zalogowania
- zapis komunikatu do miejsca przeznaczenia np. pliku, bazy danych
W implementacji, moduły niskopoziomowe mogą mieć specyficzny kod np. moduł do zapisu komunikatu może zapisywać go do pliku:
prepareMessage writeMessageToFilePóźniej, gdy będzie wymagane logowanie do bazy danych, algorytm ulegnie zmianie do następującej wersji:
prepareMessage writeMessageToDatabaseW opisywanym przypadku zmiana w module niskopoziomowym powoduje zmianę w module wysokopoziomowym. Oznacza to, że nie można użyć ponownie modułu logującego nie używając implementacji modułu niskopoziomowego do zapisu komunikatu.
Na poziomie koncepcyjnym wszystko jest w porządku, problemy pojawiają się podczas implementacji. Dlaczego nie włączyć koncepcji w samym projekcie ? Może to być wykonane poprzez wprowadzenie abstrakcji, jak w algorytmie poniżej:
logging: prepareMessage writeMessage writeMessage: writeMessageToFileZostał tutaj wprowadzony poziom pośredni, moduł logowania nie komunikuje się bezpośrednio z modułem zapisu komunikatu na dysk. Komunikuje się on z modułem zapisu komunikatu, który ukrywa całość zmian swojej implementacji wewnętrznie. W tym przypadku moduł logowania jest niezależny od implementacji zapisu komunikatu i może być użyty ponownie.
To co zostało opisane powyżej jest ogólnym sformułowaniem zasady DIP, która stanowi:
Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. Obydwa moduły natomiast powinny zależeć od abstrakcji. Abstrakcje nie powinny zależeć od szczegółów. Szczegóły powinny natomiast zależeć od abstrakcji.
W rzeczywistości oznacza to, że odseparowana została koncepcja od implementacji poprzez wprowadzenie abstrakcji. Jest to definicją reguły projektowania i programowania, która mówi, by projektować interfejsy a nie implementacje. Klasa potrzebująca funkcjonalności logowania powinna być projektowana z wykorzystaniem modułu wysokopoziomowego zamiast modułu niskopoziomowego.
LoD nazywane jest zasadą minimalnej wiedzy o innych obiektach. Głównym celem LoD jest kontrola informacji przeładowanej (overload) poprzez definicję reguł dla interakcji pomiędzy obiektami - "Rozmawiaj tylko z najbliższymi przyjaciółmi". Bardziej powszechną formą zasady jest:
Każda jednostka powinna mieć jedynie ograniczoną wiedzę o innych jednostkach. Powinna mieć wiedzę dotycząca jedynie jednostki "blisko" z nią skojarzonej.
Każda jednostka powinna jedynie rozmawiać z przyjaciółmi, a nie rozmawiać z nieznajomymi.
W przypadku OOP, jednostką jest metoda a blisko powiązanymi jednostkami metody są:
- argumenty przekazane do metody
- pola klasy, do której należy, ale nie ich pola
- obiekty, które sama tworzy
- elementy klasy do której należy
obj1 = obj2.getObj1(); obj3 = obj1.getObj3();Zakładając, że obj2 został przekazany jako argument metody, dopuszczalna jest widza o strukturze klasy dla obiektu obj2. Została jednak również wywołana metoda na obiekcie obj1 odkrywając w ten sposób jego strukturę. Jeżeli struktura klasy dla obj1 zmieni się, istnieje możliwość, że przedstawiony kod także będzie musiał ulec zmianie. Przedstawioną powyżej sekwencję można zastąpić przez:
obj3 = obj2.getObj3()Z tego, że obj2 zwraca obiekt obj1 wynika, że obj2 zna strukturę obj1 i może wywoływać jego metody. W wymienionym przypadku obj2 jest jednostką ściśle powiązaną, podczas gdy obj1 jest elementem obcym.
Rumbaugh podsumował zasadę Law of Demeter w następujący sposób:
Metoda powinna mieć ograniczoną wiedzę o modelu obiektów.
Interface Segrgation Principle (ISP)
Wiele małych interfejsów jest znacznie lepsze od jednego wielkiego.
Reuse/Release Equivalency Principle (REP)
The Common Reuse Principle (CRP)
Common Closure Principle (CCP)
Klasy w pakietach powinny być wspólnie zamknięte względem tego samego rodzaju zmian. Zmiana, która wpływa na pakiet dotyczy wszystkich klas w tym pakiecie.
Zasada określa, że klasy, które współistnieją razem powinny być grupowane razem. Co to oznacza ? Oznacza to tyle, że klasy, które zmieniają się wspólnie po zmianie wymagań, powinny być umieszczane w tym samym pakiecie. Zasada minimalizuje liczbę pakietów do zmiany w przypadku zmiany wymagań.
Acyclic Dependencies Principles (ADP)
Zależności pomiędzy pakietami nie mogą pod żadnym pozorem być cykliczne.
Stable Dependencies Principle (SDP)
Stable Abstractions Principle (SAP)
0 komentarze:
Prześlij komentarz