P-Programowanie

Zasady SOLID

25 listopada 2016, kategoria: Paradygmaty programowania

Zasady SOLID to termin jakim zostało nazwane pięć podstawowych zasad, którymi należy się kierować programując obiektowo. Skrót pochodzi od pierwszych liter poszczególnych zasad, są to: single responsibility, open/closed, liskov substitution, interface segregation oraz dependency inversion. Zasady SOLID zostały wymyślone przez znanego amerykańskiego programistę Roberta Martina. Słynie on ze swojego podejścia do czystego kodu, przyczynił się także do rozwoju manifestu zwinnego programowania. Znajomość SOLIDu może Ci znacznie pomóc, a na pewno nie podziała na Twoją niekorzyść.

Czym są zasady SOLID?

Zasady SOLID to pięć podstawowych zasad podpowiadających jak pisać dobry kod zorientowany obiektowo. Zaproponował je słynny Amerykański programista Robert Martin. Jest on także jednym z twórców manifestu zwinnego programowania agile. Do napisania artykułu opisującego zasady SOLID skłoniło mnie osobiste doświadczenie. Zauważyłem, że wielu początkujących programistów najzwyczajniej nie rozumie sensu poszczególnych zasad. W większości przypadków każdy kto programuje, wie czym jest SOLID, jednak po chwili krótkiej dyskusji każdego można złapać na złym rozumieniu jakiejś zasady.

Pytanie o SOLID często pojawia się podczas rozmów kwalifikacyjnych w szczególności programistom z mniejszym stażem (a więc np. studentom). Nieumiejętność lub co gorsze niewiedza na temat niniejszych zasad wiele mówi o poziomie wiedzy danej osoby.

Single responsibility

Zasada pojedynczej odpowiedzialności mówi o tym, aby każda klasa była odpowiedzialna za jedną konkretną rzecz. W szczególności powinien istnieć jeden konkretny powód do modyfikacji danej klasy. Stosowanie tej zasady znacząco zwiększa ilość klas w programie, a jednocześnie zmniejsza ilość klas typu scyzoryk szwajcarski. Takim mianem określa się wielkie kilkuset linijkowe klasy skupiające za dużo funkcjonalności.

Zasada pojedynczej odpowiedzialności (ang. single responsibility principle) – każda klasa powinna być odpowiedzialna za jedną konkretną rzecz

Jednym z podstawowych kroków każdej refaktoryzacji jest zawsze wydzielenie mniejszych klas z tych już istniejących. Budowanie dużych klas zawsze wcześniej czy później prowadzi do problemów.

Określenie szwajcarskiego scyzoryka zawsze pojawia się podczas opisywania zasady pojedynczej odpowiedzialności, ponieważ jest to przykład idealny. Powszechnie wiadomo, że jeżeli coś jest do wszystkiego to jest do niczego. W programowaniu to stwierdzenie ma podwójną moc. Oto przykład złej klasy:

Powyższy przykład jest typowym przykładem złej klasy. Zawiera ona w sobie metodę sprawdzającą poprawność adresu e-mail a nie powinno leżeć to w obowiązku typu Person. Czy są jeszcze błędy? – tak. Klasa osoba nie powinna zawierać atrybutów, które nie są z nią powiązane. W przyszłości ktoś będzie musiał tę klasę refaktoryzować lub powielać kod chcąc przechować sam adres zameldowania. Poprawna klasa powinna wyglądać następująco:

Klasa została rozdrobniona aż na 3 mniejsze klasy. Czy wydaje Ci się, że kod jest poprawny? – niestety nie. W klasie EmailValidator występuje fragment kodu odpowiedzialny za rzucanie wyjątku. Równie dobrze wyjątek mógłby zostać zapisany do logów, a takiej logiki w naszym walidatorze nie chcemy. Ma on spełniać prostą funkcję walidacji, bez żadnej dodatkowej logiki. W tym wypadku rzucenie wyjątku powinno zostać wyniesione wyżej:

