DEV Community

Anthony4m
Anthony4m

Posted on

The Road Not Taken: Why Mars Chose Manual Recursion Over the Visitor Pattern ๐Ÿ›ค๏ธ

Every compiler textbook preaches the gospel of the Visitor pattern. Design pattern books elevate it to architectural nirvana. Academic papers assume it as the default. So when I chose manual recursion with two-phase analysis for Mars's semantic analyzer, I knew I was swimming against the current.

This is the story of that decisionโ€”a tale of pragmatism over patterns, clarity over cleverness, and why sometimes the "wrong" choice is exactly right.

The Siren Song of the Visitor Pattern ๐ŸŽญ

When you start building a compiler, the Visitor pattern seems inevitable. Open any compiler textbook and you'll find elegant diagrams showing how visitors traverse ASTs with mathematical precision:

interface ASTVisitor {
    void visitProgram(Program node);
    void visitVarDecl(VarDecl node);
    void visitFuncDecl(FuncDecl node);
    void visitBinaryExpr(BinaryExpr node);
    // ... 20+ more methods
}
Enter fullscreen mode Exit fullscreen mode

The promise is seductive: extensibility, separation of concerns, and that satisfying feeling of implementing a "proper" design pattern. Who doesn't want to build something that would make the Gang of Four proud?

I almost fell for it. I even started implementing it. That's when reality hit us like a compiler error at 3 AM.

The Visitor Pattern: A Beautiful Prison ๐Ÿ”’

My first attempt at implementing the Visitor pattern revealed its true nature. What looked elegant in UML diagrams became a bureaucratic nightmare in code:

// Every. Single. AST. Node. Needs. This.
func (vd *VarDecl) Accept(v Visitor) error {
    return v.VisitVarDecl(vd)
}

func (fd *FuncDecl) Accept(v Visitor) error {
    return v.VisitFuncDecl(fd)
}

// ... repeated for 30+ node types
Enter fullscreen mode Exit fullscreen mode

Suddenly, my clean AST was polluted with infrastructure. My ast package now had to know about visitors, creating circular dependency nightmares. I was no longer building a language; I was feeding a pattern.

But the real pain came when trying to control traversal:

func (v *TypeChecker) VisitFuncDecl(fd *FuncDecl) error {
    // Need to enter a new scope...
    // But who controls when I exit?
    // When do parameters get processed?
    // How do I handle early returns?

    // The framework controls flow, not us!
    return fd.Body.Accept(v)  // Hope for the best?
}
Enter fullscreen mode Exit fullscreen mode

The Visitor pattern had turned from servant to master. I was writing code to satisfy the pattern, not solve my problems.

The Manual Recursion Epiphany ๐Ÿ’ก

Then I looked at how real production compilers work. Not academic toys or pattern demonstrationsโ€”actual compilers that process millions of lines of code daily.

The Go compiler? Manual recursion with type switches.

The Rust compiler? Manual recursion with pattern matching.

Clang? Manual recursion with careful state management.

Were all these world-class engineers wrong? Or had they discovered something the textbooks missed?

I decided to try their approach:

func (a *Analyzer) analyzeNode(node ast.Node) error {
    switch n := node.(type) {
    case *ast.Program:
        for _, decl := range n.Declarations {
            if err := a.analyzeNode(decl); err != nil {
                return err
            }
        }
    case *ast.VarDecl:
        return a.analyzeVarDecl(n)
    case *ast.FuncDecl:
        return a.analyzeFuncDecl(n)
    // Direct, obvious, debuggable
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The difference was immediate and profound. I was writing code that expressed my intent, not code that satisfied a pattern.

The Two-Phase Revelation ๐Ÿ”„

With manual recursion, I could easily implement something that would have been tortuous with visitors: two-phase analysis.

func (a *Analyzer) Analyze(node ast.Node) error {
    // Phase 1: Collect all declarations
    if err := a.collectDeclarations(node); err != nil {
        return err
    }

    // Phase 2: Check all usages
    if err := a.checkTypes(node); err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This solved my forward reference problem elegantly:

func main() {
    helper()  // helper is defined below
}

func helper() {
    main()    // mutual recursion!
}
Enter fullscreen mode Exit fullscreen mode

Try implementing that with a single-pass visitor. I'll wait.

The Debugging Difference ๐Ÿ›

Here's where manual recursion truly shines. When something goes wrong (and it always does), debugging manual recursion is straightforward:

func (a *Analyzer) checkTypes(node ast.Node) error {
    switch n := node.(type) {
    case *ast.FunctionCall:
        // Set a breakpoint here
        // You see EXACTLY what code runs for function calls
        // The call stack shows EXACTLY how you got here
        return a.checkFunctionCall(n)
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare that to debugging a visitor:

func (v *TypeChecker) VisitNode(node ast.Node) error {
    // Which visitor method will this call?
    // How did I get here?
    // What's the traversal order?
    // *cries in stack traces through framework code*
    return node.Accept(v)
}
Enter fullscreen mode Exit fullscreen mode

With manual recursion, the code path is the execution path. There's no indirection, no framework magic, no "accept/visit" dance. When a developer joins your team, they can understand the flow in minutes, not hours.

The Performance Truth ๐Ÿ“Š

Let's talk about something the design pattern books conveniently ignore: performance.

// Visitor pattern - virtual dispatch for every node
node.Accept(visitor)  // Interface call overhead

// Manual recursion - direct function calls
a.checkTypes(node)    // Direct call, inlinable
Enter fullscreen mode Exit fullscreen mode

For a large codebase, those virtual dispatches add up. The Go compiler team didn't choose manual recursion for aesthetic reasons they chose it because it's fast.

My benchmarks showed manual recursion was 15-20% faster in terms of development than the visitor pattern for typical Mars programs. That's the difference between a responsive development experience and a coffee break.

The Flexibility Advantage ๐Ÿคธ

Need to handle a special case? With manual recursion, it's trivial:

func (a *Analyzer) checkTypes(node ast.Node) error {
    // Special handling for unsafe blocks
    if unsafe, ok := node.(*ast.UnsafeBlock); ok {
        a.enterUnsafeContext()
        defer a.exitUnsafeContext()
        return a.checkTypes(unsafe.Body)
    }

    // Normal processing
    switch n := node.(type) {
    // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Try adding that context management to a visitor pattern without wanting to flip a table.

The Scope Management Victory ๐ŸŽฏ

Perhaps my biggest win came with scope management:

func (a *Analyzer) checkFunctionBody(decl *ast.FuncDecl) error {
    // I control EXACTLY when scope changes happen
    a.symbols.EnterScope()
    defer a.symbols.ExitScope()  // Go's defer is perfect here

    // Add parameters to scope
    for _, param := range decl.Parameters {
        a.symbols.Define(param.Name, param.Type)
    }

    // Now check the body
    return a.checkTypes(decl.Body)
}
Enter fullscreen mode Exit fullscreen mode

The manual approach lets us leverage Go's defer for bulletproof scope management. With visitors, you're often stuck with EnterScope/ExitScope pairs that can get out of sync.

The Real-World Test ๐ŸŒ

The ultimate validation came when implementing complex features:

Mutual Recursion

func isEven(n: int) -> bool {
    return n == 0 || isOdd(n - 1)
}

func isOdd(n: int) -> bool {
    return n != 0 && isEven(n - 1)
}
Enter fullscreen mode Exit fullscreen mode

Two-phase analysis: Trivial.

Visitor pattern: Requires framework modifications.

Context-Sensitive Analysis

unsafe {
    ptr := alloc(int)  // Only valid in unsafe context
}
Enter fullscreen mode Exit fullscreen mode

Manual recursion: Add a context flag.

Visitor pattern: Add state to visitor, worry about thread safety.

Custom Traversal Orders

// Check condition first, then maybe check body
if typeOf(ifStmt.Condition) != "bool" {
    return errors.New("condition must be boolean")
}
// Skip body check if condition is always false
if isAlwaysFalse(ifStmt.Condition) {
    return nil  // Dead code elimination
}
Enter fullscreen mode Exit fullscreen mode

Manual recursion: Natural and obvious.

Visitor pattern: Fight the framework.

The Pattern Trap ๐Ÿชค

The deeper lesson here transcends compiler design. Design patterns are tools, not goals. The Visitor pattern is brilliant for certain problems UI frameworks, serialization systems, plugin architectures. But applying it blindly because "that's what you do for ASTs" is cargo cult programming.

I almost fell into the trap of pattern-driven design:

  • "I need to traverse an AST"
  • "The Visitor pattern traverses trees"
  • "Therefore, I must use the Visitor pattern"

This syllogism ignores the most important question: What specific problems are am I trying to solve?

The Learning Curve Reality ๐Ÿ“ˆ

When new developers join the Mars project, the onboarding conversation goes like this:

"How does semantic analysis work?"

"First I collect all declarations, then I check if they're used correctly. Look at this switch statement each case handles a different AST node type. Set a breakpoint and step through it."

"Oh, that makes sense!"

Compare that to explaining visitors, double dispatch, and why they need to implement 30+ methods in a specific interface. Simplicity is a feature, not a limitation.

The Maintenance Win ๐Ÿ”ง

Six months later, when I needed to add a new expression type, the process was:

  1. Add a case to the switch statement
  2. Implement the handler method
  3. Done

No interface updates. No visitor method additions across multiple files. No framework archaeology. Just add the code where it logically belongs.

When Visitors Actually Make Sense ๐Ÿค

I am not a visitor hater. The pattern has its place:

  • Multiple independent traversals: If you have many different analysis passes that don't share state
  • Plugin systems: When external code needs to traverse your AST
  • Serialization: Converting ASTs to different formats
  • When you don't control the AST: If the node types come from a library

But for a compiler where you control everything and performance matters? Manual recursion wins.

The Philosophy of Pragmatism ๐Ÿ›๏ธ

My choice reflects Mars's broader philosophy: pragmatism over purity, clarity over cleverness, simplicity over sophistication.

The same thinking that led to mut for explicit mutability led to manual recursion for explicit control flow. Both choices prioritize developer understanding over architectural elegance.

The Verdict: No Regrets ๐Ÿ’ช

Choosing manual recursion with two-phase analysis wasn't just a technical decision it was a statement of values. I chose:

  • Debuggability over elegance
  • Performance over patterns
  • Flexibility over frameworks
  • Clarity over convention

Every time I easily debug a type checking issue, every time I add a complex feature without framework fights I'm reminded I made the right choice.

The Broader Lesson ๐ŸŒŸ

The Mars semantic analyzer taught us that the best pattern is the one that solves your specific problems with the least complexity. Sometimes that's a Gang of Four pattern. Sometimes it's a simple switch statement.

Don't let pattern orthodoxy override pragmatic engineering. The goal isn't to implement patterns it's to build software that works, performs well, and can be understood and maintained by humans.

The Challenge to You ๐Ÿ’ญ

Next time you reach for a design pattern, ask yourself:

  • Am I solving a real problem or satisfying a pattern?
  • Will this make the code easier or harder to debug?
  • Can a simpler approach achieve the same goal?
  • What would the maintenance story be in six months?

Sometimes the road less traveled is the one without the fancy pattern that leads to better destinations.

The Sweet Taste of Vindication ๐Ÿ†

Every successful compilation, every caught type error, every smooth debugging session validates my choice. Mars's semantic analyzer is fast, maintainable, and understandable precisely because I chose substance over style.

I didn't use the Visitor pattern. I used my visitor discretion.

And that has made all the difference. ๐Ÿš€


Have you ever chosen simplicity over patterns? When has "doing it wrong" turned out right? What drives your architectural decisionsโ€”textbook wisdom or practical experience? Share your pattern rebellion stories below!

Top comments (0)