Visitor pattern w praktyce

TL;DR: Visitor pattern separuje algorytmy od struktur danych na których operują. Pozwala dodawać nowe operacje do hierarchii klas bez ich modyfikacji, ale kosztem większej złożoności kodu i ścisłego sprzężenia z konkretną strukturą obiektów.

Dlaczego Visitor pattern rozwiązuje realny problem

Wyobraź sobie system zarządzania dokumentami, gdzie masz różne typy dokumentów (PDF, Word, Excel), a każdy wymaga innych operacji: eksport do XML, obliczanie rozmiaru, walidacja. Tradycyjne podejście oznaczałoby dodawanie metod do każdej klasy dokumentu, co narusza zasadę otwarte/zamknięte (Open/Closed Principle).

Visitor pattern pozwala separować operacje od struktur danych, umożliwiając dodawanie nowych funkcjonalności bez modyfikacji istniejących klas.

Co się nauczysz:

  • Jak zaimplementować Visitor pattern w praktycznych scenariuszach
  • Kiedy używać Visitor pattern, a kiedy go unikać
  • Double dispatch i jak działa w Javie
  • Visitor pattern w przetwarzaniu AST i strukturach drzewiastych
  • Performance implications i alternatywne rozwiązania
Wymagania wstępne: Znajomość OOP w Javie, podstawowych wzorców projektowych oraz polimorfizmu

Struktura Visitor Pattern

Visitor składa się z dwóch głównych komponentów:

Visitor – interfejs definiujący operacje dla każdego typu elementu w strukturze
Element – interfejs z metodą accept() przyjmującą visitor

Podstawowa implementacja

// Element interface
public interface DocumentElement {
    void accept(DocumentVisitor visitor);
    String getName();
}

// Visitor interface
public interface DocumentVisitor {
    void visit(PdfDocument pdf);
    void visit(WordDocument word);
    void visit(ExcelDocument excel);
}

// Concrete Elements
public class PdfDocument implements DocumentElement {
    private final String fileName;
    private final int pageCount;
    
    public PdfDocument(String fileName, int pageCount) {
        this.fileName = fileName;
        this.pageCount = pageCount;
    }
    
    @Override
    public void accept(DocumentVisitor visitor) {
        visitor.visit(this); // Double dispatch!
    }
    
    @Override
    public String getName() { return fileName; }
    
    public int getPageCount() { return pageCount; }
}
Pro tip: Kluczem do Visitor pattern jest double dispatch – najpierw wybieramy konkretny element (pierwszy dispatch), potem konkretny visitor (drugi dispatch).

Concrete Visitors – operacje biznesowe

// Size calculation visitor
public class DocumentSizeVisitor implements DocumentVisitor {
    private long totalSize = 0;
    
    @Override
    public void visit(PdfDocument pdf) {
        // PDF: ~200KB per page + metadata
        totalSize += pdf.getPageCount() * 200 * 1024 + 50 * 1024;
        System.out.println(pdf.getName() + ": ~" + 
            (pdf.getPageCount() * 200 + 50) + "KB");
    }
    
    @Override
    public void visit(WordDocument word) {
        // Word: ~100KB per page + formatting
        totalSize += word.getWordCount() / 250 * 100 * 1024 + 100 * 1024;
        System.out.println(word.getName() + ": ~" + 
            (word.getWordCount() / 250 * 100 + 100) + "KB");
    }
    
    @Override
    public void visit(ExcelDocument excel) {
        // Excel: zależny od liczby arkuszy i komórek
        long sheetSize = excel.getSheetCount() * 50 * 1024;
        long cellSize = excel.getCellCount() * 20;
        totalSize += sheetSize + cellSize;
        System.out.println(excel.getName() + ": ~" + 
            (sheetSize + cellSize) / 1024 + "KB");
    }
    
    public long getTotalSize() { return totalSize; }
}

// Export visitor
public class XmlExportVisitor implements DocumentVisitor {
    private final StringBuilder xmlOutput = new StringBuilder();
    
