Dobry kod OO Jeżeli zapytamy statystycznego programistę z czym kojarzy mu się dobry kod OO to najprawdopodobniej będzie mówił o wzorcach projektowych. Co poniektórzy wspomną o zasadach projektowania, enkapsulacji i hermetyzacji, dziedziczeniu, interfejsach. Niewielu wspomni o zasadach S.O.L.I.D. Nic w tym dziwnego, bo zasady te nie są sformalizowane tak jak wzorce. Dobrze określił je Krzysiek na spotkaniu mówiąc, że są raczej przysłowiami. Ja bym raczej nazwał je powiedzeniami informatyków. Każda z tych zasad odnosi się do jakiegoś bardzo ogólnego zagadnienia czy to projektowego, czy to architektonicznego czy też funkcjonalnego. Zazwyczaj do wszystkiego po trochu. Jako, że nie ma tu formalizacji więc nie można mówić o miejscach i sposobach stosowania czy problemach, które można rozwiązać. Jeżeli jednak przyjrzymy się wzorcom projektowym, to dojdziemy do wniosku, że wszystkie one zawierają w sobie te nieformalne zasady.
S.O.L.I.D 5 podstawowych wzorców dotyczących programowania zorientowanego obiektowo zdefiniowane przez Roberta C.Martina (szefu Object mentor Inc., międzynarodowy konsultant do spraw rozwoju oprogramowania, przewodniczył grupie odpowiedzialnej za stworzenie „Agile software development”, twórca książek takich jak: „Designing Object-Oriented C++ Applications using the Booch Method”, „Agile Software Development: Principles, Patterns and Practices”, „Clean Code”)
SRP – Single Resposibility Principle Jeden powód do zmian! Pozwala na separację poszczególnych modułów Jest jedną z najprostszych zasad ale jedną z najtrudniejszych do poprawnego zastosowania Szukanie i separacja odpowiedzialności jest tak naprawdę wszystkim o co chodzi w tworzeniu oprogramowania Reszta zasad nawiązuje w taki lub inny sposób do tej zasady
Aplikacja do geometri analitycznej Rectangle + draw() + area() : double Aplikacja graficzna GUI Klasa Rectangle ma dwie odpowiedzialności. Jedna aplikacja używa klasy Rectangle do obliczeń matematycznych i nigdy nie użyje metody do rysowania. Druga aplikacja rysuje na ekranie i sporadycznie wykonuje obliczenia matematyczne. W tym układzie klasa Rectangle narusza zasadę SRP, ponieważ ma dwie odpowiedzialności. Pierwsza odpowiedzialność to dostarczenie matematycznego modelu prostokąta, druga to narysowanie prostokąta na ekranie. Zależności: trzeba dołaczyć moduł GUI do aplikacji od geometi analitycznej (zwiekszenie czasu kompilacji i zużycia pamięci), jakakolwiek zmiana zmiana aplikacji graficznej wymagająca zmiany klasy Rectangle spowoduje konieczność przebudowy, ponowynych testów i dostarczenia klientowi aplikacji od geometri analitycznej. Zaniechanie tego może spowodować trudne do znalezienia błędy
Aplikacja do geometri analitycznej graficzna Geometric Rectangle + area() : double Rectangle + draw() GUI
public void dial(String pno); public void hangup(); interface Modem { public void dial(String pno); public void hangup(); public void send(char c); public char recv(); } Czasami trudno dostrzec pojedynczą odpowiedzialność. Z pozoru definicja Modemu wydaje się poprawna, ale są tu dwie odpowiedzialności: Zarządzanie połączeniem (dial, hangup) Przesyłanie danych (send, recv) Te dwie odpowiedzialności nie mają ze sobą nic wspólnego, powinny zostać rozdzielone (rożne części aplikacji będą wołać inne odpowiedzialności)
<<interface>> Data Channel + send(:char) + recv() : char Connection + dial(pno : String) + hangup() Modem implementation Rozdzielono odpowiedzialności, ale ponownie je złączono w klasie implementującej. Nie jest to pożądane, ale może być czasami konieczne (np. z powodów sprzętowych lub systemowych). Wtedy jesteśmy zmuszeni do złączania odpowiedzialności, ale rozdzielenie koncepcji jest wciąż prawidłowe w kontekście reszty aplikacji. Poza tym nikt nie musi używać tej klasy. Inny przykład: MVC
OCP – Open-Closed Principle Elementy oprogramowania (klasy, moduły, funkcje itd.) powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację. Kiedy pojedyncza zmiana w kodzie wywołuję łańcuch innych zmian w modułach zależnych oznacza to „zły” projekt Powinno się projektować moduły, które nigdy się nie zmieniają Konflikt! Jak zmienić zachowanie modułu, kiedy zmieniają się wymagania aplikacji? Abstrakcja! Korzystać z interfejsów, klas abstrakcyjnych, kompozycji i dziedziczenia. Kiedy zmieniają się wymagania, powinno się rozszerzać zachowanie poszczególnych modułów nie przez zmianę kodu który już działa ale przez dodawanie nowego kodu.
Zamknięty klient Client Server Klient i Server to klasy konkretne. Klient używa serwer (agregacja) Zamknięty klient
Otwarty klient Abstract Client Server Server Klasa AbstractServer reprezentuje stałą, niezmienną abstrakcję, pod którą jednak kryje się niograniczona liczba zachowań wyrażona przez konkretne klasy implementujące (dziedziczące) Otwarty klient
Aplikacja rysująca kółka i kwadraty Aplikacja posiada listę kółek i kwadratów Kółka i kwadraty muszą być rysowanę zgodnie z kolejnością na liście
enum ShapeType {circle, square}; struct Shape { ShapeType itsType; }; struct Circle double itsRadius; Point itsCenter; struct Square double itsSide; Point itsTopLeft; void DrawSquare(struct Square*) void DrawCircle(struct Circle*);
typedef struct Shape *ShapePointer; void DrawAllShapes(ShapePointer list[], int n) { int i; for (i = 0; i < n; i++) struct Shape* s = list[i]; switch (s->itsType) case square: DrawSquare((struct Square*)s); break; case circle: DrawCircle((struct Circle*)s); } Funkcja DrawAllShapes łamie zasadę open-closed, ponieważ musi być modyfikowana, ilekroć chcemy dodać nową figurę.W realnej aplikacji przy takim podejściu podobne switche byłyby w wielu miejscach i korekta wszystkich tych miejsc byłaby kłopotliwa i błędogenna.
virtual void Draw() const = 0; }; class Square : public Shape class Shape { public: virtual void Draw() const = 0; }; class Square : public Shape virtual void Draw() const; class Circle : public Shape void DrawAllShapes(Set<Shape*>& list) for (Iterator<Shape*> it(list); it; it++) (*it)->Draw(); } Domknięcie metody DrawAllShapes ze względu na kształt figury. Metoda DrawAllShapes nigdy nie ulegnie zmianie, gdy pojawi się nowy typ kształtu. Prawie żadna aplikacja/moduł może być zamknięta na 100%. Co by było gdyby program wymagał rysowania kółek przed prostokątami? Metoda DrawAllShapes nie jest odporna na tą zmianę. Musiałaby zostać zmodyfikowana. Generalnie nieważne jak odpornym nasz moduł byłby na zmiany, zawsze może pojawić się wymaganie na którą nasza abstrakcja/struktura aplikacji nie jest przygotowana.
virtual void Draw() const = 0; class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; bool operator<(const Shape& s) {return Precedes(s);} }; void DrawAllShapes(Set<Shape*>& list) OrderedSet<Shape*> orderedList = list; orderedList.Sort(); for (Iterator<Shape*> it(orderedList); it; it++) (*it)->Draw(); } bool Circle::Precedes(const Shape& s) const if (dynamic_cast<Square*>(s)) return true; else return false; Funkcja DrawAllShapes kopiuje listę do uporządkowanego zboiru, sortuje go wykorzystując metodę Precedes i rysuje na ekranie. Metoda Precedes łamie zasadę OCP. Co będzie jak porządek rysowania będzie musiał uwzględniać inne kształty?
virtual void Draw() const = 0; class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const; bool operator<(const Shape& s) const {return Precedes(s);} private: static char* typeOrderTable[]; }; char* Shape::typeOrderTable[] = “Circle”, “Square”, Podejście Data Driven. Kolejność jest ustalana przez wpis w tabeli. Teraz tylko klasa bazowa implementuje Precedes. Aczkolwiek aplikacja nie jest domknięta na 100%. Trzeba dodawać kolejne kształty do tablicy.
bool Shape::Precedes(const Shape& s) const { const char* thisType = typeid(*this).name(); const char* argType = typeid(s).name(); bool done = false; int thisOrd = -1; int argOrd = -1; for (int i = 0; !done; i++) const char* tableEntry = typeOrderTable[i]; if (tableEntry != 0) if (strcmp(tableEntry, thisType) == 0) thisOrd = i; if (strcmp(tableEntry, argType) == 0) argOrd = i; if (argOrd > 0 && thisOrd > 0) done = true; } else return thisOrd < argOrd;
OCP wiąże się z kilkoma innymi utartymi zasadami Wszystkie zmienne klasy powinny być prywatne W świetle zasady OCP powód do tego jest jasny. Jeśli zmienna klasy zmienia się, każda metoda która od nie zależy też musi zostać zmieniona i taka funkcja nie może zostać zamknięta
Co gdy dane pole nigdy się nie zmieni Co gdy dane pole nigdy się nie zmieni? Czy istnieje powód, żeby czynić je polem prywatnym? class Device { public: bool status; //status ostatniej operacji na //urządzeniu }; W OOD spodziewamy się, że metody klasy nie są zamknięte na zmiany względem pól klasy. Spodziewamy się za to, że każda inna klasa jest zamknięta na zmiany tych pól. To jest enkapsulacja. Znaczenie tego pola nigdy się nie zmieni. Więc jeśli nigdy się nie zmienia i wszystkie moduły przestrzegają zasady, że jest to pole tylko do odczytu to dlaczego nie uczynić tego pola publicznym? A co jeśli jeden moduł wykorzysta to, że to pole jest także do zapisu i zmieni jego wartosć? Wtedy inne moduły mogą odczytać nieprawdziwy status operacji. To za duże ryzyko, dlatego lepiej uczynić pole prywatnym.
int hours, minutes, seconds; Time& operator-=(int seconds); class Time { public: int hours, minutes, seconds; Time& operator-=(int seconds); Time& operator+=(int seconds); bool operator< (const Time&); bool operator> (const Time&); bool operator==(const Time&); bool operator!=(const Time&); }; Istnieje małe prawdopodobieństwo, że pola klasy Time będą zmieniane przez moduły aplikacji. Raczej będą odczytywane. Jest też mało prawdopodobne, że klasa pochodna chciałaby ustawiać poszczególne pola pojedynczo. Za to klient mógłby zmienić poszczególne pola i naruszyć spójność czasu. Ten przykład nie narusza OCP ale jest postrzegany jako „zły styl”
Żadnych zmiennych globalnych – Nigdy Żaden moduł, który zależy od zmiennej globalnej nie może zostać zamknięty względem innego modułu, jeśli ten inny moduł korzysta z tej zmiennej.
LSP – Liskov Substitution Principle Definicja Barbary Liskov: Jeśli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T, taki że dla każdego programu P zdefiniowanego w kontekście typu T, zachowanie programu P pozostaje niezmienione kiedy o1 jest podmieniane obiektem o2, to prawdziwe jest, że typ S jest podtypem typu T. Funkcje, które używają wskaźników lub referencji do klas bazowych muszą być w stanie używać obiektów klas dziedziczących po klasach bazowych bez dokładnej znajomości tych obiektów 1 dla webskich gości 2 dla mniej webskich gości
LSP Jeśli twój kod oczekuje jakiejś klasy, to zamiast niej powinieneś móc podstawić dowolną klasę z niej dziedziczącą bez zmieniania żadnych oczekiwanych zachowań. Jeśli przeciążasz metodę, napisz ją tak, by użyta polimorficznie działała poprawnie 3. Nie dla orłów 4. Dla ludu
Klasyczny problem kwadratu i prostokąta class Rectangle { protected int m_Width; protected int m_Height; public void SetWidth(int width) { m_Width = width; } public void SetHeight(int height) { m_Height = height; } public int GetWidth() { return m_Width; } public int GetHeight() { return m_Height; } public int GetArea() { return m_Width * m_Height; } }
class Square: Rectangle { public void SetWidth(int width) m_Width = width; m_Height = width; } public void SetHeight(int height) m_Width = height; m_Height = height; Rectangle r = new Square(); r.SetWidth(5); r.SetHeight(10); int area = r.GetArea(); Wynik spodziewany: 50, otrzymany: 100. Złamanie LSP: sekwencja kodu przynosi różne efekty w zależności od tego czy posiadamy referencję na klasę bazową czy potomną. Klasa potomna nie powinna zmieniać zachowania klasy. Ważne: Klasy square i rectangle widziane w izolacji wydają się być poprawne. Jednakże poprawność/spójność modelu nie może być sprawdzona w izolacji. Spójność modelu może być tylko poprawnie zbadana w kontekście wzajemnych zależności i sposobu użycia.
public class User { protected String login; protected String password; protected List modules; public void addAccessToModule(Module module) { modules.add(module); } public boolean canAccess(Module module) { return modules.contains(module);
public class Admin extends User { private List administeredModules; public boolean canAccess(Module module) { return administeredModules.contains(module); } public void subscribeToEvents(User user, Module module) { if (! user.canAccess(module)) { user.addAccessToModule(module); module.sendEventsTo(user); Na pierwszy rzut oka nie widać żadnego błędu - jeśli ktoś jeszcze nie ma dostępu do jakiegoś modułu, to jest on mu przyznawany i dopisywany jest on do listy użytkowników notyfikowanych o zdarzeniach. Ale niestety przez to, że programista nie zastosował się do zasady podstawiania, kod ten jest poprawny tylko dla zwykłych użytkowników. Wielokrotne wywoływanie tej metody dla obiektów klasy Admin spowoduje ciągłe dodawanie tych samych modułów do do kolekcji modules klasy User, choć metoda canAccess w klasie Admin w ogóle jej nie sprawdza. Błąd jest zawsze w klasie, a nie w kodzie wołającym
LSP Design by Contract. Klasy definiują warunki wejściowe i wyjściowe metod Odpowiednia dokumentacja.
ISP – Interface Segregation Principle Klienci nie powinni być zmuszani do bycia zależnymi od metod, których nie używają. Likwiduje „grube” interfejsy
interface IWorker { void Work(); void Eat(); } class Worker: IWorker public void Work() public void Eat() class Robot: IWorker throw new NotImplementedException();
class Worker: IWorkable, IFeedable public void Work() interface IWorkable { public void Work(); } interface IFeedable public void Eat(); class Worker: IWorkable, IFeedable public void Work() public void Eat() class Robot: IWorkable http://www.pzielinski.com/?p=424