Wzorce projektowe GoF okiem programisty Pythona: co zostaje, a co znika

Utworzono: 3 września 2026 Zaktualizowano: 3 września 2026

Na jednej rozmowie kwalifikacyjnej, między pracami, padło pytanie, jakie wzorce projektowe znam, i była pustka. Używałem ich od lat, tylko nie umiałem ich nazwać. Po rozmowie zrobiłem sobie notatki z nazwami i przykładami, a z tych notatek w końcu wyrósł artykuł.

Więcej

Przechodzę w nim przez katalog GoF i przy każdym wzorcu zestawiam wersję z książki z tym, co z niej zostaje w Pythonie. Adapter, Composite i Template Method przechodzą prawie bez zmian. Strategy, Command i Visitor sprowadzają się do funkcji przekazanej jako argument, functools.partial i singledispatch. Singleton to w praktyce zwykły moduł (a przy wątkach threading.local). Przy okazji jest sporo pułapek, np. płytka kopia przy Prototype, __getattr__, przez który przecieka całe API opakowanego obiektu, albo słabe referencje w sygnałach Django.

„Design Patterns” Bandy Czworga, czyli Gammy, Helma, Johnsona i Vlissidesa, wyszło w 1994 roku. Przykłady były głównie w C++, z domieszką Smalltalka. Ja do tego katalogu dotarłem dużo później, niż wypadało. Miałem za sobą Pascala, Delphi, PHP i Ruby, a od kilku lat pisałem w Pythonie. Przy zmianie pracy, na rozmowie kwalifikacyjnej, pytania z Pythona szły nieźle, ale padło też pytanie o wzorce z nazwy. Używałem ich od lat, tylko nie umiałem dopasować terminów, i wypadłem blado. C++ tamtych lat miał sztywne, statyczne typowanie, a wskaźnik do funkcji nie niósł ze sobą żadnego stanu. Żeby przekazać komuś zachowanie, trzeba było je opakować w klasę z jedną metodą i dopisać wspólny interfejs. Dwa lata po książce, w styczniu 1996 roku, wyszła Java 1.0, która wzięła tę filozofię na sztandary. To w jej świecie dwadzieścia trzy nazwy z katalogu stały się standardowym zestawem pytań rekrutacyjnych.

W Pythonie w praktyce wygląda to inaczej. Funkcję można przekazać jako argument, zwrócić z innej funkcji albo zamknąć w niej stan przez domknięcie. Dzięki duck typingowi nie trzeba deklarować żadnego interfejsu, wystarczy, że obiekt ma metodę o właściwej nazwie. Moduł importuje się raz i żyje do końca procesu, więc sam z siebie zachowuje się jak singleton. Spora część katalogu to sprytne obejście braków języków z lat 90. Tam, gdzie tych braków nie ma, wzorzec kurczy się do kilku linijek albo w ogóle przestaje być widoczny. Peter Norvig pokazał to już w 1996 roku w prezentacji „Design Patterns in Dynamic Languages”. Na jednym ze slajdów podaje, że 16 z 23 wzorców GoF ma w Lispie albo Dylanie jakościowo prostszą implementację niż w C++, przynajmniej w części zastosowań.

Wzorce kreacyjne

Kiedy w pythonowym projekcie trafiam na metaklasę (czyli klasę, która tworzy inne klasy i decyduje między innymi o tym, co się dzieje przy wywołaniu Config()), najczęściej siedzi w niej singleton. W Javie wzorzec robi się przez prywatny konstruktor i statyczną metodę getInstance(). Do tego dochodzi double-checked locking, czyli sprawdzenie przed blokadą i drugi raz już pod nią, żeby dwa wątki nie utworzyły obiektu w tej samej chwili. Chodzi o to, żeby w całym programie była dokładnie jedna konfiguracja wczytana z pliku albo jedna pula połączeń z bazą. Python nie ma prywatnych konstruktorów, więc osoba przyzwyczajona do Javy nadpisuje __new__ albo pisze metaklasę, razem z tym samym podwójnym sprawdzeniem. Działa to poprawnie, tylko każdy, kto później czyta kod, musi się zatrzymać i rozgryźć, dlaczego drugie wywołanie Config() zwraca ten sam obiekt. Ścieżka podana za drugim razem po cichu przepada, bo __init__ wykonał się tylko przy pierwszym wywołaniu.

python
import threading
import tomllib

class SingletonMeta(type):
    _instances = {}
    _lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:
                    cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Config(metaclass=SingletonMeta):
    def __init__(self, path="settings.toml"):
        with open(path, "rb") as f:
            self.data = tomllib.load(f)

Tymczasem moduł już jest singletonem, trzeba go tylko tak potraktować. Zaimportowany moduł trafia do sys.modules i kod na jego najwyższym poziomie wykonuje się raz, niezależnie od tego, z ilu plików go importujemy. Import jest chroniony blokadą, więc wątki nie zrobią dwóch kopii. Konfigurację mogę więc wczytać wprost w module. Z połączeniem z bazą jest inaczej, bo nie chcę, żeby otwierało się przy samym imporcie, na przykład kiedy pytest zbiera testy albo ktoś uruchamia --help. W skrypcie działającym w jednym wątku wystarcza wtedy funkcja z functools.cache. Pierwsze wywołanie tworzy połączenie, kolejne zwracają to samo, a w testach można je wyczyścić przez get_connection.cache_clear().

python
# config.py
import functools
import sqlite3
import tomllib

with open("settings.toml", "rb") as f:
    settings = tomllib.load(f)

@functools.cache
def get_connection():
    return sqlite3.connect(settings["db_path"])

Z wątkami ta wersja się sypie, i to z dwóch stron. functools.cache nie trzyma blokady podczas wywołania funkcji, więc dwa wątki, które przyjdą naraz przy pustym cache, oba wywołają sqlite3.connect, a zapamiętane zostanie tylko jedno z połączeń. Gorzej, że połączenie SQLite domyślnie ma check_same_thread=True i użyte w innym wątku niż ten, który je otworzył, rzuca ProgrammingError: SQLite objects created in a thread can only be used in that same thread. Jedno połączenie na cały proces po prostu tu nie pasuje, więc w programie wielowątkowym robię po jednym na wątek:

python
import threading

_local = threading.local()

def get_connection():
    conn = getattr(_local, "conn", None)
    if conn is None:
        conn = _local.conn = sqlite3.connect(settings["db_path"])
    return conn

Wyścigu tu nie ma, bo każdy wątek widzi tylko własne _local.conn. Tak samo robi Django, gdzie django.db.connections trzyma osobne połączenie dla każdego wątku, a przy PostgreSQL zamiast pisać to ręcznie sięga się po pulę z psycopg_pool albo z SQLAlchemy.

Factory Method rozwiązuje problem, który zna każdy, kto utrzymywał starszy kod. Gdzieś trzeba zdecydować, jaką konkretną klasę utworzyć, i jeśli nikt tej decyzji nie zamknie w jednym miejscu, if metoda == "CATI" zaczyna się pojawiać w kilkunastu plikach. Książka proponuje, żeby klasa bazowa miała metodę tworzącą obiekt, a podklasy nadpisywały ją i zwracały właściwą implementację. Kod korzystający z obiektu nie wie wtedy nic o konkretnych klasach. Ja zrobiłem to po raz pierwszy w Kosztorysancie, programie w Delphi do wyceny badań rynkowych, zanim w ogóle usłyszałem o katalogu GoF. Badanie telefoniczne liczyło się inaczej niż wywiad w domu respondenta czy ankieta internetowa, więc napisałem jedną funkcję, która dostawała typ badania i zwracała odpowiedni obiekt kalkulacji. Dopiero lata później, robiąc notatki po nieudanej rekrutacji, zorientowałem się, że to był Factory Method, tylko bez całej hierarchii wokół.

Wersja książkowa w Pythonie wygląda mniej więcej tak:

python
class Survey:
    def cost(self, sample_size):
        calculator = self.create_calculator()
        return calculator.total(sample_size)

    def create_calculator(self):
        raise NotImplementedError

class CatiSurvey(Survey):
    def create_calculator(self):
        return CatiCalculator()

class CapiSurvey(Survey):
    def create_calculator(self):
        return CapiCalculator()

class CawiSurvey(Survey):
    def create_calculator(self):
        return CawiCalculator()

Każda podklasa istnieje tylko po to, żeby zwrócić jeden obiekt. W Pythonie klasy też są obiektami, więc można je trzymać w słowniku i wywoływać jak funkcje. Dodanie nowej metodologii badania to wtedy jedna linijka w słowniku zamiast nowej klasy:

python
CALCULATORS = {
    "cati": CatiCalculator,
    "capi": CapiCalculator,
    "cawi": CawiCalculator,
}

def create_calculator(method, **params):
    cls = CALCULATORS.get(method)
    if cls is None:
        raise ValueError(f"Nieznana metodologia: {method}")
    return cls(**params)

Słownik można też wypełniać dekoratorem, którym oznacza się każdą klasę kalkulatora, jeśli nie chcemy pilnować importów w jednym pliku. Po podklasy sięgam wtedy, gdy hierarchia i tak już istnieje z innych powodów, a metoda tworząca jest tylko jednym z kilku punktów rozszerzenia. Tak jest w widokach klasowych Django: FormView ma metodę get_form_class(), a ListView ma get_queryset(), i nadpisuje się je w podklasie, gdy formularz albo zapytanie zależą od użytkownika czy parametru z adresu.

Najwięcej fabryk naraz napisałem w systemie do kosztorysowania badań w PHP, który zacząłem od frameworka. Ten obrastał w fabryki na wypadek podmiany bazy, szablonów albo sposobu liczenia, choć nikt takiej podmiany nie planował, i zanim policzył się w nim pierwszy kosztorys, projekt poszedł do kosza (nazwę Abstract Factory przypiąłem do tamtego kodu dopiero lata później, przy notatkach po nieudanej rekrutacji).

Banda Czworga opisuje ten wzorzec na przykładzie zestawu widżetów GUI. Okno i pasek przewijania istnieją w wersji dla Motifa i w wersji dla Presentation Managera z OS/2, a aplikacja nie może dostać ramki z jednego systemu i suwaka z drugiego. Abstrakcyjna fabryka ma więc metody create_window() i create_scrollbar(), każda platforma dostarcza własną konkretną fabrykę, a aplikacja wybiera jedną z nich na starcie i od tej chwili nie widzi żadnej konkretnej klasy. Różnica względem Factory Method polega na tym, że fabryka tworzy całą rodzinę obiektów, które muszą do siebie pasować. W Javie podobnie działa LookAndFeel w Swingu i tam ten wzorzec jest na miejscu, bo framework obsługuje kilka platform, a korzystają z niego tysiące aplikacji.