Jak widać prosta zasada jaką jest pojedyncza odpowiedzialność nawet w prostych przykładach potrafi zaskoczyć. W praktyce bardzo rzadko programiści rozdzielają swój kod na klasy w wystarczającym stopniu. Nie miej wrażenia, że zbytnie modularyzowanie kodu jest złe. Nawet w tak trywialnym przypadku jak ten wyżej, zaszycie walidacji w typie Person byłoby bardzo złe. Wcześniej czy później doprowadziłoby do powtarzania kodu, jeżeli ktoś chciałby zwalidować adres e-mail gdziekolwiek poza instancją klasy Person. A co gdyby ktoś chciał zwalidować adres e-mail bez rzucania wyjątku? Takie przykłady można podawać w nieskończoność.

Zasada pojedynczej odpowiedzialności dotyczy również interfejsów. Lepiej zdefiniować ich dostatecznie dużo, co da w przyszłości dużą elastyczność. Dotyczy to szczególnie interfejsów polimorficznych.

Open/closed

Zasada otwarty/zamknięty powinna być zawsze rozwijana do postaci „otwarty na rozbudowę, zamknięty na modyfikacje„. Dzięki temu, jest to praktycznie jej cała i kompletna definicja. Jest to bardzo ważna zasada, szczególnie w dużych projektach, nad którymi pracuje wielu programistów.

Każdą klasę powinniśmy pisać tak, aby możliwa była jej rozbudowa bez konieczności jej modyfikacji. Modyfikacja jest surowo zabroniona, ponieważ zmiana deklaracji jakiejkolwiek metody może spowodować awarię systemu w innym miejscu. Zasada ta jest szczególnie ważna dla twórców wszelkich wtyczek i bibliotek programistycznych.

Zasada otwarty/zamknięty (ang. open/close principle) – każda klasa powinna być otwarta na rozbudowę ale zamknięta na modyfikacje

Istnieje pewna zależność, im bardziej trzymamy się zasady pojedynczej odpowiedzialności, tym bardziej musimy dbać o zasadę otwarty na rozbudowę, zamknięty na modyfikacje. Rozważmy przykład:

Jest to oczywiście przykład zły. Dodanie nowej figury spowoduje konieczność modyfikacji istniejącej klasy Calc a dokładniej jej metody służącej do obliczenia pola figury. Bardzo dobrym mechanizmem wychodzenia z takich opresji jest polimorfizm. Dzięki niemu można obarczyć koniecznością implementacji metody liczącej pole figury każdą klasę reprezentującą figurę. Rozważmy przykład:

Dzięki użyciu polimorfizmu i mechanizmu dziedziczenia w dobry sposób dbamy o zasadę otwarty/zamknięty. W tym prostym przykładzie zamiast polimorfizmu mogłem użyć tylko interfejsu. Jednak interfejs jest bezstanowy i w przypadku bardziej rozbudowanych klas lepszym rozwiązaniem jest klasa wirtualna lub abstrakcyjna, w której można dodatkowo zdefiniować inne atrybuty.

Czy powyższy przykład przekonał Cię do konieczności trzymania się zasady otwarty/zamknięty? Być może nie. Aby rozumieć konieczność używania tej zasady, pomyśl o klasie Calculator jako klasie zahermetyzowanej w pliku DLL, który jest udostępniony tysiącom klientów. Drobna poprawka w kodzie zmusza tysiące programistów do pobrania nowej wersji pliku DLL z nowszą wersją metody liczenia pola. Dlatego właśnie klasa powinna być otwarta na modyfikacje bez możliwości jej edycji.

Popatrzymy na problem od drugiej strony, rozważmy inny prosty przykład biblioteki do generowania raportów:

Załóżmy, że jest to kod klasy odpowiedzialnej za generowanie raportów w formacie PDF. Czy kod jest poprawny? Teoretycznie tak. Do momentu, w którym twórca klasy nie zechce dodać do niej opcji generowania raportu w formacie Excel. Co zrobić w tym momencie? Dołożenie parametru do konstruktora jest złamaniem zasady otwarty/zamknięty. Spowoduje, że u tysięcy użytkowników naszej biblioteki kod przestanie działać.

W tym przypadku, problem leży w błędnym zaprojektowaniu klasy od samego początku. Jednym ze sposobów poradzenia sobie z takim problemem jest użycie konstruktora wieloargumentowego. W przypadku wielu konstruktorów dobry nawykiem jest korzystanie ze wzorca constructor chaining. Poprawiony kod:

Przykład jest trywialny. Nie do końca udało się nie złamać zasady otwarty/zamknięty, ale udało się wyjść z trudnej sytuacji. Funkcjonalność klasy została rozszerzona bez problemów z kompatybilnością wstecz.

Liskov substitution

Zasada podstawienia Liskov jest w moim mniemaniu zasadą, którą najciężej zrozumieć, a ludzie bardzo często mylą ją z wszelkimi innymi zasadami. Jej nazwa pochodzi od nazwiska amerykańskiej programistki Barbary Liskov. W skrócie zasada polega na tym, że w miejscu klasy bazowej można zawsze użyć dowolnej klasy pochodnej. Oznacza to, że w 100% musi być zachowana zgodność interfejsu i wszystkich metod.

Zasada podstawienia Liskov (ang. liskov substitution principle) – w miejscu klasy bazowej można użyć dowolnej klasy pochodnej (zgodność wszystkich metod)

Sztandarowym przykładem złamania zasady podstawienia Liskov, jest kwadrat dziedziczący z prostokąta. W matematyce kwadrat jest prostokątem, jednak w programowaniu nie jest, nie można użyć relacji dziedziczenia (is-a) pomiędzy tymi dwoma typami. Pole kwadratu jest liczone z innego wzoru, skutkiem czego kwadrat musi przesłonić metodę liczenia pola prostokąta. Jest to złamanie zasady podstawienia Liskov. Spójrzmy na inny przykład:

W powyższym przykładzie utworzyliśmy abstrakcję Animal jednak występuje tutaj zjawisko źle przemyślanego mechanizmu dziedziczenia. Ryba jest zwierzęciem, ale  została obarczona implementacją metody Run() znajdującej się w klasie bazowej. Ryba jak to ryba, nie może biegać i jest to złamanie zasady podstawienia Liskov. Dziedziczenie należy zaplanować inaczej, tak aby każda klasa pochodna mogła wykorzystać funkcje klasy bazowej.

Zasada podstawienia Liskov najczęściej łamana jest w przypadkach:

  • kiedy programista źle rozplanował mechanizm dziedziczenia, interfejs polimorficzny jest zbyt ogólny
  • zastosowane dziedziczenie bez mechanizmu polimorfizmu (mało efektywne i często prowadzi do złamania Liskov)
  • klasy pochodne nadpisują metody klasy bazowej zastępując jej niepasującą logikę

W dobrze zaplanowanym mechanizmie dziedziczenia, klasy pochodne nie powinny nadpisywać metod klas bazowych. Mogą je ewentualnie rozszerzać, wywołując metodę z klasy bazowej (np. poprzez słowo kluczowe base będącym wskaźnikiem na klasę bazową). Spójrzmy na przykład:

Powyższy przykład idealnie przestrzega metode podstawienia Liskov. Nie dość że obiekt klasy pochodnej można użyć w miejscu klasy bazowej, to na dodatek mimo użycia polimorfizmu nie nadpisujemy metod klasy bazowej, tylko z nich korzystamy.

Należy się także wystrzegać wszelkich instrukcji warunkowych sprawdzających typ pochodny klasy przed wywołaniem danej funkcji. Przykładowo:

Ten kod także jest błędny, złe dziedziczenie powoduje konieczność dodawania dodatkowej logiki sprawdzającej typ pochodny, ponieważ nie wszystkie są w 100% możliwe do podstawienia pod typ bazowy.

Interface segregation

Zasada segregacji interfejsów jest bardzo prosta, mówi aby nie tworzyć interfejsów z metodami, których nie używa klasa. Interfejsy powinny być konkretne i jak najmniejsze.

Zasada segregacji interfejsów (ang. interface segregation principle) interfejsy powinny być małe i konkretne aby klasy nie implementowały metod, których nie potrzebują

Do tworzenia typu bazowego przeważnie lepiej użyć klasy abstrakcyjnej. Może ona opisywać konkretny typ, zawierać odpowiednie atrybuty oraz metody, którymi następnie obarcza wszystkie klasy pochodne. Klasa bazowa definiuje model biznesowy, który akurat potrzebujemy. Interfejs natomiast jest bezstanowy, nie powinien definiować modelu biznesowego. Interfejs powinien zapewniać kontrakt, informujący programistę o zachowaniach danego typu. Przykładowy kod:

