Zasady SOLID w Pythonie dla początkujących. Dependency Inversion Principle.

solid dependency inversion

Każdy z nas pracował w projekcie, gdzie nawet mała zmiana była ciężka do wdrożenia. Najczęściej powodem jest źle zaprojektowany kod. Zasada Dependency Inversion radzi jak uczynić kod bardziej elastycznym przez co przyjemniejszym w pracy.

SOLID to:

zły design kodu

Istnieje całkiem duże prawdopodobieństwo, że i Ty doświadczyłeś pracy w źle zaprojektowanym kodzie. Jakie są tego symptomy? (źródło z Roberta C. Martina “DIP: The Dependency Inversion Principle“)

  • Ciężko jest wprowadzić zmiany w kodzie, bo dotykają ona zbyt wielu innych części systemu – Sztywność (ang. Rigidity)
  • Przy wprowadzaniu zmiany przestają działać części systemu, których się nie spodziewaliśmy – Kruchość (ang. Fragility)
  • Jest ciężko użyć kodu w innej aplikacji, ponieważ nie da się go wyodrębnić z obecnego projektu – Nieruchomość (ang. Immobility)

Według Martina te właśnie cechy jednoznacznie pomagają stwierdzić czy struktura kodu jest dobra czy nie. Jakie są więc powody?

  • Projekt jest “sztywny” jeśli nie można go łatwo modyfikować. Wynika to z faktu, że pojedyncza zmiana w kodzie powoduje konieczność modyfikacji X innych zależnych miejsc/modułach w kodzie. To z kolei wprowadza problemy w przewidzeniu czasu potrzebnego na wprowadzenie zmiany(kosztu). W taki sposób projekt staje się “sztywny“, ponieważ menedżerowie są mniej skorzy do wprowadzania zmian, nie znając ich konkretnego kosztu.
  • Kruchość” aplikacji objawia się poprzez wiele błędów w obszarach często niepołączonych logicznie z kodem który został zmieniony. Obniża to znacznie zaufanie względem projektu, gdy każdy “fix” może powodować kolejną lawinę błędów z nieoczekiwanej strony.
  • Wysoka zależność części kodu względem innych może znacząco utrudniać wyodrębnienie jej np. do użycia w innej aplikacji. Zdarza się, że czas(koszt) poświęcony na uniezależnienie komponentów jest wyższy niż napisanie go od nowa. “Nieruchomość

lek na zły design kodu

Nie kto inny jak sam Uncle Bob opracował zasadę Dependency Inversion(odwrócenie zależności), która pomaga rozwiązywać problemy z akapitu powyżej.

Oryginalna treść zasady brzmi:

  • Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. Wszystkie powinny zależeć od abstrakcji.
  • Abstrakcje nie powinny zależeć od szczegółów. To szczegóły powinny zależeć od abstrakcji.

Wyszedł on z założenia, że konkretna implementacja powinna zmieniać się częściej niż abstrakcja. Abstrakcja nie powinna posiadać zbędnych detali, a tylko dostarczyć kluczowych interfejsów. Warto dodać, że moduły niskopoziomowe są składowymi/podsystemami modułu wysokopoziomowego, ale moduł wysokopoziomowy może być również modułem niskopoziomowym względem innego modułu.

Ta zasada jest jednym ze sposobów na rozdzielenie(ang. Decouple) kodu, która pozwala łatwo zastępować moduły niższego poziomu bez większych nakładów pracy.

Przykład

Weźmy prosty wyimaginowany przykład gdzie zadanie(task) po wykonaniu ma wysłać wiadomość. Dla uproszczenia jako parametr przyjmuje tylko treść wiadomości.

class Task:
    def process(self) -> None:
        # some processing things...
        email_sender = EmailSender()
        email_sender.send('some nice message')


class EmailSender:
    def send(self, message: str) -> None:
        # send email
        print(f'Sending email with message: {message}')

Tak zdefiniowana zależność zadziała, ale klasa wyższego poziomu czyli w tym przypadku Task zależy od klasy niższego poziomu EmailSender(na detalu).

Zależność między klasami

Wadą takiego kodu jest przede wszystkim ilość zmian, które trzebaby było wprowadzić przy zmianie klasy EmailSender(wiadomości często wysyłane są w różnych miejscach aplikacji), lub gdybyśmy chcieli użyc inny serwis do wysyłania wiadomości.

Drugim złym efektem takiego rozwiązania jest testowalność jednostkowa klasy Task. Aby w teście nadpisać obiekt klasy niższego poziomu(EmailSender) należy użyć np. Monkey Patching(nie podejmuję się tłumaczenia 🙂 ). Innymi słowy testy zależą od tego JAK kod jest zaimplementowany, a nie od tego CO robi.

Aby oczyścić powyższy kod należy wprowadzić abstrakcję dla klasy wysyłającej wiadomości, która będzie mówić CO ma być zrobione. Drugą rzeczą jest pozbycie się tworzenia detalu(EmailSender) z klasy Task. Przekażmy go jako parametr w konstruktorze. Jest to prosty przykład wstrzykiwania zależności(dependency injection).

class MessageSender(ABC):
    @abstractmethod
    def send(self, message: str) -> None: ...


class EmailSender(MessageSender):
    def send(self, message: str) -> None:
        # send email
        print(f'Sending email with message: {message}')


class Task:
    def __init__(self, message_sender: MessageSender):
        self.message_sender = message_sender

    def process(self) -> None:
        # some processing things...
        self.message_sender.send('some nice message')

Od teraz klasa Task nie będzie się zmieniać przy zmianie w klasie EmailSender. Możemy jej również swobodnie przekazać inne serwisy wysyłające wiadomości (np. SMSSender). W ten sposób uwolniliśmy klasę Task od klasy EmailSender(detalu).

Testowanie klasy Task również staje się prostsze. W teście wystarczy przekazać mock jako parametr message_sender.

Dependency Inversion after
Zależności między klasami po zmianach w kodzie

W skrócie chcemy dostarczyć obiektowi wszystko to czego potrzebuje. Nie chcemy natomiast pozwolić aby sam sobie wziął to czego potrzebuje. Sprowadza się to do ograniczenia miejsc w kodzie gdzie tworzy się obiekt niższego poziomu w modułach wyższego poziomu.

Dependency inversion principle(dip) vs Open closed principle(ocp)

Obydwie zasady proponują wprowadzenie abstrakcji. Jednak Open-Closed skupia się na rozszerzalności w pojedynczym module, gdzie Dependency Inversion patrzy szerzej – na kompozycję modułów.

koniec serii solid w pythonie

To już ostatni wpis opisujący zasady SOLID w pythonie. Myślę, że warto mieć świadomość jak je zastosować w swoim kodzie. Dzięki nim oszczędzimy sobie sporo czasu i nerwów, szczególnie w dużych, skomplikowanych projektach.

Warto jednak mieć swiadomość, że to nie jest jedyna ‘dobra’ droga. Dla szybkiego prototypowania lub małych projektów wprowadzanie np. dodatkowych abstrakcji których wiemy, że nie będziemy nigdy używać jest sztuką dla sztuki i generuje dodatkowe koszta(czas). Jak to zwykle bywa należy dobrać rozwiązanie pod dany problem.


Bądź na bieżąco z blogiem!

Bądź na bieżąco z kolejnymi metariałami publikowanymi na tym blogu i innymi użytecznymi (moim zdaniem) materiałami zapisując się do newslettera 🙂

Jako prezent otrzymasz ode mnie mini e-book “Jak zacząć działać w Open Source i rozwijać swoje umiejętności” 🙂