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

  1. El problema en PideYa: operaciones que no caben en la carta
  2. El obstáculo técnico: Java elige métodos con un solo tipo
  3. Double dispatch, paso a paso
  4. Intención y estructura del patrón
  5. Implementación Java completa
  6. Los dos ejes: qué abarata Visitor y qué encarece
  7. La alternativa moderna: pattern matching de Java 21
  8. Cuándo usarlo y cuándo no
  9. Relación con otros patrones
  10. Errores comunes
  11. 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: Plato acumula 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 instanceof desde fuera— dispersa por el código los mismos if (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:

  1. El cliente llama a nodo.aceptar(visitante) con nodo de tipo estático ComponenteCarta.
  2. Primer despacho (dinámico, por el nodo): la JVM elige el aceptar de la clase real — el de Plato, digamos. Dentro de ese método, ya no hay duda: this es un Plato, con tipo estático Plato.
  3. Ese aceptar hace la segunda llamada: visitante.visitar(this). Como el tipo estático de this es Plato, el compilador liga la sobrecarga visitar(Plato). Segundo despacho (dinámico, por el visitante): la JVM elige la implementación de la clase real del visitante — ExportadorJson.visitar(Plato).
  4. 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 instanceof sin 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 aceptar puede 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 aceptar en la clase base (visitante.visitar(this) con this de tipo estático ComponenteCarta) no compila o liga mal la sobrecarga; cada clase concreta debe tener su propio aceptar, aunque parezcan idénticos — no son duplicación, son el mecanismo.
  • Visitantes reutilizados sin reiniciar: CalculadoraAlergenos acumula; pasarla por dos cartas seguidas mezcla resultados. Un visitante con estado es de usar-una-vez (o dale un reiniciar() explícito).
  • Recorrido duplicado o ausente: si SeccionCarta.aceptar recorre hijos y el visitante también los recorre en visitar(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

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados