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 — это добавление операций без изменения классов!