Using Python types for fun and profit: the Visitor Pattern

Introduction

The visitor pattern is a design pattern that allows for adding new operations to a collection of objects, without, and that is important, modifying the objects themselves.

It looks like this:

There are two main functionalities:

  1. The visit functionality is implemented in the Visitor interface. This ensures that Elements can be visited.
  2. The accept functionality is implemented in the Element interface. This ensures that when an Element is visited, an operation can be performed.

Implementation in Python

Since we will be using classes before they are defined, we need to import the annotations at the head of our program. Also we will be defining two abstract classes, so we need import the ABC as this is the base class for those. Abstract classes will have abstract methods, so we import the @abstractmethod decorator.

from __future__ import annotations
from abc import ABC, abstractmethod

The Visitor class

We will start by defining the Visitor class:

class Visitor(ABC):
    @abstractmethod
    def visit_person(self, person: Person):
        pass

    @abstractmethod
    def visit_organization(self, organization: Organization):
        pass

In our example, a visitor can only visit persons and organizations. Note that because of our preliminary import we are able to refer to the Person and Organization classes before we define them.

Also the Visitor class derives from ABC which stands for ‘Abstract Base Class’. The visit_person() and the visit_organization() methods will be implemented by subclasses of Visitor so are decorated with the @abstractmethod decorator.

The Element class

In order for our persons and organizations to accept a visitor, they need an accept() method, provided by this class:

class Element(ABC):
    @abstractmethod 
    def accept(self, visitor: Visitor):
        pass

The Person class

Now we can see how this accept method actually works in the Person class:

class Person(Element):
    def __init__(self,name:str,email:str):
        self._name = name
        self._email = email

    def accept(self, visitor: Visitor):
        visitor.visit_person(self)

    @property
    def name(self) -> str:
        return self._name

    @property
    def email(self) -> str:

Some notes:

  • Person in this example just has a name and an email
  • The accept() method has a visitor as its parameter and a reference to the current instance which is of type Person. That is why we can pass self to visit_person(). This is the actual visiting the visitor does.
  • Note the two @property decorators. A property is a kind of virtual attribute, each time we access person.name for example, the name() method will be called and its return value used. This is a very easy way to make read-only attributes. An excellent article on this subject you can find here.

The Organization class

The Organization class is unsurprisingly very similar to the Person class:

class Organization(Element):
    def __init__(self,name:str,website:str):
        self._name = name
        self._website = website

    def accept(self, visitor: Visitor):
        visitor.visit_organization(self)

    @property
    def name(self) -> str:
        return self._name

    @property
    def website(self) -> str:
        return self._website

Defining the visitor

Now we can define the visitor with the class ConcreteVisitor:

class ConcreteVisitor(Visitor):
    def visit_person(self, person: Person):
        print(f"Visiting person: {person.name}, {person.email}")

    def visit_organization(self, organization: Organization):
        print(f"Visiting organization: {organization.name}, {organization.website}")

This is an implementation of the Visitor class we defined in the beginning.

The functionality is rather limited, on purpose. All either function does is print out some message about an action. These message are of course different for different elements.

Testing time

Time to test our code:

if __name__ == "__main__":
    elements: list[Element] = [Person("Alice", "alice@example"),
                               Organization("Acme Inc.", "http://acme.com"),
                               Person("Bob", "bob@example.com")
                               ]
    visitor: Visitor = ConcreteVisitor()
    for e in elements:
        e.accept(visitor)

Line by line:

  • We initialize an array of objects from classes which derive from Element
  • Then we construct a Visitor, a ConcreteVisitor in this case.
  • Next we iterator over all the elements in the array and visit them one by one.

Conclusion

In conclusion, the Python code effectively illustrates the Visitor design pattern. This pattern provides a powerful way to separate an algorithm from the object structure it operates on. As we saw, the Visitor class allows us to define new operations (like visiting a Person or an Organization) without altering the core Element classes themselves.

This approach offers significant advantages in terms of extensibility and maintainability. It adheres to the Open/Closed Principle, meaning you can add new functionality by simply creating new visitor classes, rather than modifying existing ones. This keeps your code clean, modular, and easy to manage, especially when dealing with complex object structures or when you frequently need to add new behaviors to a stable set of classes.

The Code Nomad
The Code Nomad
Articles: 165

Leave a Reply

Your email address will not be published. Required fields are marked *