Introduction

Imagine you’re building an application with a variety of objects. Over time, you need to add new operations—compressing, analyzing, exporting—to handle these objects. Without the right approach, you might end up modifying the objects themselves each time you introduce a new operation violating the Open-Closed principle, and risking introducing bugs to existing software, creating an inflexible and bloated codebase.

This is precisely the problem the Visitor pattern solves: by separating the operations from the objects themselves, Visitor allows you to add new functionality to an existing group of objects without modifying their underlying structure. Where traditional approaches would require modifying multiple classes for each new feature, Visitor “flips the script,” consolidating related operations in one place. This makes your code more organized, easier to maintain, and less vulnerable to bugs.

Let's dive deep into the Visitor pattern and discover how it can help you write more maintainable and flexible code.

The Problem: Adding Operations to a Complex Object Structure

Imagine you’re working on an application that handles geographic information structured as a graph.

Each node in the graph represents a different type of entity, from complex ones like entire cities to more specific ones like industries or sightseeing areas. To manage these various entities, each node type has its own dedicated class, so that cities, industries, and sightseeing areas are represented by separate classes, while each unique node (like Berlin or Brandenburg Gate) is represented as an object of one of these classes.

Here's how the initial class structure might look:

class City
  attr_accessor :name, :population

  def initialize(name, population)
    @name = name
    @population = population
  end
end

class Industry
  attr_accessor :type, :employees

  def initialize(type, employees)
    @type = type
    @employees = employees
  end
end

class SightseeingArea
  attr_accessor :name, :attractions

  def initialize(name, attractions)
    @name = name
    @attractions = attractions
  end
end

Now, suppose you’ve been given the task of implementing an export feature to convert this entire graph structure into an XML format. At first glance, this might seem straightforward—you could add an export_to_xml method to each class.

class City
  def export_to_xml
    # XML export logic
  end
end

class Industry
  def export_to_xml
    # XML export logic
  end
end

class SightseeingArea
  def export_to_xml
    # XML export logic
  end
end

However, this approach introduces several significant problems that become more apparent as your application grows.

First, every time you need a new feature or a different export format (such as JSON or CSV), you’d have to modify each class to accommodate the new method. This approach would violate the Open-Closed Principle because each new functionality requires altering the existing classes, which increases the risk of bugs and makes the code harder to maintain.

class City
  def export_to_xml
    # XML export logic
  end

  def export_to_json
    # JSON export logic
  end

  def export_to_csv
    # CSV export logic
  end

  def calculate_statistics
    # Statistics calculation logic
  end
end

#... and so on for each Node type

Second, your node classes quickly become bloated. What started as simple classes representing geographic entities now become cluttered with various export methods, statistical calculations, and other operations. This violates the Single Responsibility Principle—each class should have only one reason to change.

# A glimpse into the future of our City class...
class City
  # Original purpose
  attr_accessor :name, :population

  # Export formats
  def export_to_xml; end
  def export_to_json; end
  def export_to_csv; end

  # Analytics
  def calculate_population_density; end
  def predict_growth; end

  # Reporting
  def generate_tourism_report; end
  def create_economic_summary; end

  # Future requirements...?
end

Finally, this approach creates a maintenance snowball when it comes to extensibility. Need to add a new export format? You'll need to:

  1. Identify all node classes in your system
  2. Add the new export method to each class
  3. Ensure consistent implementation across all classes
  4. Test each class's new functionality
  5. Repeat this process for every new operation

In a real-world application with dozens of node types, this becomes increasingly unsustainable. You'd need to modify and test numerous classes for each new feature, making the codebase rigid and resistant to change.

The fundamental issue here is that we're trying to add new operations (XML export, potentially JSON export, SQL export, etc.) to an existing object structure without:

  • Modifying every class when adding a new operation
  • Scattering related logic across multiple classes
  • Breaking encapsulation
  • Creating maintenance headaches

This is where the Visitor pattern comes in, providing a much cleaner solution. By separating the export functionality from the data structure itself, Visitor allows us to add new operations, like exporting to XML, without modifying the classes representing each type of node. Let's see how...

The Visitor Pattern in Action

Instead of adding new behaviors directly to existing classes, the Visitor pattern introduces a separate class called a "visitor" that contains this new functionality.When you need to perform the new operation, rather than calling it as a method on the original object, you pass the object to the visitor. This gives the visitor access to all the object's data while keeping the new behavior separate from the original class.

# With Visitor Pattern - Behavior in separate class
class XMLExportVisitor
  def visit_city(city)   # New behavior isolated in visitor class
    # Export XML logic
  end

  def visit_industry(industry)
    # Export XML logic
  end

  def visit_sightseeing_area(sightseeing_area)
    # Export XML logic
  end
end

class City
  def accept(visitor)    # City only needs to know how to accept visitors
    visitor.visit_city(self)
  end
end