W Pythonie najbardziej znany przykład siedzi w Django, choć dokumentacja nie używa tej nazwy. W DATABASES podaje się ENGINE jako ścieżkę do pakietu, na przykład django.db.backends.postgresql, a Django importuje z modułu base w tym pakiecie, czyli z django.db.backends.postgresql.base, klasę DatabaseWrapper. Ta trzyma spójny zestaw dla jednego silnika: operacje, introspekcję, edytor schematu i kompilator SQL. Nie da się dostać edytora schematu z PostgreSQL razem z introspekcją z SQLite, bo wszystko przychodzi z jednego pakietu. Rolę fabryki pełni tu moduł wybrany po nazwie z konfiguracji. Ten sam układ w małej skali, dla generatora raportów tworzącego tabelę i wykres raz do PDF, raz do Excela:

python
# reports/pdf.py
def table(rows): ...
def chart(series): ...

# reports/xlsx.py
def table(rows): ...
def chart(series): ...

# reports/__init__.py
import importlib

def get_backend(fmt):
    return importlib.import_module(f"reports.{fmt}")

def build_report(fmt, rows, series):
    backend = get_backend(fmt)
    return [backend.table(rows), backend.chart(series)]

Klasy PdfReport i XlsxReport z metodami table() i chart() zrobiłyby dokładnie to samo, a dzięki duck typingowi nie potrzebowałyby nawet wspólnej klasy bazowej. Tyle że nie miałyby żadnego stanu, więc cała ich zawartość i tak sprowadzałaby się do dwóch funkcji na format. Inaczej jest z backendem, który między wywołaniami trzyma na przykład otwarty skoroszyt z openpyxl, bo tabela i wykres dopisują do niego kolejne arkusze, i ten skoroszyt musi gdzieś mieszkać, a pole obiektu nadaje się do tego lepiej niż zmienna modułu. Poza frameworkami z kilkoma wymiennymi backendami rzadko trafiam na miejsce, w którym ten wzorzec się opłaca. W kodzie aplikacji zwykle jest jeden format wyjściowy i jedna baza, a gdy dochodzi drugi, słownik albo importlib z nazwą wziętą z settings.py w zupełności wystarczają.

Przy wyszukiwarce dużego katalogu części zapytanie do Elasticsearcha powstawało w trzech miejscach. Jedna funkcja dokładała filtr po marce, druga po kategorii, a trzecia podbijała w rankingu pozycje z dostępnym stanem magazynowym. Każda dostawała obiekt Search z elasticsearch-dsl, dopisywała swój kawałek i oddawała go dalej, nie wiedząc nic o pozostałych. Do Elasticsearcha nic nie leciało, dopóki na końcu ktoś nie wywołał execute(). W zespole mówiliśmy na to po prostu „składanie zapytania”, choć pasuje tu opis Buildera: obiekt budowany krok po kroku, z którego gotowy produkt powstaje na samym końcu. Tak samo działa QuerySet w Django, gdzie filter(), exclude() i order_by() zwracają nowy QuerySet, a SQL generuje się przy pierwszym odczycie danych. W SQLAlchemy select().where().order_by() też tylko dokłada kolejne klauzule, a do bazy trafia to dopiero w session.execute().

Banda czworga podchodzi do tego od nieco innej strony. Ich przykładem jest RTFReader, który czyta dokument RTF i przy każdym napotkanym elemencie, czyli akapicie, zmianie czcionki albo kawałku tekstu, woła odpowiednią metodę obiektu TextConverter. Podklasa ASCIIConverter składa z tych samych wywołań czysty tekst, TeXConverter plik dla TeX-a, a TextWidgetConverter widżet do edycji. Czytnik, w książce nazywany dyrektorem, zna kolejność kroków, ale nie wie, co z nich powstanie, a gotowy produkt odbiera się od budowniczego osobną metodą. Książce chodzi więc o oddzielenie procesu konstrukcji od reprezentacji wyniku. Moje trzy funkcje od zapytania były bliżej tej wersji, bo każda wołała metody na obiekcie Search i nie interesowało jej, jaki JSON z tego w końcu wyjdzie.

Wersja, którą większość programistów kojarzy z nazwą Builder, pochodzi skądinąd, z drugiego wydania „Effective Java” Joshuy Blocha z 2008 roku. Tam problem jest prostszy. Obiekt ma dwa wymagane pola i kilkanaście opcjonalnych, a Java nie zna wartości domyślnych parametrów ani argumentów nazwanych. Kończy się to konstruktorem teleskopowym, czyli czterema przeciążonymi wersjami, albo wywołaniem, w którym pięć argumentów to null i nikt nie pamięta, który false oznacza wagowanie. Bloch proponuje klasę pomocniczą z metodami łańcuchowymi zwracającymi this i metodą build() na końcu. Ta odmiana przechodzi potem do Pythona bez zmian. Na przykładzie specyfikacji próby do badania wygląda tak:

python
class SampleBuilder:
    def __init__(self):
        self._size = 1000
        self._regions = []
        self._quotas = {}
        self._weighted = False

    def size(self, n):
        self._size = n
        return self

    def region(self, name):
        self._regions.append(name)
        return self

    def quota(self, variable, shares):
        self._quotas[variable] = shares
        return self

    def weighted(self):
        self._weighted = True
        return self

    def build(self):
        return Sample(
            size=self._size,
            regions=tuple(self._regions),
            quotas=self._quotas,
            weighted=self._weighted,
        )

sample = (SampleBuilder()
          .size(1200)
          .region("mazowieckie")
          .quota("plec", {"K": 0.52, "M": 0.48})
          .build())

Prawie trzydzieści linijek, żeby obejść brak czegoś, co Python ma od zawsze. Argumenty nazwane z wartościami domyślnymi załatwiają konstruktor teleskopowy, a dataclass dopisuje resztę. Przy kw_only=True nie da się przekazać parametrów pozycyjnie, więc nikt nie pomyli wielkości próby z liczbą regionów, i dlatego build() wyżej też podaje je z nazwy. frozen=True blokuje przypisanie do pól, ale nie zagląda w ich zawartość. Zwykły słownik w quotas dałoby się dalej zmienić przez sample.quotas["plec"]["K"] = 0.9, a dataclasses.replace przekazałby ten sam słownik do kopii, więc poprawka w pilotażu wyszłaby też w głównej próbie. Dlatego __post_init__ przepisuje kwoty do MappingProxyType, czyli widoku tylko do odczytu, i przy okazji robi walidację, która w Builderze siedziałaby w build():

python
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from types import MappingProxyType

@dataclass(frozen=True, kw_only=True)
class Sample:
    size: int = 1000
    regions: tuple[str, ...] = ()
    quotas: Mapping[str, Mapping[str, float]] = field(default_factory=dict)
    weighted: bool = False

    def __post_init__(self):
        for variable, shares in self.quotas.items():
            if abs(sum(shares.values()) - 1) > 0.001:
                raise ValueError(f"Kwoty dla {variable} nie sumują się do 1")
        copied = {k: MappingProxyType(dict(v)) for k, v in self.quotas.items()}
        object.__setattr__(self, "quotas", MappingProxyType(copied))

sample = Sample(size=1200, regions=("mazowieckie",), quotas={"plec": {"K": 0.52, "M": 0.48}})
pilot = replace(sample, size=50)

Przypisanie idzie przez object.__setattr__, bo zamrożona klasa odrzuca zwykłe self.quotas = .... Kopia przez dict(v) odcina też słownik, który przyszedł z zewnątrz, na przykład z _quotas w builderze. Wariant istniejącej próby, taki jak mały pilotaż przed właściwym badaniem, robi dataclasses.replace, które tworzy nowy obiekt ze zmienionymi polami i jeszcze raz przepuszcza go przez __post_init__. Pomimo zamrożenia hash(sample) dalej kończy się TypeError, bo pod widokiem tylko do odczytu siedzi zwykły słownik, a ten hasha nie ma (dokładna treść komunikatu różni się między wersjami Pythona, więc nie ma co się do niej przywiązywać). Jeśli próby miałyby trafiać do zbioru albo służyć jako klucz słownika, kwoty trzeba trzymać jako krotkę par albo wyłączyć z hasha przez field(default_factory=dict, hash=False).

Prototype w książce odpowiada na pytanie, jak utworzyć nowy obiekt, kiedy nie znamy jego konkretnej klasy albo kiedy zbudowanie go od zera kosztuje za dużo. Zamiast wywoływać konstruktor, bierze się gotowy egzemplarz i prosi go o kopię. Przykładem jest edytor partytur. Paleta narzędzi ma przyciski do wstawiania nut i pięciolinii, a każde narzędzie trzyma egzemplarz wzorcowy i po kliknięciu wstawia do partytury jego klon. Każda klasa implementuje metodę clone(), a kod kliencki trzyma rejestr prototypów i klonuje to, co mu akurat potrzebne. Dzięki temu nowe rodzaje nut dochodzą bez nowych podklas narzędzi, wystarczy dopisać kolejny prototyp do palety.

W C++ clone() było wręcz konieczne, a powód jest dość techniczny. Metoda wirtualna to taka, którą podklasa może nadpisać, a to, która wersja się wykona, zależy od rzeczywistego typu obiektu w czasie działania programu, a nie od typu wskaźnika. Konstruktor kopiujący wirtualny nie jest. Jeśli kod ma w ręku wskaźnik do klasy bazowej Graphic, a pod nim siedzi HalfNote z dodatkowymi polami, to kopia zrobiona przez ten wskaźnik powstaje na poziomie klasy bazowej, a pola dopisane w podklasie po drodze giną. Wirtualne clone(), nadpisane w każdej podklasie, wie, jakim obiektem naprawdę jest, i kopiuje go w całości. Java poszła inną drogą i dała interfejs Cloneable bez ani jednej metody. Object.clone() jest chroniona, kopiuje płytko, a jeśli klasa zapomni o Cloneable, rzuca wyjątek. Joshua Bloch w „Effective Java” poświęcił temu osobny punkt, w trzecim wydaniu trzynasty, i radził raczej pisać konstruktory kopiujące, niż się z tym mocować.

W Pythonie nie trzeba niczego deklarować, bo moduł copy umie skopiować prawie każdy obiekt. copy.copy robi kopię płytką, a copy.deepcopy schodzi rekurencyjnie w głąb i przez słownik memo radzi sobie nawet z cyklami. Pasuje to do solvera doboru prób, w którym warianty algorytmu przełączałem jednym argumentem. Tabele kwot budowane z danych inicjacyjnych liczą się dość długo, a każdy wariant wypełnia je po swojemu, więc raz zbudowany plan może być prototypem, a warianty dostają jego głęboką kopię.

