Double Dispatch and Using the Visitor Pattern
Overview
In this lesson, we'll explore the double dispatch mechanism that powers the Visitor pattern, how Visitor works with the Composite pattern, and guidelines for when to use Visitor in your applications.
Duration: 15 minutes
Understanding Double Dispatch
Single dispatch (normal method calls) selects a method based on the runtime type of one object:
typescript1class Package {2 process(): void {3 console.log('Processing package');4 }5}67class FragilePackage extends Package {8 process(): void {9 console.log('Processing fragile package carefully');10 }11}1213const pkg: Package = new FragilePackage();14pkg.process(); // "Processing fragile package carefully"15// Method selected based only on runtime type of 'pkg'
Double dispatch selects a method based on the runtime types of two objects:
typescript1// First dispatch: based on package type2package.accept(visitor); // Which accept() method?34// Second dispatch: based on visitor type5visitor.visitFragilePackage(this); // Which visitFragilePackage() method?
How Double Dispatch Works
Let's trace through a complete example:
typescript1interface PackageVisitor {2 visitStandardPackage(pkg: StandardPackage): void;3 visitFragilePackage(pkg: FragilePackage): void;4}56interface Package {7 accept(visitor: PackageVisitor): void;8}910class StandardPackage implements Package {11 accept(visitor: PackageVisitor): void {12 visitor.visitStandardPackage(this); // "this" is StandardPackage13 }14}1516class FragilePackage implements Package {17 accept(visitor: PackageVisitor): void {18 visitor.visitFragilePackage(this); // "this" is FragilePackage19 }20}2122class TaxCalculator implements PackageVisitor {23 visitStandardPackage(pkg: StandardPackage): void {24 console.log('Calculating standard tax');25 }2627 visitFragilePackage(pkg: FragilePackage): void {28 console.log('Calculating fragile tax');29 }30}3132class Inspector implements PackageVisitor {33 visitStandardPackage(pkg: StandardPackage): void {34 console.log('Standard inspection');35 }3637 visitFragilePackage(pkg: FragilePackage): void {38 console.log('Fragile inspection');39 }40}4142// Usage43const packages: Package[] = [44 new StandardPackage(),45 new FragilePackage()46];4748const taxCalc = new TaxCalculator();49const inspector = new Inspector();5051packages[0].accept(taxCalc); // "Calculating standard tax"52packages[1].accept(taxCalc); // "Calculating fragile tax"53packages[0].accept(inspector); // "Standard inspection"54packages[1].accept(inspector); // "Fragile inspection"
Execution trace for packages[1].accept(taxCalc):
- Runtime determines
packages[1]isFragilePackage(first dispatch) - Calls
FragilePackage.accept(taxCalc) - Inside
accept(),thisisFragilePackage - Calls
taxCalc.visitFragilePackage(this) - Runtime determines
taxCalcisTaxCalculator(second dispatch) - Calls
TaxCalculator.visitFragilePackage(pkg) - Executes "Calculating fragile tax"
The correct method is selected based on both the package type (FragilePackage) and the visitor type (TaxCalculator).
Why Double Dispatch Matters
Without double dispatch, you'd need type checks:
typescript1// BAD: Using instanceof (violates Open/Closed Principle)2class TaxCalculator {3 calculate(pkg: Package): number {4 if (pkg instanceof StandardPackage) {5 return this.calculateStandard(pkg as StandardPackage);6 } else if (pkg instanceof FragilePackage) {7 return this.calculateFragile(pkg as FragilePackage);8 } else if (pkg instanceof HazmatPackage) {9 return this.calculateHazmat(pkg as HazmatPackage);10 }11 throw new Error('Unknown package type');12 }13}1415// GOOD: Using Visitor (double dispatch)16class TaxCalculator implements PackageVisitor<number> {17 visitStandardPackage(pkg: StandardPackage): number {18 return pkg.getWeight() * 0.05;19 }2021 visitFragilePackage(pkg: FragilePackage): number {22 return pkg.getWeight() * 0.08;23 }2425 visitHazmatPackage(pkg: HazmatPackage): number {26 return pkg.getWeight() * 0.15;27 }28}2930// Usage: no type checking needed31const tax = pkg.accept(new TaxCalculator());
Visitor with Composite Pattern
The Visitor pattern works exceptionally well with the Composite pattern for traversing hierarchical structures:
typescript1// Composite structure: Shipment contains packages2interface ShipmentComponent {3 accept(visitor: ShipmentVisitor): void;4}56interface ShipmentVisitor {7 visitPackage(pkg: Package): void;8 visitContainer(container: Container): void;9 visitShipment(shipment: Shipment): void;10}1112class Package implements ShipmentComponent {13 constructor(14 private id: string,15 private weight: number,16 private value: number17 ) {}1819 accept(visitor: ShipmentVisitor): void {20 visitor.visitPackage(this);21 }2223 getId(): string { return this.id; }24 getWeight(): number { return this.weight; }25 getValue(): number { return this.value; }26}2728class Container implements ShipmentComponent {29 private packages: Package[] = [];3031 constructor(private id: string) {}3233 addPackage(pkg: Package): void {34 this.packages.push(pkg);35 }3637 accept(visitor: ShipmentVisitor): void {38 visitor.visitContainer(this);39 // Traverse children40 this.packages.forEach(pkg => pkg.accept(visitor));41 }4243 getId(): string { return this.id; }44 getPackages(): Package[] { return [...this.packages]; }45}4647class Shipment implements ShipmentComponent {48 private containers: Container[] = [];4950 constructor(private id: string, private destination: string) {}5152 addContainer(container: Container): void {53 this.containers.push(container);54 }5556 accept(visitor: ShipmentVisitor): void {57 visitor.visitShipment(this);58 // Traverse children59 this.containers.forEach(container => container.accept(visitor));60 }6162 getId(): string { return this.id; }63 getDestination(): string { return this.destination; }64}6566// Visitor to calculate total weight67class WeightCalculatorVisitor implements ShipmentVisitor {68 private totalWeight = 0;6970 visitPackage(pkg: Package): void {71 this.totalWeight += pkg.getWeight();72 }7374 visitContainer(container: Container): void {75 // Container weight is handled by its packages76 // Just add container's own weight77 this.totalWeight += 5; // 5kg container weight78 }7980 visitShipment(shipment: Shipment): void {81 // Shipment overhead82 this.totalWeight += 2; // 2kg for paperwork, labels, etc.83 }8485 getTotalWeight(): number {86 return this.totalWeight;87 }88}8990// Visitor to calculate total value91class ValueCalculatorVisitor implements ShipmentVisitor {92 private totalValue = 0;9394 visitPackage(pkg: Package): void {95 this.totalValue += pkg.getValue();96 }9798 visitContainer(container: Container): void {99 // Containers don't have value themselves100 }101102 visitShipment(shipment: Shipment): void {103 // Shipments don't have value themselves104 }105106 getTotalValue(): number {107 return this.totalValue;108 }109}110111// Visitor to generate report112class ReportGeneratorVisitor implements ShipmentVisitor {113 private lines: string[] = [];114 private indent = 0;115116 visitShipment(shipment: Shipment): void {117 this.lines.push(`Shipment ${shipment.getId()} to ${shipment.getDestination()}`);118 this.indent++;119 }120121 visitContainer(container: Container): void {122 this.lines.push(`${' '.repeat(this.indent)}Container ${container.getId()} (${container.getPackages().length} packages)`);123 this.indent++;124 }125126 visitPackage(pkg: Package): void {127 this.lines.push(`${' '.repeat(this.indent)}Package ${pkg.getId()}: ${pkg.getWeight()}kg, $${pkg.getValue()}`);128 }129130 getReport(): string {131 return this.lines.join('\n');132 }133}134135// Usage136const shipment = new Shipment('SHIP001', 'Los Angeles');137138const container1 = new Container('CNT001');139container1.addPackage(new Package('PKG001', 10, 100));140container1.addPackage(new Package('PKG002', 5, 50));141142const container2 = new Container('CNT002');143container2.addPackage(new Package('PKG003', 8, 200));144container2.addPackage(new Package('PKG004', 12, 150));145146shipment.addContainer(container1);147shipment.addContainer(container2);148149// Calculate total weight150const weightCalc = new WeightCalculatorVisitor();151shipment.accept(weightCalc);152console.log(`Total weight: ${weightCalc.getTotalWeight()}kg`);153// Output: Total weight: 47kg (10+5+8+12 packages + 5+5 containers + 2 shipment)154155// Calculate total value156const valueCalc = new ValueCalculatorVisitor();157shipment.accept(valueCalc);158console.log(`Total value: $${valueCalc.getTotalValue()}`);159// Output: Total value: $500160161// Generate report162const reportGen = new ReportGeneratorVisitor();163shipment.accept(reportGen);164console.log(reportGen.getReport());165// Output:166// Shipment SHIP001 to Los Angeles167// Container CNT001 (2 packages)168// Package PKG001: 10kg, $100169// Package PKG002: 5kg, $50170// Container CNT002 (2 packages)171// Package PKG003: 8kg, $200172// Package PKG004: 12kg, $150
Adding New Operations vs. New Elements
Easy: Adding New Operations
Adding a new operation is trivial - just create a new visitor:
typescript1// Add customs declaration visitor2class CustomsDeclarationVisitor implements ShipmentVisitor {3 private declarations: string[] = [];45 visitPackage(pkg: Package): void {6 this.declarations.push(7 `Item ${pkg.getId()}: Commercial value $${pkg.getValue()}, Weight ${pkg.getWeight()}kg`8 );9 }1011 visitContainer(container: Container): void {12 this.declarations.push(`Container ${container.getId()}`);13 }1415 visitShipment(shipment: Shipment): void {16 this.declarations.push(`Destination: ${shipment.getDestination()}`);17 }1819 getDeclaration(): string {20 return this.declarations.join('\n');21 }22}2324// Use it immediately without modifying any existing code25const customsDecl = new CustomsDeclarationVisitor();26shipment.accept(customsDecl);27console.log(customsDecl.getDeclaration());
Hard: Adding New Elements
Adding a new element type requires updating all existing visitors:
typescript1// Add a new element type: Pallet2class Pallet implements ShipmentComponent {3 private containers: Container[] = [];45 constructor(private id: string) {}67 addContainer(container: Container): void {8 this.containers.push(container);9 }1011 accept(visitor: ShipmentVisitor): void {12 visitor.visitPallet(this); // Error: visitPallet doesn't exist!13 this.containers.forEach(c => c.accept(visitor));14 }1516 getId(): string { return this.id; }17}1819// Must update the visitor interface20interface ShipmentVisitor {21 visitPackage(pkg: Package): void;22 visitContainer(container: Container): void;23 visitShipment(shipment: Shipment): void;24 visitPallet(pallet: Pallet): void; // New method25}2627// Must update ALL existing visitors28class WeightCalculatorVisitor implements ShipmentVisitor {29 // ... existing methods ...3031 visitPallet(pallet: Pallet): void { // Must implement32 this.totalWeight += 20; // 20kg pallet weight33 }34}3536class ValueCalculatorVisitor implements ShipmentVisitor {37 // ... existing methods ...3839 visitPallet(pallet: Pallet): void { // Must implement40 // Pallets have no value41 }42}4344class ReportGeneratorVisitor implements ShipmentVisitor {45 // ... existing methods ...4647 visitPallet(pallet: Pallet): void { // Must implement48 this.lines.push(`${' '.repeat(this.indent)}Pallet ${pallet.getId()}`);49 this.indent++;50 }51}5253class CustomsDeclarationVisitor implements ShipmentVisitor {54 // ... existing methods ...5556 visitPallet(pallet: Pallet): void { // Must implement57 this.declarations.push(`Pallet ${pallet.getId()}`);58 }59}
When to Use the Visitor Pattern
Good Use Cases
1. Stable Element Hierarchy with Varying Operations
typescript1// Element hierarchy rarely changes2interface ASTNode { accept(visitor: ASTVisitor): void; }3class VariableNode implements ASTNode { /* ... */ }4class FunctionNode implements ASTNode { /* ... */ }5class BinaryOpNode implements ASTNode { /* ... */ }67// Operations change frequently8class TypeCheckerVisitor implements ASTVisitor { /* ... */ }9class CodeGeneratorVisitor implements ASTVisitor { /* ... */ }10class OptimizationVisitor implements ASTVisitor { /* ... */ }11class DocumentationVisitor implements ASTVisitor { /* ... */ }
2. Operations That Accumulate State
typescript1class ShipmentAnalyticsVisitor implements ShipmentVisitor {2 private stats = {3 totalPackages: 0,4 totalContainers: 0,5 totalWeight: 0,6 totalValue: 0,7 avgPackageWeight: 0,8 avgPackageValue: 09 };1011 visitPackage(pkg: Package): void {12 this.stats.totalPackages++;13 this.stats.totalWeight += pkg.getWeight();14 this.stats.totalValue += pkg.getValue();15 }1617 visitContainer(container: Container): void {18 this.stats.totalContainers++;19 }2021 visitShipment(shipment: Shipment): void {22 // Calculate averages23 if (this.stats.totalPackages > 0) {24 this.stats.avgPackageWeight = this.stats.totalWeight / this.stats.totalPackages;25 this.stats.avgPackageValue = this.stats.totalValue / this.stats.totalPackages;26 }27 }2829 getStats() { return this.stats; }30}
3. Export/Serialization to Different Formats
typescript1class XMLExportVisitor implements ShipmentVisitor { /* ... */ }2class JSONExportVisitor implements ShipmentVisitor { /* ... */ }3class CSVExportVisitor implements ShipmentVisitor { /* ... */ }4class PDFReportVisitor implements ShipmentVisitor { /* ... */ }
4. Complex Validation with Multiple Rules
typescript1class WeightLimitValidator implements ShipmentVisitor { /* ... */ }2class DimensionValidator implements ShipmentVisitor { /* ... */ }3class HazmatComplianceValidator implements ShipmentVisitor { /* ... */ }4class CustomsRequirementsValidator implements ShipmentVisitor { /* ... */ }
Poor Use Cases
1. Frequently Changing Element Hierarchy
If you're constantly adding new element types, Visitor becomes a maintenance burden because every new type requires updating all visitors.
2. Simple Operations Without State
If operations are simple and don't need to accumulate state, regular methods might be simpler:
typescript1// DON'T use Visitor for this2interface Package {3 accept(visitor: PackageVisitor): void;4}56class WeightVisitor implements PackageVisitor {7 visitPackage(pkg: Package): number {8 return pkg.getWeight(); // Too simple for Visitor9 }10}1112// DO this instead13interface Package {14 getWeight(): number;15}
3. When Encapsulation is Critical
Visitors often need access to element internals, which can break encapsulation:
typescript1class FragilePackage {2 private paddingThickness: number; // Private detail34 accept(visitor: PackageVisitor): void {5 visitor.visitFragilePackage(this);6 }78 // Must expose private detail for visitor9 getPaddingThickness(): number {10 return this.paddingThickness;11 }12}
Visitor Pattern Trade-offs
| Aspect | Benefit | Trade-off |
|---|---|---|
| Adding operations | Very easy - just create new visitor | N/A |
| Adding elements | N/A | Hard - must update all visitors |
| Related logic | Kept together in one visitor class | Logic split from data |
| Single Responsibility | Each visitor has one purpose | N/A |
| Open/Closed Principle | Open to new operations | Closed to new element types |
| Encapsulation | N/A | Often must expose element internals |
| Type safety | TypeScript ensures all types handled | N/A |
| Complexity | Clean separation of concerns | Double dispatch can be hard to understand |
Key Takeaways
- Double dispatch selects methods based on two runtime types (element and visitor)
- Works great with Composite for traversing hierarchical structures
- Easy to add operations - create new visitor without changing elements
- Hard to add elements - new element types require updating all visitors
- Use when: Element hierarchy is stable, operations vary, need state accumulation
- Avoid when: Element types change frequently, operations are trivial, encapsulation is critical
- Common uses: Compilers (AST visitors), reporting, export, validation, analysis
The Visitor pattern is a powerful tool for separating operations from data structures, especially when you have a stable set of types but need to perform many different operations on them.