cv.cm

Programación · Patrones de diseño

Patrón Visitor explicado con ejemplos

6 min de lectura

Respuesta corta

Visitor es un patrón de diseño de comportamiento que permite añadir nuevas operaciones a un grupo de clases relacionadas sin cambiar esas clases. Cada operación se escribe como un objeto visitante con un método por cada tipo de elemento, y cada elemento tiene un método accept(visitor) que llama al método del visitante correspondiente. Esta técnica, llamada doble despacho, elige el código que se ejecuta según el tipo del elemento y el tipo del visitante. Los compiladores, los linters y los transformadores de código usan visitantes para recorrer árboles de sintaxis.

Intención y el problema que resuelve

Intención: representar una operación que se 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. Es uno de los patrones de comportamiento del libro de la Banda de los Cuatro (Gang of Four, GoF).

Supón que un programa de dibujo tiene tres clases de figuras: Circle, Rectangle y Group. Con el tiempo necesitas muchas operaciones sobre ellas: calcular el área total, exportar a SVG, exportar a JSON, contar las figuras, comprobar solapamientos. Si cada operación se convierte en un método de cada clase de figura, las clases de figuras crecen con código que no tiene nada que ver con ser una figura, y cada operación nueva las toca todas.

Con Visitor, cada operación se convierte en una clase (un visitante de área, un visitante de SVG) que contiene el código para cada tipo de figura. Las figuras solo necesitan un método genérico, accept. Añadir una operación significa escribir un visitante nuevo; las clases de figuras quedan sin cambios.

Estructura y participantes

RolResponsabilidad
VisitanteInterfaz con un método visit por cada tipo de elemento concreto, como visitCircle y visitRectangle
Visitante concretoImplementa una operación para todos los tipos de elemento y puede acumular un resultado
ElementoInterfaz con un método accept(visitor)
Elemento concretoImplementa accept llamando al método visit de su propio tipo y pasándose a sí mismo
Estructura de objetosUna colección o árbol de elementos, como un documento o un árbol de sintaxis, que los visitantes recorren

Por qué hace falta accept: en TypeScript, Java y C#, una llamada a un método elige su implementación según el tipo del objeto sobre el que se llama (despacho simple). La llamada a accept despacha según el tipo del elemento, y la llamada dentro de ella, como visitor.visitCircle(this), despacha según el tipo del visitante. Juntas forman el doble despacho.

Implementación en TypeScript

La interfaz del visitante es genérica en su tipo de retorno, de modo que un visitante puede calcular números y otro puede producir cadenas.

interface ShapeVisitor<R> {
  visitCircle(c: Circle): R;
  visitRectangle(r: Rectangle): R;
  visitGroup(g: Group): R;
}
interface Shape {
  accept<R>(visitor: ShapeVisitor<R>): R;
}

class Circle implements Shape {
  constructor(readonly radius: number) {}
  accept<R>(v: ShapeVisitor<R>) { return v.visitCircle(this); }
}
class Rectangle implements Shape {
  constructor(readonly width: number, readonly height: number) {}
  accept<R>(v: ShapeVisitor<R>) { return v.visitRectangle(this); }
}
class Group implements Shape {
  constructor(readonly children: Shape[]) {}
  accept<R>(v: ShapeVisitor<R>) { return v.visitGroup(this); }
}

class AreaVisitor implements ShapeVisitor<number> {
  visitCircle(c: Circle) { return Math.PI * c.radius ** 2; }
  visitRectangle(r: Rectangle) { return r.width * r.height; }
  visitGroup(g: Group): number {
    return g.children.reduce((sum, child) => sum + child.accept(this), 0);
  }
}

class SvgVisitor implements ShapeVisitor<string> {
  visitCircle(c: Circle) { return '<circle r="' + c.radius + '"/>'; }
  visitRectangle(r: Rectangle) { return '<rect width="' + r.width + '" height="' + r.height + '"/>'; }
  visitGroup(g: Group): string { return "<g>" + g.children.map((c) => c.accept(this)).join("") + "</g>"; }
}

const drawing = new Group([new Circle(1), new Rectangle(2, 3), new Group([new Rectangle(1, 1)])]);
console.log(drawing.accept(new AreaVisitor()).toFixed(2)); // 10.14
console.log(drawing.accept(new SvgVisitor()));
// <g><circle r="1"/><rect width="2" height="3"/><g><rect width="1" height="1"/></g></g>