python
import copy

class QuotaPlan:
    def __init__(self, path):
        self.cells = load_quota_tables(path)
        self.filled = {}
        self.log = open_log(path)

    def __deepcopy__(self, memo):
        clone = copy.copy(self)
        memo[id(self)] = clone
        clone.cells = copy.deepcopy(self.cells, memo)
        clone.filled = {}
        return clone

base = QuotaPlan("kwoty.csv")

shallow = copy.copy(base)
shallow.cells["mazowieckie"]["K"] = 300

variant = copy.deepcopy(base)
variant.cells["mazowieckie"]["K"] = 300

Zmiana w shallow zmienia też base, bo oba obiekty trzymają ten sam zagnieżdżony słownik. To klasyczna pułapka płytkiej kopii. Dwa warianty algorytmu wypełniałyby po cichu jedną tabelę, a wyniki mogłyby przez długi czas wyglądać wiarygodnie. Metoda __deepcopy__ jest pythonowym odpowiednikiem książkowego clone(), tylko pisze się ją wyłącznie wtedy, gdy domyślne zachowanie nie pasuje. Wpis w memo idzie od razu po utworzeniu kopii, żeby komórka tabeli odwołująca się z powrotem do planu dostała nowy obiekt, zamiast wpaść w nieskończoną rekurencję.

Uchwyt do logu zostaje wspólny, bo otwartego pliku nie da się sensownie skopiować, tabele kwot są niezależne, a licznik wypełnienia zaczyna od zera. Dla dataclassów podobną rolę pełni dataclasses.replace, użyte wyżej przy pilotażu. Tworzy nowy obiekt z tymi samymi wartościami pól, podmieniając tylko wskazane, więc przy zamrożonych obiektach bez zagnieżdżonych słowników zupełnie wystarcza.

Wzorce strukturalne

Adapter to jeden z tych wzorców, które przechodzą do Pythona prawie bez zmian. Problem jest prosty. Mamy klasę, która robi to, czego potrzebujemy, ale jej interfejs nie pasuje do tego, czego oczekuje reszta kodu. Zmienić jej nie możemy albo nie chcemy, bo pochodzi z cudzej biblioteki albo korzysta z niej jeszcze pięć innych miejsc w systemie, którego nikt nie odważy się ruszyć. Książka opisuje dwie odmiany. Adapter klasowy opiera się na wielodziedziczeniu z C++. Adapter obiektowy trzyma opakowany obiekt w polu i tłumaczy wywołania. Python ma wielodziedziczenie, a mimo to prawie zawsze wybiera się wersję obiektową. Nie miesza ona metod dwóch klas w jednej przestrzeni nazw, a w testach łatwo podsunąć jej atrapę, bo opakowany obiekt przychodzi w konstruktorze i adapter nie sprawdza, czy to prawdziwy klient bazy, czy unittest.mock.Mock.

Najwięcej adapterów napisałem przy migracji systemu fakturowego z kilkoma milionami rekordów. Stare identyfikatory nie przetrwały przeniesienia, więc fakturę trzeba było odnajdywać po kilku kolumnach naraz: numerze, dacie wystawienia i NIP-ie sprzedawcy. Nowy kod oczekiwał źródła z metodą get(), która zwraca gotowy obiekt Invoice. Stary klient bazy miał zupełnie inne API. Zwracał wiersze jako słowniki z kolumnami w stylu NR_FAKT, datę trzymał jako tekst RRRRMMDD, NIP bez kresek, a kwoty w groszach.

python
from decimal import Decimal

class LegacyInvoiceSource:
    def __init__(self, client):
        self._client = client

    def get(self, number, issued_on, seller_tax_id):
        rows = self._client.select(
            "FAKTURY",
            NR_FAKT=number.ljust(20),
            DATA_WYST=issued_on.strftime("%Y%m%d"),
            NIP_SPRZ=seller_tax_id.replace("-", ""),
        )
        if len(rows) != 1:
            raise LookupError(f"{number}: znaleziono {len(rows)} rekordów")
        row = rows[0]
        return Invoice(
            number=number,
            issued_on=issued_on,
            seller_tax_id=seller_tax_id,
            gross=Decimal(row["KWOTA_BR"]) / 100,
        )

    def __getattr__(self, name):
        return getattr(self._client, name)

Kod migracji dostawał LegacyInvoiceSource i nie miał pojęcia, że pod spodem siedzi baza z polami dopełnianymi spacjami do dwudziestu znaków. Ostatnia metoda to pythonowy skrót, którego w książce nie ma. Wszystko, czego adapter sam nie definiuje, __getattr__ przekazuje do opakowanego obiektu, więc nie trzeba przepisywać metod, które i tak pasują. Płaci się za to tym, że przez adapter przecieka całe API starego klienta, razem z select() i nazwami kolumn, a wąski interfejs z jedną metodą get() zostaje tylko w intencjach autora. U nas po kilku tygodniach ktoś w kodzie migracji wołał już source.select("FAKTURY", ...) z pominięciem tłumaczenia dat i NIP-ów. Skończyło się na usunięciu __getattr__ i jawnym przepuszczeniu dwóch metod, które naprawdę były potrzebne, count() i close().

Po klasę sięgnąłem w ogóle dlatego, że adapter musiał trzymać połączenie z bazą. W samej migracji większość tłumaczenia i tak robiła zwykła funkcja ze słownikiem mapującym stare nazwy kolumn na nowe, a klasę dopisałem dopiero wtedy, gdy doszło wyszukiwanie. W zespole mówiliśmy o tym „ta warstwa, co tłumaczy stare na nowe”, bez żadnego słowa z katalogu, i do dogadania się to wystarczało. Biblioteka standardowa też ma swoje adaptery, choć ich tak nie nazywa. io.TextIOWrapper opakowuje strumień bajtów i udostępnia go jako tekst, a open() w trybie tekstowym po cichu zwraca właśnie jego.

Decorator zajmuje się dokładaniem zachowania do jednego konkretnego obiektu bez ruszania jego klasy i bez mnożenia podklas. Przykładem są strumienie. Jeśli plik ma być czasem buforowany, czasem kompresowany, a czasem szyfrowany, to podklasa na każdą kombinację daje po chwili BufferedCompressedEncryptedFileStream i kilkanaście jego kuzynów. Dekorator ma ten sam interfejs co obiekt, który opakowuje, trzyma go w polu, przekazuje mu wywołania i dokłada coś od siebie. Dekoratory da się zakładać jeden na drugi w dowolnej kolejności, i to w czasie działania programu. Java zbudowała na tym cały pakiet java.io, stąd słynne new BufferedReader(new InputStreamReader(new FileInputStream(path))), które każdy przepisywał ze Stack Overflow, zanim zrozumiał, co w nim siedzi. Pythonowe io jest zresztą zbudowane podobnie: BufferedReader opakowuje FileIO, a TextIOWrapper z poprzedniego punktu siedzi na samej górze.

Klasa opakowująca przełożona wprost na Pythona, na przykładzie wyszukiwania dokumentów, z których trzeba usunąć numery PESEL, zanim trafią dalej:

python
import re

PESEL = re.compile(r"\b\d{11}\b")

class MaskingRetriever:
    def __init__(self, inner):
        self._inner = inner

    def search(self, query):
        return [PESEL.sub("[PESEL]", doc) for doc in self._inner.search(query)]

    def __getattr__(self, name):
        return getattr(self._inner, name)

retriever = MaskingRetriever(VectorRetriever(index))

Wygląda prawie jak Adapter, z tą różnicą, że interfejs zostaje ten sam, a zmienia się wynik. Python ma ten wzorzec wbudowany w składnię od wersji 2.4 i PEP 318, czyli od 2004 roku, choć pod tą samą nazwą kryje się nieco inny mechanizm. Zapis @masked nad definicją funkcji to skrót od search = masked(search) i wykonuje się raz, przy definicji, więc opakowana jest funkcja dla wszystkich wywołań. Klasyczny Decorator opakowuje wybrany obiekt w chwili, gdy go potrzebujemy. W praktyce obie rzeczy mocno na siebie zachodzą, bo funkcja jest obiektem, a masked(search) można wywołać w dowolnym miejscu, bez małpy, i przekazać dalej tylko tam, gdzie maskowanie ma działać:

python
import functools

