Skip to Content
Course content

140: The Visitor Pattern

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was working on a small game engine project recently, and I hit a wall that I think you'll recognize. I had a hierarchy of game elements: Player, Enemy, and Prop. They all inherited from a base GameObject. Initially, it was simple. I just added a draw() virtual function to the base class, and each subclass implemented its own rendering logic.

The Bloated Base Class Problem

Everything felt fine until I started adding more features. First, I needed to save the game state, so I added saveToJson() to the base class. Then I needed a way to calculate collision bounds, so I added calculateBounds(). Then I wanted a debug logger, so I added logState().

// This is where I was a few weeks ago
class GameObject {
public:
    virtual void draw() = 0;
    virtual void saveToJson() = 0;
    virtual void calculateBounds() = 0;
    virtual void logState() = 0;
    virtual ~GameObject() = default;
};

class Player : public GameObject {
    void draw() override { /* Player draw logic */ }
    void saveToJson() override { /* Player save logic */ }
    void calculateBounds() override { /* Player bounds logic */ }
    void logState() override { /* Player log logic */ }
};
// ... and the same for Enemy and Prop

I stopped and looked at my Player class and realized it was becoming a dumping ground. Every time I wanted to add a new operation to the game, I had to modify every single class in the hierarchy. If I have 20 types of game objects and I want to add a "Export to XML" feature, I'm touching 21 files. That's a maintenance nightmare and a direct violation of the Open/Closed Principle.

Trying to Externalize the Logic

I thought, "Why not just move the logic into a separate class?" I tried creating a GameExporter class that took a GameObject*. But then I ran into a classic C++ problem: polymorphism only works one way.

class GameExporter {
public:
    void exportObject(GameObject* obj) {
        // Wait, how do I know if this is a Player or an Enemy?
        // I can't just call obj->exportLogic() because that's exactly 
        // what I'm trying to move OUT of the GameObject class.
    }
};

My first instinct was to use dynamic_cast. I started writing a chain of if (auto p = dynamic_cast<Player*(obj)>). I deleted it immediately. Using dynamic_cast for core logic is slow, ugly, and brittle. If I add a new NPC class, I have to remember to go back to the GameExporter and add another if statement. I was just moving the problem from one place to another.

The Double Dispatch Trick

This is where the Visitor pattern comes in. The goal is to separate the algorithm from the object structure. The trick is something called "Double Dispatch."

Instead of the visitor trying to guess the type of the object, the object tells the visitor exactly what it is. I'll add one single method to my base class: accept(). This method doesn't do any work; it just hands the object back to the visitor.

class Player;
class Enemy;
class Prop;

// The "Visitor" interface defines an operation for every concrete type
class GameObjectVisitor {
public:
    virtual void visit(Player* player) = 0;
    virtual void visit(Enemy* enemy) = 0;
    virtual void visit(Prop* prop) = 0;
    virtual ~GameObjectVisitor() = default;
};

class GameObject {
public:
    // The only method we'll ever need to add to the hierarchy for new operations
    virtual void accept(GameObjectVisitor* visitor) = 0;
    virtual ~GameObject() = default;
};

class Player : public GameObject {
public:
    void accept(GameObjectVisitor* visitor) override {
        visitor->visit(this); // 'this' is known to be a Player* here!
    }
};

class Enemy : public GameObject {
public:
    void accept(GameObjectVisitor* visitor) override {
        visitor->visit(this); // 'this' is known to be an Enemy* here!
    }
};

Look at what just happened in accept(). When Player::accept is called, the compiler knows for a fact that this is a Player*. It then calls visitor->visit(this), which resolves to the visit(Player* player) overload in the visitor. We've dispatched twice: first to the object's accept method, then back to the visitor's visit method.

Putting it to Work

Now, if I want to add a "Save to JSON" feature, I don't touch the Player or Enemy classes at all. I just create a new visitor.

class JsonExportVisitor : public GameObjectVisitor {
public:
    void visit(Player* player) override {
        std::cout <"Exporting player data to JSON..."> std::endl;
    }
    void visit(Enemy* enemy) override {
        std::cout <"Exporting enemy data to JSON..."> std::endl;
    }
    void visit(Prop* prop) override {
        std::cout <"Exporting prop data to JSON..."> std::endl;
    }
};

// Usage
std::vector<GameObject*> scene = { new Player(), new Enemy(), new Prop() };
JsonExportVisitor exporter;

for (auto obj : scene) {
    obj->accept(&exporter); 
}

I love this because my GameObject classes stay lean. They only care about their own data. The logic for saving, rendering, or AI is encapsulated in its own visitor class. If I want to add a XmlExportVisitor tomorrow, I just write the new class and I'm done. No existing code is modified, which means I can't accidentally break the Player class while trying to implement XML export.




📋 Practical Task

Implementing a Combat Damage Visitor

You are building a combat system for a tactical RPG. You have three types of units: Warrior, Mage, and Archer. Each unit has a different way of receiving damage based on their armor and resistances.

Your Task:

  • Create a Unit base class and three derived classes: Warrior, Mage, and Archer.
  • Implement the Visitor pattern so that you can apply damage without adding a takeDamage() method that contains specific logic inside every unit class.
  • Create a DamageVisitor that implements different damage calculations:
    • Warriors reduce damage by 50%.
    • Mages take 2x damage.
    • Archers take normal damage.
  • In your main() function, create a std::vector of Unit* containing one of each type and use the DamageVisitor to process them.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.