    @Override
    public void visit(PdfDocument pdf) {
        xmlOutput.append("")
                 .append("").append(pdf.getName()).append("")
                 .append("").append(pdf.getPageCount()).append("")
                 .append("portable")
                 .append("");
    }
    
    @Override
    public void visit(WordDocument word) {
        xmlOutput.append("")
                 .append("").append(word.getName()).append("")
                 .append("").append(word.getWordCount()).append("")
                 .append("true")
                 .append("");
    }
    
    @Override
    public void visit(ExcelDocument excel) {
        xmlOutput.append("")
                 .append("").append(excel.getName()).append("")
                 .append("").append(excel.getSheetCount()).append("")
                 .append("").append(excel.getCellCount()).append("")
                 .append("");
    }
    
    public String getXml() { return xmlOutput.toString(); }
}

Visitor w strukturach drzewiastych

Visitor pattern świetnie sprawdza się w przetwarzaniu Abstract Syntax Trees (AST), gdzie mamy hierarchię węzłów reprezentujących różne konstrukcje językowe.

// AST Node hierarchy
public abstract class AstNode {
    public abstract void accept(AstVisitor visitor);
}

public class BinaryOperationNode extends AstNode {
    private final AstNode left;
    private final AstNode right;
    private final String operator;
    
    public BinaryOperationNode(AstNode left, String operator, AstNode right) {
        this.left = left;
        this.operator = operator;
        this.right = right;
    }
    
    @Override
    public void accept(AstVisitor visitor) {
        visitor.visit(this);
    }
    
    // getters...
    public AstNode getLeft() { return left; }
    public AstNode getRight() { return right; }
    public String getOperator() { return operator; }
}

public class NumberNode extends AstNode {
    private final double value;
    
    public NumberNode(double value) { this.value = value; }
    
    @Override
    public void accept(AstVisitor visitor) {
        visitor.visit(this);
    }
    
    public double getValue() { return value; }
}

AST Visitor – interpreter wzorców

public interface AstVisitor {
    void visit(BinaryOperationNode node);
    void visit(NumberNode node);
}

// Calculator visitor - oblicza wartość wyrażenia
public class CalculatorVisitor implements AstVisitor {
    private double result = 0;
    
    @Override
    public void visit(BinaryOperationNode node) {
        // Recursive descent - odwiedzamy dzieci
        node.getLeft().accept(this);
        double leftValue = result;
        
        node.getRight().accept(this);
        double rightValue = result;
        
        // Wykonujemy operację
        switch (node.getOperator()) {
            case "+": result = leftValue + rightValue; break;
            case "-": result = leftValue - rightValue; break;
            case "*": result = leftValue * rightValue; break;
            case "/": 
                if (rightValue == 0) throw new ArithmeticException("Division by zero");
                result = leftValue / rightValue; 
                break;
            default: throw new UnsupportedOperationException("Unknown operator: " + node.getOperator());
        }
    }
    
    @Override
    public void visit(NumberNode node) {
        result = node.getValue();
    }
    
    public double getResult() { return result; }
}

// Pretty printer visitor - generuje czytelną reprezentację
public class PrettyPrintVisitor implements AstVisitor {
    private final StringBuilder output = new StringBuilder();
    
    @Override
    public void visit(BinaryOperationNode node) {
        output.append("(");
        node.getLeft().accept(this);
        output.append(" ").append(node.getOperator()).append(" ");
        node.getRight().accept(this);
        output.append(")");
    }
    
    @Override
    public void visit(NumberNode node) {
        output.append(node.getValue());
    }
    
    public String getOutput() { return output.toString(); }
}

Praktyczne użycie – system zarządzania dokumentami

public class DocumentManager {
    private final List documents = new ArrayList<>();
    
    public void addDocument(DocumentElement document) {
        documents.add(document);
    }
    
    public void processDocuments(DocumentVisitor visitor) {
        for (DocumentElement document : documents) {
            document.accept(visitor);
        }
    }
    