Kod jest błędny, ponieważ nie każda metoda definiowana przez interfejs jest wykorzystana w klasach pochodnych. Zamiast głównego interfejsu IRaportable można utworzyć wiele mniejszych interfejsów. Przykładowy kod:

Dzięki podzieleniu interfejsu na mniejsze, utrzymujemy porządek w interfejsie polimorficznym typu. Dzięki temu typy pochodne nie są związane kontraktami, które nie są im potrzebne.

Łamanie zasady segregacji interfejsów prowadzi do niemiłych sytuacji, kiedy iterując po liście typów bazowych ze wspólnym interfejsem polimorficznym rzucony zostaje wyjątek, ponieważ któraś z klas nie implementuje metody rozbudowanego interfejsu.

Dependency inversion

Zasada odwrócenia odpowiedzialności jest prostą i bardzo ważną zasadą. Polega ona na używaniu interfejsu polimorficznego wszędzie tam gdzie jest to możliwe, szczególnie w parametrach funkcji.

Zasada odwrócenia odpowiedzialności (ang. dependency inversion principle) – wszystkie zależności powinny w jak największym stopniu zależeć od abstrakcji a nie od konkretnego typu

Pojęcia odwrócenia odpowiedzialności nie należy mylić ze wstrzyknięciem zależności (ang. dependency injection). Jeżeli mamy parametr funkcji, który przyjmuje figurę matematyczną, znaczenie lepszym rozwiązaniem będzie przyjęcie interfejsu lub klasy abstrakcyjnej figur matematycznych niż konkretnej figury.

Dzięki przestrzeganiu zasady nie uzależniamy pojedynczej metody od konkretnego typu, tylko od interfejsu, który mogą implementować duże grupy podtypów.

Dlaczego warto przestrzegać zasad SOLID?

Zasady SOLID są niezłą bazą dla każdego początkującego programisty. W dużych projektach nie zawsze wszystkie zasady da się idealnie przestrzegać, jednak powinniśmy dążyć do poprawy jakości kodu i wdrażania zasad SOLID jeżeli tylko jest to możliwe. Zły kod w większości przypadków łamie kilka zasad SOLID jednocześnie. Jeżeli na drodze refaktoryzacji okaże się, że łamie już tylko jedną lub wcale, jest to ogromny sukces. Nawet jeżeli teraz nie dostrzeżesz tego sukcesu, dostrzeże go zapewne ktoś, kto będzie pracował na tym kodzie za kilka miesięcy lub lat.

Pisząc kod osobiście w większości przypadków wszystko się rozumie, nawet gdyby kod był najgorszej jakości. Każdy po prostu rozumie to co sam napisał. Prawdziwy problem pojawia się gdy obca osoba jest zmuszona przesiąść się do nieswojego projektu i pisać w kodzie, którego nigdy wcześniej nie widziała. Są to momenty, w których bardzo pomaga to że:

  • zamiast jednej klasy zawierającej 2000 linii kodu jest 20 małych klas, z której każda jest odpowiedzialna za jedną, małą, konkretną rzecz (zasada pojedynczej odpowiedzialności)
  • autor klasy przewidział poszerzenie funkcjonalności jego klasy bez konieczności przerabiania jej kodu np. poprzez mechanizm dziedziczenia i polimorfizmu (zasada otwarty/zamknięty)
  • korzystając z klas pochodnych mamy pewność, że implementują one wszystkie metody klas bazowych i nie musimy tego sprawdzać (zasada liskov substitution)
  • system zbudowany jest z małych interfejsów (często tylko z jedną metodą), dzięki czemu jesteśmy w stanie zaimplementować w nowo dopisanej przez nas klasie 2 interfejsy których potrzebujemy i ani jednego więcej, bez niepotrzebnych metod (interface segregation)
  • poprzedni programista używał typów abstrakcyjnych tam gdzie to tylko możliwe (np. w parametrach funkcji) więc możemy przekazać do funkcji każdą kolekcję implementującą interfejs IEnumerable a nie tylko listę. A interfejsy znajdujące się w projekcie mają sens i mogą zostać użyte, a nie są tylko sztuką dla sztuki, bo po co nam interfejsy, jeżeli wszystkie funkcje przyjmują jako parametry typy pochodne (dependency inversion)

Mam nadzieje, że po przeczytaniu tego artykułu zrozumienie zasad SOLID będzie dla Ciebie łatwiejsze. Jest to pierwszy krok na drodze do pisania czystszego kodu.

Użytkownik Maciek napisał/a:

02 stycznia 2017


Hej,

czy przykład OCP nie łamie SRP? Widzę to w wielu miejscach (tutorialach) i nie rozumiem. Dlaczego nagle dodajemy nową funkcjonalność do klas, które nie miały mieć z nią nic wspólnego?

Pozdro!

Użytkownik Karol napisał/a:

07 stycznia 2017


Cześć,
kod prawdziwej aplikacji, który spełniałby wszystkie zasady SOLID na raz nie istnieje. Zawsze można coś rozdzielić lub wydzielić bardziej. Ważne aby znać i stosować SOLID wszędzie tam gdzie jest możliwe jego zastosowanie. Czasem trzeba zdecydować się poświęcić jakąś zasadę w zamian za profity w kodzie.

Użytkownik Marcin napisał/a:

15 stycznia 2017


Cześć
Artykuł w sam raz na szybkie rozeznanie tematu.
Btw. na jednej z rekrutacji na developera jaką miałem na 1/3 pytań odpowiedziałem na podstawie tego co nauczyłem się z artykułów na tej stronie o cpp i oop. Czekam na więcej. Możesz napisać coś o testowaniu jednostkowym za pomocą jakiejś biblioteki w cpp?.

Użytkownik Leszek napisał/a:

13 lutego 2017


Ładnie piszesz, ale testuj swoje kody.
Metody w interfejsach nie mogą być publiczne.
Pozdrawiam

Użytkownik Karol napisał/a:

13 lutego 2017


Zgadza się!
Poprawię w najbliższym czasie :)

Użytkownik decho napisał/a:

03 marca 2017


SUPER.
Piszę programy od kilkunastu lat, ale jestem samoukiem i wielu rzeczy nie wiem.
Cieszę się że młodzi programiści dzielą się wiedzą z której mogą skorzystać również starsi.

Nie jestem pewien ale chyba tu jest błąd:
„klasy bazowe nadpisują metody klasy bazowej zastępując jej niepasującą logikę”
jeżeli dobrze zrozumiałem to chyba powinno być :
„klasy pochodne nadpisują metody klasy bazowej zastępując jej niepasującą logikę”

BTW. Przydała by się informacja które pola są obowiązkowe aby wysłać komentarz.:)
Pozdrawiam

Użytkownik ProgramistaJava napisał/a:

13 maja 2017


Bardzo dobry artykuł, pozwala w łatwy sposób zrozumieć koncepcje, które są niepotrzebnie utrudniane w innych źródłach wiedzy.

Użytkownik Draktes napisał/a:

13 sierpnia 2017


„Zasady SOLID zostały wymyślone przez znanego amerykańskiego programistę Roberta Martina.” Oj to nie prawda… Przeczytaj jego książkę mówiącą o SOLID: Agile. Programowanie zwinne: zasady, wzorce i praktyki zwinnego wytwarzania oprogramowania w C#. Pisze on kto wymyślił każdą z zasad on je tylko zebrał.

Dependency inversion principle
Wysokopoziomowe moduły nie powinny zależeć od modułów niskopoziomowych – zależności między nimi powinny wynikać z abstrakcji.

„Zasada odwrócenia odpowiedzialności jest prostą i bardzo ważną zasadą. Polega ona na używaniu interfejsu polimorficznego wszędzie tam gdzie jest to możliwe, szczególnie w parametrach funkcji.”- Ważna w tej zasadzie jest warstwa systemu w której implementujemy interface…Chodzi o to aby np warstwa prezentacji nie zależała bezpośrednio od warstwy aplikacji, komunikacja między nimi powinna odbywać się za pomocą abstrakcji. Czyli w warstwie prezentacji np kontroler MVC (ASP.NET) korzysta z interface z warstwy aplikacji, w której znajduje się też jego implementacja. To o czym piszesz w tej części artykułu bardziej odpowiada strategy pattern

Zachęcam Cię do zostawienia komentarza!

Ilość znaków: 0