Python podchodzi do wzorca Visitor po swojemu. Zamiast hierarchii klas można użyć dopasowania wzorców (od Pythona 3.10) i dekoratorów. Kod wychodzi mniej schematyczny niż w klasycznym accept/visit.

Wyzwanie: brak przeciążania funkcji w Pythonie

W przeciwieństwie do C++ Python nie obsługuje przeciążania funkcji. Nie da się zdefiniować kilku metod o tej samej nazwie i różnych typach parametrów. Jeśli zdefiniujesz dwie metody o tej samej nazwie, druga nadpisze pierwszą:

class Visitor:
    def visit(self, element):
        # To jest jedyna definicja
        pass

    def visit(self, element):
        # To NADPISUJE poprzednią definicję!
        pass

To komplikuje klasyczny Visitor, bo nie można mieć wielu przeciążeń visit() dla różnych typów elementów. Potrzebny jest mechanizm, który wybierze metodę na podstawie typu argumentu.

Rozwiązanie 1: pojedyncza dyspozycja z functools.singledispatch

Dekorator functools.singledispatch daje pojedynczą dyspozycję. Wybiera implementację na podstawie typu pierwszego argumentu:

from functools import singledispatch

class Circle:
    def __init__(self, radius):
        self.radius = radius

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

class AreaVisitor:
    @singledispatch
    def visit(self, element):
        raise NotImplementedError(f"Cannot visit {type(element)}")

    @visit.register(Circle)
    def _(self, element: Circle):
        return 3.14159 * element.radius ** 2

    @visit.register(Rectangle)
    def _(self, element: Rectangle):
        return element.width * element.height

# Użycie
visitor = AreaVisitor()
print(visitor.visit(Circle(5)))           # 78.53975
print(visitor.visit(Rectangle(4, 6)))     # 24

Działa. Nienazwane funkcje (_) i rozwlekłe dekoratory @visit.register() nadal utrudniają czytanie.

Dlaczego isinstance() to zły pomysł

Kusi, żeby w jednej metodzie sprawdzać typ przez isinstance(). To anty-wzorzec:

class BadAreaVisitor:
    def visit(self, element):
        if isinstance(element, Circle):
            return 3.14159 * element.radius ** 2
        elif isinstance(element, Rectangle):
            return element.width * element.height
        else:
            raise ValueError(f"Unknown element type: {type(element)}")

# Użycie
visitor = BadAreaVisitor()
print(visitor.visit(Circle(5)))
print(visitor.visit(Rectangle(4, 6)))

Dlaczego to problem:

  1. Silne sprzężenie z konkretnymi typami. Visitor zna każdy konkretny typ elementu.
  2. Narusza zasadę Open/Closed. Dodanie nowego typu wymaga zmiany visitora.
  3. Trudne utrzymanie. Długie łańcuchy if/elif szybko przestają być czytelne.
  4. Kłopoty z bezpieczeństwem typów. Pominięty typ wyjdzie dopiero w trakcie działania.
  5. Brak rozszerzalności. Trudno rozszerzać visitory przez dziedziczenie, żeby obsłużyły nowe typy.
  6. Mieszanie odpowiedzialności. Logika wszystkich typów siedzi w jednej metodzie.

W Visitorze chodzi właśnie o unikanie tego anty-wzorca.

Rozwiązanie 2: dopasowanie wzorców (nowoczesny Python)

Python 3.10+ ma dopasowanie wzorców przez match/case. To alternatywa dla jawnego szkieletu accept/visit:

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Triangle:
    base: float
    height: float

def calculate_area(shape):
    match shape:
        case Circle(radius=r):
            return 3.14159 * r ** 2
        case Rectangle(width=w, height=h):
            return w * h
        case Triangle(base=b, height=h):
            return b * h / 2

def calculate_perimeter(shape):
    match shape:
        case Circle(radius=r):
            return 2 * 3.14159 * r
        case Rectangle(width=w, height=h):
            return 2 * (w + h)
        case Triangle(base=b, height=h):
            return 3 * b  # Uproszczone

# Użycie
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 4)]

for shape in shapes:
    print(f"Area: {calculate_area(shape):.2f}, "
          f"Perimeter: {calculate_perimeter(shape):.2f}")

Wynik:

Area: 78.54, Perimeter: 31.42
Area: 24.00, Perimeter: 20.00
Area: 6.00, Perimeter: 9.00

Co daje w porównaniu z isinstance():

  1. Czystsza składnia. Bez rozwlekłych łańcuchów if/elif.
  2. Dekonstrukcja. Zmienne wiążą się bezpośrednio z polami.
  3. Czytelność. Od razu widać intencję.

Uwaga: samo dopasowanie po typie nie jest wzorcem

Dopasowanie wzorców służy do czegoś więcej niż wybieranie po typie. Wzorzec powinien opisywać strukturę danych, a nie tylko ich typ.

Porównaj te dwa podejścia:

# Dyspozycja po typie (nie używa wzorców efektywnie)
def process(data):
    match data:
        case Circle(radius=r):      # Dopasowanie tylko po TYPIE
            return 2 * 3.14159 * r
        case Rectangle(width=w, height=h):
            return 2 * (w + h)