def masked(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return [PESEL.sub("[PESEL]", doc) for doc in func(*args, **kwargs)]
    return wrapper

@masked
def search(query):
    return index.search(query)

Z tego korzystałem przy chatbocie RAG dla klienta z branży farmaceutycznej. Dane przechodziły przez kilka etapów anonimizacji: numery PESEL, nazwiska lekarzy, adresy, a do tego pomiar czasu każdego etapu. Każdy etap był dekoratorem funkcji, więc zestaw dało się zmieniać, dopisując albo usuwając jedną linijkę nad definicją. W pierwszej wersji zabrakło mi functools.wraps. Dekorator od pomiaru czasu logował nazwę funkcji, a że każda warstwa zwracała funkcję wewnętrzną, w logach wszystkie etapy nazywały się wrapper i przez pół dnia nie dało się ustalić, który z nich jest wolny. wraps kopiuje __name__, __doc__ i __qualname__ z oryginału i dopisuje __wrapped__, przez które można się dostać do funkcji bez opakowania, na przykład w teście.

Dekoratory spotyka się w Pythonie na każdym kroku, choć rzadko ktoś myśli o nich jako o wzorcu: functools.cache z punktu o singletonie, login_required i transaction.atomic w Django. Nie każda małpa nad funkcją to jednak Decorator w sensie książkowym. @app.get("/...") w FastAPI niczego nie opakowuje, tylko wpisuje funkcję do tablicy routingu i oddaje ją bez zmian, więc bliżej mu do słownika wypełnianego dekoratorem z punktu o Factory Method. Klasa opakowująca obiekt zostaje na sytuacje, gdy trzeba udekorować coś, co ma kilka metod i stan, jak MaskingRetriever wyżej.

Facade w książce ukrywa skomplikowany podsystem za jednym prostym interfejsem. Przykładem jest kompilator. W środku pracują Scanner, Parser, ProgramNode i CodeGenerator, ale większość użytkowników chce po prostu skompilować plik, więc dostaje klasę Compiler z jedną metodą compile(). Fasada niczego nie zabrania. Kto chce podpiąć własny generator kodu, dalej może sięgnąć do klas podsystemu bezpośrednio. W bibliotece standardowej Pythona fasady rzadko są klasami. subprocess.run() to funkcja, która przykrywa Popen razem z potokami, limitem czasu i czekaniem na zakończenie procesu. shutil.make_archive() dostaje od wywołującego nazwę formatu, na przykład "zip" albo "gztar", i sama przechodzi po katalogach i obsługuje zipfile czy tarfile. Spoza biblioteki standardowej najbardziej znany przykład to requests.get(), pod którym siedzą Session, PreparedRequest i pula połączeń z urllib3.

Moja pierwsza fasada powstała przy generatorze raportów, który w Pythonie sterował PowerPointem przez OLE, za pomocą win32com. API COM-u było niewygodne na każdym kroku i pisało się pod nie z dokumentacją MSDN otwartą obok. Analitycy, którzy mieli z tego korzystać, nie chcieli nic wiedzieć o Shapes ani TextFrame. Chcieli podać szablon i liczby, a na wyjściu dostać gotową prezentację. Całe to COM-owe zaplecze zamknąłem więc w jednym module z jedną publiczną funkcją, bez żadnej klasy:

python
# pptreport.py
import os

import pywintypes
import win32com.client

MSO_TRUE, MSO_FALSE = -1, 0

def _replace_all(text_range, old, new):
    found = text_range.Replace(old, new)
    while found is not None:
        after = found.Start + found.Length - 1
        found = text_range.Replace(old, new, after)

def _powerpoint():
    try:
        return win32com.client.GetActiveObject("PowerPoint.Application"), False
    except pywintypes.com_error:
        return win32com.client.Dispatch("PowerPoint.Application"), True

def render(template, output, values):
    app, started_here = _powerpoint()
    try:
        pres = app.Presentations.Open(os.path.abspath(template), MSO_TRUE, MSO_FALSE, MSO_FALSE)
        try:
            for slide in pres.Slides:
                for shape in slide.Shapes:
                    if shape.HasTextFrame:
                        for key, value in values.items():
                            _replace_all(shape.TextFrame.TextRange, f"{{{key}}}", str(value))
            pres.SaveAs(os.path.abspath(output))
        finally:
            pres.Close()
    finally:
        if started_here:
            app.Quit()

TextRange.Replace w PowerPoincie podmienia za jednym wywołaniem tylko pierwsze trafienie i zwraca zakres z nowym tekstem, a gdy nic nie znajdzie, zwraca Nothing, które win32com zamienia na None. Stąd pętla, a trzeci argument, After, każe szukać dopiero za ostatnim znakiem wstawionej wartości. Bez niego każde wywołanie zaczynałoby od początku i gdyby wartość sama zawierała znacznik w rodzaju {n}, pętla kręciłaby się w nieskończoność. Prostsze text_range.Text = text_range.Text.replace(...) też podmienia tekst, tylko przy okazji spłaszcza formatowanie całego pola do formatu pierwszego znaku, więc pogrubiona liczba w środku zdania przestaje być pogrubiona.

Sporo z tego kodu to pilnowanie, żeby PowerPoint poprawnie się zamykał i nie zostawał w tle z zablokowanym plikiem, stąd zagnieżdżone finally i Quit() tylko wtedy, gdy skrypt sam uruchomił aplikację. Po stronie analityka zostawało pptreport.render("szablon.pptx", "fala_12.pptx", {"n": 1204, "udzial": "37,5%"}), a o COM-ie wiedział już tylko ten jeden plik. Nikt wtedy nie nazywał tego wzorcem, pierwszy raport, który przeszedł od początku do końca, skwitowałem krótko: „działa, chwała Bogu” :-) Fasada to zresztą jeden z niewielu wzorców, które programiści piszą odruchowo, nie nazywając ich po imieniu. Zwykle zaczyna się od utils.py, do którego ktoś po raz trzeci wkleja te same sześć linijek konfiguracji klienta boto3.

Proxy w książce to zastępca, który stoi przed prawdziwym obiektem, ma ten sam interfejs i decyduje, kiedy i czy w ogóle przekazać mu wywołanie. Banda Czworga wymienia kilka odmian. Proxy zdalne udaje lokalny obiekt, choć ten żyje w innym procesie. Proxy wirtualne odkłada utworzenie drogiego obiektu do chwili, gdy ktoś go naprawdę użyje. Proxy ochronne sprawdza uprawnienia przed przekazaniem wywołania, a inteligentna referencja liczy odwołania albo zakłada blokadę. Przykładem w książce jest edytor dokumentów, w którym obrazek wstawiony do tekstu ładuje się z dysku dopiero przy pierwszym rysowaniu. Do tego czasu ImageProxy zna tylko jego rozmiar, żeby dało się złożyć stronę. W C++ ta sama idea stoi za inteligentnymi wskaźnikami, które przeciążają operator-> i przy każdym dostępie mogą coś zrobić po drodze.

W Javie proxy musi zaimplementować cały interfejs obiektu, metodę po metodzie, chyba że ktoś sięgnie po java.lang.reflect.Proxy i InvocationHandler. W Pythonie, tak jak przy Adapterze, wystarcza __getattr__, a dzięki duck typingowi nikt nie sprawdza, czy zastępca deklaruje ten sam interfejs co oryginał. Na tej zasadzie działa request.user w Django. Middleware wstawia tam SimpleLazyObject i zapytanie o użytkownika do sesji i bazy idzie dopiero przy pierwszym odczycie, na przykład request.user.is_authenticated. Widoki, które w ogóle nie zaglądają do użytkownika, tego zapytania nie wykonują. Tak samo można potraktować klienta Elasticsearcha, którego nie chce się tworzyć przy imporcie modułu:

python
class Lazy:
    def __init__(self, factory):
        self._factory = factory
        self._target = None

    def __getattr__(self, name):
        if self._target is None:
            self._target = self._factory()
        return getattr(self._target, name)

es = Lazy(lambda: Elasticsearch(settings["es_url"]))
es.search(index="parts", query={"match": {"name": "klocki hamulcowe"}})

Jest w tym jedna pułapka, która wychodzi, gdy tak opakuje się listę albo inną kolekcję. Metody specjalne, jak __len__, __iter__ czy __eq__, Python szuka na typie obiektu, a nie na samym egzemplarzu, więc __getattr__ ich nie przechwytuje. proxy.search() działa, za to len(proxy) kończy się TypeError: object of type 'Lazy' has no len(), a proxy == coś porównuje sam obiekt proxy. Django rozwiązuje to w LazyObject ręcznie. Kilkanaście metod specjalnych jest tam zdefiniowanych przez pomocniczą funkcję new_method_proxy, która przed wywołaniem rozwija opakowany obiekt. Biblioteka standardowa ma zresztą klasę z tą nazwą wprost. weakref.proxy zwraca obiekt zachowujący się jak oryginał, który nie podtrzymuje go przy życiu i rzuca ReferenceError, gdy oryginał zniknie.

Proxy ochronne kojarzy mi się z systemem w PHP, w którym osoba edytująca rekord blokowała go dla pozostałych, a ci dostawali komunikat i przycisk z prośbą o zwolnienie edycji. Sprawdzenie, czy rekord nie jest zablokowany przez kogoś innego, siedziało wtedy w kilku if-ach i w kolumnie z identyfikatorem użytkownika w bazie. Dziś zamknąłbym je w jednym miejscu przed zapisem. W Pythonie, gdy chroniona jest pojedyncza operacja, a nie cały obiekt, zamiast klasy wystarcza funkcja opakowująca:

python
import functools

def requires_lock(func):
    @functools.wraps(func)
    def wrapper(user, record, *args, **kwargs):
        if record.locked_by not in (None, user.id):
            raise RecordLocked(record.id, record.locked_by)
        return func(user, record, *args, **kwargs)
    return wrapper

@requires_lock
def save(user, record, changes):
    ...

Budową niczym to się nie różni od dekoratorów z punktu o Decoratorze i książka sama przyznaje, że oba wzorce mają podobną strukturę. Różnią się celem. Decorator dokłada zachowanie i zakłada się go dowolnie wiele razy, a Proxy pilnuje dostępu do jednego obiektu i zwykle sam decyduje, czy i kiedy ten obiekt powstanie. W Django login_required i permission_required są w tym sensie bardziej proxy ochronnymi niż dekoratorami, bo dla niezalogowanego użytkownika w ogóle nie wywołują widoku, tylko zwracają przekierowanie na stronę LOGIN_URL.

Composite w książce służy do budowania drzew, w których klient nie musi odróżniać pojedynczego elementu od całej grupy. Przykładem jest edytor graficzny. Line, Rectangle i Text są liśćmi, a Picture grupuje je i sama może trafić do innego Picture. Kod rysujący woła draw() i nie sprawdza, czy ma przed sobą jedną linię, czy grupę pięćdziesięciu elementów. Klasa bazowa Graphic deklaruje zarówno draw(), jak i add(), remove() oraz get_child(). Banda Czworga poświęca kilka stron na rozważania, czy liść w ogóle powinien mieć metody do zarządzania dziećmi. W wersji przezroczystej ma je i rzuca wyjątek, a w wersji bezpiecznej ich nie ma, tyle że wtedy klient znowu musi sprawdzać typy. Java ma ten wzorzec w AWT jako Component i Container, dlatego JPanel da się włożyć w inny JPanel i tak dalej, aż do okna.

Mnie drzewo najbardziej kojarzy się z kosztorysem badania, czyli z tym, co liczył Kosztorysant. Badanie dzieli się na fale, fala na etapy, takie jak realizacja w terenie, kodowanie czy raport, a etapy na pozycje: wynagrodzenia ankieterów, druk kwestionariuszy, dojazdy. Kierownik projektu chce widzieć sumę na każdym poziomie, a klient zwykle tylko jedną liczbę na samym dole oferty. Wzorzec przechodzi do Pythona prawie bez zmian, a klasę bazową, tak jak przy Adapterze, zastępuje duck typing:

python
from dataclasses import dataclass, field
from decimal import Decimal

@dataclass
class Item:
    name: str
    unit_price: Decimal
    quantity: int = 1

    def cost(self):
        return self.unit_price * self.quantity

@dataclass
class Group:
    name: str
    children: list = field(default_factory=list)

    def add(self, *items):
        self.children.extend(items)
        return self

    def cost(self):
        return sum(child.cost() for child in self.children)

fieldwork = Group("Realizacja").add(
    Item("Wywiady CAPI", Decimal("42.50"), 1200),
    Item("Dojazdy ankieterów", Decimal("18.00"), 310),
)
wave = Group("Fala 1").add(fieldwork, Item("Kodowanie pytań otwartych", Decimal("3.20"), 1200))
wave.cost()

