Patrón Visitante en Python

El patrón visitante es un patrón de diseño de comportamiento que te permite agregar nuevas operaciones a clases existentes sin modificar su estructura. En pocas palabras, el patrón visitante separa los algoritmos de la estructura de objetos sobre la que operan.

Idea central

Imagina que vas al hospital para un chequeo médico: tú (el visitado) permaneces quieto, mientras diferentes médicos (visitantes) se acercan a ti para examinarte. Cada médico se especializa en su área, pero todos examinan al mismo tú.

En programación, el patrón visitante funciona de manera similar:

  • El elemento visitadoPermanece estable y sin cambios
  • El visitantePuede agregar nuevas operaciones de forma flexible
  • El elemento acepta la visita del visitante, permitiendo que el visitante realice operaciones sobre sí mismo.

Por qué se necesita el patrón visitante

Limitaciones del enfoque tradicional

Supongamos que tenemos un sistema de gráficos que contiene varias formas:

Ejemplo

class Circle:
    def __init__(self, radius):
        self.radius = radius
   
    def area(self):
        return 3.14 * self.radius * self.radius
   
    def perimeter(self):
        return 2 * 3.14 * self.radius

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
   
    def area(self):
        return self.width * self.height
   
    def perimeter(self):
        return 2 * (self.width + self.height)

Aquí viene el problema: si queremos agregar nuevas funcionalidades, como calcular el centro de gravedad de las figuras, exportar a formato SVG, etc., tendríamos que modificar cada clase de forma. Esto viola el principio abierto/cerrado (abierto a la extensión, cerrado a la modificación).

Ventajas del patrón visitante

El patrón visitante resuelve este problema de las siguientes maneras:

  • Fácil de agregar nuevas operaciones: solo necesitas crear una nueva clase de visitante, sin modificar las clases de elementos existentes.
  • Gestión centralizada de operaciones relacionadas: organiza las operaciones relacionadas en un mismo visitante.
  • Estructura de elementos estable: las clases de elementos no necesitan modificarse con frecuencia.

Implementación del patrón visitante

Estructura básica

Entendamos la implementación del patrón visitante con un ejemplo concreto:

Ejemplo

from abc import ABC, abstractmethod

# 1. Definir la interfaz de elementos
class ShapeElement(ABC):
    @abstractmethod
    def accept(self, visitor):
        pass

# 2. Definir la interfaz de visitante
class ShapeVisitor(ABC):
    @abstractmethod
    def visit_circle(self, circle):
        pass
   
    @abstractmethod
    def visit_rectangle(self, rectangle):
        pass

# 3. Clases de elementos concretos
class Circle(ShapeElement):
    def __init__(self, radius):
        self.radius = radius
   
    def accept(self, visitor):
        visitor.visit_circle(self)

class Rectangle(ShapeElement):
    def __init__(self, width, height):
        self.width = width
        self.height = height
   
    def accept(self, visitor):
        visitor.visit_rectangle(self)

# 4. Clases de visitantes concretos
class AreaCalculator(ShapeVisitor):
    def visit_circle(self, circle):
        area = 3.14 * circle.radius * circle.radius
        print(f"El área del círculo: {area:.2f}")
        return area
   
    def visit_rectangle(self, rectangle):
        area = rectangle.width * rectangle.height
        print(f"El área del rectángulo: {area:.2f}")
        return area

class PerimeterCalculator(ShapeVisitor):
    def visit_circle(self, circle):
        perimeter = 2 * 3.14 * circle.radius
        print(f"El perímetro del círculo: {perimeter:.2f}")
        return perimeter
   
    def visit_rectangle(self, rectangle):
        perimeter = 2 * (rectangle.width + rectangle.height)
        print(f"El perímetro del rectángulo: {perimeter:.2f}")
        return perimeter

Ejemplo de uso

Ejemplo

# Crear objetos de figuras
shapes = [
    Circle(5),
    Rectangle(4, 6),
    Circle(3)
]

# Crear visitante
area_calculator = AreaCalculator()
perimeter_calculator = PerimeterCalculator()

print("=== Calcular área ===")
for shape in shapes:
    shape.accept(area_calculator)