Una tercera operación, como contar figuras, sería una clase nueva que implemente ShapeVisitor<number>. Ninguna de las clases de figuras cambiaría.

La alternativa en TypeScript: tipos unión y switch

En TypeScript, una unión discriminada con un switch exhaustivo da muchos de los mismos beneficios con menos código. El compilador informa de un error si falta un caso, lo que sustituye la seguridad que ofrece la interfaz del visitante:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number }
  | { kind: "group"; children: Shape[] };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "rect": return s.width * s.height;
    case "group": return s.children.reduce((sum, c) => sum + area(c), 0);
    default: {
      const unreachable: never = s; // compile error if a kind is not handled
      return unreachable;
    }
  }
}

console.log(area({ kind: "group", children: [{ kind: "rect", width: 2, height: 3 }] })); // 6

Cada operación nueva es una función nueva. Este estilo encaja con datos simples, como JSON o árboles de sintaxis, y es lo que usan muchas bases de código de TypeScript en lugar de visitantes basados en clases.

Cuándo usarlo y cuándo no

  • Úsalo cuando tienes un conjunto estable de clases de elementos y necesitas muchas operaciones no relacionadas sobre ellas, como evaluación, impresión, comprobación de tipos y optimización en un árbol de sintaxis.
  • Úsalo para mantener junto el código de las operaciones: toda la exportación a SVG vive en un visitante, no repartida en muchas clases.
  • Úsalo cuando un visitante necesita acumular estado mientras recorre una estructura, como una tabla de símbolos o un total acumulado.
  • No lo uses cuando los tipos de elemento cambian a menudo; cada cambio repercute en todos los visitantes.
  • No lo uses cuando los elementos son pocos y las operaciones simples. Los métodos en las clases, o una función con un switch, son más fáciles de leer.
  • Ten en cuenta que los visitantes suelen necesitar acceso a los datos de los elementos, lo que puede empujarte a hacer públicos los campos.

Usos en el mundo real

  • Los plugins de Babel son visitantes: un objeto con métodos que llevan el nombre de tipos de nodo, como Identifier o CallExpression, a los que @babel/traverse llama mientras recorre el árbol de sintaxis.
  • Las reglas de ESLint devuelven un objeto de métodos visitantes indexados por tipo de nodo, y ESLint los llama durante el recorrido.
  • Las clases ast.NodeVisitor y ast.NodeTransformer de Python llaman a métodos visit_ con el nombre de la clase del nodo, como visit_Name.
  • java.nio.file.FileVisitor de Java, usado con Files.walkFileTree, recibe callbacks por cada directorio y archivo de un árbol de archivos.

Patrones relacionados

PatrónCómo se relaciona con Visitor
CompositeLos visitantes se aplican a menudo a estructuras Composite; el método visit de un grupo visita a sus hijos, como en el ejemplo.
InterpreterUn intérprete evalúa un árbol de sintaxis; un visitante puede implementar esa evaluación, además de otras operaciones sobre el mismo árbol.
IteratorUn iterador recorre los elementos; un visitante define la operación sobre cada elemento. Pueden combinarse.
StrategyUna estrategia varía un algoritmo para un contexto; un visitante agrupa una operación para muchos tipos de elemento.

Preguntas frecuentes

¿Qué es el doble despacho?

Doble despacho significa que el código que se ejecuta depende de los tipos de dos objetos. En Visitor, element.accept(visitor) selecciona el tipo del elemento, y visitor.visitX(this) dentro de ella selecciona el tipo del visitante.

¿Cuándo no debería usar el patrón Visitor?

Evítalo cuando se añaden nuevos tipos de elemento a menudo, porque cada tipo nuevo requiere cambios en todos los visitantes. También añade una ceremonia que no compensa para unas pocas operaciones simples.

¿Es útil Visitor en TypeScript?

Es útil para estructuras de objetos basadas en clases. Para datos simples, una unión discriminada con un switch exhaustivo suele lograr el mismo resultado con menos código y comprobación de tipos completa.

¿Por qué Babel y ESLint usan visitantes?

Ambos trabajan sobre árboles de sintaxis con muchos tipos de nodo y admiten muchos plugins o reglas independientes. Los visitantes permiten que cada plugin o regla maneje solo los tipos de nodo que le interesan, mientras la herramienta se encarga del recorrido.

Todos los tutoriales de programación