Skip to Content
Course content

221: The Visitor Pattern in Rust

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

You've likely hit this wall before: you have a complex data structure—maybe an Abstract Syntax Tree (AST) or a nested UI component tree—and you find yourself needing to perform a dozen different operations on it. One day it's rendering to HTML, the next it's a JSON export, and then suddenly you need a word counter or a spell checker.

The Bloated Struct Problem

The intuitive way to handle this is to just add methods to your structs. If you have a Paragraph struct and an Image struct, you give them both a render() method. It feels clean at first. But then you add export_to_pdf(), calculate_layout(), and validate_accessibility(). Suddenly, your data definitions are buried under hundreds of lines of behavioral logic that have nothing to do with what the data is and everything to do with what you're doing with it.

// This gets messy fast
struct Paragraph { text: String }
impl Paragraph {
    fn render(&self) { /* ... */ }
    fn export_pdf(&self) { /* ... */ }
    fn word_count(&self) -> usize { /* ... */ }
    // Imagine 10 more of these
}

I've seen this lead to "God Objects" where a single file becomes a nightmare to maintain because every single feature request requires modifying the core data structures. You're violating the Single Responsibility Principle, and in Rust, this often leads to awkward lifetime struggles or borrowing conflicts when the logic for one operation clashes with the needs of another.

When Enums Hit a Wall

Since we're in Rust, your first instinct is probably to use an enum and a big match statement. For a lot of cases, that's actually the right move. If your set of types is closed—meaning you know you'll only ever have Paragraph, Image, and Table—a match statement is idiomatic and fast.

But here's the rub: what if you're writing a library? You provide the AST, but you want the user of your library to be able to define their own new operations without having to modify your enum source code. You can't just tell your users to "add a match arm to the library's internal enum." That's impossible. You need a way to add new behaviors to existing types without changing those types.

Decoupling Logic with the Visitor Trait

This is where the Visitor pattern saves us. Instead of the data structure knowing how to "render" or "count," the data structure simply knows how to "accept" a visitor. The visitor is a separate trait that defines a method for every single concrete type in your structure.

Look at how this shifts the responsibility. The Element trait just says "I can be visited," and the Visitor trait says "I know how to handle every type of element."

trait Visitor {
    fn visit_paragraph(&mut self, p: &Paragraph);
    fn visit_image(&mut self, i: &Image);
}

trait Element {
    fn accept(&self, visitor: &mut dyn Visitor);
}

struct Paragraph { text: String }
impl Element for Paragraph {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_paragraph(self);
    }
}

struct Image { url: String }
impl Element for Image {
    fn accept(&self, visitor: &mut dyn Visitor) {
        visitor.visit_image(self);
    }
}

Now, if you want to add a WordCountVisitor, you don't touch the Paragraph or Image structs at all. You just implement the Visitor trait in a new file. You've effectively decoupled the algorithm from the data structure.

The Cost of Double Dispatch

I should be honest with you: this isn't a free lunch. The Visitor pattern uses "double dispatch." First, you call accept() (first dispatch), and then accept() calls back into the visitor's specific method (second dispatch). In Rust, using &mut dyn Visitor means you're paying for dynamic dispatch (vtable lookups) twice.

For a small UI tree, you'll never notice the performance hit. But if you're traversing a million-node AST in a compiler, this overhead adds up. In those cases, I'd suggest sticking to enums and match statements if you can control the types. But for extensibility and clean architecture in larger systems, the Visitor pattern is the gold standard for a reason. It keeps your data "dumb" and your logic "smart," which is exactly how you want it when a project starts to scale.




📋 Practical Task

Implementing a Markdown Statistics Collector

You are building a document analysis tool. You have an existing AST consisting of Heading, Text, and List elements. Your task is to implement a DocumentStatsVisitor that traverses these elements to calculate the total character count and the total number of headings.

Requirements:

  • Create a Visitor trait with methods for visit_heading, visit_text, and visit_list.
  • Implement an Element trait with an accept method.
  • Create the three concrete structs (Heading, Text, List) and implement Element for each.
  • Implement the DocumentStatsVisitor struct and its corresponding Visitor trait implementation. The visitor should maintain internal state (counters) that are updated as it visits elements.
  • In your main function, create a Vec<Box<dyn Element>> representing a document and pass it through your visitor to print the final statistics.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.