Group nie wie, czy dziecko jest pozycją, czy kolejną grupą, i nie musi wiedzieć. Jeśli zależy nam, żeby mypy to sprawdzał, można dopisać typing.Protocol z jedną metodą cost(). Item i Group będą z nim zgodne bez dziedziczenia, a children dostanie typ list[Costed]. Metod add() i remove() liść w tym przykładzie w ogóle nie ma, bo drzewo składa się w jednym miejscu, a przechodzi w wielu, więc wersja bezpieczna z książki wystarcza.

Jest jedna pułapka. Gdy ktoś przez pomyłkę doda grupę do niej samej albo do jej potomka, cost() wpadnie w nieskończoną rekurencję i skończy się RecursionError. Limit z sys.getrecursionlimit(), domyślnie 1000, dotyczy ramek stosu, a nie poziomów drzewa, i każdy poziom Group zajmuje co najmniej dwie ramki, samą metodę cost() i wyrażenie generatorowe przekazane do sum(). Ten sam błąd dostanie więc także zupełnie poprawne drzewo, jeśli ma kilkaset poziomów, choć kosztorysu tak głębokiego jeszcze nie widziałem.

W bibliotekach Composite siedzi częściej, niż się wydaje. Obiekt Q w Django po połączeniu przez & albo | zwraca nowy Q, który w polu children trzyma składniki, a w connector informację, czy to AND, czy OR. filter() przyjmuje pojedynczy warunek i całe drzewo dokładnie tak samo. W elasticsearch-dsl zapytanie Bool może zawierać w must i should inne zapytania Bool, co przy wyszukiwarce części dawało drzewa po cztery, pięć poziomów. Moduł ast z biblioteki standardowej reprezentuje kod Pythona jako drzewo węzłów, w którym ast.Module zawiera ast.FunctionDef, a ta z kolei listę instrukcji w polu body.

Wzorce behawioralne

Strategy rozwiązuje kłopot, który pojawia się w prawie każdym większym programie. Ten sam cel da się osiągnąć kilkoma algorytmami, a wybór zależy od danych, konfiguracji albo decyzji użytkownika. Zamiast rozbudowanego if w środku metody Banda Czworga proponuje wydzielić każdy algorytm do osobnej klasy ze wspólnym interfejsem. Obiekt, który z algorytmu korzysta, nazywany kontekstem, trzyma referencję do strategii i woła jej jedyną metodę. Autorzy ilustrują to łamaniem tekstu na linie w edytorze. Composition dostaje SimpleCompositor, TeXCompositor albo ArrayCompositor i nie wie, który z nich właśnie dzieli akapit. To ten sam powód, o którym pisałem na początku: w C++ tamtych lat zachowanie dało się przekazać tylko w klasie z jedną metodą, więc Strategy jest w książce przede wszystkim hierarchią takich klas.

W katalogu części samochodowych, tym samym, dla którego zapytania do Elasticsearcha składały się kawałek po kawałku w punkcie o Builderze, ranking zależał od tego, co użytkownik wpisał. Numer katalogowy w rodzaju 0 986 494 524 wymagał dokładnego dopasowania po znormalizowanym numerze, bez żadnej tolerancji na literówki. Za to „klocki hamulcowe przod” musiały przejść przez dopasowanie rozmyte po nazwie i opisie, z nazwą ważniejszą od opisu. Pierwsza wersja miała to wszystko w jednej funkcji z trzema gałęziami if i każdy nowy typ zapytania dopisywał czwartą. Wersja książkowa rozbija to na klasy:

python
class RankingStrategy:
    def build(self, text):
        raise NotImplementedError

class CatalogNumberRanking(RankingStrategy):
    def build(self, text):
        return {"term": {"number.keyword": text.upper().replace(" ", "")}}

class DescriptionRanking(RankingStrategy):
    def build(self, text):
        return {"multi_match": {"query": text,
                                "fields": ["name^3", "description"],
                                "fuzziness": "AUTO"}}

class PartSearch:
    def __init__(self, ranking):
        self.ranking = ranking

    def run(self, text):
        return es.search(index="parts", query=self.ranking.build(text))

Każda z tych klas ma jedną metodę i zero stanu, a w takiej sytuacji to nie jest wzorzec, to jest po prostu funkcja. W Pythonie funkcję przekazuje się jako argument tak samo jak każdą inną wartość, więc strategie zostają zwykłymi funkcjami, a kontekst przyjmuje je wprost. Wybór strategii na podstawie treści zapytania też jest funkcją. Kiedy strategia potrzebuje parametru, na przykład innej wartości fuzziness dla krótkich zapytań, functools.partial robi z niej nową funkcję bez dopisywania klasy z konstruktorem:

python
import functools
import re

NUMBER = re.compile(r"^(?=.*\d)[A-Z0-9 .-]{5,20}$", re.IGNORECASE)

def normalize_number(text):
    return re.sub(r"[^A-Z0-9]", "", text.upper())

def by_number(text):
    return {"term": {"number.keyword": normalize_number(text)}}

def by_description(text, fuzziness="AUTO"):
    return {"multi_match": {"query": text,
                            "fields": ["name^3", "description"],
                            "fuzziness": fuzziness}}

def choose_ranking(text):
    if NUMBER.match(text):
        return by_number
    if len(text) < 5:
        return functools.partial(by_description, fuzziness=0)
    return by_description

def search(text, ranking=None):
    ranking = ranking or choose_ranking(text)
    return es.search(index="parts", query=ranking(text))

Osobna normalize_number pojawiła się po zgłoszeniu z działu obsługi. Na początku by_number robiła to samo co klasa wyżej, czyli usuwała tylko spacje. NUMBER przepuszczał jednak kropki i myślniki, więc numer wklejony z katalogu dostawcy jako 0-986-494-524 albo 0.986.494.524 był rozpoznawany jako numer katalogowy, trafiał do zapytania term razem z separatorami i nie znajdował nic, bo w indeksie numery leżały same z cyfr i liter. Teraz przed wysłaniem zostają tylko litery i cyfry, dokładnie tak jak w skrypcie, który budował indeks.

W testach mogłem podać search("abc", ranking=lambda t: {"match_all": {}}) i sprawdzać resztę ścieżki bez całej logiki rankingu. Tak samo wyglądał później solver doboru prób. Warianty algorytmu przydzielające respondentów do komórek kwot były funkcjami i domknięciami, które przekazywałem do pętli głównej, i przełączało się je jednym argumentem z linii poleceń. Biblioteka standardowa robi to od zawsze: sorted() i max() przyjmują key, re.sub() zamiast tekstu zastępującego przyjmuje funkcję, a json.dumps() ma parametr default na obiekty, których nie umie zapisać. Strategię w wersji książkowej, z klasami, ma za to Django w PASSWORD_HASHERS. Każdy hasher ma tam encode(), verify() i must_update(), a pierwszy z listy decyduje o formacie nowo zapisywanych haseł, pozostałe służą już tylko do sprawdzania starych.

W systemie w PHP z blokowaniem rekordów, o którym pisałem przy Proxy, osoba, która kliknęła prośbę o zwolnienie edycji, dowiadywała się, że rekord jest wolny, dopiero po odświeżeniu strony. Dziś powiedziałbym, że zwolnienie blokady to zdarzenie, na które chce zareagować kilka niezależnych kawałków kodu. Trzeba powiadomić czekającego, dopisać wpis w historii zmian i wyczyścić podgląd rekordu w cache. Kod od blokad nie powinien przy tym znać żadnego z nich ani wiedzieć, ilu ich jest.

W książce ten wzorzec nazywa się Observer i jest pokazany na danych arkusza, wyświetlanych naraz jako tabela, wykres słupkowy i wykres kołowy. Po zmianie liczby w tabeli oba wykresy mają się odświeżyć, a obiekt z danymi nie wie, ile widoków na niego patrzy. Podmiot ma metody attach(), detach() i notify(), a każdy obserwator implementuje update(). Wzorzec wyrósł z MVC w Smalltalku-80, a Java miała go od wersji 1.0 jako java.util.Observable. Tyle że Observable była klasą, więc zabierała jedyne miejsce na dziedziczenie, i w Javie 9 oznaczono ją jako przestarzałą. W Pythonie obserwatorem może być dowolna funkcja, więc interfejs z update() zastępuje lista obiektów, które da się wywołać:

python
class RecordLock:
    def __init__(self, record_id):
        self.record_id = record_id
        self.locked_by = None
        self._listeners = []

    def subscribe(self, callback):
        self._listeners.append(callback)
        return lambda: self._listeners.remove(callback)

    def release(self):
        previous, self.locked_by = self.locked_by, None
        for callback in list(self._listeners):
            callback(self.record_id, previous)

lock = RecordLock(4711)
unsubscribe = lock.subscribe(notify_waiting_users)
lock.subscribe(lambda rid, uid: audit.write("unlock", rid, uid))

Zamiast detach() metoda subscribe zwraca funkcję do wypisania się, a pętla idzie po kopii listy, żeby obserwator mógł się wypisać w trakcie powiadamiania. Ręczna lista ma jedną wadę, o której książka ledwie wspomina. Podmiot trzyma silne referencje, więc gdy ktoś zapisze się przez lock.subscribe(panel.refresh), lista trzyma przy życiu cały obiekt panel, nawet gdy okno dawno zamknięto i reszta programu o nim zapomniała. Zwykłe weakref.ref(panel.refresh) nic tu nie da, bo panel.refresh przy każdym odczycie tworzy nowy obiekt metody powiązanej, a na ten jeden nikt poza słabą referencją nie wskazuje, więc znika od razu. weakref.WeakMethod trzyma osobno słabą referencję do obiektu i do funkcji, a przy wywołaniu składa je z powrotem albo zwraca None, jeśli obiektu już nie ma:

python
import weakref

def subscribe(self, callback):
    if hasattr(callback, "__self__"):
        ref = weakref.WeakMethod(callback)
    else:
        ref = weakref.ref(callback)
    self._listeners.append(ref)
    return lambda: self._listeners.remove(ref)

def release(self):
    previous, self.locked_by = self.locked_by, None
    for ref in list(self._listeners):
        callback = ref()
        if callback is None:
            self._listeners.remove(ref)
        else:
            callback(self.record_id, previous)