print("\n"=== Calcular perímetro ===")
for shape in shapes:
    shape.accept(perimeter_calculator)

Resultado de salida:

=== 计算面积 ===
圆的面积: 78.50
矩形的面积: 24.00
圆的面积: 28.26

=== 计算周长 ===
圆的周长: 31.40
矩形的周长: 20.00
圆的周长: 18.84

Componentes principales del patrón visitante

1. Visitor (interfaz de visitante)

Define los métodos de acceso para cada clase de elemento concreto.

Ejemplo

class ShapeVisitor(ABC):
    def visit_circle(self, circle): pass
    def visit_rectangle(self, rectangle): pass
    def visit_triangle(self, triangle): pass  # Se puede ampliar fácilmente

2. ConcreteVisitor (visitante concreto)

Implementa las operaciones definidas en la interfaz del visitante.

Ejemplo

class ExportVisitor(ShapeVisitor):
    def visit_circle(self, circle):
        return f'<circle r="{circle.radius}" />'
   
    def visit_rectangle(self, rectangle):
        return f'<rect width="{rectangle.width}" height="{rectangle.height}" />'

3. Element (interfaz de elemento)

Define el método que acepta al visitante.

Ejemplo

class ShapeElement(ABC):
    @abstractmethod
    def accept(self, visitor):
        pass

4. ConcreteElement (elemento concreto)

Implementa la interfaz de elemento, llamando al método correspondiente del visitante en el método accept.

Ejemplo

class Circle(ShapeElement):
    def accept(self, visitor):
        visitor.visit_circle(self)  # Aquí ocurre el doble despacho

Ejemplo de aplicación avanzada

Sistema complejo de procesamiento de documentos

Veamos un ejemplo más práctico: manejar diferentes tipos de elementos de documentos.

Ejemplo

from abc import ABC, abstractmethod

# Interfaz de elemento de documento
class DocumentElement(ABC):
    @abstractmethod
    def accept(self, visitor):
        pass

# Elemento concreto de documento
class Paragraph(DocumentElement):
    def __init__(self, text):
        self.text = text
   
    def accept(self, visitor):
        return visitor.visit_paragraph(self)

class Heading(DocumentElement):
    def __init__(self, text, level):
        self.text = text
        self.level = level
   
    def accept(self, visitor):
        return visitor.visit_heading(self)

class List(DocumentElement):
    def __init__(self, items):
        self.items = items
   
    def accept(self, visitor):
        return visitor.visit_list(self)

# Interfaz de visitante
class DocumentVisitor(ABC):
    @abstractmethod
    def visit_paragraph(self, paragraph): pass
   
    @abstractmethod
    def visit_heading(self, heading): pass
   
    @abstractmethod
    def visit_list(self, list_element): pass

# Visitante concreto: Exportación HTML
class HTMLExportVisitor(DocumentVisitor):
    def visit_paragraph(self, paragraph):
        return f"<p>{paragraph.text}</p>"
   
    def visit_heading(self, heading):
        return f"<h{heading.level}>{heading.text}</h{heading.level}>"
   
    def visit_list(self, list_element):
        items = "".join(f"<li>{item}</li>" for item in list_element.items)
        return f"<ul>{items}</ul>"

# Visitante concreto: Conteo de palabras
class WordCountVisitor(DocumentVisitor):
    def __init__(self):
        self.total_words = 0
   
    def visit_paragraph(self, paragraph):
        words = len(paragraph.text.split())
        self.total_words += words
        return words
   
    def visit_heading(self, heading):
        words = len(heading.text.split())
        self.total_words += words
        return words
   
    def visit_list(self, list_element):
        words = sum(len(item.split()) for item in list_element.items)
        self.total_words += words
        return words

# Ejemplo de uso
document = [
    Heading("Tutorial de Python", 1),
    Paragraph("Python es un lenguaje de programación potente."),
    List(["Elemento de lista 1", "Elemento de lista 2", "Elemento de lista 3"]),
    Paragraph("Aprender Python es muy interesante.")
]

# Exportación HTML
html_visitor = HTMLExportVisitor()
print("=== Exportación HTML ===")
for element in document:
    print(element.accept(html_visitor))

# Conteo de palabras
word_visitor = WordCountVisitor()
print("\n"=== Conteo de palabras ===")
for element in document:
    words = element.accept(word_visitor)
    print(f"{element.__class__.__name__}: {words} palabras")

print(f"Total de palabras: {word_visitor.total_words}")

Ventajas y desventajas del patrón visitante

Ventajas

  1. Principio abierto/cerrado: fácil de agregar nuevas operaciones sin modificar las clases existentes.
  2. Principio de responsabilidad única: concentra el comportamiento relacionado en una clase de visitante.
  3. Flexibilidad: puedes elegir diferentes visitantes en tiempo de ejecución.
  4. Separación de datos: separa los algoritmos de la estructura de datos.

Desventajas

  1. Difícil de cambiar la interfaz de elementos: agregar una nueva clase de elemento requiere modificar todos los visitantes.
  2. Puede romper la encapsulación: el visitante puede necesitar acceder a miembros privados del elemento.
  3. Complejidad: puede resultar demasiado complejo para estructuras de datos simples.

Escenarios de aplicación

Escenarios adecuados para usar el patrón visitante

  1. Estructura de objetos estable: se necesitan definir múltiples operaciones sobre una estructura de objetos relativamente estable.
  2. Operaciones que cambian con frecuencia: se necesitan agregar nuevas operaciones o algoritmos con frecuencia.
  3. Operaciones relacionadas centralizadas: se desea organizar las operaciones relacionadas juntas.
  4. Estructura de objetos compleja: la estructura de objetos contiene muchos tipos diferentes de objetos.

Casos de aplicación prácticos

  • Compiladores: diversos análisis sobre el árbol sintáctico (verificación de tipos, optimización de código, etc.)
  • Procesamiento de documentos: diferentes tratamientos de los elementos del documento (exportación, estadísticas, formato, etc.)
  • Sistemas GUI: diferentes operaciones sobre componentes de interfaz (renderizado, manejo de eventos, etc.)
  • Desarrollo de juegos: diferentes tratamientos de los objetos del juego (detección de colisiones, IA, etc.)

Mejores prácticas y consideraciones

1. Uso de doble despacho

El núcleo del patrón visitante es el doble despacho (double dispatch):

  • Primer despacho: el elemento acepta al visitante.
  • Segundo despacho: el visitante visita el elemento concreto.

Ejemplo

# En el elemento
def accept(self, visitor):
    visitor.visit_circle(self)  # Aquí se determina el método de acceso específico

2. Manejo del estado del visitante

Si el visitante necesita mantener un estado:

Ejemplo

class StatisticsVisitor(ShapeVisitor):
    def __init__(self):
        self.total_area = 0
        self.shape_count = 0
   
    def visit_circle(self, circle):
        area = 3.14 * circle.radius * circle.radius
        self.total_area += area
        self.shape_count += 1
   
    def visit_rectangle(self, rectangle):
        area = rectangle.width * rectangle.height
        self.total_area += area
        self.shape_count += 1
   
    def get_statistics(self):
        return {
            'total_area': self.total_area,
            'shape_count': self.shape_count,
            'average_area': self.total_area / self.shape_count if self.shape_count > 0 else 0
        }

3. Manejo de excepciones durante la visita

Ejemplo

class SafeVisitor(ShapeVisitor):
    def visit_circle(self, circle):
        try:
            # Ejecutar la operación
            pass
        except Exception as e:
            print(f"Error al procesar el círculo: {e}")
   
    def visit_rectangle(self, rectangle):
        try:
            # Ejecutar la operación
            pass
        except Exception as e:
            print(f"Error al procesar el rectángulo: {e}")

Resumen

El patrón visitante es un potente patrón de diseño que proporciona una gran extensibilidad al separar los algoritmos de la estructura de datos. Aunque tiene cierta complejidad, en escenarios adecuados puede mejorar notablemente la mantenibilidad y extensibilidad del código.

Puntos clave:

  • El patrón Visitor es adecuado para escenarios donde la estructura de objetos es estable pero las operaciones cambian con frecuencia
  • Se implementa el comportamiento polimórfico mediante el mecanismo de doble despacho
  • Es fácil añadir nuevas operaciones, pero difícil añadir nuevos tipos de elementos
  • En proyectos reales, hay que sopesar su complejidad y los beneficios que aporta
Otras extensiones