StackPractices
advanced Por Mathias Paulenko

Patrón Visitor: Operaciones Extensibles en TypeScript

Separa algoritmos de los objetos sobre los que operan, permitiendo añadir nuevas operaciones sin modificar las clases de elementos existentes

Temas: design

El patrón Visitor separa un algoritmo de la estructura de objetos sobre la que opera. Mover las operaciones a clases visitor te permite apilar comportamientos nuevos sobre una jerarquía de clases sin tocar las clases de elementos. Es un buen trato cuando los tipos de nodo son estables pero las operaciones no paran de multiplicarse.

Esta página aplica el patrón en TypeScript sobre un AST de expresiones, el caso de uso canónico. Para la versión GoF independiente del lenguaje en Python, Java y JavaScript, consulta el Patrón Visitor.

Cuándo usar esto

  • Muchas operaciones no relacionadas se ejecutan sobre la misma estructura de objetos: evaluación, impresión, serialización, linting.
  • La estructura de objetos casi no cambia pero las operaciones cambian a menudo. Para algoritmos intercambiables sobre un único objeto, consulta el Patrón Strategy.
  • Hay que acumular estado a medida que el recorrido avanza por los elementos: totales, buffers de salida, pilas de ámbito. Para la estructura de árbol en sí, consulta el Patrón Composite.
  • Añadir comportamiento como métodos contaminaría las clases de elementos con responsabilidades que no les corresponden.

Problema

Pensemos en un AST (Abstract Syntax Tree) de un lenguaje pequeño: necesita formateo, linting, optimización y generación de código. Añadir cada operación como método en cada clase de nodo contamina la jerarquía, y cada operación nueva obliga a editarlas todas.

Solución

// visitor/ASTVisitor.ts
interface ASTNode {
  accept(visitor: ASTVisitor): void;
}

interface ASTVisitor {
  visitNumber(node: NumberNode): void;
  visitBinaryOp(node: BinaryOpNode): void;
  visitVariable(node: VariableNode): void;
}

class NumberNode implements ASTNode {
  constructor(public value: number) {}
  accept(visitor: ASTVisitor): void { visitor.visitNumber(this); }
}

class BinaryOpNode implements ASTNode {
  constructor(
    public operator: string,
    public left: ASTNode,
    public right: ASTNode
  ) {}
  accept(visitor: ASTVisitor): void { visitor.visitBinaryOp(this); }
}

class VariableNode implements ASTNode {
  constructor(public name: string) {}
  accept(visitor: ASTVisitor): void { visitor.visitVariable(this); }
}

// Visitor evaluador: acumula resultados en una pila interna
class Evaluator implements ASTVisitor {
  private stack: number[] = [];

  constructor(private variables: Record<string, number>) {}

  visitNumber(node: NumberNode): void {
    this.stack.push(node.value);
  }

  visitVariable(node: VariableNode): void {
    this.stack.push(this.variables[node.name] ?? 0);
  }

  visitBinaryOp(node: BinaryOpNode): void {
    node.left.accept(this);
    node.right.accept(this);

    const right = this.stack.pop()!;
    const left = this.stack.pop()!;

    switch (node.operator) {
      case '+': this.stack.push(left + right); break;
      case '-': this.stack.push(left - right); break;
      case '*': this.stack.push(left * right); break;
      case '/': this.stack.push(left / right); break;
    }
  }

  getResult(): number {
    return this.stack[this.stack.length - 1];
  }
}

// Visitor impresor: una operación nueva sin tocar los nodos
class Printer implements ASTVisitor {
  private output = '';

  visitNumber(node: NumberNode): void {
    this.output += node.value;
  }

  visitVariable(node: VariableNode): void {
    this.output += node.name;
  }

  visitBinaryOp(node: BinaryOpNode): void {
    this.output += '(';
    node.left.accept(this);
    this.output += ` ${node.operator} `;
    node.right.accept(this);
    this.output += ')';
  }

  getOutput(): string {
    return this.output;
  }
}