This approach achieves a clean separation of concerns - each node class stays focused on its core responsibility of managing data, while all operation-specific logic lives in dedicated visitor classes.Second, the pattern makes it incredibly easy to add new operations. Need a new export format or analysis feature? Simply create a new visitor class without touching any existing code. This elegant approach means our system remains open for extension (through new visitors) while keeping our existing classes closed for modification - a perfect example of the Open-Closed Principle in action.

“But Wait, Didn't We Still Modify the Original Classes?" — Yes, we did add the accept method to our node classes. However, this is fundamentally different from our original problem for several reasons:

  1. One-time Change: We only need to add the accept method once, and it never needs to change again
  2. Open for Extension: Once the accept method is in place, we can add any number of new visitors without touching the node classes again
  3. No Logic Leakage: The accept method doesn't contain any export logic - it's just a mechanism for double dispatch

"Double Dispatch? What are you talking about? Couldn't we just do this?"

# Seemingly simpler approach
class XMLExporter
  def export(node)
    if node.is_a?(City)
      # Export city to XML
    elsif node.is_a?(Industry)
      # Export industry to XML
    elsif node.is_a?(SightseeingArea)
      # Export sightseeing area to XML
    end
  end
end

This approach seems simpler, but it has a fundamental flaw: it relies on explicit type checking with if/elsif statements, making the code fragile and harder to maintain. Every time you add a new node type, you need to modify every visitor's export method.

The Visitor pattern solves this through double dispatch. Here's how it works:

  1. When you call node.accept(visitor), the first dispatch occurs based on the concrete type of the node
  2. Inside the accept method, the second dispatch occurs when calling the appropriate visit_ method on the visitor

The Visitor pattern uses a technique called double dispatch to automatically select the correct operation based on both the node's type and the visitor's type. When a node calls accept(visitor), it's essentially telling the visitor "I'm a City, use your visit_city method on me." This means a JSON visitor's visit_city method will be called for cities, while an XML visitor's visit_city method handles the same city differently - all without any explicit type checking or if statements. It's like each node and visitor working together to ensure the right operation happens at the right time.

Understanding the Visitor Pattern Structure

Let's zoom out and break down the key components that make up the Visitor pattern:

  • Visitor
    • The Visitor Interface declares a set of visit methods, one for each type of element in our system. In Ruby, where we don't have explicit interfaces, we implement this as a base class with methods that raise NotImplementedError.
    • Concrete Visitors (like our XMLExportVisitor and JSONExportVisitor) implement these methods with specific behaviors for each element type. Each visitor represents a different operation we want to perform on our elements.
  • Element
    • The Element Interface (again, a base class in Ruby) declares the accept method that all elements must implement. This method takes a visitor as its parameter.
    • Concrete Elements (like City, Industry, SightseeingArea) implement the accept method, which routes the visitor to the appropriate visit method based on the element's type.
  • Client
    • The code that brings everything together. It creates the elements and visitors, and triggers the visiting process.

Final ImplementationNow let's go back to our solution using the Visitor Pattern and implement a complete solution using the Visitor pattern, breaking it down step by step to understand how each component works together.

First, we create our base Visitor class that defines what operations our concrete visitors must implement:

# Visitor Interface
class Visitor
  def visit_city(city)
    raise NotImplementedError, 'You must implement the visit_city method'
  end

  def visit_industry(industry)
    raise NotImplementedError, 'You must implement the visit_industry method'
  end

  def visit_sightseeing_area(sightseeing_area)
    raise NotImplementedError, 'You must implement the visit_sightseeing_area method'
  end
end

Next, we define our base Node class adding the abstract “acceptance” method to the base class of the hierarchy. This method should accept a visitor object as an argument.

# Base Node Interface
class Node
  def accept(visitor)
    raise NotImplementedError, 'You must implement the accept method'
  end
end

Ruby Tip: We could leverage Ruby's metaprogramming capabilities to automatically handle the accept method:

# Base Node Class
class Node
  def accept(visitor)
    method_name = "visit_#{self.class.name.downcase}"
    visitor.send(method_name, self)
  end
end

Each concrete element must implement an accept method that routes the visitor to the appropriate method for that element's type. While we could use Ruby's metaprogramming to handle this dynamically, we'll implement explicit methods for clarity:

# Concrete Node Classes
class City < Node
  attr_accessor :name, :population

  def initialize(name, population)
    @name = name
    @population = population
  end

  def accept(visitor)
    visitor.visit_city(self)
  end
end

class Industry < Node
  attr_accessor :type, :employees

  def initialize(type, employees)
    @type = type
    @employees = employees
  end

  def accept(visitor)
    visitor.visit_industry(self)
  end
end

class SightseeingArea < Node
  attr_accessor :name, :attractions

  def initialize(name, attractions)
    @name = name
    @attractions = attractions
  end

  def accept(visitor)
    visitor.visit_sightseeing_area(self)
  end
end

Next, we implement our concrete visitor class with the specific export logic. The Visitor pattern allows us to share state across different visit methods - in our case, we maintain a single XML document that all methods contribute to. However, be aware that visitors sometimes need access to private element data, which might force you to choose between maintaining strict encapsulation and implementing the desired functionality:

# Concrete Visitor for XML Export
require 'rexml/document'

class XMLExportVisitor < Visitor
  attr_reader :document

  def initialize
    @document = REXML::Document.new
    @current_element = @document.add_element('Graph')
  end

  def visit_city(city)
    city_element = @current_element.add_element('City')
    city_element.add_attribute('name', city.name)
    city_element.add_attribute('population', city.population.to_s)
  end

  def visit_industry(industry)
    industry_element = @current_element.add_element('Industry')
    industry_element.add_attribute('type', industry.type)
    industry_element.add_attribute('employees', industry.employees.to_s)
  end

  def visit_sightseeing_area(sightseeing_area)
    area_element = @current_element.add_element('SightseeingArea')
    area_element.add_attribute('name', sightseeing_area.name)
    attractions_element = area_element.add_element('Attractions')
    sightseeing_area.attractions.each do |attraction|
      attraction_element = attractions_element.add_element('Attraction')
      attraction_element.text = attraction
    end
  end

  def to_xml
    formatter = REXML::Formatters::Pretty.new
    output = ''
    formatter.write(@document, output)
    output
  end
end

The client must create visitor objects and pass them into elements via “acceptance” methods:

# Constructing the list of nodes
nodes = []

city1 = City.new('Metropolis', 1_000_000)
city2 = City.new('Gotham', 500_000)
industry = Industry.new('Steel', 5_000)
sightseeing = SightseeingArea.new('Central Park', ['Lake', 'Zoo'])

nodes << city1
nodes << city2
nodes << industry
nodes << sightseeing

# Initialize the visitor
visitor = XMLExportVisitor.new

# Apply the visitor to each node
nodes.each do |node|
  node.accept(visitor)
end

# Output the XML
puts visitor.to_xml
<Nodes>
  <City name='Metropolis' population='1000000'/>
  <City name='Gotham' population='500000'/>
  <Industry type='Steel' employees='5000'/>
  <SightseeingArea name='Central Park'>
    <Attractions>
      <Attraction>Lake</Attraction>
      <Attraction>Zoo</Attraction>
    </Attractions>
  </SightseeingArea>
</Nodes>

Extending the Example: JSON Export

The real power of the Visitor pattern becomes apparent when we need to add new operations. Let's say our system now needs to support JSON export alongside XML. Thanks to our pattern implementation, this only requires creating a new visitor class:

require 'json'

class JSONExportVisitor < Visitor
  attr_reader :result

  def initialize
    @result = []
  end

  def visit_city(city)
    city_data = {
      'type' => 'City',
      'name' => city.name,
      'population' => city.population
    }
    @result << city_data
  end

  def visit_industry(industry)
    industry_data = {
      'type' => 'Industry',
      'industry_type' => industry.type,
      'employees' => industry.employees
    }
    @result << industry_data
  end

  def visit_sightseeing_area(sightseeing_area)
    area_data = {
      'type' => 'SightseeingArea',
      'name' => sightseeing_area.name,
      'attractions' => sightseeing_area.attractions
    }
    @result << area_data
  end

  def to_json
    JSON.pretty_generate(@result)
  end
end

And then client could call this new Visitor class.

# Initialize the visitor
json_visitor = JSONExportVisitor.new

# Apply the visitor to each node
nodes.each do |node|
  node.accept(json_visitor)
end

# Output the JSON
puts json_visitor.to_json
[
  {
    "type": "City",
    "name": "Metropolis",
    "population": 1000000
  },
  {
    "type": "City",
    "name": "Gotham",
    "population": 500000
  },
  {
    "type": "Industry",
    "industry_type": "Steel",
    "employees": 5000
  },
  {
    "type": "SightseeingArea",
    "name": "Central Park",
    "attractions": [
      "Lake",
      "Zoo"
    ]
  }
]

By adding the JSONExportVisitor, we've demonstrated a key strength of the Visitor pattern: the ability to add new operations without modifying existing classes. We can keep adding new visitors for different export formats or analysis operations, and our node classes remain untouched - perfectly embodying the Open-Closed Principle.

Making the Most of the Visitor Pattern

The Visitor pattern offers an elegant solution to a common software design challenge: how to add operations to an existing object structure without modifying it. While it does require some initial setup - implementing the accept method in our node classes - this one-time modification enables unlimited extension through new visitors.

Think back to our geographic information system. We started with a simple need to export data to XML, but as our system grew, we easily added JSON export without touching our core classes. Tomorrow, we might need to generate statistical reports, create visualizations, or export to new formats - each of these can be implemented as a new visitor, keeping our codebase clean and maintainable.

However, like any design pattern, Visitor isn't a silver bullet. It's most valuable when:

  • You have a stable set of element classes but frequently need to add new operations
  • You want to keep related operations grouped together rather than spread across multiple classes
  • You're dealing with complex operations that don't belong in your core business objects

When used appropriately, the Visitor pattern helps create more maintainable, extensible code that's easier to understand and modify. It's another powerful tool in your software design toolkit, ready to be deployed when the situation calls for it.