    public static void main(String[] args) {
        DocumentManager manager = new DocumentManager();
        
        // Dodajemy dokumenty
        manager.addDocument(new PdfDocument("report.pdf", 15));
        manager.addDocument(new WordDocument("proposal.docx", 2500));
        manager.addDocument(new ExcelDocument("budget.xlsx", 3, 450));
        
        // Obliczamy rozmiary
        System.out.println("=== ROZMIARY DOKUMENTÓW ===");
        DocumentSizeVisitor sizeVisitor = new DocumentSizeVisitor();
        manager.processDocuments(sizeVisitor);
        System.out.println("Łączny rozmiar: " + sizeVisitor.getTotalSize() / 1024 + "KB\n");
        
        // Eksportujemy do XML
        System.out.println("=== EKSPORT XML ===");
        XmlExportVisitor xmlVisitor = new XmlExportVisitor();
        manager.processDocuments(xmlVisitor);
        System.out.println(xmlVisitor.getXml());
        
        // Testujemy AST
        System.out.println("\n=== AST CALCULATOR ===");
        // Tworzymy wyrażenie: (3 + 4) * 2
        AstNode expression = new BinaryOperationNode(
            new BinaryOperationNode(
                new NumberNode(3), 
                "+", 
                new NumberNode(4)
            ),
            "*",
            new NumberNode(2)
        );
        
        CalculatorVisitor calculator = new CalculatorVisitor();
        expression.accept(calculator);
        System.out.println("Wynik: " + calculator.getResult());
        
        PrettyPrintVisitor printer = new PrettyPrintVisitor();
        expression.accept(printer);
        System.out.println("Wyrażenie: " + printer.getOutput());
    }
}
Visitor pattern szczególnie przydaje się gdy mamy stabilną hierarchię klas, ale często dodajemy nowe operacje. Jest przeciwieństwem wzorca Strategy – tam mamy stabilne operacje, ale zmieniające się implementacje.

Wyzwania i ograniczenia

Problem z dodawaniem nowych typów elementów

Największą wadą Visitor pattern jest trudność w dodawaniu nowych typów elementów. Każdy nowy typ wymaga modyfikacji wszystkich istniejących visitors.

Uwaga: Jeśli często dodajesz nowe typy elementów, ale rzadko nowe operacje, Visitor pattern może nie być najlepszym wyborem.

Performance considerations

// Benchmark porównujący visitor vs instanceof
public class PerformanceBenchmark {
    private static final int ITERATIONS = 1_000_000;
    
    // Tradycyjne podejście z instanceof
    public static double calculateSizeTraditional(DocumentElement element) {
        if (element instanceof PdfDocument) {
            PdfDocument pdf = (PdfDocument) element;
            return pdf.getPageCount() * 200 + 50;
        } else if (element instanceof WordDocument) {
            WordDocument word = (WordDocument) element;
            return word.getWordCount() / 250 * 100 + 100;
        }
        return 0;
    }
    
    // Visitor approach
    public static double calculateSizeVisitor(DocumentElement element) {
        DocumentSizeVisitor visitor = new DocumentSizeVisitor();
        element.accept(visitor);
        return visitor.getTotalSize() / 1024.0;
    }
    
    // Wyniki testów (przybliżone dla 2018):
    // instanceof: ~15ms dla 1M operacji
    // visitor: ~22ms dla 1M operacji
    // Overhead: ~47% (ale lepsze OOP design)
}

Visitor vs alternatywne rozwiązania

PodejścieZaletyWadyKiedy używać
Visitor PatternSeparacja operacji, łatwe dodawanie nowych operacjiTrudne dodawanie typów, większa złożonośćStabilna hierarchia, częste nowe operacje
instanceof + castingProstsze, szybszeNarusza OOP, trudne w utrzymaniuProste przypadki, jednorazowe operacje
Method dispatchNaturalne OOP, czytelneMieszanie logiki z danymiOperacje związane z domeną obiektu
ReflectionBardzo elastyczneWolne, prone to errorsFrameworki, generic processing

Modern Java alternatives

Java 8+ oferuje alternatywy używając functional programming:

// Functional approach z Java 8
public class FunctionalDocumentProcessor {
    private final Map, Function> processors = new HashMap<>();
    
