poniedziałek, 26 maja 2008

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.

Zasady dotyczące projektu klas - --- SOLID ---

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.
Otwartość na rozszerzanie pozwala na zaspokojenie rzeczywistej potrzeby zmian w oprogramowaniu spowodowanej zmianą wymagań biznesowych bez konieczności modyfikacji kodu źródłowego. Dlaczego nie powinno być zmian w kodzie źródłowym ? Aby zdać sobie sprawę z korzyści otwartości rozważymy kod składający się z logiki if-then-else w przykładzie dotyczącym logowania. Załóżmy, że mamy przygotowaną i działającą następującą funkcję logującą:
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):
  1. Jest bardzo trudna do zmiany, ponieważ każda zmiana ma wpływ na wiele części systemu - rigidity.
  2. W przypadku wprowadzenia zmiany, nieoczekiwane części systemu ulegają zepsuciu - fragality.
  3. Jest bardzo ciężko ją użyć ponownie w innej aplikacji, ponieważ nie może być wyciągnięta z aktualnej aplikacji - immobility.
Gdziekolwiek funkcjonalność jest zaimplementowana, może ona być podzielona na moduły wysokopoziomowe i niskopoziomowe. Moduły wysokopoziomowe są zwykle reprezentatywne dla koncepcji aktywności, podczas gdy niskopoziomowe aktywności są szczegółami modułu wysokopoziomowego. Przykładowo aktywność logowania (moduł wysokopoziomowy) może być podzielona na:
  • 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
writeMessageToFile
Później, gdy będzie wymagane logowanie do bazy danych, algorytm ulegnie zmianie do następującej wersji:
prepareMessage
writeMessageToDatabase
W 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:
writeMessageToFile
Został 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.

Law of Demeter (LoD)
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
Kiedykolwiek metoda wywołuje metody należące do innych obiektów, zakłada znajomość struktury ich klas. Idąc dalej, taka sama sytuacja występuje, gdy używa obiektów zwracanych z tych metod i wywołuje na nich metody. Zmiana w strukturze wyżej wymienionych obiektów może powodować zmiany w opisywanej metodzie. LoD określa zakres obiektów, których struktura może być znana i do których mogą odwoływać się metody. Przedstawiony zostanie teraz przykład łamiący LoD:
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.



Zasady dotyczące projektu pakietów.

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

GlossyBlue Blogger by Black Quanta. Theme & Icons by N.Design Studio
Entries RSS Comments RSS