Czy wzorce projektowe są zbędne w Pythonie?
Na koniec lektury tego wpisu będziesz dogłębnie rozumieć czym są wzorce projektowe, poznasz kilka przykładów i przekonasz się, że nie ma od nich ucieczki.
Wzorce projektowe kontra Python
Ucząc się programowania prędzej czy później napotykamy na temat wzorców projektowych, czyli gotowych receptur do wykorzystania w kodzie. Jednym z najsławniejszych źródeł informacji o nich jest książka Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku. Wciąż nieźle się sprzedaje, mimo tego że ma… ponad 25 lat. Była pisana w innych czasach, gdy dominowały aplikacje okienkowe uruchamiane lokalnie na komputerach.
Przykłady w rzeczonej książce są w C++, jednak bez problemu można znaleźć w internecie strony z przykładami sportowanymi do Pythona, całe repozytoria czy też nagrania na YouTube.
A komu to potrzebne? A na co?
Kilka lat temu jeździłem po świecie z prezentacją o przewrotnym tytule Why you don’t need design patterns in Python? Przesłaniem było, że naiwne kopiowanie implementacji, szczególnie z ponad ćwierćwiecznej książki, jest niewłaściwe. Co nie znaczy, że same wzorce są bezużyteczne.
Ten post uzupełnia tamtą prezentację o głębsze zrozumienie designu oprogramowania, wzorców i pokazuje, jak najlepiej z nich korzystać.
Czym naprawdę jest wzorzec projektowy?
Wzorzec projektowy to powtarzające się rozwiązanie pewnego problemu w programowaniu. Prosto mówiąc, jeżeli niezależnie pracujące od siebie osoby czy zespoły dochodzą do tego samego designu rozwiązującego dany problem, to właśnie mamy do czynienia z wzorcem.
Wzorce nie są przez nikogo wymyślane czy projektowane – są odnajdywane. Jeżeli ktoś zakoduje jakiś design i zacznie go nazywać wzorcem, to nagina rzeczywistość. Ale jeśli ta sama osoba odkryje, że podobne rozwiązanie pojawia się w wielu innych projektach i przypadkiem nie ma jeszcze nazwy, to wtedy może nazwać je wzorcem.
Oznacza to, że być może w twoim kodzie już znajdują się wzorce projektowe! Jeden wzorzec projektowy, który często pojawia się w projektach napisanych w Pythonie, to metoda szablonowa (ang. template method).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | # Przykład template method import abc class Report(abc.ABC): def generate(self) -> StringIO: # ogólny algorytm generowania raportu w .CSV do bufora w pamięci @abc.abstractmethod def _get_header(self) -> list[str]: pass @abc.abstractmethod def _get_data(self) -> Iterator[dict]: pass class CustomersReport(Report): def _get_header(self) -> list[str]: return ["id", "first_name"] @abc.abstractmethod def _get_data(self) -> Iterator[dict]: for customer in Customer.objects.all(): yield {"id": customer.pk, "first_name": customer.first_name} |
Metoda szablonowa pozwala zakodować wspólny algorytm dla kilku raportów w klasie bazowej, która pozostawia wybrane kroki lub krok do zaimplementowania klasom pochodnym. W tym przypadku, sam algorytm generowania raport w formacie .CSV jest wspólny – utwórz bufor, zapisz nagłówek, zapisz wiersze z danymi, zwróć bufor. To co się różni pomiędzy raportami, to ich nagłówek oraz źródło danych.
Co więcej, autor nie musiał w ogóle znać wzorców – metodę szablonową można wymyślić samemu znając dziedziczenie i moduł abc z biblioteki standardowej.
Baa, w bibliotece standardowej Pythona też znajdziemy przykłady wzorców.
1 | datetime.datetime |
1 | datetime.timedelta |
1 | uuid.UUID |
Nie ma więc ucieczki od wzorców projektowych – albo korzystasz z rozwiązań implementujących wzorce, albo przypadkiem piszesz kod, który jest poznanym wzorcem. Szanse są tym większe, im bardziej świadomie i celowo podchodzisz do designu kodu.
Wzorce wzorcami, ale czym jest ten design kodu?
Design kodu można zdefiniować jako świadome rozdzielanie zadań pomiędzy strukturami w kodzie, jak klasy czy funkcje i układanie ich w pewien hierarchiczny sposób. Robimy to używając technik jak hermetyzacja czy rozmaitych heurystyk (o których więcej za moment).
Takim zadaniem może być np. zapis danych do bazy, obliczenie należności, wysłanie żądania HTTP. Część takich „zadań” wynika z wymagań biznesowych, a inne są konsekwencją wybranego designu. Na przykład wyslanie żądania HTTP umieszczamy w klasie, ale do stworzenie jej instacji trzeba pewnych danych. I tu mamy nowe zadanie – tworzenia tejże klasy – do przydzielenia. Może po prostu w miejscu użycia? A może tworzymy dedykowaną fabrykę – klasę czy funkcję.
Jedną z bardzo wygodnych metodyk designu przez myślenie o takich zadaniach jest Responsibility-Driven Design. RDD nazywa takie zadania „odpowiedzialnościami” i kategoryzuje je na odpowiedzialność przechowywania i zapewniania innym danych, robienie czegoś lub decydowanie o czymś. Oryginalnie jest powiązana z programowaniem obiektowym. Promuje tworzenie „inteligentych obiektów”, które mają przydzielona odpowiedzialności i robią coś z informacjami, które znają.
To zupełnie jak ludzkie organizacje! Na przykład zespół produtkowy twierdzący, że pracuje w Scrumie. Odpowiedzialnością członków zespołu jest programowanie na podstawie otrzymanych zadań, ale już odpowiedzialność decydowania ile zadań zostanie wziętych na dany sprint i terminach podejmuje menadżer projektu. To akurat znany wzorzec – nazywa się patologia. Podstawowym założeniem zwinności jest, że to zespół powinien decydować ile weźmie na sprint.
Myślenie steroetypami nie zawsze jest złe!
RDD daje nam też do ręki wygodne narzędzie do klasyfikacji struktur (klas czy funkcji) przez pryzmat tego, jakie rodzaje odpowiedzialności mają. To jeszcze nie wzorce projektowe, ale zestawy odpowiedzialności, które często pojawiały się razem. Można ich na przykład użyć do refaktoryzacji wielkiego kawałka kodu i rozdzielić go właśnie na różne klasy/funkcje sugerując się stereotypami.
Wśród stereotypów RDD wyróżniamy:
- Information Holder – przetrzymuje i zapewnia innym informacje
- Structurer – utrzymuje powiązania między obiektami
- Service Provider – wykonuje pracę, zapewnia innym usługi
- Coordinator – reaguje na zdarzenia i deleguje pracę innym
- Controller – podejmuje decyzje i koordynuje pracę innych
- Interfacer – przekazuje informacje i żądania między częściami systemu
Sama rozpoznawanie stereotypów nie powinno przysporzyć trudności, spróbuj na kilku przykładach (odpowiedzi na końcu artykułu)
Model w Django
1 2 3 4 5 | class Comment(models.Model): author = models.ForeignKey(User, related_name='written_comments') task = models.ForeignKey(Task, related_name='comments') created_at = models.DateTimeField(auto_now_add=True) content = models.TextField() |
Przypadek użycia w czystej architekturze
1 2 3 4 5 6 7 8 | class PlacingBid: def execute(self, input_dto: PlacingBidInputDto) -> None: auction = self.auctions_repo.get(input_dto.auction_id) auction.place_bid(bidder_id=input_dto.bidder_id, amount=input_dto.amount) self.auctions_repo.save(auction) output_dto = PlacingBidOutputDto(input_dto.bidder_id in auction.winners, auction.current_price) self.output_boundary.present(output_dto) |
Klasa do używania API od obsługi płatności
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | class ApiConsumer: def __init__(self, login: str, password: str) -> None: self.auth = login, password # basic auth def charge(self, amount: Money, source: str) -> str: currency, converted_amount = self._get_iso_code_and_amount(amount) request = ChargeRequest(source, currency, str(converted_amount)) response = self._execute_request(request, ChargeResponse) return response.id def capture(self, charge_id: str) -> None: request = CaptureRequest(charge_id) self._execute_request(request, CaptureResponse) def _execute_request(self, request: Request, response_cls: Type[ResponseCls]) -> ResponseCls: response = requests.post(request.url, auth=self.auth, data=request.to_params()) if not response.ok: raise PaymentFailedError else: return response_cls.from_dict(response.json()) # type: ignore def _get_iso_code_and_amount(self, money_amount: Money) -> Tuple[str, int]: return money_amount.currency.iso_code, int(money_amount.amount * 100) |
Obiektowo czy funkcyjnie?
RDD odwołuje się dużo do programowania obiektowego. Jednak nieprawdą jest, że jest do niego ograniczone. To byłoby szczególnie bolesne, bo Python to język hybrydowy – nie wymusza żadnego konkretnego paradygmatu (czy to funkcyjnego, czy obiektowego) – czerpie pełnymi garściami z obu. Do tego samego zachęcam ciebie.
Z programowania obiektowego można wziąć właśnie RDD, myślenie odpowiedzialnościami, tworzenie inteligentnych obiektów. Potem zastosować ten sposób myslenia również do… projektowania mikroserwisów.
Z kolei z programowania funkcyjnego można zaczerpnąć niemutowalne struktury danych do przekazywania (jak Value Object), staranne obchodzenie się z efektami ubocznymi (jak zapis do bazy danych czy innego API). Później można stosować ten sposób myslenia do… pisania hiper-testowalnego kodu.
Nie daj się wkręcić w sztucznie polaryzowany spór między paradygmatami. Z jednej strony zeloci obiektówki kpią z podejścia funkcyjnego. Z drugiej strony na konferencjach poświęconych programowaniu funkcyjnemu cała sala ludzi słucha przez godzinę jakie to programowanie zorientowane obiektowo jest złe. Pomijam jakość tych dyskusji – dobrze podsumowuje to jeden z komentarzy
I wish he spent even a fraction of time on explaining how FP is good or how it helps and is worth using
Zamiast tego czerpmy z obu paradygmatów to, co mają najlepszego do zastosowania.
Jak nie narobić sobie kłopotów próbując wciskać wzorce?
To proste, nie używaj ich jeżeli robią coś, czego twój kod nie robi! Przeanalizuj wzorzec i odkryj, jakie ma przypisane odpowiedzialności. Dalej sprawdź ze swoim kodem, czy warto go zrefaktoryzować w stronę danego wzorca. Zacznij od sprawdzenie, czy występują te same odpowiedzialności. Ostatnią rzeczą do weryfikacji jest czy elastyczność wzorca jest pożądana – jak w metodzie szablonowej).
Inaczej naginasz problem do wybranego wzorca. Zamiast tego, powinniśmy zastosować odpowiednie rozwiązanie do problemu.
Trzeba dostrzegać esencję wzorca. Po to, by zaimplementować go pythonicznie. Dzięki temu nie wprowadzimy niepotrzebnych komplikacji.
Odpowiedzi do zagadki ze stereotypami
- Model w Django – Information Holder
- Przypadek użycia w czystej architekturze – Controller
- Klasa do używania API od obsługi płatności – Interfacer
Mateusz Mazurek