Design Patterns - Obserwator i Strategia
Mentor & Software Engineer
Spis treści
Podziel się wpisem ze znajomymi!
Wstęp
Wzorce projektowe (ang. design patterns) to najprościej - ustalone, uniwersalne i sprawdzone w praktyce rozwiązania często pojawiających się problemów projektowych. Przyznaj, ile razy zdarzyło Ci się stworzyć ciężko rozszerzalny i po prostu źle przemyślany projekt aplikacji. Powodem takiego wypadku przy pracy jest zazwyczaj niewzięcie wszystkich czynników pod uwagę czy przekombinowane i nieoptymalne rozwiązania. I to właśnie w momencie, gdy poszukujemy jak najbardziej efektywnego sposobu na rozwiązanie problemu, na horyzoncie pojawiają się wzorce projektowe. Są to niejako strategie, którymi powinien kierować się programista, aby mieć pewność, że dobrze projektuje swój program. Zapytasz teraz pewnie, czy to znaczy, że jeżeli wcześniej nie używałem żadnych wzorców w swoich programach, to były one z góry źle projektowane? Z góry? Na pewno nie!
Nie jest powiedziane, że wzorce to jest niejako punkt konieczny do umieszczenia w swoich programach. Mają one służyć jedynie jako pomoc, drogowskaz, więc nie powinny być nieodłącznym elementem programu.
Założę się również, że w wielu przypadkach nieświadomie reimplementowałeś istniejące już wzorce! Bądź świadomy tego, że istnieje naprawdę wiele wszelakich wzorców projektowych i żaden programista nie zna ich wszystkich. A to wcale nie dlatego, że opierają się one na jakiejś górnolotnej, ciężkiej do opanowania filozofii. Są one po prostu na tyle proste, że programiści korzystają z nich nieświadomie.
Jednak są też pewne wzorce projektowe, na które już ciężko wpaść mimowolnie i to właśnie ten artykuł rozpocznie serię ich omawiania. Nawet jeśli nigdy nie natkniemy się na problemy, które te wzorce opisują, to ich znajomość nadal się przyda, bo uczą one jak poradzić sobie z wieloma ciężkimi do rozgryzienia sytuacjami w kodzie.
Zbierając więc wszystkie zalety, dzięki wzorcom otrzymujemy:
- Pełną zgodność z zasadami SOLID
- Łatwość w komunikacji między programistami
- Możliwość łatwego utrzymania i rozwijania kodu
Już wyobrażam sobie entuzjazm na Twojej twarzy i gotowość do implementowania wzorców projektowych. Mega mnie to cieszy, ale muszę Cię tylko troszeczkę ostudzić. Bądź świadomy tego, iż nie warto na siłę umieszczać w swoim programie pozornie pasujących wzorców projektowych.
Zanim zdecydujesz się na zastosowanie któregoś, musisz zrobić solidny rachunek sumienia i dogłębnie zastanowić się, czy wzorzec nie skomplikuje logiki programu. Łatwo bowiem postrzegać wzorce projektowe jako, tzw. golden hammery - czyli te same rozwiązania na wszystkie bóle i gorączki tego świata. Mając przecież takie młotki, wszystko, niekoniecznie słusznie, może wydawać się gwoździem. Na dowód tego, co mówię - zobacz jak można łatwo utrudnić napisanie prostego Hello World, na siłę implementując różne wzorce projektowe. Link tutaj
Klasyfikacja wzorców
W zależności od tego, jaką kategorię problemu rozwiązuje określony design pattern, możemy przypisać go do jednej z 3 grup:
Wzorce kreacyjne - opisują, w jaki sposób tworzone są obiekty
Wzorce behawioralne - opisują, w jaki sposób zachowują się obiekty
Wzorce strukturalne - opisują sposób, w jaki obiekty są zbudowane
Poniżej lista wzorców z podziałem na ich kategorie.
Kreacyjne (Creational) | Behawioralne (Behavioral) | Strukturalne (Structural) |
Factory Method Abstract Factory Prototype Singleton Builder Lazy initialization |
Chain of Responsibility Command Interpreter Observer Strategy Visitor |
Adapter Bridge Decorator Proxy Facade Composite |
Tak jak więc widzisz - potencjalnego materiału do nauki jest sporo, jednak głupotą byłoby wyuczenie się wszystkich powyższych wzorców na pamięć. Tym bardziej że z ideą, choćby wzorca Composite, na pewno miałeś nieświadomie już do czynienia (zagadnienie kompozycji).
Wzorce projektowe na, których skupimy się w tym artykule, zaliczają się do kategorii wzorców Behawioralnych (ang. Behavioral) konkretnie wzorce, które omówimy, będą to Observer i Strategy.
Wzorzec Obserwator (Observer)
Obserwator to czynnościowy (behawioralny) wzorzec projektowy pozwalający zdefiniować mechanizm subskrypcji w celu powiadamiania wielu obiektów o zdarzeniach dziejących się w obserwowanym obiekcie.
Idealnym przykładem z życia codziennego będzie sklep internetowy i system powiadamiania użytkowników o dostępności przedmiotu. Gdy wejdziesz na stronę któregokolwiek sklepu z elektroniką, a przedmiot będzie niedostępny, zobaczysz możliwość zostawienia kontaktu i otrzymania informacji, gdy produkt się pojawi. Wtedy Ty, jako subskrybujący, obserwujesz taki obiekt i gdy jego stan zmieni się z "niedostępny" na "dostępny", otrzymasz stosowne powiadomienie. Dzięki temu nie będziesz musiał nieustannie śledzić stanu magazynu. Jest to niezwykle przydatne szczególnie w dobie zamówień internetowych i szybkiej dostawy do domu.
Mechanizm subskrybcji pozwala pojedynczym obiektom subskrybować lub rezygnować z powiadomień o zdarzeniu
Omówienie
Za każdym razem, gdy zmieni się dostępność przedmiotu w sklepie, powiadamiać on będzie wszystkich swoich subskrybentów. Bądź świadomy również tego, że w programie możemy operować na wielu różnych klasach subskrybentów. Dlatego wzorzec ten unika sztywnego łączenia nadawcy z wszystkimi subskrybentami. Wszyscy subskrybenci implementują ten sam interfejs, aby publikujący komunikował się z nimi wyłączenie przez uspójnione metody.
Publikujący natomiast powinien deklarować generyczną metodę powiadamiania (np. update), która wraz z zestawem parametrów wyśle powiadomienie, używając odpowiedniego medium czy też przekaże dodatkowe dane kontekstowe.
Oczywiste jest również, że możemy chcieć zaimplementować także wiele różnych typów nadawców. Wówczas projekt oparlibyśmy o różne implementacje klasy Item wykorzystujących ten sam interfejs. Taki interfejs musiałby tylko opisywać kilka metod subskrybowania. Dzięki temu mielibyśmy możliwość obserwowania stanów obiektów publikujących bez konieczności sprzęgania ich z konkretnymi implementacjami.
Sklep przy użyciu klasy przedmiot powiadamia subskrybentów o ponownej dostępności przedmiotu, wywołując odpowiednie metody powiadomienia.
W celu lepszego zrozumienia wyżej przedstawionej teorii, przyjrzyjmy się praktycznej implementacji Obserwatora.
from __future__ import annotations
from abc import ABC, abstractmethod
class Item:
"""
The Item's class with set of methods for subscribers' management.
"""
def __init__(self):
self._state: bool = False
self._price: float = 0.0
self._subscribers: list['Subscriber'] = []
def add_subscriber(self, subscriber: Subscriber) -> None:
"""
Attach a new subscriber who observe to the item.
"""
print("New subscriber attached as observer")
self._subscribers.append(subscriber)
def unsubscribe(self, subscriber: Subscriber) -> None:
"""
Remove a subscriber.
"""
self._subscribers.remove(subscriber)
def notify_subscribers(self) -> None:
"""
Notify all subscribers which observe the item.
"""
print("\nNotifying all subscribers")
for subscriber in self._subscribers:
subscriber.update(self)
def change_item_state(self):
"""
Change the state of the item.
"""
print("\nI am changing the state of the item")
self._state = not self._state
print("Item state changed")
def change_item_price(self, price: float) -> None:
"""
Change the price of the item.
"""
self._price = price
class Subscriber(ABC):
"""
The observer interface declares the update method, used by the item
"""
def update(self, item: Item) -> None:
"""
Update the subscriber with the new state of the item
"""
pass
class ConcreteSubscriberA(Subscriber):
"""
The ConcreteSubscriberA class is an observer that implements the update method.
"""
def update(self, item: Item) -> None:
"""
Update the subscriber with the new state of the item.
"""
if item._state and item._price < 3999.99:
print("ConcreteSubscriberA: Reacted to the event")
class ConcreteSubscriberB(Subscriber):
"""
The ConcreteSubscriberB class is an observer that implements the update method.
"""
def update(self, item: Item) -> None:
"""
Update the subscriber with the new state of the item
"""
if item._state:
print("ConcreteSubscriberB: Reacted to the event")
if __name__ == "__main__":
item = Item()
item.change_item_price(3500.0)
subscriber_a = ConcreteSubscriberA()
item.add_subscriber(subscriber_a)
subscriber_b = ConcreteSubscriberB()
item.add_subscriber(subscriber_b)
item.change_item_state()
item.notify_subscribers()
item.unsubscribe(subscriber_a)
item.notify_subscribers()
Wynik działania
New subscriber attached as observer
New subscriber attached as observer
I am changing the state of the item
Item state changed
Notifying all subscribers
ConcreteSubscriberA: Reacted to the event
ConcreteSubscriberB: Reacted to the event
Notifying all subscribers
ConcreteSubscriberB: Reacted to the event
Observer i Twitter?
Poznaliśmy już teorię i sposób implementowania omawianego wzorca. Aby jeszcze dodatkowo podnieść Twoją ciekawość i uwydatnić praktyczność omawianego wzorca projektowego - zastanów się, jak działa mechanizm powiadamiania na Twitterze. Okazuje się, że opiera się on również na wzorcu Observer!
Zauważ bowiem, żę w Twitterze mamy zaimplementowany system friends-connection. Czyli mechanizm, w którym każdy z użytkowników buduje swoją listę profili obserwowanych (obiektów observable). Każda zmiana przeprowadzona w obrębie takiego profilu powoduje powiadomienie followers-ów (observer-ów).
Programowanie reaktywne
Omawiając Observer-a, czuję się zobowiązany do wspomnienia również o paradygmacie programowania reaktywnego (temat mocno powiązany z zagadnieniami webowymi, które omawiam w trakcie mentoringu z uczniami będącymi na zaawansowanym poziomie wiedzy). W skrócie - reaktywność polega na polega na przetwarzaniu strumieni danych i propagowaniu ich zmian. Słowo "propagowanie" jest tutaj kluczowe, ponieważ podejście to nawiązuje do wzorca projektowego Observer i koncepcja programowania reaktywnego wywodzi się właśnie z niego
Zalety i wady
Na podniesienie Twojej programistycznej świadomości, przeanalizujmy jeszcze zalety i wady omawianego wzorca.
Zalety | Wady |
1. Stosujemy zasadę O/C (Open/Close). 2. Jesteśmy w stanie utworzyć związek pomiędzy obiektami dynamicznie - w trakcie działania programu (poprzez dodanie do listy obiektów obserwującego kolejnego obiektu klasy). |
1. Subskrybenci są powiadamiani w losowej kolejności, aby kontrolować kolejność notyfikacji, musimy wdrożyć osobną logikę obserwowania. |
Na koniec dodam jeszcze, że obserwator ma powiązania również z innymi design pattern-ami, takimi jak: Polecenie (Commander), Mediator (Mediator) i Łańcuchem Żądań (Chain of Responsibility). Każdy z nich umożliwia łączenie nadawców z odbiorcami żądań. Zachęcam Cię do zorientowania się również w obszarze przed chwilą wymienionych wzorców. Szczególnie dlatego, że są to niezwykle popularne rozwiązania w dobie programowania.
Tymczasem kontynuujmy nasze zmagania i przejdźmy do omówienia następnego wzorca, czyli Strategii.
Wzorzec Strategia (Strategy)
Strategia będzie kolejny wzorcem, który poznamy. Strategia, tak jak Observer, zalicza się również do wzorców behawioralnych - nazywanych zamiennie czynnościowymi.
Strategia - jak sama nazwa wskazuje umożliwia realizowanie konkretnej strategii wykonawczej w programie. Tak więc dzięki niej możemy definiować rodziny algorytmów, a następnie wybierać i realizować konkretną implementację algorytmu z takiej rodziny.
Wyobraźmy sobie, że jesteśmy właścicielami firmy wysyłającej paczki wraz z zapakowaniem ich dla klienta. Każdy z obiektów będzie charakteryzował się konkretnym materiałem, który użyjemy do zapakowania takiego produktu (przykładowo - szklane produkty będziemy pakowali w folii bąbelkowej, a wyroby papiernicze - w zwykłym papierze).
Możesz sobie teraz wyobrazić jak duża różnica i trudność w pakowaniu pojawi się podczas pakunku standardowego listu, owoców, wspomnianego szkła, czy też dużej gitary. Te różne formy zapakowania muszą być reprezentowane przez odmienne i unikalne strategie pakowania.
Strategia dla większości listów będzie po prostu zaklejeniem koperty i naklejeniem znaczka. Dla zapakowania nietrwałych, szybko psujących się owoców będzie wymagała pudełka, pianki do pakowania, a może nawet suchego lodu. Podobne specyficzne warunki możemy przenieść do innych produktów.
W celu rozwiązania takiego problemu, idealnie sprawdzi nam się właśnie Strategia i dzięki temu nasza firma będzie mogła spojrzeć indywidualnie na każdy obiekt i wdrożyć odpowiednio dostosowaną strategię pakowania pod każdy produkt.
Strategie zapakowania paczki w odpowiedni sposób.
W naszej aplikacji każdy algorytm pakowania paczki może zostać wyekstrahowany do swojej własnej, odrębnej klasy, posiadającej jedną metodą - pack_item. Metoda ta przyjmuje informacje o przedmiocie, który trzeba zapakować, po czym zwraca odpowiednio zapakowany przedmiot. Każda klasa pakująca może zapakować inną paczkę na podstawie użycia tych samych argumentów, a główna klasa Packager nie musi wiedzieć nic o obranym algorytmie, gdyż zajmuje się jedynie przekazywaniem zapakowanej paczki do innego działu w celu, np. wysyłki.
To, co natomiast jest bardzo ważne - klasa Packager posiada metodę umożliwiająca zmianę strategii pakowania, dzięki czemu firma może zmienić styl pakowania paczki, jak na przykład mniejsze zabezpieczenie, gdy użytkownik nie dopłacił za usługę ostrożnego pakowania. Dzięki temu Packager będzie koordynował cały proces pakowania i odpowiednio określał, jaka strategia na pakunek paczki ma być w danym momencie przyjęta.
W poniższym przykładzie koncepcyjnym używając pseudokodu wykorzystamy strategię w celu obsługi pakowania w naszej firmie.
from __future__ import annotations
from abc import ABC, abstractmethod
class Item:
"""
Class represents the item to be packed.
"""
pass
class Context:
"""
The Context defines the interface of products.
"""
def __init__(self, strategy: Strategy) -> None:
self._strategy = strategy
@property
def strategy(self) -> Strategy:
"""
The context maintains a reference to one of the strategy objects.
It does not know the concrete implementation of a strategy.
Thus it can work with all strategies via the Strategy interface.
"""
return self._strategy
@strategy.setter
def strategy(self, strategy: Strategy) -> None:
"""
Depending on the strategy, the context can change the behaviour of the algorithm at runtime.
"""
self._strategy = strategy
def do_some_business_logic(self, item) -> None:
"""
The context delegates some work to the strategy object instaed
of implementing multiple versions of the algorithm on its own.
"""
print("Context: Packing package using the strategy. Does not know how to do it.")
self.strategy.pack_item(item)
class Strategy(ABC):
"""
The strategy interface declares operations common to all supported version of some algorithm
But the context uses the strategy interface to call the algorithm's specific methods.
"""
@abstractmethod
def pack_item(self, item: Item) -> None:
"""
Pack the item
"""
pass
class ConcreteStrategyA(Strategy):
def pack_item(self, item: Item) -> None:
print("Item packed using ConcreteStrategyA")
print("This item is an ordinary document, we will send it in letter. ")
class ConcreteStrategyB(Strategy):
def pack_item(self, item: Item) -> None:
print("Item packed using ConcreteStrategyB")
print("There is some food to be packed, I will need some dry ice and foam packing.")
if __name__ == "__main__":
context = Context(ConcreteStrategyA())
print("Client: Strategy was set to document packing.\n")
item = Item()
print("Client: Document will be backed using strategy for letters (ConcreteStrategyA)")
context.do_some_business_logic(item)
print()
context.strategy = ConcreteStrategyB()
print("Client: Strategy was set to food packing.\n")
print("Client: Food will be packed using strategy for food (ConcreteStrategyB)")
context.do_some_business_logic(item)
Wynik działania
Client: Strategy was set to document packing.
Client: Document will be backed using strategy for letters (ConcreteStrategyA)
Context: Packing package using the strategy. It does not know how to do it.
Item packed using ConcreteStrategyA
This item is an ordinary document we will send it by letter.
Client: Strategy was set to food packing.
Client: Food will be packed using strategy for food (ConcreteStrategyB)
Context: Packing package using the strategy. Does not know how to do it.
Item packed using ConcreteStrategyB
There is some food to be packed, I will need dry ice and foam packing.
Używaj strategii...
- Gdy chcesz używać różnych wariantów algorytmu w obrębie obiektu i zyskać możliwość łatwego podmieniania takiego algorytmu w trakcie działania programu. Pośrednio pozwala nam to zmienić zachowanie obiektu poprzez przypisywanie mu różnych "podobiektów" wykonujących określone działania na różne sposoby.
- Gdy masz w programie wiele podobnych klas, różniących się jedynie sposobem wykonywania jakichś zadań. Wzorzec ten pozwala nam na ekstrakcję różniących się zachowań do odrębnej hierarchii klas i połączenie pierwotnych klas w jedną, redukując tym samym powtórzenia kodu i łamanie zasady DRY.
- Strategia również pozwala nam odizolować logikę biznesową klasy od szczegółów implementacyjnych algorytmów, które nie są istotne w kontekście tej logiki. Klienci więc otrzymują prosty interfejs umożliwiający uruchamianie algorytmów i wymiany ich na inne w trakcie działania programu
- Stosuj strategię również wtedy, gdy Twoja klasa zawiera duży operator warunkowy, którego zadaniem jest wybór odpowiedniego wariantu tego samego algorytmu. Pozbędziemy się niepotrzebnie dużego operatora warunkowego, dzięki możliwości ekstrakcji algorytmów do odrębnych klas implementujących taki sam interfejs. Wtedy pierwotny obiekt deleguje uruchamianie jednemu z tych obiektów, zamiast samodzielnie implementować wszystkie warianty algorytmu
Zalety i wady
Zalety | Wady |
1. Możesz wymieniać algorytmy stosowane w obrębie obiektu w trakcie działania programu. 2. Jesteś w stanie lepiej odizolować szczegóły implementacji algorytmu od kodu, który z niego nie korzysta. 3. Umożliwia zamianę dziedziczenia na kompozycję (przez co unikamy, np. multidziedziczenia) 4. Stosuje się do zasady O/C i w łatwy sposób pozwala nam na wprowadzanie nowych strategii i dokonywania zmian w już istniejących. |
1. Jeżeli masz w swojej aplikacji tylko kilka algorytmów, to nie ma sensu komplikować aplikacji przez dodawanie nowych klas i interfejsów związanych z tym wzorcem. 2. Klienci muszą być świadomi różnic pomiędzy strategiami, żeby mogli wybrać odpowiednią dla nich. 3. Wiele nowoczesnych języków posiada wsparcie dla typów funkcyjnych, które pozwalają implementować różne wersje algorytmu wewnątrz zestawu anonimowych funkcji. Przez to rozbudowywanie kodu o klasy i interfejsy jest przerostem formy nad treścią. |
Nie byłbym sobą, gdybym nie wspomniał, Ci o powiązaniach Strategii z innymi wzorcami projektowymi. Duże podobieństwo w strukturze do wzorca Strategia ma bowiem Most (Bridge) i Stan (State). Wszystkie z nich opierają się na kompozycji, co oznacza delegowanie zadań innym obiektom. Jednak każdy z nich służy do rozwiązania innego problemu.
Podsumowanie
Kończąc już, ten artykuł o wzorcach projektowych, chcę Cię zachęcić do poznawania innych rozwiązań niż te dwa omówione powyżej. Pamiętaj, że jest to bardzo uniwersalna i przydatna wiedza, która nawet w przypadku zmiany technologii będzie aktualna i możliwa do wdrożenia. Gwarantuję Ci, że wzorce ułatwią Ci rozwiązywanie codziennych problemów programistycznych i pozwolą wskoczyć na wyższy poziom developmentu.
Pamiętaj, tylko, żeby zgodnie z tym, co wspomniałem na początku - nie nadużywać ich i nie wdrażać "na siłę", bo jest to jednak miecz obosieczny i może zmniejszyć czytelność i zrozumienie kodu.
Mam nadzieję, że była to porządna dawka wiedzy i wykorzystasz ją w praktyce.
Wszystkiego optymalnego!