O padrão do visitante visit
/accept
construções são um mal necessário devido à semântica das linguagens C-like (C #, Java, etc.). O objetivo do padrão de visitante é usar o despacho duplo para rotear sua chamada como você esperaria da leitura do código.
Normalmente, quando o padrão de visitante é usado, uma hierarquia de objeto está envolvida em que todos os nós são derivados de um Node
tipo base , denominado doravante Node
. Instintivamente, escreveríamos assim:
Node root = GetTreeRoot();
new MyVisitor().visit(root);
Aqui está o problema. Se nossa MyVisitor
classe foi definida da seguinte forma:
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
Se, em tempo de execução, independentemente do tipo realroot
, nossa chamada iria para a sobrecarga visit(Node node)
. Isso seria verdadeiro para todas as variáveis declaradas do tipo Node
. Por que é isso? Porque Java e outras linguagens semelhantes a C consideram apenas o tipo estático , ou o tipo como a variável é declarada, do parâmetro ao decidir qual sobrecarga chamar. Java não dá o passo extra para perguntar, para cada chamada de método, em tempo de execução, "Ok, qual é o tipo dinâmico de root
? Ah, entendo. É a TrainNode
. Vamos ver se há algum método MyVisitor
que aceita um parâmetro do tipoTrainNode
... ". O compilador, em tempo de compilação, determina qual é o método que será chamado. (Se o Java realmente inspecionasse os tipos dinâmicos dos argumentos, o desempenho seria péssimo.)
Java nos dá uma ferramenta para levar em consideração o tipo de tempo de execução (ou seja, dinâmico) de um objeto quando um método é chamado - envio de método virtual . Quando chamamos um método virtual, a chamada realmente vai para uma tabela na memória que consiste em ponteiros de função. Cada tipo possui uma mesa. Se um método específico for substituído por uma classe, a entrada da tabela de funções dessa classe conterá o endereço da função substituída. Se a classe não sobrescrever um método, ela conterá um ponteiro para a implementação da classe base. Isso ainda incorre em uma sobrecarga de desempenho (cada chamada de método basicamente desreferencia dois ponteiros: um apontando para a tabela de funções do tipo e outro da própria função), mas ainda é mais rápido do que inspecionar os tipos de parâmetros.
O objetivo do padrão de visitante é realizar o despacho duplo - não apenas o tipo de destino da chamada é considerado ( MyVisitor
por meio de métodos virtuais), mas também o tipo do parâmetro (que tipo Node
estamos olhando)? O padrão Visitor nos permite fazer isso pela combinação visit
/ accept
.
Mudando nossa linha para esta:
root.accept(new MyVisitor());
Podemos obter o que queremos: via envio de método virtual, inserimos a chamada aceita () correta conforme implementada pela subclasse - em nosso exemplo com TrainElement
, inseriremos TrainElement
a implementação de accept()
:
class TrainNode extends Node implements IVisitable {
void accept(IVisitor v) {
v.visit(this);
}
}
O que faz o know compilador neste momento, dentro do âmbito da TrainNode
's accept
? Sabe que o tipo estático de this
é aTrainNode
. Este é um fragmento adicional importante de informação que o compilador não estava ciente no escopo de nosso chamador: lá, tudo o que ele sabia root
era que era um Node
. Agora, o compilador sabe que this
( root
) não é apenas um Node
, mas na verdade é um TrainNode
. Em conseqüência, a única linha encontrada dentro de accept()
:, v.visit(this)
significa algo totalmente diferente. O compilador agora procurará por uma sobrecarga de visit()
que leva a TrainNode
. Se não conseguir encontrar um, ele irá compilar a chamada para uma sobrecarga que leva umNode
. Se nenhum dos dois existir, você obterá um erro de compilação (a menos que haja uma sobrecarga object
). A execução entrará, portanto, no que pretendíamos o tempo todo: MyVisitor
a implementação de visit(TrainNode e)
. Nenhum molde foi necessário e, mais importante, nenhuma reflexão foi necessária. Portanto, a sobrecarga desse mecanismo é bastante baixa: ele consiste apenas em referências de ponteiro e nada mais.
Você está certo em sua pergunta - podemos usar um gesso e obter o comportamento correto. No entanto, muitas vezes, nem sabemos que tipo de nó é. Considere o caso da seguinte hierarquia:
abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }
E estávamos escrevendo um compilador simples que analisa um arquivo de origem e produz uma hierarquia de objetos que está em conformidade com a especificação acima. Se estivéssemos escrevendo um intérprete para a hierarquia implementada como Visitante:
class Interpreter implements IVisitor<int> {
int visit(AdditionNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left + right;
}
int visit(MultiplicationNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left * right;
}
int visit(LiteralNode n) {
return n.value;
}
}
O casting não nos levaria muito longe, pois não conhecemos os tipos de left
ou right
nos visit()
métodos. Nosso analisador provavelmente também retornaria um objeto do tipo Node
que também apontasse para a raiz da hierarquia, portanto, também não podemos lançar isso com segurança. Portanto, nosso intérprete simples pode ser semelhante a:
Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);
O padrão de visitante nos permite fazer algo muito poderoso: dada uma hierarquia de objeto, ele nos permite criar operações modulares que operam sobre a hierarquia sem a necessidade de colocar o código na própria classe da hierarquia. O padrão de visitante é amplamente usado, por exemplo, na construção do compilador. Dada a árvore de sintaxe de um programa específico, muitos visitantes são escritos para operar nessa árvore: verificação de tipo, otimizações, emissão de código de máquina são geralmente implementados como visitantes diferentes. No caso do visitante de otimização, ele pode até gerar uma nova árvore de sintaxe dada a árvore de entrada.
Isso tem suas desvantagens, é claro: se adicionarmos um novo tipo à hierarquia, precisamos também adicionar um visit()
método para esse novo tipo na IVisitor
interface e criar implementações stub (ou completas) em todos os nossos visitantes. Também precisamos adicionar o accept()
método, pelas razões descritas acima. Se desempenho não significa muito para você, existem soluções para escrever visitantes sem precisar do accept()
, mas normalmente envolvem reflexão e, portanto, podem incorrer em uma grande sobrecarga.