A tak wygląda prawdziwe dopasowanie strukturalne:

# Dopasowanie oparte na strukturze (w tym dopasowanie wzorców jest najlepsze)
def process(data):
    match data:
        case {"shape": "circle", "radius": r}:
            return 2 * 3.14159 * r
        case {"shape": "rectangle", "width": w, "height": h}:
            return 2 * (w + h)
        case [x, y, z]:
            # Dopasuj dowolną sekwencję 3 elementów
            return x + y + z
        case {"nested": {"value": v}} if v > 10:
            # Dopasowanie na strukturze ORAZ strażnikach
            return v * 2

Dopasowanie wzorców sprawdza się, gdy pasujesz do kształtu danych, a nie do samego typu. Jeśli sprawdzasz tylko typ, użyj singledispatch. Wyraźniej pokazuje, co robisz.

To nie znaczy, że dopasowanie wzorców jest bezużyteczne w Visitorze. Znaczy tylko, że: - Używaj dopasowania wzorców, gdy twoje typy elementów mają bogatą strukturę, którą trzeba badać. - Jeśli wybierasz tylko po typie i nie badasz struktury, singledispatch lepiej oddaje twoją intencję. - Nie używaj dopasowania wzorców jako składniowego zamiennika sprawdzeń isinstance().

Kompletny przykład: wiele operacji na tych samych typach

W Pythonie funkcje są obiektami pierwszej klasy, więc visitor nie musi być klasą. Przykład z funkcjami:

from dataclasses import dataclass

@dataclass
class Circle:
    radius: float

@dataclass
class Rectangle:
    width: float
    height: float

@dataclass
class Triangle:
    a: float
    b: float
    c: float

# Funkcje wizytatorów
def area(shape):
    match shape:
        case Circle(radius=r):
            return 3.14159 * r ** 2
        case Rectangle(width=w, height=h):
            return w * h
        case Triangle(a=a, b=b, c=c):
            # Wzór Herona
            s = (a + b + c) / 2
            return (s * (s - a) * (s - b) * (s - c)) ** 0.5

def perimeter(shape):
    match shape:
        case Circle(radius=r):
            return 2 * 3.14159 * r
        case Rectangle(width=w, height=h):
            return 2 * (w + h)
        case Triangle(a=a, b=b, c=c):
            return a + b + c

def describe(shape):
    match shape:
        case Circle(radius=r):
            return f"Okrąg o promieniu {r}"
        case Rectangle(width=w, height=h):
            return f"Prostokąt {w} × {h}"
        case Triangle(a=a, b=b, c=c):
            return f"Trójkąt o bokach {a}, {b}, {c}"

# Użycie
shapes = [
    Circle(5),
    Rectangle(4, 6),
    Triangle(3, 4, 5)
]

visitors = {
    'Pole': area,
    'Obwód': perimeter,
}

for shape in shapes:
    print(f"\n{describe(shape)}")
    for name, visitor_func in visitors.items():
        result = visitor_func(shape)
        print(f"  {name}: {result:.2f}")

Wynik:

Okrąg o promieniu 5
  Pole: 78.54
  Obwód: 31.42

Prostokąt 4 × 6
  Pole: 24.00
  Obwód: 20.00

Trójkąt o bokach 3, 4, 5
  Pole: 6.00
  Obwód: 12.00

To prostsze niż wersja klasowa. Nie potrzeba interfejsów visitorów, dziedziczenia ani self. Wystarczą funkcje, które przyjmują kształt i wybierają działanie na podstawie jego struktury.

Kiedy używać każdego z podejść

Wybierz dopasowanie wzorców (Python 3.10+), gdy: - Chcesz prostych, czytelnych visitorów opartych na funkcjach - Twoje typy elementów mają strukturę, którą trzeba badać - Aktywnie dopasowujesz pola, a nie tylko sam typ

Wybierz singledispatch, gdy: - Musisz obsługiwać Pythona < 3.10 - Chcesz rozszerzać visitorów przez dziedziczenie - Potrzebujesz visitorów ze stanem, współdzielonym między wywołaniami - Masz wiele typów elementów i chcesz uniknąć dużych instrukcji match

Unikaj sprawdzeń isinstance(). Naruszają zasadę Open/Closed, wiążą visitora z konkretnymi typami, a kod trudniej utrzymać i rozszerzyć.

Podsumowanie

  • Python nie ma przeciążania funkcji, więc potrzebne są inne mechanizmy dyspozycji.
  • Sprawdzenia isinstance() to anty-wzorzec. Silnie wiążą visitora z konkretnymi typami.
  • Dopasowanie wzorców (Python 3.10+) dobrze pasuje do visitorów opartych na funkcjach. Używaj go jednak do dopasowania strukturalnego, a nie do samego wybierania po typie.
  • Visitor może być funkcją (najprościej) albo klasą (gdy potrzebujesz stanu).
  • Podstawowy kompromis pozostaje: łatwo dodawać operacje, trudno dodawać typy elementów.