Lapa Knowledge Base
Поведенческие паттерны

Visitor

Паттерн Visitor

Visitor — это поведенческий паттерн проектирования, который позволяет добавлять новые операции к объектам, не изменяя их классов.

Проблема

Представьте, что у вас есть иерархия классов, представляющих различные элементы документа: текст, изображения, таблицы и так далее. Вам нужно добавить возможность экспорта документа в различные форматы: PDF, HTML, XML.

Если вы добавите методы экспорта прямо в классы элементов, это нарушит принцип единственной ответственности. Кроме того, каждый раз при добавлении нового формата экспорта вам придется изменять все классы элементов.

Решение

Паттерн Visitor предлагает поместить новую функциональность в отдельный класс-посетитель, а не добавлять её в существующие классы.


Структура

Visitor
├── visitConcreteElementA(element: ConcreteElementA): void
└── visitConcreteElementB(element: ConcreteElementB): void

ConcreteVisitor1
├── visitConcreteElementA(element: ConcreteElementA): void
└── visitConcreteElementB(element: ConcreteElementB): void

ConcreteVisitor2
├── visitConcreteElementA(element: ConcreteElementA): void
└── visitConcreteElementB(element: ConcreteElementB): void

Element
├── accept(visitor: Visitor): void
└── operation(): void

ConcreteElementA
├── accept(visitor: Visitor): void
├── operation(): void
└── operationA(): void

ConcreteElementB
├── accept(visitor: Visitor): void
├── operation(): void
└── operationB(): void

Пример реализации

JavaScript

// Интерфейс посетителя
class Visitor {
    visitTextElement(element) {
        throw new Error('Метод visitTextElement должен быть переопределен');
    }
    
    visitImageElement(element) {
        throw new Error('Метод visitImageElement должен быть переопределен');
    }
    
    visitTableElement(element) {
        throw new Error('Метод visitTableElement должен быть переопределен');
    }
}

// Интерфейс элемента
class Element {
    accept(visitor) {
        throw new Error('Метод accept должен быть переопределен');
    }
}

// Конкретные элементы документа
class TextElement extends Element {
    constructor(content, fontSize = 12, fontFamily = 'Arial') {
        super();
        this.content = content;
        this.fontSize = fontSize;
        this.fontFamily = fontFamily;
    }
    
    accept(visitor) {
        visitor.visitTextElement(this);
    }
    
    getContent() {
        return this.content;
    }
    
    getFontSize() {
        return this.fontSize;
    }
    
    getFontFamily() {
        return this.fontFamily;
    }
}

class ImageElement extends Element {
    constructor(src, width, height, alt = '') {
        super();
        this.src = src;
        this.width = width;
        this.height = height;
        this.alt = alt;
    }
    
    accept(visitor) {
        visitor.visitImageElement(this);
    }
    
    getSrc() {
        return this.src;
    }
    
    getWidth() {
        return this.width;
    }
    
    getHeight() {
        return this.height;
    }
    
    getAlt() {
        return this.alt;
    }
}

class TableElement extends Element {
    constructor(headers, rows) {
        super();
        this.headers = headers;
        this.rows = rows;
    }
    
    accept(visitor) {
        visitor.visitTableElement(this);
    }
    
    getHeaders() {
        return this.headers;
    }
    
    getRows() {
        return this.rows;
    }
    
    getRowCount() {
        return this.rows.length;
    }
    
    getColumnCount() {
        return this.headers.length;
    }
}

// Конкретные посетители
class HTMLExportVisitor extends Visitor {
    constructor() {
        super();
        this.html = '';
    }
    
    visitTextElement(element) {
        this.html += `<p style="font-size: ${element.getFontSize()}px; font-family: ${element.getFontFamily()};">${element.getContent()}</p>\n`;
    }
    
    visitImageElement(element) {
        this.html += `<img src="${element.getSrc()}" width="${element.getWidth()}" height="${element.getHeight()}" alt="${element.getAlt()}">\n`;
    }
    
