[KongchangAI]
· 2 min read· 1,236 words

Python Visitor Pattern and Better Alternatives Explained

Python Visitor Pattern and Better Alternatives Explained

Five approaches break down the two-axis matrix design challenge of node types and operations, weighing the direction of change.

The article explores decoupling node types from operations in document editors as a two-axis matrix. It introduces five approaches: method pattern, pattern-matching functions, visitor pattern, dictionary mapping, and functools.singledispatch. Each suits different change scenarios. The conclusion: no silver bullet—choose based on which axis changes more often; single dispatch offers a good balance.

Title

Python Visitor Pattern and Better Alternatives Explained

Meta Description

A deep dive into Python's visitor pattern and four alternatives: method pattern, pattern matching, dictionary mapping, and functools.singledispatch. Starting from the two-axis matrix of nodes and operations, it helps you choose the most suitable decoupled design based on how frequently things change.

Article Content

When building systems like document editors, we often face a classic design challenge: how to decouple node types (paragraphs, images, sections) from operations (HTML rendering, accessibility checks, asset collection)? YouTube tech creator ArjanCodes breaks down this "matrix problem" with five approaches in one video and offers what he considers a more Pythonic solution.

The design dilemma of nodes and operations

The Essence of the Problem: A Two-Axis Matrix of Nodes and Operations

Suppose you're building a document editor with multiple node types: paragraphs, images, sections. Each node needs to support operations like HTML rendering, accessibility checks, and asset collection. The most intuitive approach is to write these operations as methods on the nodes—paragraphs have to_html(), images have to_html() and collect_assets(), and sections recursively delegate to child nodes. The code is direct and readable. When behavior naturally belongs to the model, or when new node types are added frequently while operations stay relatively fixed, this is the right choice.

But the cost is: every time you add an operation (say, Markdown export), all node classes must be modified. Nodes gradually become a "directory of unrelated operations," with bloated responsibilities.

Conversely, moving operations out of nodes and using standalone functions with match statements for dispatch aggregates rendering, checking, and collection each in one place—but now every operation must know all node types. The problem has merely shifted direction. ArjanCodes summarizes this dilemma in two dimensions: node types and operations form a matrix. The method pattern organizes by rows, external functions organize by columns, and the visitor pattern is the classic column-oriented solution.

The Visitor Pattern: The Classic Solution with an Explicit Contract

The visitor pattern creates one object per operation. In the video, a DocumentVisitor protocol class is defined, declaring methods like visit_paragraph, visit_image, and visit_section for each node type. Each node implements accept(visitor), calling the corresponding visit method internally.

The accept method in the visitor pattern

Python's modern generic syntax is useful here: HTMLRenderer as DocumentVisitor[str] returns a string, while AccessibilityChecker returns list[str]. Compared to the pattern-matching version, the visitor pattern encapsulates operations as objects, making the contract between nodes and operations more explicit. Adding an operation only requires adding a visitor class—very lightweight. But adding a node type is costly—every visitor needs a new method. It suits systems where the model is relatively stable and operations keep evolving.

The visitor pattern centers on double dispatch: the first dispatch happens when a node calls accept(visitor), selecting the corresponding visit_xxx method based on node type; the second dispatch happens inside the visit_xxx method, executing the appropriate operation based on the visitor's concrete type. This structure means new operations only require new visitor classes without modifying node classes, adhering to the open-closed principle. The cost is that adding a new node type requires modifying all visitor interfaces and their implementations, so the visitor pattern is best suited for scenarios where "node types are stable and operations change frequently." In Python, visitor protocols are often defined via typing.Protocol, leveraging structural subtyping for implicit interfaces and avoiding the coupling of explicit inheritance.

Dictionary Mapping: Flattening the Matrix Explicitly

The third approach goes further: nodes are just simple data classes, operations are represented as functions, and dispatch happens via a mapping table from "node type × operation" to handler functions.

Function selection for node types and operations

Specifically, you maintain several dictionaries keyed by node type with handler functions as values, e.g., HTML paragraph, HTML image, image accessibility check. Missing combinations can fall back to a default handler—if a paragraph has no accessibility issues and no assets, a default function returns an empty result. This "flattening the matrix into sequences" approach has the big advantage of gracefully defining default behavior: no registration, fallback. The downside is that multiple scattered dictionaries make the code a bit messy.

Single Dispatch: Maintaining the Matrix with Python Syntax

ArjanCodes' favorite is the functools.singledispatch approach. Define render_html(node) with a TypeError raised as a fallback, then use @render_html.register to register implementations for paragraphs, images, and sections. Accessibility checks work the same way, and a default implementation can be specified. At call time, you just write render_html(doc), and the single-dispatch mechanism automatically routes to the correct function based on the node's actual type.

The registration structure of the single-dispatch version

This approach needs neither visitor objects nor hand-written dictionaries—dispatch is handled entirely by Python's internal mechanisms, while still supporting recursive calls on child nodes. For scenarios where "model and operations evolve independently," single dispatch maintains aggregation while avoiding the clutter of explicit mapping tables.

Single dispatch is a polymorphism mechanism in object-oriented programming that decides which function implementation to call based on the actual type of one argument. In contrast, multiple dispatch selects implementations based on combinations of multiple argument types. The functools.singledispatch decorator in Python's standard library brings this mechanism to the function level: the decorated function becomes the "default implementation," and then @func.register registers specialized implementations for specific types. At call time, the interpreter checks the type of the first argument and searches along its MRO (method resolution order) for the most matching registered function, falling back to the default implementation if none is found. Compared to class inheritance, single dispatch does not require node classes to implement specific interfaces or accept methods, thereby decoupling nodes and operations while maintaining type safety.

How to Choose: No Silver Bullet, Only Trade-offs

Each of the five approaches has its applicable boundary. The method pattern suits systems where behavior belongs to the model and node types vary; pattern-matching functions suit scenarios with static node types and concentrated operations; the visitor pattern suits architectures with a stable model but frequently added operations; dictionary mapping and single dispatch have advantages when default behavior and flexible extension are needed. The key is not to find the "best pattern" but to identify which axis in your system changes more frequently, and let the design grow in the direction of change.

The video author's judgment is pragmatic: in modern Python, single dispatch strikes a good balance between readability and flexibility. As for the final choice, it depends on whether your nodes or operations are more "fickle."

One-line Summary

Five approaches break down the two-axis matrix design challenge of node types and operations, weighing the direction of change.

Paragraph Summary

The article explores the classic design problem of decoupling node types from operations in document editors, abstracting it into a two-axis matrix of node types and operations. The author introduces five approaches: method pattern, pattern-matching functions, visitor pattern, dictionary mapping, and functools.singledispatch. The method pattern suits scenarios where behavior belongs to the model and nodes vary; pattern matching suits static nodes with concentrated operations; the visitor pattern suits architectures with a stable model but frequently added operations; dictionary mapping can gracefully define default behavior; single dispatch uses Python's internal mechanisms to maintain the matrix, balancing aggregation and flexibility. The conclusion is that there is no silver bullet—the choice depends on which axis changes more frequently in your system, and in modern Python, single dispatch achieves a good balance between readability and flexibility.

#Python visitor pattern#functools.singledispatch#design patterns#double dispatch#single dispatch
Share:

Related articles