Table of Contents
- What is Dispatch?
- 1.1 Single Dispatch
- Double Dispatch: Definition and Need
- Implementing Double Dispatch in Python
- Design Patterns and Double Dispatch: The Visitor Pattern
- Practical Use Cases
- Pitfalls and Best Practices
- Conclusion
- References
What is Dispatch?
Dispatch refers to the process of selecting the appropriate function or method to execute based on the types (or classes) of the arguments passed to it. In OOP, this is fundamental to polymorphism. Let’s start with the simpler case: single dispatch.
Single Dispatch
Single dispatch is the most common form of dispatch, where the method to invoke is determined by the type of the receiver (the object on which the method is called). For example, in Python, when you call obj.method(), Python dispatches to the method defined in obj’s class.
Python 3.4 introduced functools.singledispatch, which extends this to functions by allowing them to dispatch based on the type of their first argument.
Example: Single Dispatch with functools.singledispatch
from functools import singledispatch
@singledispatch
def process_data(data):
raise NotImplementedError(f"Cannot process data of type {type(data)}")
@process_data.register(int)
def _(data: int):
print(f"Processing integer: {data * 2}")
@process_data.register(str)
def _(data: str):
print(f"Processing string: {data.upper()}")
@process_data.register(list)
def _(data: list):
print(f"Processing list: {[x * 2 for x in data]}")
# Usage
process_data(42) # Output: Processing integer: 84
process_data("hello") # Output: Processing string: HELLO
process_data([1, 2, 3])# Output: Processing list: [2, 4, 6]
Here, process_data dispatches to the registered function based on the type of its first (and only) argument.
Double Dispatch: Definition and Need
Double dispatch is a mechanism where the method to execute is determined by the types of two objects: the receiver and at least one other argument. Unlike single dispatch (which considers only the receiver’s type), double dispatch resolves the method based on the interaction between two types.
Why Double Dispatch?
Consider a scenario where two objects interact, and the behavior depends on both types. For example:
- In a game, collision detection between shapes (e.g.,
Circlevs.Squarevs.Triangle) requires different logic for each pair. - In a GUI framework, event handling (e.g., a
MouseClickevent on aButtonvs. aTextbox) may need type-specific responses.
Single dispatch alone cannot handle this, because the receiver’s type (e.g., Circle) isn’t enough to determine the collision logic with another shape (e.g., Square). We need to consider both types.
Implementing Double Dispatch in Python
Python does not natively support double dispatch, but we can implement it using a few techniques. Let’s explore two common approaches.
Approach 1: Using isinstance Checks
The simplest way to implement double dispatch is to use isinstance to check the type of the second object within the receiver’s method. This is straightforward but may violate the Open/Closed Principle (adding new types requires modifying existing code).
Example: Collision Detection with isinstance
Suppose we have two shape classes, Circle and Square, and we need to compute collision logic for each pair.
class Circle:
def __init__(self, radius):
self.radius = radius
def collide_with(self, other):
# Dispatch based on the type of 'other'
if isinstance(other, Circle):
return self._collide_circle(other)
elif isinstance(other, Square):
return self._collide_square(other)
else:
raise NotImplementedError(f"Collision with {type(other)} not supported")
def _collide_circle(self, other_circle):
# Logic for Circle vs. Circle collision
return f"Circle (radius {self.radius}) collides with Circle (radius {other_circle.radius})"
def _collide_square(self, other_square):
# Logic for Circle vs. Square collision
return f"Circle (radius {self.radius}) collides with Square (side {other_square.side})"
class Square:
def __init__(self, side):
self.side = side
def collide_with(self, other):
if isinstance(other, Circle):
return self._collide_circle(other)
elif isinstance(other, Square):
return self._collide_square(other)
else:
raise NotImplementedError(f"Collision with {type(other)} not supported")
def _collide_circle(self, other_circle):
# Reuse Circle's logic (since collision is symmetric)
return other_circle.collide_with(self)
def _collide_square(self, other_square):
# Logic for Square vs. Square collision
return f"Square (side {self.side}) collides with Square (side {other_square.side})"
# Usage
circle = Circle(5)
square = Square(10)
other_circle = Circle(3)
print(circle.collide_with(square)) # Output: Circle (radius 5) collides with Square (side 10)
print(square.collide_with(circle)) # Output: Circle (radius 5) collides with Square (side 10) (symmetric)
print(other_circle.collide_with(circle)) # Output: Circle (radius 3) collides with Circle (radius 5)
How It Works:
- Each shape’s
collide_withmethod checks the type ofotherusingisinstance. - It then delegates to a helper method (e.g.,
_collide_circle) for the specific type pair. - Symmetry (e.g.,
Square.collide_with(Circle)reusesCircle.collide_with(Square)) avoids code duplication.
Approach 2: Using a Type Registry
To avoid cluttering methods with isinstance checks (and to adhere to the Open/Closed Principle), we can use a registry (a dictionary) that maps pairs of types to collision functions. This decouples type pairs from the classes themselves.
Example: Collision Registry
from typing import Type, Dict, Callable
# Registry: maps (Type[Shape], Type[Shape]) to collision function
_collision_registry: Dict[tuple[Type, Type], Callable] = {}
def register_collision(shape1: Type, shape2: Type, func: Callable):
"""Register a collision function for a pair of shapes."""
_collision_registry[(shape1, shape2)] = func
# Register symmetric pair (optional, for commutativity)
_collision_registry[(shape2, shape1)] = func
class Circle:
def __init__(self, radius):
self.radius = radius
class Square:
def __init__(self, side):
self.side = side
# Define collision functions
def circle_circle_collision(circle1: Circle, circle2: Circle):
return f"Circle (radius {circle1.radius}) collides with Circle (radius {circle2.radius})"
def circle_square_collision(circle: Circle, square: Square):
return f"Circle (radius {circle.radius}) collides with Square (side {square.side})"
def square_square_collision(square1: Square, square2: Square):
return f"Square (side {square1.side}) collides with Square (side {square2.side})"
# Register collision functions
register_collision(Circle, Circle, circle_circle_collision)
register_collision(Circle, Square, circle_square_collision)
register_collision(Square, Square, square_square_collision)
# Dispatch function
def collide(shape1, shape2):
key = (type(shape1), type(shape2))
if key not in _collision_registry:
raise NotImplementedError(f"Collision between {key} not registered")
return _collision_registry[key](shape1, shape2)
# Usage
circle = Circle(5)
square = Square(10)
other_circle = Circle(3)
print(collide(circle, square)) # Output: Circle (radius 5) collides with Square (side 10)
print(collide(square, circle)) # Output: Circle (radius 5) collides with Square (side 10) (symmetric)
print(collide(other_circle, circle)) # Output: Circle (radius 3) collides with Circle (radius 5)
Advantages:
- Adding a new shape (e.g.,
Triangle) only requires registering new collision functions, not modifying existing classes (Open/Closed Principle). - Clean separation of collision logic from shape classes.
Design Patterns and Double Dispatch: The Visitor Pattern
The Visitor pattern is a behavioral design pattern that uses double dispatch to separate an algorithm from the objects it operates on. It allows you to define new operations on a set of objects without changing their classes.
Visitor Pattern Structure
The Visitor pattern involves four components:
- Element: An interface/abstract class defining an
accept(visitor)method that accepts a Visitor. - ConcreteElement: Classes implementing
Element; theiracceptmethod calls the Visitor’s method specific to their type. - Visitor: An interface/abstract class defining methods for each
ConcreteElement(e.g.,visit_paragraph(paragraph)). - ConcreteVisitor: Classes implementing
Visitor; they contain the logic for operating onConcreteElements.
How Double Dispatch Works in Visitor
- The
acceptmethod ofConcreteElementtakes aVisitorand callsvisitor.visit_<element_type>(self). - This triggers double dispatch: the method is selected based on the
ConcreteElementtype (viaaccept) and theConcreteVisitortype (viavisit_<element_type>).
Python Implementation of Visitor
Let’s implement a Visitor pattern for processing a document with two element types: Paragraph and Image. We’ll create two visitors: RenderVisitor (renders elements) and WordCountVisitor (counts words in text elements).
Step 1: Define Elements
from abc import ABC, abstractmethod
class Element(ABC):
@abstractmethod
def accept(self, visitor):
pass
class Paragraph(Element):
def __init__(self, text):
self.text = text
def accept(self, visitor):
# Double dispatch: calls visitor's visit_paragraph method
return visitor.visit_paragraph(self)
class Image(Element):
def __init__(self, url, caption):
self.url = url
self.caption = caption
def accept(self, visitor):
# Double dispatch: calls visitor's visit_image method
return visitor.visit_image(self)
Step 2: Define Visitors
class Visitor(ABC):
@abstractmethod
def visit_paragraph(self, paragraph: Paragraph):
pass
@abstractmethod
def visit_image(self, image: Image):
pass
class RenderVisitor(Visitor):
def visit_paragraph(self, paragraph: Paragraph):
return f"<p>{paragraph.text}</p>"
def visit_image(self, image: Image):
return f'<img src="{image.url}" alt="{image.caption}">'
class WordCountVisitor(Visitor):
def __init__(self):
self.count = 0
def visit_paragraph(self, paragraph: Paragraph):
self.count += len(paragraph.text.split())
def visit_image(self, image: Image):
# Images have no words, so do nothing
pass
Step 3: Use the Visitor Pattern
# Create a document with elements
document = [
Paragraph("Hello, World!"),
Image("logo.png", "Company Logo"),
Paragraph("Double dispatch is powerful with Visitor!")
]
# Render the document
renderer = RenderVisitor()
rendered_output = [element.accept(renderer) for element in document]
print("Rendered Output:\n", "\n".join(rendered_output))
# Count words in the document
word_counter = WordCountVisitor()
for element in document:
element.accept(word_counter)
print(f"\nTotal Words: {word_counter.count}") # Output: Total Words: 8
Output:
Rendered Output:
<p>Hello, World!</p>
<img src="logo.png" alt="Company Logo">
<p>Double dispatch is powerful with Visitor!</p>
Total Words: 8
Double Dispatch in Action:
- When
paragraph.accept(renderer)is called,renderer.visit_paragraph(paragraph)is invoked (dispatch based onParagraphandRenderVisitortypes). - Similarly,
image.accept(renderer)callsrenderer.visit_image(image).
Practical Use Cases
Double dispatch and the Visitor pattern shine in scenarios like:
- Game Development: Collision detection, physics simulations (e.g.,
RigidBodyvs.SoftBodyinteractions). - Data Processing: Parsing heterogeneous data structures (e.g., JSON with nested objects/arrays) with type-specific logic.
- Code Analysis: Static analyzers (e.g., lint tools) that traverse an AST (Abstract Syntax Tree) and apply operations (e.g.,
CheckStyleVisitor,FindBugsVisitor).
Pitfalls and Best Practices
Pitfalls
- Complexity: Double dispatch can make code harder to follow, especially with many types.
- Open/Closed Violation: The
isinstanceapproach requires modifying existing classes when adding new types. - Visitor Limitations: Adding new
ConcreteElementtypes requires modifying allConcreteVisitorclasses (violates Open/Closed for elements).
Best Practices
- Prefer the Registry Approach: For double dispatch, use a registry/dictionary to map type pairs to functions (avoids
isinstancechecks and adheres to Open/Closed). - Use Visitor Sparingly: Only use the Visitor pattern if you have many operations on stable element types (frequent operations, rare element changes).
- Document Type Pairs: Clearly document supported type pairs to avoid runtime errors.
Conclusion
Double dispatch is a powerful technique for resolving method calls based on two object types, enabling flexible interactions between objects. While Python lacks native support, we can implement it using isinstance checks or type registries. The Visitor pattern leverages double dispatch to separate operations from object structures, promoting code reuse and maintainability.
By understanding double dispatch and its role in patterns like Visitor, you can write more modular, extensible code for scenarios involving type-specific interactions.
References
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- Python Documentation: functools.singledispatch
- Refactoring Guru: Visitor Pattern
- Real Python: Design Patterns in Python