    visitTableElement(element) {
        this.html += '<table border="1">\n';
        
        // Заголовки
        this.html += '<thead><tr>';
        element.getHeaders().forEach(header => {
            this.html += `<th>${header}</th>`;
        });
        this.html += '</tr></thead>\n';
        
        // Строки
        this.html += '<tbody>';
        element.getRows().forEach(row => {
            this.html += '<tr>';
            row.forEach(cell => {
                this.html += `<td>${cell}</td>`;
            });
            this.html += '</tr>';
        });
        this.html += '</tbody>\n';
        
        this.html += '</table>\n';
    }
    
    getHTML() {
        return `<html><body>\n${this.html}</body></html>`;
    }
}

class PDFExportVisitor extends Visitor {
    constructor() {
        super();
        this.pdfContent = [];
    }
    
    visitTextElement(element) {
        this.pdfContent.push(`PDF Text: "${element.getContent()}" (${element.getFontSize()}px, ${element.getFontFamily()})`);
    }
    
    visitImageElement(element) {
        this.pdfContent.push(`PDF Image: ${element.getSrc()} (${element.getWidth()}x${element.getHeight()})`);
    }
    
    visitTableElement(element) {
        this.pdfContent.push(`PDF Table: ${element.getRowCount()} rows x ${element.getColumnCount()} columns`);
        this.pdfContent.push(`Headers: ${element.getHeaders().join(', ')}`);
    }
    
    getPDFContent() {
        return this.pdfContent.join('\n');
    }
}

class XMLExportVisitor extends Visitor {
    constructor() {
        super();
        this.xml = '';
    }
    
    visitTextElement(element) {
        this.xml += `<text fontSize="${element.getFontSize()}" fontFamily="${element.getFontFamily()}">${element.getContent()}</text>\n`;
    }
    
    visitImageElement(element) {
        this.xml += `<image src="${element.getSrc()}" width="${element.getWidth()}" height="${element.getHeight()}" alt="${element.getAlt()}"/>\n`;
    }
    
    visitTableElement(element) {
        this.xml += '<table>\n';
        this.xml += '<headers>\n';
        element.getHeaders().forEach(header => {
            this.xml += `<header>${header}</header>\n`;
        });
        this.xml += '</headers>\n';
        
        this.xml += '<rows>\n';
        element.getRows().forEach(row => {
            this.xml += '<row>\n';
            row.forEach(cell => {
                this.xml += `<cell>${cell}</cell>\n`;
            });
            this.xml += '</row>\n';
        });
        this.xml += '</rows>\n';
        
        this.xml += '</table>\n';
    }
    
    getXML() {
        return `<document>\n${this.xml}</document>`;
    }
}

// Документ
class Document {
    constructor() {
        this.elements = [];
    }
    
    addElement(element) {
        this.elements.push(element);
    }
    
    accept(visitor) {
        this.elements.forEach(element => {
            element.accept(visitor);
        });
    }
}

// Использование
const document = new Document();

// Добавляем элементы в документ
document.addElement(new TextElement('Заголовок документа', 18, 'Times New Roman'));
document.addElement(new TextElement('Это основной текст документа.', 12, 'Arial'));
document.addElement(new ImageElement('logo.png', 200, 100, 'Логотип компании'));
document.addElement(new TableElement(
    ['Имя', 'Возраст', 'Город'],
    [
        ['Иван', '25', 'Москва'],
        ['Мария', '30', 'Санкт-Петербург'],
        ['Петр', '28', 'Казань']
    ]
));

console.log('=== Экспорт документа ===');

// HTML экспорт
console.log('\n--- HTML экспорт ---');
const htmlVisitor = new HTMLExportVisitor();
document.accept(htmlVisitor);
console.log(htmlVisitor.getHTML());

// PDF экспорт
console.log('\n--- PDF экспорт ---');
const pdfVisitor = new PDFExportVisitor();
document.accept(pdfVisitor);
console.log(pdfVisitor.getPDFContent());

// XML экспорт
console.log('\n--- XML экспорт ---');
const xmlVisitor = new XMLExportVisitor();
document.accept(xmlVisitor);
console.log(xmlVisitor.getXML());

TypeScript с интерфейсами

// Интерфейс посетителя
interface Visitor {
    visitCircle(circle: Circle): void;
    visitRectangle(rectangle: Rectangle): void;
    visitTriangle(triangle: Triangle): void;
}

// Интерфейс элемента
interface Shape {
    accept(visitor: Visitor): void;
    getArea(): number;
    getPerimeter(): number;
}