Martwe wpisy wylatują przy najbliższym powiadomieniu. Jest też druga strona. Lambda z audytem z pierwszego przykładu nie ma teraz żadnej silnej referencji i przepadnie jeszcze przed pierwszym release(), więc taki obserwator musi być przypisany do zmiennej albo zdefiniowany na poziomie modułu. Gdy obserwatorami są obiekty z metodą update(), jak w książce, zamiast listy wystarcza weakref.WeakSet, z którego wpisy znikają same, gdy obiekt przestaje istnieć. Takiej listy rzadko jednak piszę sam, bo framework, na którym stoi projekt, zwykle już ją ma. Sygnały w Django to Observer z rejestracją przez dekorator i domyślnie ze słabymi referencjami:

python
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Invoice, dispatch_uid="reindex_invoice")
def reindex_invoice(sender, instance, created, **kwargs):
    transaction.on_commit(lambda: search_index.update(instance.pk))

Słabe referencje zaskoczyły mnie przy pierwszym podejściu, dokładnie w ten sposób co lambda z audytem. Odbiornik zdefiniowany wewnątrz funkcji konfigurującej nie odpalił ani razu. Jedyną silną referencją do niego była zmienna lokalna, więc CPython zwolnił go od razu po wyjściu z funkcji, a sygnałowi została słaba referencja do obiektu, którego już nie było. Pomaga weak=False albo definicja na poziomie modułu. transaction.on_commit jest potrzebne, bo post_save przychodzi jeszcze w trakcie transakcji, i bez niego indeks potrafi dostać fakturę, której zapis za chwilę zostanie wycofany. Sam post_save wysyła tylko save() na pojedynczym obiekcie. QuerySet.update(), bulk_create() i bulk_update() omijają go zawsze, niezależnie od bazy. Przy migracji faktur odbiorniki milczały więc dla kilku milionów rekordów i indeks trzeba było przebudować osobnym poleceniem.

Czasem cała ta maszyneria jest za duża, bo obserwować trzeba jedno pole jednego obiektu. Wtedy wystarcza property z setterem, który po przypisaniu woła podaną funkcję:

python
class QuotaCell:
    def __init__(self, target, on_change):
        self.target = target
        self._filled = 0
        self._on_change = on_change

    @property
    def filled(self):
        return self._filled

    @filled.setter
    def filled(self, value):
        self._filled = value
        self._on_change(self, value)

W solverze doboru prób tak podpiąłem pasek postępu pod wypełnianie komórek kwot. Algorytm robił zwykłe cell.filled += 1 i nie wiedział, że ktoś to rysuje. Callback odpalał się przy każdym przydzielonym respondencie. Przy kilkudziesięciu tysiącach przypisań odświeżałem więc pasek dopiero co setne wywołanie, bo inaczej samo rysowanie w terminalu zajmowało więcej czasu niż dobór.

Command w książce zamienia żądanie w obiekt. Zamiast od razu wywołać metodę, pakuje się do obiektu to, co ma być zrobione, na czym i z jakimi parametrami. Taki obiekt można odłożyć do kolejki, zapisać w logu, wykonać później albo cofnąć. Banda Czworga pokazuje to na menu edytora tekstu. MenuItem nie wie, co robi, trzyma tylko obiekt Command i po kliknięciu woła jego execute(). PasteCommand wkleja zawartość schowka, OpenCommand pyta o nazwę pliku i otwiera dokument, a MacroCommand trzyma listę innych poleceń i wykonuje je po kolei. Do cofania służy druga metoda, unexecute(), i lista wykonanych poleceń, po której edytor cofa się krok po kroku. W Javie ten sam pomysł siedzi w Runnable przekazywanym do wątku i w Action ze Swinga, który podpina się jednocześnie pod pozycję menu, przycisk na pasku narzędzi i skrót klawiszowy.

Książka potrzebuje tu klasy z tego samego powodu co przy Strategy, a w Pythonie functools.partial robi to samo bez niej: zamraża funkcję z częścią albo całością argumentów i oddaje obiekt, który wystarczy wywołać. Przy migracji faktur dzieliłem kilka milionów rekordów na partie i każda partia była takim zamrożonym wywołaniem. Lista zadań powstawała na początku, a wykonywała się potem, z logowaniem i odkładaniem nieudanych na bok:

python
import functools
import logging

log = logging.getLogger("migration")
BATCH = 50_000

def migrate_batch(source, start, stop):
    ...

jobs = [functools.partial(migrate_batch, source, start, start + BATCH)
        for start in range(0, total, BATCH)]

failed = []
for job in jobs:
    log.info("%s %s", job.func.__name__, job.args[1:])
    try:
        job()
    except Exception:
        log.exception("Partia %s nieudana", job.args[1:])
        failed.append(job)

Obiekt partial ma atrybuty func, args i keywords, więc w logu widać, co dokładnie się wykonuje, a lista failed to gotowa kolejka do ponowienia po poprawieniu danych. Jeśli na przykład trzy partie wywalą się na fakturach z pustym NIP-em sprzedawcy, to zamiast przekopywać logi i ręcznie wyliczać zakresy, poprawia się dane i puszcza jeszcze raz tylko to, co zostało w failed.

partial da się też zserializować przez pickle, o ile opakowana funkcja jest zdefiniowana na poziomie modułu, a argumenty same dają się zserializować. Obiekt z otwartym połączeniem do bazy już się nie da, więc do innego procesu przekazuje się raczej ścieżkę albo parametry połączenia. Wtedy polecenie można wysłać do ProcessPoolExecutor.submit(), które zresztą samo przyjmuje wywołanie w postaci submit(fn, *args). Celery idzie o krok dalej. migrate_batch.s(0, 50_000) zwraca sygnaturę, czyli wywołanie zapisane jako słownik z nazwą zadania i argumentami, które przez brokera trafia na inną maszynę. chain() i group() składają z sygnatur większe całości, trochę jak MacroCommand z książki.

Z cofaniem jest trudniej, bo polecenie musi pamiętać, jak wyglądał stan przed wykonaniem. Da się to zrobić funkcją, która po wykonaniu zwraca domknięcie odwracające zmianę, i przy jednym poziomie „cofnij” to wystarcza. Kiedy dochodzi ponowienie cofniętej zmiany i opis w menu w rodzaju „Cofnij: zmiana ilości”, przydaje się obiekt z dwiema metodami i danymi. Gdybym dziś dopisywał cofanie do rozbicia kosztów z punktu o Composite, wyglądałoby to mniej więcej tak:

python
from dataclasses import dataclass

@dataclass
class SetQuantity:
    item: Item
    quantity: int
    previous: int | None = None
    label = "zmiana ilości"

    def execute(self):
        self.previous = self.item.quantity
        self.item.quantity = self.quantity

    def undo(self):
        self.item.quantity = self.previous

class History:
    def __init__(self):
        self._done = []
        self._undone = []

    def run(self, command):
        command.execute()
        self._done.append(command)
        self._undone.clear()

    def undo(self):
        command = self._done.pop()
        command.undo()
        self._undone.append(command)

    def redo(self):
        command = self._undone.pop()
        command.execute()
        self._done.append(command)

History nie wie nic o pozycjach kosztorysu. Działa z każdym obiektem, który ma execute() i undo(), więc kolejne polecenia, na przykład zmiana ceny jednostkowej albo przeniesienie pozycji do innego etapu, dochodzą jako nowe dataclassy bez ruszania historii. Wyczyszczenie _undone przy nowym poleceniu to ta sama reguła, którą zna każdy edytor: po cofnięciu i wprowadzeniu innej zmiany ponowienie przepada. W Django najbliżej książkowego wzorca są migracje. Każda operacja ma database_forwards() i database_backwards(), a python manage.py migrate faktury 0012 cofa bazę do wskazanej migracji, wykonując te drugie w odwrotnej kolejności. RunPython przyjmuje dwie zwykłe funkcje, code i reverse_code. Jeśli drugiej zabraknie, próba cofnięcia kończy się IrreversibleError, a gdy cofać nie ma czego, podaje się migrations.RunPython.noop.

Iterator w książce rozwiązuje problem, który w latach 90. był całkiem realny. Kolekcja trzyma elementy po swojemu, w tablicy, liście wiązanej albo drzewie, a kod, który po nich przechodzi, nie powinien tego wiedzieć. Autorzy wydzielają więc przechodzenie do osobnego obiektu z metodami First(), Next(), IsDone() i CurrentItem(). Kolekcja udostępnia tylko metodę tworzącą iterator, a różne iteratory mogą chodzić po tej samej liście od przodu, od tyłu albo z filtrem. Książka odróżnia też iterator zewnętrzny, którym steruje klient, od wewnętrznego, który sam przechodzi po elementach i dla każdego woła podaną operację. Java 1.0 miała Enumeration, w wersji 1.2 doszedł Iterator z hasNext() i next(), a dopiero Java 5 dała pętlę for (Invoice i : invoices), która pod spodem woła właśnie te dwie metody.

W Pascalu u mnie wyglądało to zupełnie inaczej. Skrypty do doboru prób stały na TCollection z Turbo Vision i chodziło się po nich pętlą od zera do Count - 1 z At(i) w środku albo przez ForEach z adresem lokalnej procedury, czyli iteratorem wewnętrznym, tylko bez tej nazwy. W Pythonie wzorzec jest wbudowany w sam język od wersji 2.1 i PEP 234. Pętla for woła iter() na obiekcie, co uruchamia jego __iter__, a potem next(), dopóki nie poleci StopIteration. Wersja książkowa przełożona na ten protokół, na przykładzie czytania faktur ze starej bazy stronami po kilka tysięcy rekordów, wygląda tak:

python
class InvoiceIterator:
    def __init__(self, client, page_size=5000):
        self._client = client
        self._page_size = page_size
        self._rows = iter(())
        self._last_id = 0

    def __iter__(self):
        return self

    def __next__(self):
        row = next(self._rows, None)
        if row is None:
            page = self._client.select_after("FAKTURY", self._last_id, self._page_size)
            if not page:
                raise StopIteration
            self._rows = iter(page)
            row = next(self._rows)
        self._last_id = row["ID"]
        return row

Cały stan, czyli bieżąca strona i ostatni identyfikator, musi siedzieć w polach, bo __next__ przy każdym wywołaniu zaczyna od początku i odtwarza, gdzie skończył. Generatory, które weszły w Pythonie 2.2 z PEP 255, zdejmują ten ciężar. Funkcja z yield zatrzymuje się w miejscu, w którym oddała wartość, i wraca do niego przy kolejnym next(), razem ze zmiennymi lokalnymi. Stan trzyma więc sam interpreter, a z klasy zostaje kilka linijek:

python
def invoices(client, page_size=5000):
    last_id = 0
    while page := client.select_after("FAKTURY", last_id, page_size):
        yield from page
        last_id = page[-1]["ID"]