// Uso
const ast = new BinaryOpNode(
  '+',
  new NumberNode(2),
  new BinaryOpNode('*', new VariableNode('x'), new NumberNode(3))
);

const evaluator = new Evaluator({ x: 4 });
ast.accept(evaluator);
console.log(evaluator.getResult()); // 14

const printer = new Printer();
ast.accept(printer);
console.log(printer.getOutput()); // (2 + (x * 3))

Variantes

Visitor serializador

Misma estructura, operación distinta. JSONSerializer convierte el árbol en un objeto plano — fíjate en que guarda this.result entre llamadas en lugar de una pila, así que captura el subárbol izquierdo antes de que el recorrido por la derecha lo sobrescriba:

// visitor/Serializer.ts
class JSONSerializer implements ASTVisitor {
  private result: unknown = null;

  visitNumber(node: NumberNode): void {
    this.result = { type: 'number', value: node.value };
  }

  visitVariable(node: VariableNode): void {
    this.result = { type: 'variable', name: node.name };
  }

  visitBinaryOp(node: BinaryOpNode): void {
    node.left.accept(this);
    const left = this.result;
    node.right.accept(this);
    const right = this.result;

    this.result = {
      type: 'binaryOp',
      operator: node.operator,
      left,
      right,
    };
  }

  serialize(): string {
    return JSON.stringify(this.result);
  }
}

Cuándo basta una unión discriminada

TypeScript te da una alternativa más ligera: un tipo unión más un switch exhaustivo. El compilador sigue garantizando que todos los casos están cubiertos, pero sin el boilerplate de accept():

type Expr =
  | { kind: 'number'; value: number }
  | { kind: 'variable'; name: string }
  | { kind: 'binaryOp'; op: string; left: Expr; right: Expr };

function evaluate(node: Expr, vars: Record<string, number>): number {
  switch (node.kind) {
    case 'number': return node.value;
    case 'variable': return vars[node.name] ?? 0;
    case 'binaryOp': {
      const l = evaluate(node.left, vars);
      const r = evaluate(node.right, vars);
      switch (node.op) {
        case '+': return l + r;
        case '-': return l - r;
        case '*': return l * r;
        case '/': return l / r;
        default: throw new Error(`unknown operator ${node.op}`);
      }
    }
  }
}

Elige la unión cuando las operaciones son pocas y puras. Elige Visitor cuando cada operación es un objeto cohesionado con estado propio — un type checker que mantiene una pila de ámbitos, un generador de código con un buffer de salida — o cuando quieres publicar las operaciones como unidades separadas y testeables.

Cómo funciona

  1. Visitor declara un método visitX para cada tipo de elemento.
  2. Concrete visitor implementa la operación para cada elemento.
  3. Element declara un método accept que recibe un visitor.
  4. Object structure pasa cada elemento al visitor vía accept.
  5. Double dispatch enruta la llamada al método correcto según ambos tipos.

El recorrido de ast.accept(evaluator):

  • BinaryOpNode.accept llama a evaluator.visitBinaryOp(this) — el primer dispatch elige el nodo concreto.
  • visitBinaryOp vuelve a despachar sobre node.left y node.right, que llaman de vuelta al visitor — el segundo dispatch elige la operación.
  • Los resultados se acumulan en la pila del evaluador, y getResult() lee el valor raíz cuando el recorrido se desenrolla.
sequenceDiagram diagram: participant C as Cliente

Consideraciones de producción

  • Añadir un tipo de nodo rompe todos los visitors, a propósito. En un lenguaje tipado el compilador no te deja publicar un visitor al que le falte un visitX; esa es la seguridad que compraste con el boilerplate.
  • El estado vive en el visitor, no en los nodos. Evaluator lleva una pila; JSONSerializer lleva this.result. Resetea el estado entre recorridos o crea un visitor nuevo por ejecución, o un segundo accept() verá restos del primero.
  • La recursión de accept() es tan profunda como el árbol. En AST muy profundos llegarás al límite de la pila de llamadas antes de que falle cualquier lógica. En ese escenario conviene que la estructura de objetos dirija el recorrido con una pila explícita en vez de recursión dentro de visitX.
  • Los visitors necesitan internals de los nodos. Evaluator lee node.value, node.operator y los hijos directamente. Mantén esa superficie mínima; ampliarla “para el visitor” acopla cada nodo a cada operación.