// Конкретные фигуры
class Circle implements Shape {
    constructor(private radius: number) {}
    
    accept(visitor: Visitor): void {
        visitor.visitCircle(this);
    }
    
    getArea(): number {
        return Math.PI * this.radius * this.radius;
    }
    
    getPerimeter(): number {
        return 2 * Math.PI * this.radius;
    }
    
    getRadius(): number {
        return this.radius;
    }
}

class Rectangle implements Shape {
    constructor(private width: number, private height: number) {}
    
    accept(visitor: Visitor): void {
        visitor.visitRectangle(this);
    }
    
    getArea(): number {
        return this.width * this.height;
    }
    
    getPerimeter(): number {
        return 2 * (this.width + this.height);
    }
    
    getWidth(): number {
        return this.width;
    }
    
    getHeight(): number {
        return this.height;
    }
}

class Triangle implements Shape {
    constructor(private side1: number, private side2: number, private side3: number) {}
    
    accept(visitor: Visitor): void {
        visitor.visitTriangle(this);
    }
    
    getArea(): number {
        const s = (this.side1 + this.side2 + this.side3) / 2;
        return Math.sqrt(s * (s - this.side1) * (s - this.side2) * (s - this.side3));
    }
    
    getPerimeter(): number {
        return this.side1 + this.side2 + this.side3;
    }
    
    getSides(): [number, number, number] {
        return [this.side1, this.side2, this.side3];
    }
}

// Конкретные посетители
class AreaCalculatorVisitor implements Visitor {
    private totalArea: number = 0;
    
    visitCircle(circle: Circle): void {
        const area = circle.getArea();
        this.totalArea += area;
        console.log(`Площадь круга (r=${circle.getRadius()}): ${area.toFixed(2)}`);
    }
    
    visitRectangle(rectangle: Rectangle): void {
        const area = rectangle.getArea();
        this.totalArea += area;
        console.log(`Площадь прямоугольника (${rectangle.getWidth()}x${rectangle.getHeight()}): ${area.toFixed(2)}`);
    }
    
    visitTriangle(triangle: Triangle): void {
        const area = triangle.getArea();
        this.totalArea += area;
        const [a, b, c] = triangle.getSides();
        console.log(`Площадь треугольника (${a}, ${b}, ${c}): ${area.toFixed(2)}`);
    }
    
    getTotalArea(): number {
        return this.totalArea;
    }
    
    reset(): void {
        this.totalArea = 0;
    }
}

class PerimeterCalculatorVisitor implements Visitor {
    private totalPerimeter: number = 0;
    
    visitCircle(circle: Circle): void {
        const perimeter = circle.getPerimeter();
        this.totalPerimeter += perimeter;
        console.log(`Периметр круга (r=${circle.getRadius()}): ${perimeter.toFixed(2)}`);
    }
    
    visitRectangle(rectangle: Rectangle): void {
        const perimeter = rectangle.getPerimeter();
        this.totalPerimeter += perimeter;
        console.log(`Периметр прямоугольника (${rectangle.getWidth()}x${rectangle.getHeight()}): ${perimeter.toFixed(2)}`);
    }
    
    visitTriangle(triangle: Triangle): void {
        const perimeter = triangle.getPerimeter();
        this.totalPerimeter += perimeter;
        const [a, b, c] = triangle.getSides();
        console.log(`Периметр треугольника (${a}, ${b}, ${c}): ${perimeter.toFixed(2)}`);
    }
    
    getTotalPerimeter(): number {
        return this.totalPerimeter;
    }
    
    reset(): void {
        this.totalPerimeter = 0;
    }
}

class ShapeInfoVisitor implements Visitor {
    private shapesInfo: string[] = [];
    
    visitCircle(circle: Circle): void {
        this.shapesInfo.push(`Круг: радиус=${circle.getRadius()}, площадь=${circle.getArea().toFixed(2)}, периметр=${circle.getPerimeter().toFixed(2)}`);
    }
    
    visitRectangle(rectangle: Rectangle): void {
        this.shapesInfo.push(`Прямоугольник: ${rectangle.getWidth()}x${rectangle.getHeight()}, площадь=${rectangle.getArea().toFixed(2)}, периметр=${rectangle.getPerimeter().toFixed(2)}`);
    }
    
