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.
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.
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ć.
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.
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.
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:
Sama rozpoznawanie stereotypów nie powinno przysporzyć trudności, spróbuj na kilku przykładach (odpowiedzi na końcu artykułu)
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() |
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) |
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) |
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.
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.
Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More
Cześć! Zapraszam na krótkie podsumowanie kwietnia. Wyjazd do Niemiec A dokładniej pod granicę z Francją. Chrześnica miała pierwszą komunię. Po… Read More
Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More
Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More
Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More
Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More