I modelli visit
/ accept
costrutti del visitatore sono un male necessario a causa della semantica dei linguaggi C # (C #, Java, ecc.). L'obiettivo del pattern visitatore è utilizzare il doppio invio per instradare la chiamata come ci si aspetterebbe dalla lettura del codice.
Normalmente quando viene utilizzato il pattern del visitatore, viene coinvolta una gerarchia di oggetti in cui tutti i nodi derivano da un Node
tipo di base , denominato d'ora in poi Node
. Istintivamente, lo scriveremmo così:
Node root = GetTreeRoot();
new MyVisitor().visit(root);
Qui sta il problema. Se la nostra MyVisitor
classe fosse definita come la seguente:
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
Se, in fase di esecuzione, indipendentemente dal tipo effettivoroot
, la nostra chiamata andasse in overload visit(Node node)
. Questo sarebbe vero per tutte le variabili dichiarate di tipo Node
. Perchè è questo? Perché Java e altri linguaggi simili a C considerano solo il tipo statico , o il tipo con cui è dichiarata la variabile, del parametro quando si decide quale sovraccarico chiamare. Java non fa il passo in più per chiedere, per ogni chiamata di metodo, in fase di esecuzione, "Ok, qual è il tipo dinamico di root
? Oh, capisco. È un TrainNode
. Vediamo se c'è qualche metodo in MyVisitor
cui accetta un parametro di tipoTrainNode
... ". Il compilatore, in fase di compilazione, determina qual è il metodo che verrà chiamato. (Se Java effettivamente ispezionasse i tipi dinamici degli argomenti, le prestazioni sarebbero piuttosto terribili).
Java ci fornisce uno strumento per prendere in considerazione il tipo di runtime (cioè dinamico) di un oggetto quando viene chiamato un metodo: invio del metodo virtuale . Quando chiamiamo un metodo virtuale, la chiamata va effettivamente a una tabella in memoria che consiste di puntatori a funzione. Ogni tipo ha una tabella. Se un particolare metodo viene sovrascritto da una classe, la voce della tabella delle funzioni di quella classe conterrà l'indirizzo della funzione sovrascritta. Se la classe non sovrascrive un metodo, conterrà un puntatore all'implementazione della classe base. Ciò comporta ancora un sovraccarico delle prestazioni (ogni chiamata al metodo fondamentalmente dereferenzia due puntatori: uno che punta alla tabella delle funzioni del tipo e un altro alla funzione stessa), ma è ancora più veloce di dover ispezionare i tipi di parametro.
L'obiettivo del pattern del visitatore è realizzare un doppio invio : non viene considerato solo il tipo di target della chiamata ( MyVisitor
tramite metodi virtuali), ma anche il tipo di parametro (che tipo di target Node
stiamo guardando)? Il pattern Visitor ci consente di farlo tramite la combinazione visit
/ accept
.
Modificando la nostra linea in questo:
root.accept(new MyVisitor());
Possiamo ottenere ciò che vogliamo: tramite l'invio del metodo virtuale, inseriamo la chiamata accept () corretta come implementata dalla sottoclasse - nel nostro esempio con TrainElement
, entreremo TrainElement
nell'implementazione di accept()
:
class TrainNode extends Node implements IVisitable {
void accept(IVisitor v) {
v.visit(this);
}
}
Cosa sa il compilatore a questo punto, nell'ambito di TrainNode
s accept
? Sa che il tipo statico di this
è aTrainNode
. Questo è un importante frammento di informazioni aggiuntive di cui il compilatore non era a conoscenza nell'ambito del nostro chiamante: lì, tutto ciò che sapeva root
era che si trattava di un file Node
. Ora il compilatore sa che this
( root
) non è solo un Node
, ma in realtà è un file TrainNode
. Di conseguenza, l'una linea trovato all'interno accept()
: v.visit(this)
, significa qualcosa di completamente diverso. Il compilatore ora cercherà un sovraccarico visit()
che richiede un file TrainNode
. Se non riesce a trovarne uno, compilerà la chiamata a un overload che richiede un fileNode
. Se nessuno dei due esiste, riceverai un errore di compilazione (a meno che tu non abbia un sovraccarico che tenga object
). L'esecuzione entrerà così in ciò che avevamo inteso fin dall'inizio: MyVisitor
l'implementazione di visit(TrainNode e)
. Non erano necessari calchi e, soprattutto, non era necessaria alcuna riflessione. Pertanto, il sovraccarico di questo meccanismo è piuttosto basso: consiste solo di riferimenti a puntatori e nient'altro.
Hai ragione nella tua domanda: possiamo usare un cast e ottenere il comportamento corretto. Tuttavia, spesso, non sappiamo nemmeno che tipo sia Node. Prendi il caso della seguente gerarchia:
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 stavamo scrivendo un semplice compilatore che analizza un file sorgente e produce una gerarchia di oggetti conforme alla specifica sopra. Se stessimo scrivendo un interprete per la gerarchia implementata come Visitatore:
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;
}
}
Il casting non ci porterebbe molto lontano, dal momento che non conosciamo i tipi di left
o right
nei visit()
metodi. Il nostro parser molto probabilmente restituirebbe anche un oggetto di tipo Node
che punta alla radice della gerarchia, quindi non possiamo nemmeno eseguire il cast in sicurezza. Quindi il nostro semplice interprete può assomigliare a:
Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);
Il pattern del visitatore ci permette di fare qualcosa di molto potente: data una gerarchia di oggetti, ci permette di creare operazioni modulari che operano sulla gerarchia senza dover richiedere di inserire il codice nella classe della gerarchia stessa. Il pattern del visitatore è ampiamente utilizzato, ad esempio, nella costruzione del compilatore. Dato l'albero della sintassi di un particolare programma, vengono scritti molti visitatori che operano su quell'albero: il controllo del tipo, le ottimizzazioni, l'emissione del codice macchina sono tutti normalmente implementati come visitatori diversi. Nel caso del visitatore di ottimizzazione, può anche produrre un nuovo albero di sintassi dato l'albero di input.
Ha i suoi svantaggi, ovviamente: se aggiungiamo un nuovo tipo nella gerarchia, dobbiamo anche aggiungere un visit()
metodo per quel nuovo tipo IVisitor
nell'interfaccia e creare implementazioni stub (o complete) in tutti i nostri visitatori. Dobbiamo anche aggiungere anche il accept()
metodo, per i motivi sopra descritti. Se le prestazioni non significano molto per te, ci sono soluzioni per scrivere ai visitatori senza bisogno di accept()
, ma normalmente implicano una riflessione e quindi possono incorrere in un sovraccarico abbastanza grande.