La carta Composite de PideYa lleva dos módulos acumulando pretendientes: exportarla a JSON para la app, calcular los alérgenos acumulados de cada sección, auditar precios contra la política de la cadena... Cada operación nueva amenaza con engordar Plato y SeccionCarta con métodos que no son asunto suyo — ¿qué pinta exportarAJson() en una clase de dominio? Visitor invierte el reparto: la jerarquía queda congelada con un único método de entrada (aceptar), y cada operación nueva es una clase visitante aparte, añadible sin tocar un solo nodo. El precio del truco es el más alto del catálogo en complejidad conceptual —un mecanismo llamado double dispatch que veremos paso a paso— y un compromiso muy concreto: la jerarquía debe ser estable, porque cada nodo nuevo rompe todos los visitantes.
Contenido
- El problema en PideYa: operaciones que no caben en la carta
- El obstáculo técnico: Java elige métodos con un solo tipo
- Double dispatch, paso a paso
- Intención y estructura del patrón
- Implementación Java completa
- Los dos ejes: qué abarata Visitor y qué encarece
- La alternativa moderna: pattern matching de Java 21
- Cuándo usarlo y cuándo no
- Relación con otros patrones
- Errores comunes
- Ejercicios y conclusión
El problema en PideYa: operaciones que no caben en la carta
Recordemos la jerarquía del módulo 3: ComponenteCarta con hijas Plato (hoja), SeccionCarta (grupo recursivo) y Menu (composición de platos con precio propio). Las operaciones de dominio (precio, disponibilidad) viven dentro, y ahí están bien. Pero las peticiones que llegan ahora son de otra especie:
- Exportar a JSON para la app móvil (asunto de integración, no de dominio).
- Calcular alérgenos agregados por sección (asunto del equipo de salud alimentaria).
- Auditar precios (¿algún plato por debajo del coste?, ¿menús más caros que la suma de sus partes?) — asunto de negocio-finanzas.
Primer impulso: un método por operación en ComponenteCarta, implementado en cada nodo. Tres operaciones × tres clases = nueve métodos... y los problemas:
- La jerarquía se convierte en cajón de sastre:
Platoacumula responsabilidades de integración, salud y finanzas (SRP pulverizado); cada equipo toca las mismas clases centrales, con conflictos y despliegues cruzados. - Cada operación nueva modifica toda la jerarquía (adiós OCP en el eje de operaciones).
- La alternativa artesanal —recorrer con
instanceofdesde fuera— dispersa por el código los mismosif (x instanceof Plato)... else if (x instanceof SeccionCarta)...que el Iterator vino a erradicar, y el compilador no avisa cuando falta un caso.
Lo que queremos, dicho con precisión: las operaciones fuera de la jerarquía (una clase por operación), pero con despacho seguro por tipo de nodo (que el compilador obligue a cubrir platos, secciones y menús). Ahí hay un obstáculo técnico serio.
El obstáculo técnico: Java elige métodos con un solo tipo
Intentemos la operación externa con sobrecarga:
public class ExportadorJson {
public String exportar(Plato plato) { /* ... */ }
public String exportar(SeccionCarta seccion) { /* ... */ }
public String exportar(Menu menu) { /* ... */ }
}
ComponenteCarta nodo = carta.getHijos().get(0); // tipo estático: ComponenteCarta
exportador.exportar(nodo); // ¡ERROR de compilación!No compila (no hay exportar(ComponenteCarta)), y es el síntoma de una regla profunda: la sobrecarga se resuelve en compilación con el tipo estático del argumento. Java solo despacha dinámicamente por un tipo: el del receptor de la llamada (objeto.metodo() elige la implementación según la clase real de objeto). Eso es single dispatch. Elegir método según dos tipos reales a la vez —¿qué operación? y ¿qué nodo?— es double dispatch, y Java no lo trae de serie. Visitor es, en esencia, el truco para simularlo con dos llamadas de single dispatch.
Double dispatch, paso a paso
El truco completo en cuatro pasos. Léelo dos veces; es el corazón de la lección:
- El cliente llama a
nodo.aceptar(visitante)connodode tipo estáticoComponenteCarta. - Primer despacho (dinámico, por el nodo): la JVM elige el
aceptarde la clase real — el dePlato, digamos. Dentro de ese método, ya no hay duda:thises unPlato, con tipo estáticoPlato. - Ese
aceptarhace la segunda llamada:visitante.visitar(this). Como el tipo estático dethisesPlato, el compilador liga la sobrecargavisitar(Plato). Segundo despacho (dinámico, por el visitante): la JVM elige la implementación de la clase real del visitante —ExportadorJson.visitar(Plato). - Resultado: se ha ejecutado el método que corresponde a la combinación (nodo real, visitante real). Dos llamadas virtuales encadenadas = double dispatch.
sequenceDiagram
participant C as Cliente
participant P as Plato (nodo real)
participant V as ExportadorJson (visitante real)
C->>P: aceptar(visitante)
Note over P: 1er despacho: la JVM eligió<br/>el aceptar() de Plato,<br/>aquí this ES Plato
P->>V: visitar(this)
Note over V: 2º despacho: se ejecuta<br/>ExportadorJson.visitar(Plato)
V-->>C: (operación aplicada al tipo exacto)
La frase para recordarlo: el nodo no hace la operación; el nodo confiesa su tipo llamando a la sobrecarga correcta del visitante. El aceptar de cada nodo es idéntico en apariencia (visitante.visitar(this)) pero cada uno liga una sobrecarga distinta, porque el tipo estático de this es distinto en cada clase.
Intención y estructura del patrón
Intención (GoF): representar una operación a realizar sobre los elementos de una estructura de objetos. Visitor permite definir una nueva operación sin cambiar las clases de los elementos sobre los que opera.
classDiagram
class VisitanteCarta {
<<interface>>
+visitar(plato: Plato)
+visitar(seccion: SeccionCarta)
+visitar(menu: Menu)
}
class ComponenteCarta {
<<abstract>>
+aceptar(v: VisitanteCarta)*
}
class Plato {
+aceptar(v: VisitanteCarta)
}
class SeccionCarta {
+aceptar(v: VisitanteCarta)
}
class Menu {
+aceptar(v: VisitanteCarta)
}
class CalculadoraAlergenos {
-acumulados: Set~Alergeno~
+visitar(plato) +visitar(seccion) +visitar(menu)
}
class ExportadorJson {
+visitar(plato) +visitar(seccion) +visitar(menu)
}
class AuditorPrecios {
-incidencias: List~String~
+visitar(plato) +visitar(seccion) +visitar(menu)
}
ComponenteCarta <|-- Plato
ComponenteCarta <|-- SeccionCarta
ComponenteCarta <|-- Menu
VisitanteCarta <|.. ExportadorJson
VisitanteCarta <|.. CalculadoraAlergenos
VisitanteCarta <|.. AuditorPrecios
ComponenteCarta ..> VisitanteCarta : aceptar(v) llama a v.visitar(this)
| Rol GoF | En PideYa |
|---|---|
Visitor (una sobrecarga visitar por tipo de nodo) |
VisitanteCarta |
| ConcreteVisitor (una clase por operación) | ExportadorJson, CalculadoraAlergenos, AuditorPrecios |
Element (declara aceptar) |
ComponenteCarta |
| ConcreteElement | Plato, SeccionCarta, Menu |
| ObjectStructure (donde viven los nodos) | la carta Composite del módulo 3 |
Implementación Java completa
El único cambio en la jerarquía — se hace una vez y se congela. Es la concesión honesta del patrón: para no tocar la jerarquía nunca más, hay que tocarla una vez:
public interface VisitanteCarta {
void visitar(Plato plato);
void visitar(SeccionCarta seccion);
void visitar(Menu menu);
}
public abstract class ComponenteCarta {
// ... todo lo del módulo 3 intacto ...
public abstract void aceptar(VisitanteCarta visitante);
}
public class Plato extends ComponenteCarta {
@Override
public void aceptar(VisitanteCarta visitante) {
visitante.visitar(this); // this es Plato → liga visitar(Plato)
}
}
public class SeccionCarta extends ComponenteCarta {
@Override
public void aceptar(VisitanteCarta visitante) {
visitante.visitar(this); // liga visitar(SeccionCarta)
for (ComponenteCarta hijo : getHijos()) {
hijo.aceptar(visitante); // recorrido del composite: aquí
}
}
}
public class Menu extends ComponenteCarta {
@Override
public void aceptar(VisitanteCarta visitante) {
visitante.visitar(this); // liga visitar(Menu)
// decisión de diseño: el menú NO recorre sus platos internos
// (cada visitante decide en visitar(Menu) si le interesan)
}
}Nota la decisión incrustada: SeccionCarta.aceptar recorre a sus hijos — el recorrido vive en la estructura, y los visitantes reciben los nodos uno a uno sin saber navegar. (Alternativa igual de válida: recorrido en el visitante, o delegado a un Iterator; lo importante es decidirlo una vez y documentarlo.)
Primera operación: los alérgenos. Un visitante con estado: acumula durante el recorrido y se cosecha al final — el modismo típico:
public class CalculadoraAlergenos implements VisitanteCarta {
private final Set<Alergeno> acumulados = EnumSet.noneOf(Alergeno.class);
@Override
public void visitar(Plato plato) {
acumulados.addAll(plato.getAlergenos());
}
@Override
public void visitar(SeccionCarta seccion) {
// nada: los alérgenos están en las hojas; los hijos llegarán solos
}
@Override
public void visitar(Menu menu) {
menu.getPlatos().forEach(p -> acumulados.addAll(p.getAlergenos()));
}
public Set<Alergeno> getResultado() {
return Set.copyOf(acumulados);
}
}Segunda operación: la auditoría de precios — la que enseña por qué las sobrecargas por tipo valen oro: cada tipo de nodo tiene su propia regla:
public class AuditorPrecios implements VisitanteCarta {
private final List<String> incidencias = new ArrayList<>();
@Override
public void visitar(Plato plato) {
if (plato.getPrecio().compareTo(plato.getCoste()) <= 0) {
incidencias.add("Plato bajo coste: " + plato.getNombre());
}
}
@Override
public void visitar(SeccionCarta seccion) {
if (seccion.getHijos().isEmpty()) {
incidencias.add("Sección vacía publicada: " + seccion.getNombre());
}
}
@Override
public void visitar(Menu menu) {
BigDecimal sumaPartes = menu.getPlatos().stream()
.map(Plato::getPrecio).reduce(BigDecimal.ZERO, BigDecimal::add);
if (menu.getPrecio().compareTo(sumaPartes) > 0) {
incidencias.add("Menú más caro que sus partes: " + menu.getNombre());
}
}
public List<String> getIncidencias() { return List.copyOf(incidencias); }
}Uso — y aquí se ve el patrón entero en tres líneas:
AuditorPrecios auditor = new AuditorPrecios();
carta.aceptar(auditor); // un recorrido, despachos exactos
auditor.getIncidencias().forEach(RegistroEventos.INSTANCIA::aviso);La operación nueva de mañana ("contar platos veganos por sección", "generar el PDF de la carta") será una clase nueva que implementa VisitanteCarta — ni Plato, ni SeccionCarta, ni Menu volverán a tocarse. Y si la interfaz del visitante gana un método, el compilador obliga a cada visitante a pronunciarse: cobertura por tipo garantizada, sin instanceof ni casos olvidados.
Los dos ejes: qué abarata Visitor y qué encarece
El patrón es un intercambio de ejes de extensibilidad, y elegirlo bien exige ver la tabla completa:
| Cambio | Sin Visitor (métodos en la jerarquía) | Con Visitor |
|---|---|---|
| Operación nueva | Tocar todas las clases de nodo | Una clase visitante nueva; nodos intactos |
| Tipo de nodo nuevo | Una clase nueva; operaciones existentes intactas (cada una implementada en ella) | Tocar la interfaz visitante y TODOS los visitantes |
Esa segunda fila es el precio famoso del patrón: si mañana la carta gana un nodo Combo, hay que añadir visitar(Combo) a VisitanteCarta y a cada visitante existente (el compilador al menos los señalará todos). De ahí la regla de decisión canónica: Visitor cuando la jerarquía es estable y las operaciones proliferan; métodos en la jerarquía cuando los tipos proliferan y las operaciones son estables. La carta de PideYa cumple el perfil: Plato/SeccionCarta/Menu no cambian desde el módulo 3, mientras los tres equipos hacen cola con operaciones nuevas.
Otros dos costes menores pero reales: el double dispatch desconcierta a quien no lo conoce (el flujo rebota entre clases; documenta el patrón por su nombre), y los visitantes solo ven la API pública de los nodos — si una operación necesita tripas privadas, o abres accesores (debilitando la encapsulación que Memento tanto protegió) o esa operación pertenece dentro.
La alternativa moderna: pattern matching de Java 21
La mención prometida: desde Java 21, el switch con patrones y las jerarquías sealed atacan el mismo problema sin ceremonial:
public sealed interface ComponenteCarta permits Plato, SeccionCarta, Menu { }
public String exportarJson(ComponenteCarta nodo) {
return switch (nodo) {
case Plato p -> "{\"plato\":\"" + p.getNombre() + "\",\"precio\":" + p.getPrecio() + "}";
case SeccionCarta s -> "{\"seccion\":\"" + s.getNombre() + "\",\"hijos\":["
+ s.getHijos().stream().map(this::exportarJson)
.collect(joining(",")) + "]}";
case Menu m -> "{\"menu\":\"" + m.getNombre() + "\",\"precio\":" + m.getPrecio() + "}";
// sin default: al ser sealed, el compilador EXIGE cubrir todos los tipos
};
}Lo crucial es el sealed: la jerarquía declara sus hijas permitidas y el switch sin default obliga a la exhaustividad — la misma garantía "operación nueva sin tocar nodos + cobertura por tipo verificada por el compilador" que Visitor lograba con el double dispatch, ahora con una función y cero métodos aceptar. Si además el nodo nuevo aparece, todos los switches sin default dejan de compilar: el mismo aviso que daban los visitantes. En código Java 21+ con jerarquías selladas, esta es hoy la opción por defecto para operaciones externas; Visitor conserva la ventaja cuando la operación es grande y con estado (una clase la organiza mejor que un switch kilométrico), cuando la jerarquía no puede sellarse (plugins, nodos de terceros), o en bases de código anteriores a Java 21.
Cuándo usarlo y cuándo no
Úsalo cuando:
- Una estructura de objetos estable necesita muchas operaciones no relacionadas entre sí, y meterlas dentro contaminaría las clases (o las firmarían equipos distintos).
- Necesitas despacho por tipo garantizado por el compilador al operar desde fuera (nada de
instanceofsin red). - Las operaciones necesitan acumular estado durante el recorrido (exportadores, auditores, calculadoras).
Evítalo cuando:
- La jerarquía cambia a menudo: cada nodo nuevo rompe todos los visitantes — estás comprando extensibilidad en el eje equivocado.
- Hay una o dos operaciones estables: el ceremonial (interfaz + aceptar en cada nodo) no se amortiza; métodos normales o un switch sellado bastan.
- Trabajas en Java 21+ con jerarquía sellada y las operaciones son funciones simples: el pattern matching da lo mismo con mucho menos.
Relación con otros patrones
- Composite: su pareja histórica — el composite define la estructura, el visitante le añade operaciones. Con Iterator forman el trío estructura/recorrido/operaciones; de hecho el recorrido de
aceptarpuede delegarse en el iterador del módulo anterior. - Interpreter: los AST son el hábitat clásico de Visitor (evaluar, imprimir, optimizar el árbol como visitantes distintos) — lo mencionamos allí.
- Iterator: Iterator entrega elementos uniformes (nuestro
Iterator<Plato>aplanaba); Visitor distingue cada tipo de nodo. Se eligen por esa pregunta: ¿recorrer o despachar? - Command: un visitante con estado que registra acciones por nodo puede generar comandos a ejecutar después.
Errores comunes
- Romper el double dispatch con atajos: un
aceptaren la clase base (visitante.visitar(this)conthisde tipo estáticoComponenteCarta) no compila o liga mal la sobrecarga; cada clase concreta debe tener su propioaceptar, aunque parezcan idénticos — no son duplicación, son el mecanismo. - Visitantes reutilizados sin reiniciar:
CalculadoraAlergenosacumula; pasarla por dos cartas seguidas mezcla resultados. Un visitante con estado es de usar-una-vez (o dale unreiniciar()explícito). - Recorrido duplicado o ausente: si
SeccionCarta.aceptarrecorre hijos y el visitante también los recorre envisitar(SeccionCarta), cada nodo se visita dos veces; si nadie lo hace, ninguno. Decide el dueño del recorrido (estructura o visitante) y sé consecuente en toda la jerarquía. - Abrir las tripas de los nodos para servir a un visitante (getters de estado interno "solo para la exportación"): si la operación necesita lo privado, su sitio es dentro de la clase, no en un visitante.
- Aplicar Visitor a jerarquías volátiles "porque es el patrón elegante": relee la tabla de los dos ejes; con tipos cambiantes, es la elección exactamente opuesta a la correcta.
Ejercicios
Ejercicio 1: el contador de la carta
Implementa EstadisticasCarta implements VisitanteCarta, que en un solo recorrido cuente platos, secciones y menús, y calcule el precio medio de los platos. Escribe también las tres líneas de uso.
Ejercicio 2: seguir el double dispatch con el dedo
Dado ComponenteCarta nodo = new Menu("Menú del día", ...) y VisitanteCarta v = new AuditorPrecios(), la llamada es nodo.aceptar(v). Enumera, en orden: (1) qué método elige la JVM en el primer despacho y con qué criterio; (2) qué sobrecarga liga el compilador dentro de ese método y por qué; (3) qué implementación ejecuta la JVM en el segundo despacho. Señala qué decisión es de compilación y cuál de ejecución.
Ejercicio 3: ¿Visitor o pattern matching?
Para cada escenario, elige Visitor clásico o switch con patrones sobre jerarquía sellada, y justifica: (a) proyecto en Java 17 (sin pattern matching completo sobre switch), carta estable, cinco operaciones previstas; (b) Java 21, jerarquía sellada, una función "profundidad máxima del árbol"; (c) Java 21, pero los nodos de la carta pueden ser aportados por módulos de terceros (jerarquía abierta), y la exportación acumula estado complejo.
Soluciones
Solución 1:
public class EstadisticasCarta implements VisitanteCarta {
private int platos, secciones, menus;
private BigDecimal sumaPrecios = BigDecimal.ZERO;
@Override public void visitar(Plato plato) {
platos++;
sumaPrecios = sumaPrecios.add(plato.getPrecio());
}
@Override public void visitar(SeccionCarta seccion) { secciones++; }
@Override public void visitar(Menu menu) { menus++; }
public String resumen() {
BigDecimal medio = platos == 0 ? BigDecimal.ZERO
: sumaPrecios.divide(BigDecimal.valueOf(platos), 2, RoundingMode.HALF_UP);
return platos + " platos, " + secciones + " secciones, " + menus
+ " menús; precio medio " + medio + " €";
}
}
EstadisticasCarta stats = new EstadisticasCarta();
carta.aceptar(stats);
System.out.println(stats.resumen());Solución 2: (1) primer despacho — la JVM elige Menu.aceptar(VisitanteCarta) según la clase real de nodo (Menu), decisión de ejecución; (2) dentro de ese método, el compilador liga visitar(Menu) porque el tipo estático de this en Menu.aceptar es Menu — decisión de compilación (elección de sobrecarga); (3) segundo despacho — la JVM ejecuta AuditorPrecios.visitar(Menu) según la clase real del visitante, decisión de ejecución. Ese sándwich —dinámica, estática, dinámica— es el double dispatch completo.
Solución 3: (a) Visitor: sin pattern matching exhaustivo disponible, es el único que da cobertura por tipo verificada por compilador, y con cinco operaciones el ceremonial se amortiza; (b) switch con patrones: función pequeña, jerarquía sellada con exhaustividad garantizada — un visitante para esto es liturgia vacía; (c) Visitor: la jerarquía no puede sellarse (el switch pierde la exhaustividad, su gran argumento) y la operación con estado complejo encaja mejor en una clase visitante; además los módulos de terceros pueden traer su aceptar implementado, cosa que un switch central no puede prever.
Conclusión
Visitor cierra el catálogo de comportamiento con su intercambio característico: la carta quedó congelada tras un único aceptar por nodo, y las operaciones —exportar, alérgenos, auditoría, estadísticas— viven cada una en su clase, añadibles sin rozar Plato, SeccionCarta ni Menu, con el compilador garantizando la cobertura por tipo. Entendiste el motor por dentro —dos despachos de single dispatch encadenados que simulan el double dispatch que Java no tiene— y su factura exacta: jerarquía estable o visitantes rotos, más la alternativa moderna del pattern matching sobre jerarquías selladas que en Java 21+ cubre los casos simples con mucho menos ruido.
Y con esto, los once están sobre la mesa. Once patrones de comportamiento en once lecciones: peticiones que viajan por cadenas, se congelan en comandos y se interpretan como lenguajes; recorridos, mediaciones y fotografías; observadores, estados, estrategias, plantillas y visitantes. Demasiados como para elegir de memoria: falta la lección que los pone frente a frente —los pares que todo el mundo confunde, el flowchart de decisión, las combinaciones que funcionan juntas— y que repasa qué quedó instalado en cada rincón de PideYa. Nos vemos en la Comparativa y Elección de Patrones de Comportamiento.
Curso de Patrones de Diseño de Software
Módulo 1: Introducción a los Patrones de Diseño
- ¿Qué son los Patrones de Diseño?
- Historia y Origen de los Patrones de Diseño
- Principios de Diseño: SOLID y Otros Fundamentos
- UML Esencial para Entender Patrones
- Clasificación de los Patrones de Diseño
- Ventajas y Desventajas de Usar Patrones de Diseño
Módulo 2: Patrones Creacionales
- Introducción a los Patrones Creacionales
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparativa y Elección de Patrones Creacionales
Módulo 3: Patrones Estructurales
- Introducción a los Patrones Estructurales
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparativa y Elección de Patrones Estructurales
Módulo 4: Patrones de Comportamiento
- Introducción a los Patrones de Comportamiento
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparativa y Elección de Patrones de Comportamiento
Módulo 5: Aplicación de Patrones de Diseño
- Cómo Seleccionar el Patrón Adecuado
- Ejemplos Prácticos de Uso de Patrones
- Patrones de Diseño en Proyectos Reales
- Refactorización Usando Patrones de Diseño
- Antipatrones: Cuándo los Patrones se Vuelven un Problema
Módulo 6: Patrones de Diseño Avanzados
- Patrones de Diseño en Arquitecturas Modernas
- Patrones de Diseño en Microservicios
- Patrones de Diseño en Sistemas Distribuidos
- Patrones de Concurrencia
- Patrones de Diseño en Desarrollo Ágil