Errores comunes

  • Olvidar llamar a accept en los elementos hijos, lo que trunca el recorrido en el primer nivel sin avisar.
  • Leer getResult() antes de que termine el recorrido: la pila todavía contiene valores intermedios.
  • Reutilizar un visitor con estado en dos árboles sin resetearlo.
  • Añadir un tipo de nodo y apoyarse en un visit de fallback: eso anula la exhaustividad que justifica el patrón.
  • Meter Visitor cuando un método polimórfico resuelve lo mismo — con una sola operación, el método vive mejor en el nodo.

Solución de problemas

  • Resultado incorrecto en expresiones anidadas: probablemente apilas o desapilas en el orden equivocado, o lees el resultado antes de visitar los hijos. Visita left y right primero, desapila después.
  • Un tipo de nodo nuevo no hace nada en runtime: o le falta su accept o al visitor le falta el visitX correspondiente. En TypeScript, un método de interfaz sin implementar falla en compilación — si llegó a runtime, algo esquivó los tipos con any.
  • Stack overflow en árboles profundos: la recursión de accept iguala la profundidad del árbol. Mueve el recorrido a una pila explícita en la estructura de objetos, o sube el límite solo como parche.
  • La salida serializada tiene subárboles izquierdo y derecho idénticos: el serializador sobrescribió this.result antes de que lo capturaras. Guarda el resultado izquierdo antes de aceptar el hijo derecho, como hace JSONSerializer.
  • Un visitor necesita estado que el nodo no expone: no amplíes la superficie pública del nodo por una operación. En lugar de eso, pásale a visitX lo que la visita necesita — y si ni así encaja, quizá esa operación no pertenece a un visitor.

Un ejemplo más grande: Visitor para el AST de un compilador

Una estructura más realista añade strings y llamadas a función. Los dos visitors de antes — evaluación e impresión — se escriben igual, solo que con más tipos de nodo:

interface ASTNode {
  accept(visitor: ASTVisitor): void;
}

interface ASTVisitor {
  visitNumber(node: NumberNode): void;
  visitString(node: StringNode): void;
  visitBinaryOp(node: BinaryOpNode): void;
  visitFunctionCall(node: FunctionCallNode): void;
}

class NumberNode implements ASTNode {
  constructor(public value: number) {}
  accept(v: ASTVisitor) { v.visitNumber(this); }
}

class StringNode implements ASTNode {
  constructor(public value: string) {}
  accept(v: ASTVisitor) { v.visitString(this); }
}

class BinaryOpNode implements ASTNode {
  constructor(public op: string, public left: ASTNode, public right: ASTNode) {}
  accept(v: ASTVisitor) { v.visitBinaryOp(this); }
}

class FunctionCallNode implements ASTNode {
  constructor(public name: string, public args: ASTNode[]) {}
  accept(v: ASTVisitor) { v.visitFunctionCall(this); }
}

// Visitor 1: evaluación
class EvaluatorVisitor implements ASTVisitor {
  private stack: unknown[] = [];
  getResult(): unknown { return this.stack[0]; }
  visitNumber(n: NumberNode) { this.stack.push(n.value); }
  visitString(n: StringNode) { this.stack.push(n.value); }
  visitBinaryOp(n: BinaryOpNode) {
    n.left.accept(this); n.right.accept(this);
    const r = this.stack.pop() as number;
    const l = this.stack.pop() as number;
    switch (n.op) {
      case "+": this.stack.push(l + r); break;
      case "-": this.stack.push(l - r); break;
      case "*": this.stack.push(l * r); break;
      case "/": this.stack.push(l / r); break;
    }
  }
  visitFunctionCall(n: FunctionCallNode) {
    const args = n.args.map(a => { a.accept(this); return this.stack.pop(); });
    if (n.name === "sqrt") this.stack.push(Math.sqrt(args[0] as number));
  }
}