    public FunctionalDocumentProcessor() {
        processors.put(PdfDocument.class, doc -> {
            PdfDocument pdf = (PdfDocument) doc;
            return "PDF: " + pdf.getName() + " (" + pdf.getPageCount() + " pages)";
        });
        
        processors.put(WordDocument.class, doc -> {
            WordDocument word = (WordDocument) doc;
            return "Word: " + word.getName() + " (" + word.getWordCount() + " words)";
        });
    }
    
    public String process(DocumentElement document) {
        Function processor = processors.get(document.getClass());
        return processor != null ? processor.apply(document) : "Unknown document type";
    }
}
Pułapka: Functional approach może być prostsze dla małych przypadków, ale traci type safety i IDE support w porównaniu do Visitor pattern.

Best practices

1. Używaj generic visitors dla większej elastyczności

public interface GenericVisitor {
    T visit(PdfDocument pdf);
    T visit(WordDocument word);
    T visit(ExcelDocument excel);
}

public class DocumentSizeVisitor implements GenericVisitor {
    @Override
    public Long visit(PdfDocument pdf) {
        return (long) (pdf.getPageCount() * 200 * 1024 + 50 * 1024);
    }
    // ... inne implementacje
}

2. Rozważ composite visitor dla złożonych operacji

public class CompositeDocumentVisitor implements DocumentVisitor {
    private final List visitors = new ArrayList<>();
    
    public void addVisitor(DocumentVisitor visitor) {
        visitors.add(visitor);
    }
    
    @Override
    public void visit(PdfDocument pdf) {
        visitors.forEach(visitor -> visitor.visit(pdf));
    }
    
    // ... pozostałe metody
}

Kiedy używać Visitor pattern zamiast prostego instanceof?

Visitor pattern gdy masz stabilną hierarchię klas ale często dodajesz nowe operacje. instanceof dla prostych, jednorazowych operacji lub gdy performance jest krytyczne.

Czy Visitor pattern narusza enkapsulację?

Częściowo tak – visitor musi mieć dostęp do internal state elementów. Można to ograniczyć przez gettery lub package-protected metody, ale trade-off między enkapsulacją a separacją concerns zawsze istnieje.

Co to jest double dispatch i dlaczego jest ważny?

Double dispatch to mechanizm wyboru metody na podstawie typów dwóch obiektów (element i visitor). Java ma tylko single dispatch, więc Visitor pattern symuluje double dispatch przez metodę accept().

Jak obsługiwać błędy w Visitor pattern?

Możesz użyć checked exceptions w interfejsie visitor, Optional return types, lub visitor z error handling state. Najlepiej jest jednolite podejście w całej hierarchii.

Czy Visitor działa z dziedziczeniem?

Tak, ale wymaga ostrożności. Podklasy muszą override metodę accept() i wywołać odpowiednią metodę visit(). Alternatywnie można użyć refleksji do automatycznego dispatching.

Performance overhead Visitor pattern?

Około 30-50% overhead vs instanceof w 2018. Dwa virtual method calls vs jeden instanceof + cast. JVM optymalizacje mogą to poprawić w hot spots.

Czy można kombinować Visitor z innymi wzorcami?

Tak! Visitor + Composite dla hierarchii obiektów, Visitor + Strategy dla różnych algorytmów, Visitor + Factory dla tworzenia visitors. Bardzo elastyczny wzorzec.

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Zaimplementuj system analizy kodu źródłowego używając Visitor pattern:

  1. Stwórz hierarchię AST nodes: ClassNode, MethodNode, VariableNode
  2. Zaimplementuj visitors: LineCounterVisitor, ComplexityAnalyzerVisitor, CodeFormatterVisitor
  3. Dodaj obsługę błędów i logging
  4. Porównaj performance z prostym instanceof approach
  5. Napisz testy jednostkowe dla wszystkich visitors

Bonus: Dodaj generic visitor interface i composite visitor dla łączenia operacji.

Czy już używałeś Visitor pattern w swoich projektach? Jakie były największe wyzwania i korzyści? Podziel się doświadczeniami w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry