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.

  1. First Dispatch: The first object receives a method call and invokes an accept method on the second object, passing itself as an argument.
  2. Second Dispatch: The second object’s accept implementation calls a typed visit method 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 Shape requires updating the ShapeVisitor interface, 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.