for row in invoices(client):
    migrate(row)

Przy migracji faktur to był główny powód, żeby w ogóle myśleć o iteratorach. Wczytanie kilku milionów wierszy do listy kończyło się na maszynie z 8 GB RAM-u zabiciem procesu przez OOM killer, a generator trzymał w pamięci jedną stronę naraz. Stronicowanie po ID zamiast przez OFFSET miało też swój sens, bo baza przy każdym dużym OFFSET przewijała wszystkie wcześniejsze wiersze od nowa. Django ma to samo w QuerySet.iterator(chunk_size=2000), które przy PostgreSQL korzysta z kursora po stronie serwera i nie odkłada wyników w cache querysetu. Z itertools najczęściej biorę islice, żeby w teście przejść tylko przez pierwsze sto rekordów, i batched z Pythona 3.12, które dzieli dowolny iterator na krotki o zadanej długości.

Generator ma jedną cechę, która dała mi się we znaki już przy pierwszej próbie migracji: przechodzi się po nim tylko raz. Przed właściwą pętlą policzyłem rekordy przez sum(1 for _ in rows), żeby mieć liczbę do paska postępu. Liczenie trwało dobrych kilka minut, a pętla, która przyszła zaraz po nim, skończyła się w ułamku sekundy. Log uczciwie raportował zero błędów i zero zmigrowanych faktur, więc przez chwilę byłem pewien, że padła baza albo zerwało się połączenie, i zacząłem przeglądać logi serwera. Dopiero po kwadransie dotarło do mnie, że generator wyczerpałem sam, linijkę wcześniej, a liczbę rekordów i tak dało się wziąć z jednego SELECT COUNT(*).

Lista czy słownik to obiekty iterowalne, z których iter() za każdym razem robi nowy iterator. Generator jest iteratorem sam dla siebie, a jego __iter__ zwraca self, dokładnie jak w InvoiceIterator wyżej. Jeśli po danych trzeba przejść dwa razy, najprościej zrobić klasę, której __iter__ jest generatorem, bo wtedy każda pętla dostaje świeży. itertools.tee też to załatwia, ale buforuje w pamięci wszystko, co jedna kopia przeczytała, a druga jeszcze nie, więc przy milionach wierszy zużycie pamięci wraca do poziomu zwykłej listy.

State w książce dotyczy obiektu, który w zależności od swojego wewnętrznego stanu ma się zachowywać zupełnie inaczej. Przykładem jest TCPConnection, które przyjmuje ActiveOpen(), PassiveOpen(), Close() i Acknowledge(), a reakcja na każde z nich zależy od tego, czy połączenie jest nawiązane, nasłuchuje, czy jest zamknięte. Bez wzorca każda z tych metod zaczyna się od takiego samego switch po polu ze stanem, a dodanie nowego stanu oznacza dopisanie gałęzi w każdej z nich. Autorzy przenoszą więc każdy stan do osobnej klasy: TCPEstablished, TCPListen, TCPClosed. Kontekst trzyma referencję do bieżącego stanu i przekazuje mu wywołania, a przejście do innego stanu polega na podmianie tej referencji. W Javie od wersji 5 często robi się to przez enum z metodą abstrakcyjną, którą każda stała implementuje po swojemu.

Ten sam system w PHP z przyciskiem „zwolnij edycję”, który wracał już przy Proxy i Observerze, trzymał stan we flagach w bazie: identyfikatorze blokującego i informacji o prośbie o zwolnienie. Każda akcja, czyli wejście w edycję, zapis, prośba i zwolnienie, zaczynała się od kilku if-ów sprawdzających te flagi. Działało, tylko każda nowa reguła, na przykład automatyczne zwalnianie po jakimś czasie bezczynności, oznaczałaby dopisanie warunku w kilku miejscach, a wystarczy zapomnieć o jednym, żeby rekord z prośbą o zwolnienie dało się zablokować drugi raz. Automat ma trzy stany i cztery przejścia, więc hierarchia klas byłaby tu przerostem formy. W Pythonie wystarcza słownik, w którym kluczem jest para stan i akcja, a wartością funkcja zwracająca nowy stan:

python
from enum import StrEnum

class LockState(StrEnum):
    FREE = "free"
    LOCKED = "locked"
    REQUESTED = "requested"

def lock(record, user):
    record.locked_by = user.id
    return LockState.LOCKED

def request_release(record, user):
    notify(record.locked_by, f"{user.name} prosi o zwolnienie rekordu {record.id}")
    return LockState.REQUESTED

def release(record, user):
    record.locked_by = None
    return LockState.FREE

TRANSITIONS = {
    (LockState.FREE, "edit"): lock,
    (LockState.LOCKED, "request"): request_release,
    (LockState.LOCKED, "release"): release,
    (LockState.REQUESTED, "release"): release,
}

def handle(record, action, user):
    handler = TRANSITIONS.get((record.state, action))
    if handler is None:
        raise InvalidTransition(record.state, action)
    record.state = handler(record, user)

Cały automat mieści się na jednym ekranie, a niedozwolone przejście kończy się wyjątkiem w jednym miejscu, zamiast przechodzić po cichu przez zapomnianą gałąź. StrEnum z Pythona 3.11 zapisuje się w bazie jako zwykły tekst, więc kolumna state może być zwykłym polem tekstowym. Z takiego słownika da się też w kilku linijkach wygenerować plik dla Graphviza i pokazać diagram komuś, kto nie czyta kodu. Klasy zaczynają się opłacać, gdy stan ma własne dane i kilka zachowań naraz. Później pracowałem przy systemie, który zbierał informacje o sprzęcie i sieciach, i tam zmiany w dokumentach szły przez ankiety z cyklem zatwierdzania. W uproszczeniu wygląda to tak, że ankieta w szkicu daje się edytować, po wysłaniu czeka na zatwierdzających, a odrzucona wraca do szkicu z komentarzem. Stan „oczekuje” musi pamiętać, kto jeszcze nie zatwierdził, i ta lista naturalnie mieszka w obiekcie stanu:

python
class Draft:
    def edit(self, survey, changes):
        survey.changes.update(changes)

    def submit(self, survey):
        survey.state = Pending(set(survey.required_approvers))

class Pending:
    def __init__(self, approvers):
        self.approvers = approvers

    def edit(self, survey, changes):
        raise SurveyLocked(survey.id)

    def approve(self, survey, user):
        self.approvers.discard(user.id)
        if not self.approvers:
            survey.state = Approved()

    def reject(self, survey, user, comment):
        survey.comments.append((user.id, comment))
        survey.state = Draft()

W wersji ze słownikiem lista zatwierdzających trafiłaby do pola ankiety, które w szkicu i po zatwierdzeniu nie znaczy nic, i każda funkcja musiałaby pamiętać, kiedy je czyścić. Tu znika razem z obiektem Pending, a klasa bazowa dla stanów, tak jak przy Composite, jest zbędna dzięki duck typingowi.

Przy modelach Django sięgnąłbym raczej po gotową bibliotekę. django-fsm dodaje pole FSMField i dekorator @transition(field=state, source="draft", target="pending"), który przed wywołaniem metody sprawdza, czy obiekt jest w stanie źródłowym. Z jej utrzymaniem bywało różnie. Kiedy ostatnio sprawdzałem, oryginalne repozytorium było zarchiwizowane i autor odsyłał do viewflow, a obok krążył fork django-fsm-2 dla tych, którzy chcą zostać przy starym API (takie rzeczy zmieniają się szybciej niż artykuły, więc to stan na dzień pisania). Przed dodaniem zależności do projektu i tak zaglądam na GitHuba, kiedy był ostatni commit i czy ktoś odpowiada na zgłoszenia. Poza Django podobnie działa biblioteka transitions, która z listy słowników opisujących przejścia sama dopisuje do obiektu metody w rodzaju submit() i approve().

Delphi miało ten wzorzec w samym sercu biblioteki. Jeśli coś miało liczyć się w tle, dziedziczyło się po TThread i nadpisywało jedną metodę, Execute. Utworzeniem wątku, synchronizacją z głównym oknem przez Synchronize i sprzątaniem po zakończeniu zajmowała się klasa bazowa, a programista dopisywał tylko samo liczenie. Pisałem w Delphi kilka lat, więc ten układ był dla mnie czymś oczywistym, a nazwę Template Method dopasowałem do niego dużo później, kiedy nadrabiałem słownictwo z katalogu. Kolejność kroków algorytmu jest stała, a różnią się tylko niektóre z nich. Klasa bazowa ma metodę prowadzącą cały proces i woła z niej metody pomocnicze. Część z nich jest abstrakcyjna, a część ma domyślną, zwykle pustą implementację. Podklasa nadpisuje tylko to, co u niej wygląda inaczej, a kolejności kroków nie rusza.

Przykład z książki to klasa Application i jej metoda OpenDocument(). Ta najpierw pyta CanOpenDocument(), potem woła DoCreateDocument(), a przed wczytaniem pliku jeszcze AboutToOpenDocument(). Metody z domyślną implementacją autorzy nazywają operacjami-hakami (hook operations) i radzą, żeby w dokumentacji było jasno napisane, które metody trzeba nadpisać, a które tylko można. Całość ma też potoczną nazwę, zasada Hollywood: „nie dzwoń do nas, my zadzwonimy do ciebie”, bo to klasa bazowa decyduje, kiedy wywołać kod podklasy.

W odróżnieniu od większości wzorców z katalogu ten przenosi się do Pythona wprost. Opiera się na dziedziczeniu i nadpisywaniu metod, a te działają w Pythonie tak samo jak w Javie. Weźmy import wyników z terenu. Pliki z CATI i CAWI przychodzą w różnych formatach, ale dalsza obróbka jest już wspólna:

python
import csv
from abc import ABC, abstractmethod

class WaveImport(ABC):
    def run(self, path):
        rows = [self.normalize(row) for row in self.read(path)]
        errors = self.validate(rows)
        if errors:
            raise ImportFailed(path, errors)
        self.save(rows)
        self.after_save(rows)

    @abstractmethod
    def read(self, path): ...

    @abstractmethod
    def normalize(self, row): ...

    def validate(self, rows):
        return [row["id"] for row in rows if not row.get("region")]

    def save(self, rows):
        Respondent.objects.bulk_create(Respondent(**row) for row in rows)

    def after_save(self, rows):
        pass

class CawiImport(WaveImport):
    def read(self, path):
        with open(path, encoding="utf-8") as f:
            return list(csv.DictReader(f, delimiter=";"))

    def normalize(self, row):
        return {"id": row["resp_id"], "region": row["woj"].lower()}