    visitTriangle(triangle: Triangle): void {
        const [a, b, c] = triangle.getSides();
        this.shapesInfo.push(`Треугольник: стороны=${a},${b},${c}, площадь=${triangle.getArea().toFixed(2)}, периметр=${triangle.getPerimeter().toFixed(2)}`);
    }
    
    getShapesInfo(): string[] {
        return this.shapesInfo;
    }
    
    reset(): void {
        this.shapesInfo = [];
    }
}

// Использование
const shapes: Shape[] = [
    new Circle(5),
    new Rectangle(4, 6),
    new Triangle(3, 4, 5),
    new Circle(3),
    new Rectangle(2, 8)
];

console.log('=== Анализ фигур ===');

// Расчет общей площади
console.log('\n--- Расчет площади ---');
const areaVisitor = new AreaCalculatorVisitor();
shapes.forEach(shape => shape.accept(areaVisitor));
console.log(`Общая площадь: ${areaVisitor.getTotalArea().toFixed(2)}`);

// Расчет общего периметра
console.log('\n--- Расчет периметра ---');
const perimeterVisitor = new PerimeterCalculatorVisitor();
shapes.forEach(shape => shape.accept(perimeterVisitor));
console.log(`Общий периметр: ${perimeterVisitor.getTotalPerimeter().toFixed(2)}`);

// Информация о фигурах
console.log('\n--- Информация о фигурах ---');
const infoVisitor = new ShapeInfoVisitor();
shapes.forEach(shape => shape.accept(infoVisitor));
infoVisitor.getShapesInfo().forEach(info => console.log(info));

Когда использовать

Используйте Visitor, когда:

  • Нужно выполнять операции над объектами разных классов
  • Операции не должны изменять классы объектов
  • Нужно добавлять новые операции без изменения существующих классов
  • Структура объектов стабильна, но операции могут изменяться

Преимущества

  • Легко добавлять новые операции
  • Принцип открытости/закрытости
  • Группирует связанные операции в одном классе
  • Может накапливать состояние при обходе структуры

Недостатки

  • Сложно добавлять новые классы элементов
  • Нарушает инкапсуляцию
  • Может быть сложно поддерживать при изменении иерархии классов

Отличия от других паттернов

  • Iterator — обходит элементы коллекции
  • Command — инкапсулирует запросы как объекты
  • Strategy — выбирает алгоритм во время выполнения

Реальный пример

// Visitor для системы анализа кода
class CodeAnalysisVisitor {
    constructor() {
        this.metrics = {
            totalLines: 0,
            totalFunctions: 0,
            totalClasses: 0,
            complexity: 0,
            issues: []
        };
    }
    
    visitClass(classNode) {
        this.metrics.totalClasses++;
        this.metrics.totalLines += classNode.getLineCount();
        
        if (classNode.getMethodCount() > 10) {
            this.metrics.issues.push(`Класс ${classNode.getName()} имеет слишком много методов`);
        }
        
        // Анализируем методы класса
        classNode.getMethods().forEach(method => {
            method.accept(this);
        });
    }
    
    visitMethod(methodNode) {
        this.metrics.totalFunctions++;
        this.metrics.totalLines += methodNode.getLineCount();
        this.metrics.complexity += methodNode.getComplexity();
        
        if (methodNode.getComplexity() > 10) {
            this.metrics.issues.push(`Метод ${methodNode.getName()} слишком сложный`);
        }
        
        if (methodNode.getLineCount() > 50) {
            this.metrics.issues.push(`Метод ${methodNode.getName()} слишком длинный`);
        }
    }
    
    visitVariable(variableNode) {
        if (variableNode.getName().length > 20) {
            this.metrics.issues.push(`Переменная ${variableNode.getName()} имеет слишком длинное имя`);
        }
    }
    
    getMetrics() {
        return this.metrics;
    }
}

// Использование
const codeAnalyzer = new CodeAnalysisVisitor();

// Анализируем код
codeFiles.forEach(file => {
    file.getClasses().forEach(classNode => {
        classNode.accept(codeAnalyzer);
    });
});

const metrics = codeAnalyzer.getMetrics();
console.log('Метрики кода:', metrics);

Visitor — это добавление операций без изменения классов!

Copyright © 2026