Clean Double Dispatch in C++ Without dynamic_cast
Double dispatch allows a program to select a function implementation
dynamically based on the runtime types of two interacting objects. While
standard C++ virtual methods only provide single dispatch based on the
object receiving the call, developers often fall back on inefficient and
brittle dynamic_cast chains to inspect the second object's
type. This article demonstrates how to implement clean, high-performance
double dispatch in C++ using the Visitor design pattern, relying
entirely on virtual method resolution rather than runtime type
inspection.
The Problem with Single Dispatch
In C++, virtual functions resolve calls dynamically based solely on the object on which the method is invoked (the receiver). Consider an interaction between geometric shapes:
shapeA->collideWith(shapeB);While the dynamic type of shapeA is resolved via its
vtable, shapeB is passed as a reference to the base class
Shape. Inside shapeA's implementation, the
dynamic type of shapeB remains unknown.
Using dynamic_cast to probe for each derived type of
shapeB introduces runtime overhead and creates fragile,
hard-to-maintain if-else blocks. Missing a type results in
runtime errors rather than compile-time checks.
The Visitor Pattern Architecture
The Visitor pattern solves this problem by chaining two virtual calls.
- First Dispatch: The first object receives a method
call and invokes an
acceptmethod on the second object, passing itself as an argument. - Second Dispatch: The second object’s
acceptimplementation calls a typedvisitmethod back on the caller (or visitor). Because the call happens inside the concrete class, the compiler statically resolves the second object's dynamic type as*this.
This completely eliminates runtime casting and relies entirely on two consecutive, highly optimized vtable lookups.
Implementation Example
Below is a complete implementation of double dispatch for handling collisions between different shapes.
1. Forward Declarations and Interfaces
Define the elements and the visitor contract.
#include <iostream>
class Circle;
class Rectangle;
// Visitor Interface defining overloads for each concrete type
class ShapeVisitor {
public:
virtual ~ShapeVisitor() = default;
virtual void visit(Circle& circle) = 0;
virtual void visit(Rectangle& rectangle) = 0;
};
// Base Element Interface
class Shape {
public:
virtual ~Shape() = default;
virtual void accept(ShapeVisitor& visitor) = 0;
virtual void collideWith(Shape& other) = 0;
};2. Concrete Shapes
Each shape implements accept by forwarding
*this to the visitor, preserving its concrete type.
class Circle : public Shape {
public:
void accept(ShapeVisitor& visitor) override {
visitor.visit(*this);
}
void collideWith(Shape& other) override;
};
class Rectangle : public Shape {
public:
void accept(ShapeVisitor& visitor) override {
visitor.visit(*this);
}
void collideWith(Shape& other) override;
};3. Concrete Visitors for Handling Interactions
Create a visitor for each concrete shape to resolve the interaction with the second shape:
class CircleCollider : public ShapeVisitor {
Circle& circle;
public:
explicit CircleCollider(Circle& c) : circle(c) {}
void visit(Circle& other) override {
std::cout << "Handling Circle-Circle collision.\n";
}
void visit(Rectangle& other) override {
std::cout << "Handling Circle-Rectangle collision.\n";
}
};
class RectangleCollider : public ShapeVisitor {
Rectangle& rectangle;
public:
explicit RectangleCollider(Rectangle& r) : rectangle(r) {}
void visit(Circle& other) override {
std::cout << "Handling Rectangle-Circle collision.\n";
}
void visit(Rectangle& other) override {
std::cout << "Handling Rectangle-Rectangle collision.\n";
}
};4. Completing the Double Dispatch
Implement collideWith by instantiating the appropriate
visitor and passing it to the other object:
void Circle::collideWith(Shape& other) {
CircleCollider collider(*this);
other.accept(collider); // Second dispatch happens here
}
void Rectangle::collideWith(Shape& other) {
RectangleCollider collider(*this);
other.accept(collider); // Second dispatch happens here
}5. Execution
int main() {
Circle c;
Rectangle r;
Shape* shape1 = &c;
Shape* shape2 = &r;
// Resolves to Circle::collideWith, then Rectangle::accept
shape1->collideWith(*shape2);
// Resolves to Rectangle::collideWith, then Circle::accept
shape2->collideWith(*shape1);
return 0;
}Benefits of the Approach
- Performance: Executes two constant-time vtable
pointer dereferences rather than traversing inheritance hierarchies with
dynamic_cast. - Compile-Time Safety: Adding a new concrete
Shaperequires updating theShapeVisitorinterface, which immediately forces all visitors to handle the new type at compile time. - Separation of Concerns: Interaction algorithms stay organized within visitor classes rather than cluttering core domain classes with branching logic.