Wzorzec SOLID

Dzisiaj czas na pierwszy wpis z serii wzorców projektowych. Zaczniemy od pięciu podstawowych założeniach programowania obiektowego – SOLID. W przypadku każdej z zasad pokażę zarówno błędne jak i prawidłowe zastosowanie konkretnych reguł.

Nawet jeżeli sami nie mamy problemów z organizacją i zrozumieniem swojego kodu to okazuje się, że w momencie gdy przychodzi nam pracować w grupie lub analizować czyiś kod, stajemy przed nie lada wyzwaniem. W takim celu powstały wzorce projektowe, które pozwalają nam z tym walczyć. Zapraszam do lektury.

SOLID?

Na sam początek pozwolę sobie rozwinąć ten akronim.
Single responsibility principle – zasada pojedynczej odpowiedzialności
Open-closed principle – zasada otwarte-zamknięte
Liskov substitution principle – zasada podstawienia Liskov
Interface segregation principle – zasada segregacji interfejsów
Dependency inversion principle – zasada odwrócenia zależności
Przejdźmy teraz do objaśnienia każdej z nich.

Single responsibility principle

Jest to bardzo prosta do zrozumienia zasada. Mówi ona o tym, że każda klasa powinna być odpowiedzialna za dokładnie jedno zadanie. Dlaczego? Wiążę się to z redukcją ryzyka przypadkowej modyfikacji oraz konieczności powielania kodu.

W praktyce gdy każde z zadań rozdzielamy pomiędzy odpowiednie klasy kod staje się o wiele bardziej czytelny oraz bardzo mocno ułatwia nam to sprawę w przypadku próby lokalizacji błędu.

Dodatkowo jak już wcześniej wspomniałem w przypadku klas odpowiedzialnych za więcej niż jedno zadanie istnieje szansa, że będziemy musieli powielić niektóre części kodu.

Sprawdźmy to w praktyce. Załóżmy, że tworzymy aplikację, która pozwala ludziom sprawdzić swoje BMI oraz na jego podstawie zamierzamy wyświetlić im odpowiednie porady. Nie powinniśmy jednak robić tego w taki sposób jak ja poniżej.

Okazuje się, że w przypadku powyższego rozwiązania nie mielibyśmy możliwości zweryfikowania adresu email w innym miejscu niż w klasie naszej osoby. Właściwie moglibyśmy to zrobić, ale musielibyśmy powielić ten sam fragment kodu. Dodatkowo gdy okazałoby się, że w formule znajdował się błąd, a my nieumyślnie poprawilibyśmy go tylko w jednym miejscu mogły by wyniknąć z tego kłopoty.

A co jeżeli zrobilibyśmy to tak?

Jak widać objętościowo zajmuje to troszkę więcej miejsca, wszystko jest wtedy jednak bardziej zorganizowane oraz pozbyliśmy się wyżej wymienionych zagrożeń.

Open-closed principle

Przed nami kolejna z prostszych zasad, czyli otwarty na rozszerzenia (rozbudowę), zamknięty na modyfikację. Jak bardzo łatwo można się domyślić całość odnosi się do polimorfizmu. Projektując nasze klasy powinniśmy robić to w taki sposób, tak, abyśmy nie musieliśmy modyfikować wcześniej napisanego przez nas kody gdy zechcemy dodać do niego nową funkcjonalność.

Wyobraźmy to sobie na przykładzie kalkulatora. Nie robimy tego w taki sposób jak poniżej.

W przypadku powyższego kodu występuje kilka problemów. Po pierwsze, w przypadku gdy zechcielibyśmy dodać do niego nową funkcjonalność taką jak na przykład dodawanie zmuszeni będziemy poprawić już wcześniej napisany przez nas kod. Po drugie jest strasznie niewygodny w użyciu, co jednak nie jest najbardziej istotne w tym przypadku, dlatego zapomnijmy o tym na chwilkę.

Oczywiście wzorzec nie zabrania nam poprawy błędów w naszych aplikacjach, nie zrozummy tego źle ^^. Istnieją dwa najważniejsze powody, dla których powinniśmy to rozwiązywać w sposób który zaraz wam zaprezentuję.

Po pierwsze, jeżeli z naszej biblioteki korzystaliby jacyś użytkownicy a my zdecydowalibyśmy się na zmianę wcześniej wymyślonego przez nas schematu na przykład poprzez dodanie dodatkowego parametru do konstruktora, okazałoby się, że nagle cały kod przestałby im działać i zmuszeni byliby oni do poprawy wcześniej napisanego przez siebie kodu.

Po drugie, gdy modyfikujemy jakiś fragment kodu z którego korzysta potem wiele następnych metod może się okazać, że wpłynie to na nich poprawność co może się okazać trudne do wyłapania (bo przecież kiedyś dokonaliśmy już odpowiednich testów i wiemy, że dana metoda działa jak trzeba).

Dlatego nasze aplikacje budujemy w taki sposób, aby nowe funkcjonalności rozszerzały wcześniej zdefiniowane przez nas schematy. W dużym skrócie oznacza to kompatybilność wsteczną.

Oto w jaki sposób możemy to rozwiązać z powyższą regułą.

Liskov substitution principle

