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:
- Silne sprzężenie z konkretnymi typami. Visitor zna każdy konkretny typ elementu.
- Narusza zasadę Open/Closed. Dodanie nowego typu wymaga zmiany visitora.
- Trudne utrzymanie. Długie łańcuchy
if/elifszybko przestają być czytelne. - Kłopoty z bezpieczeństwem typów. Pominięty typ wyjdzie dopiero w trakcie działania.
- Brak rozszerzalności. Trudno rozszerzać visitory przez dziedziczenie, żeby obsłużyły nowe typy.
- 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():
- Czystsza składnia. Bez rozwlekłych łańcuchów
if/elif. - Dekonstrukcja. Zmienne wiążą się bezpośrednio z polami.
- 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.