// Visitor 2: impresión
class PrettyPrintVisitor implements ASTVisitor {
  private output = "";
  getOutput(): string { return this.output; }
  visitNumber(n: NumberNode) { this.output += n.value; }
  visitString(n: StringNode) { this.output += `"${n.value}"`; }
  visitBinaryOp(n: BinaryOpNode) {
    this.output += "("; n.left.accept(this);
    this.output += ` ${n.op} `; n.right.accept(this);
    this.output += ")";
  }
  visitFunctionCall(n: FunctionCallNode) {
    this.output += `${n.name}(`;
    n.args.forEach((a, i) => { if (i > 0) this.output += ", "; a.accept(this); });
    this.output += ")";
  }
}

// Uso: AST para (3 + 4) * sqrt(16)
const ast = new BinaryOpNode("*",
  new BinaryOpNode("+", new NumberNode(3), new NumberNode(4)),
  new FunctionCallNode("sqrt", [new NumberNode(16)])
);

const evalVisitor = new EvaluatorVisitor();
ast.accept(evalVisitor);
console.log(evalVisitor.getResult()); // 28

const printer = new PrettyPrintVisitor();
ast.accept(printer);
console.log(printer.getOutput()); // ((3 + 4) * sqrt(16))

Dos cosas a notar aquí. El EvaluatorVisitor mezcla tipos en una sola pila — unknown[] — porque visitString apila un string donde visitBinaryOp espera números; un evaluador de producción etiquetaría los resultados o usaría pilas separadas. Y visitFunctionCall tiene sqrt hardcodeado, que es justo la rigidez que el patrón intenta evitar: una implementación real mantiene una tabla de funciones que el visitor consulta por nombre.

Cuándo NO usar Visitor

No uses Visitor cuando la estructura de objetos cambia con frecuencia: cada nuevo tipo de nodo obliga a editar todos los visitors. Úsalo cuando la estructura es estable pero los algoritmos cambian o se multiplican. Para jerarquías inestables, mantén los métodos en las propias clases o usa el switch sobre unión discriminada de la sección de Variantes — conservas la comprobación de exhaustividad y te ahorras la maquinaria de accept().

Ver también

Preguntas frecuentes

¿En qué se diferencia de Strategy?

Strategy varía un algoritmo para un único objeto. Visitor aplica operaciones diferentes a lo largo de toda una estructura de objetos, despachando tanto por el tipo de elemento como por el tipo de visitor.

¿Puedo usar Visitor con el patrón Composite?

Sí, es el emparejamiento habitual. Composite es dueño de la estructura de árbol; Visitor es dueño de las operaciones sobre ella. Los ejemplos de AST de esta página son estructuras Composite con recorridos Visitor.

¿Cuándo preferir una unión discriminada en lugar de Visitor?

Cuando las operaciones son pocas, sin estado y poco probables de crecer. El switch sobre la unión conserva la garantía de exhaustividad en compilación con mucho menos código. Visitor compensa cuando las operaciones llevan estado propio o quieres cada una aislada y testeable.

¿Qué pasa cuando añado un tipo de nodo nuevo?

Todos los visitors concretos necesitan un visitX para el nuevo tipo. TypeScript lo exige en compilación — nada se rompe en silencio —, pero el coste recae sobre todos a la vez; ese intercambio es la esencia del patrón.

¿Deberían los visitors ser sin estado?

Prefiere sin estado cuando la operación lo permita. Evaluator necesita su pila, así que no puede serlo — la solución es disciplina: resetea el estado antes de cada recorrido o instancia un visitor nuevo por árbol.

¿Funciona Visitor sobre estructuras recursivas?

Las estructuras recursivas son su terreno natural. visitBinaryOp llama a accept sobre sus hijos, así que la recursión sale sola. Ojo con los ciclos: el patrón asume un árbol, y un grafo cíclico recorrerá para siempre salvo que el visitor registre los nodos visitados.