Trzecia zasada jest bardzo podobna do drugiej, można wręcz powiedzieć, że jest jej rozszerzeniem. Tak jak zasada „open-closed” dotyczy ona dziedziczenia.

Chodzi o to, aby wszystkie klasy które implementują dany interfejs lub dziedziczą po danej klasie miały taką samą funkcjonalność.

W dużym skrócie, na przykładzie.

Pomimo, że obie te klasy dziedziczą po tym samym interfejsie, okazuje się, że ich działanie jest zupełnie inne, co łamie naszą trzecią zasadę SOLID. Do naszego interfejsu chłodzącego wkładamy mięso, okazuje się jednak, że pierwsza klasa ją ochłodzi, druga natomiast zamrozi.

Jednym z rozwiązań jest zastosowanie instrukcji warunkowych, które wyeliminują błędne przypadki, no ale właśnie tego chcemy uniknąć.

Podobny, może nawet bardziej obrazowy, przykład to przykład z ptakami. Wyobraźmy sobie prosty interfejs ptaka, ponownie błędne zastosowanie.

Posiada on dwie metody do zaimplementowania: „zjedzenie czegoś” oraz „latanie”. Tworzymy dwie klasy konkretnych ptaków które implementują ten interfejs.

Wiemy jednak, że pingwiny nie potrafią latać, dlatego podana operacja jest niemożliwa do zrealizowania.

Zgodnie z zasadą podstawienia Liskov powinniśmy najpierw podzielić ptaki na te latające i te, które nie latają. Dopiero wtedy powinniśmy się wziąć za ich implementacje. Poniżej poprawne rozwiązanie.

Jeszcze raz w skrócie. Zasada podstawienia Liskov wymaga od nas, aby wszystkie klasy implementujące dany interfejs spełniały wszystkie jego wymogi abyśmy nie musieli dodawać specjalnej logiki „poprawiającej” dane przypadki.

Interface segregation

Moim zdaniem jest to najprostsza do zapamiętania ze wszystkich pięciu reguł (chociaż właściwie wszystkie nie są takie złe).

Mówi ona o tym, że każdy interfejs powinien zawierać tylko te metody których naprawdę potrzebuje. W tym przypadku chcemy uniknąć sytuacji gdzie okaże się, że jedna z klas nie będzie mogła zaimplementować którejś z metod.

Jak zawsze zacznijmy od błędnego przykładu zastosowania powyższej zasady.

Jak widać spotykamy się z pewnym problemem. Interfejs pracownika oczekuje od nas, że każda z klas implementujących będzie jadła, spała i pisała kod. Okazuje się jednak, że asystent nie ma obowiązku kodowania, dlatego jesteśmy zmuszeni zwrócić wyjątek.

Aby uniknąć takiej sytuacji powinniśmy lepiej segregować interfejsy. Oto w jaki sposób to rozwiązujemy.

Jak widać nasz interfejs pracownika rozbiliśmy na dwa mniejsze, „pracownik” oraz „deweloper”. Teraz nie spotykamy się już z problemem konieczności implementacji metody „pisanie kodu” w przypadku asystenta, ponieważ zwyczajnie nie implementuje on teraz interfejsu, który zawiera tą metodę.

Deweloper natomiast implementuje dwa mniejsze interfejsy.

Dependency inversion

W sumie mogę z łatwością stwierdzić, że jest to zasada równie prosta jak poprzednia.

Polega ona na używaniu interfejsów lub klas abstrakcyjnych wszędzie tam, gdzie jest to możliwe. Chodzi o to, aby możliwie jak najwięcej zależało od abstrakcji a nie konkretnych implementacji.

Po zobaczeniu prostego przykładu od razu zrozumiecie o co chodzi.

Na potrzeby zarówno dobrego jak i złego przykładu tworzymy prosty interfejs oraz dwie jego implementacje.

Czas na przykład niezgodny z piątą zasadą.

Jak widać w powyższym przykładzie jako typ naszych danych wybraliśmy konkretną implementację „SomeKindOfList”. Okazuje się jednak wtedy, że jeżeli postanowilibyśmy dodać do naszej listy „SomeKindOfTree” nie mamy takiej możliwości. Podobnie w przypadku parametru naszej metody doubleSize.

Okazuje się jednak, że jeżeli zamienilibyśmy typ naszych danych na interfejs, mógłby on wtedy pomieścić zarówno jeden jak i drugi typ danych.

Podobny przykład z życia to na przykład deklarowanie list (co już właściwie także pokazałem w poprzednim przykładzie).

To wszystko jeżeli chodzi o tą zasadę. Dzięki zastosowaniu takiego zabiegu nie uzależniamy naszej metody lub zmiennej od konkretnego typu danych, tylko od jakiegoś zbioru.

Takim oto sposobem dotarliśmy do końca pierwszego wpisu z serii wzorców projektowych. Jak zapewne się już przekonałeś zasady SOLID są o wiele prostsze niż można byłoby się tego spodziewać. Gratuluję wytrwałości i zachęcam do ciężkiej pracy, która z pewnością się opłaci. Miłego wieczoru!

One Comment, RSS

  1. Damian Cebulski 23 maja 2017 @ 03:59

    Naprawdę fajna strona. Dzięki !

Your email address will not be published. Required fields are marked *

*