Programowanie

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
czy
1
uuid.UUID
są przykładem wzorca Value Object – są niemutowalne (niezmienialne), sprawdzają poprawność danych wejściowych przy tworzeniu instancji oraz są uważane za identyczne, jeżeli zostały utworzone z tymi samymi danymi wejściowymi.

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

https://www.reddit.com/r/ProgrammerHumor/comments/tk9fd3/the_30_yearold_ooper/

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

Dzięki za wizytę,
Mateusz Mazurek
Sebastian Buczyński

Ostatnie wpisy

Podsumowanie: maj, czerwiec, lipiec i sierpień 2024

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

2 miesiące ago

Podsumowanie: kwiecień 2024

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

6 miesięcy ago

Podsumowanie: luty i marzec 2024

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

7 miesięcy ago

Podsumowanie: styczeń 2024

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

9 miesięcy ago

Podsumowanie roku 2023

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

10 miesięcy ago

Podsumowanie: grudzień 2023

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

11 miesięcy ago