abc.abstractmethod daje tu więcej niż raise NotImplementedError z wcześniejszych przykładów. Jeśli podklasa zapomni o normalize(), błąd pojawi się już przy tworzeniu obiektu, jako TypeError: Can't instantiate abstract class CawiImport.... Bez niego wyszedłby dopiero w trakcie importu, po wczytaniu całego pliku. after_save() jest hakiem i domyślnie nic nie robi. Import z CATI mógłby go nadpisać, żeby oznaczyć numery telefonów, pod które już dzwoniono, a CAWI zostawiłby go w spokoju.

Tak samo zbudowane są rzeczy, których używa się codziennie. BaseCommand w Django parsuje argumenty, uruchamia sprawdzenia systemowe i obsługuje wyjątki, a programista dopisuje tylko add_arguments() i handle(). unittest.TestCase woła w stałej kolejności setUp(), metodę testową i tearDown(), a socketserver.BaseRequestHandler robi to samo z setup(), handle() i finish(). threading.Thread pozwala z kolei nadpisać run() dokładnie tak, jak kiedyś nadpisywało się Execute w Delphi.

Hierarchia nie zawsze się jednak opłaca. Gdy warianty różnią się jednym krokiem, a nie czterema, zamiast niej wystarcza funkcja przekazana jako argument, jak w punkcie o Strategy. Oba wzorce rozwiązują zresztą ten sam problem różnymi środkami: Template Method przez dziedziczenie, Strategy przez kompozycję. W głębszych hierarchiach zdarza się inna pułapka. Ktoś nadpisuje w podklasie całe run() zamiast pojedynczego kroku, bo tak szybciej, i szkielet przestaje być wspólny. Python nie ma na to wbudowanej blokady. Dekorator typing.final nad run() sprawdzi mypy, ale interpreter tylko ustawia na metodzie atrybut __final__ (od wersji 3.11) i niczego nie wymusza. Blokadę da się jednak dopisać samemu w klasie bazowej, przez __init_subclass__, które Python woła przy definicji każdej podklasy:

python
class WaveImport(ABC):
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        if "run" in cls.__dict__:
            raise TypeError(f"{cls.__name__} nadpisuje run(), nadpisz pojedyncze kroki")

Sprawdzenie cls.__dict__ patrzy tylko na to, co podklasa sama zdefiniowała, więc odziedziczone run() przechodzi bez problemu, a nadpisane kończy się TypeError już przy imporcie modułu, zanim ktokolwiek uruchomi import danych. To samo da się zrobić metaklasą, tylko WaveImport ma już ABCMeta, więc własną metaklasę trzeba by od niej wyprowadzić, a __init_subclass__ obchodzi się bez tego. Przed przypisaniem CawiImport.run = ... po definicji klasy to nie chroni, ale tego nikt nie robi przypadkiem.

Visitor w książce odwraca problem, z którym zmagał się Composite. Hierarchia klas jest stabilna, a operacje na niej mnożą się z miesiąca na miesiąc. Przykładem jest tam znowu kompilator, w którym drzewo składni składa się z węzłów w rodzaju AssignmentNode i VariableRefNode. Na tym samym drzewie trzeba sprawdzać typy, generować kod i formatować źródło do wydruku. Gdyby każda z tych operacji była metodą w każdej klasie węzła, logika generatora kodu rozeszłaby się po kilkunastu plikach. Autorzy przenoszą więc każdą operację do osobnej klasy odwiedzającego z metodami VisitAssignment() i VisitVariableRef(). Węzły mają tylko Accept(visitor), które woła na odwiedzającym metodę właściwą dla siebie. Nową operację dodaje się jako nową klasę odwiedzającego, bez ruszania węzłów. Za to nowy typ węzła oznacza dopisanie metody we wszystkich odwiedzających, co książka uczciwie przyznaje.

Całe accept bierze się z tego, jak C++ i Java wybierają przeciążoną metodę. Odwiedzający ma kilka metod visit() o tej samej nazwie, różniących się typem parametru. Kompilator dobiera jedną z nich po statycznym typie argumentu, czyli takim, jaki stoi w deklaracji zmiennej i jest znany już przy kompilacji. Nie patrzy na to, jaki obiekt faktycznie przyjdzie, kiedy program już ruszy. Jeśli w ręku mamy referencję typu Node, ani visit(AssignmentNode), ani visit(VariableRefNode) się nie dopasuje. Dlatego najpierw wirtualne accept() ustala rzeczywisty typ węzła, a dopiero wewnątrz niego visitor.visit(this) trafia we właściwe przeciążenie. To podwójna dyspozycja zrobiona na piechotę, dwoma wywołaniami zamiast jednego.

Python nie ma ani przeciążania metod, ani statycznych typów w tym sensie. Druga definicja visit w klasie po prostu nadpisuje pierwszą, a adnotacje typów interpreter ignoruje, więc w tej postaci problem w ogóle się nie pojawia. Norvig w tej samej prezentacji zaliczał Visitora do wzorców, które znikają w CLOS, czyli systemie obiektowym Common Lispa. Są tam multimetody, funkcje, które wybierają implementację po typach wszystkich argumentów naraz, podczas gdy zwykła metoda patrzy wyłącznie na obiekt przed kropką. Python multimetod w języku nie ma, ale rzeczywisty typ obiektu zna w chwili wywołania, a dla Visitora to wystarcza. Operację wybiera się tu po prostu nazwą wywoływanej funkcji, więc zostaje do rozstrzygnięcia tylko typ węzła.

Na kosztorysie złożonym z Item i Group łatwo sobie wyobrazić kolejne operacje. Poza sumą przydałby się wydruk do oferty z wcięciami, płaska lista pozycji do arkusza dla księgowości i sprawdzanie cen jednostkowych, żeby dojazdy nie trafiły do oferty za zero złotych. Dopisywanie każdej z nich jako metody do obu klas szybko zamieniłoby dwa proste dataclassy w klasy na sto linijek. functools.singledispatch wybiera implementację po typie pierwszego argumentu, a od Pythona 3.7 typ można podać w adnotacji zamiast w register:

python
from functools import singledispatch

@singledispatch
def offer_lines(node, depth=0):
    raise TypeError(f"Nie wiem, jak wypisać {type(node).__name__}")

@offer_lines.register
def _(node: Item, depth=0):
    yield f"{'  ' * depth}{node.name}: {node.quantity} x {node.unit_price} zł"

@offer_lines.register
def _(node: Group, depth=0):
    yield f"{'  ' * depth}{node.name} (razem {node.cost()} zł)"
    for child in node.children:
        yield from offer_lines(child, depth + 1)

print("\n".join(offer_lines(wave)))

Item i Group nie dostały ani jednej nowej linijki, a cała operacja siedzi w jednym module, który można trzymać zupełnie gdzie indziej niż definicje węzłów. singledispatch przechodzi po MRO (Method Resolution Order, czyli kolejności, w jakiej Python przeszukuje klasę i jej klasy bazowe), więc gdyby doszła podklasa Discount(Item), bez własnej rejestracji dostałaby wersję dla Item. Brak jakiejkolwiek pasującej implementacji kończy się funkcją bazową, a ta rzuca TypeError z nazwą klasy, zamiast po cichu zwracać None. Od Pythona 3.11 register przyjmuje też unię typów w adnotacji, na przykład Item | Discount. Gdy operacja potrzebuje wspólnego stanu, choćby licznika pozycji albo zbioru już odwiedzonych grup, jest functools.singledispatchmethod z Pythona 3.8. Działa tak samo, tylko wewnątrz klasy, i wybiera implementację po pierwszym argumencie po self.

Gotowy Visitor siedzi też w samym Pythonie, i to bez accept po stronie węzłów. ast.NodeVisitor w metodzie visit() składa nazwę "visit_" + type(node).__name__, szuka jej przez getattr, a gdy nie znajdzie, woła generic_visit(), które schodzi do dzieci. Wystarczy podklasa z metodą visit_Call, żeby wyłowić z pliku wszystkie wywołania, a ast.NodeTransformer pozwala dodatkowo podmieniać węzły na inne. Przy małych operacjach na kilku typach sięgam czasem po match z Pythona 3.10. Wzorce klas w rodzaju case Item(unit_price=0): sprawdzają typ i od razu wyciągają pola, więc walidacja zerowych cen zajmuje kilka linijek jednego match. singledispatch ma nad nim tę przewagę, że implementacje dla nowego typu można zarejestrować z innego modułu, bez edytowania miejsca, w którym stoją wszystkie case.

Po przejściu przez całą listę podział układa się dość wyraźnie. Adapter, Composite i Template Method, a obok nich State z własnymi danymi, trafiły do Pythona niemal w wersji książkowej. Opisują, jak obiekty mają się do siebie, czyli kto kogo opakowuje albo zawiera i kto decyduje o kolejności kroków. Takie relacje istnieją w każdym języku, a zmieniało się najwyżej to, że abstrakcyjną klasę bazową zastępował duck typing albo typing.Protocol. Facade też przetrwała, tylko zwykle jako moduł z jedną funkcją, jak pptreport.render, a nie klasa. Strategy, Command, Visitor i Singleton powstały z innego powodu. W C++ z 1994 roku zachowanie razem z argumentami musiało dostać własną klasę, przeciążenie wybierał kompilator po statycznym typie, a jedną instancję pilnował prywatny konstruktor. W Pythonie z każdego z nich zostało po jednym narzędziu: funkcja podana jako key albo argument, functools.partial, singledispatch i moduł, który interpreter wykonuje raz na cały proces.

Nazwy z katalogu mam w notatkach od tamtej nieudanej rekrutacji i przydają się dalej. Kiedy ktoś na code review pisze „tu by pasował Strategy”, cały zespół wie, o co chodzi, nawet jeśli skończy się na słowniku z trzema funkcjami. W Javie i C++ te same nazwy oznaczają konkretną strukturę klas i tam ta struktura ma swoje uzasadnienie. Kłopot zaczyna się wtedy, gdy przyjeżdża do Pythona w komplecie, z interfejsem, fabryką i klasą z jedną metodą, bo tak było w książce. Mój framework w PHP od kosztorysów był właśnie takim przypadkiem, tylko w innym języku. Notatki z tamtego okresu mają dziś przy większości wzorców dwie sekcje, „w książce” i „w Pythonie”, a przy Singletonie ta druga to kilka linijek: moduł z ustawieniami, do leniwego połączenia functools.cache, o ile program ma jeden wątek, a przy kilku wątkach threading.local.