Qual è il punto del metodo accept () nel pattern Visitor?

Si parla molto di disaccoppiare gli algoritmi dalle classi. Ma una cosa rimane da parte non spiegata.

Usano il visitatore in questo modo

abstract class Expr { public  T accept(Visitor visitor) {visitor.visit(this);} } class ExprVisitor extends Visitor{ public Integer visit(Num num) { return num.value; } public Integer visit(Sum sum) { return sum.getLeft().accept(this) + sum.getRight().accept(this); } public Integer visit(Prod prod) { return prod.getLeft().accept(this) * prod.getRight().accept(this); } 

Invece di chiamare direttamente la visita (elemento), Visitor chiede all’elemento di chiamare il suo metodo di visita. È in contraddizione con l’idea dichiarata di non consapevolezza della class sui visitatori.

PS1 Per favore spiega con le tue parole o indica la spiegazione esatta. Poiché due risposte mi sono riferite a qualcosa di generale e incerto.

PS2 La mia ipotesi: dal momento che getLeft() restituisce l’ Expression base, chiamare visit(getLeft()) comporterà la visit(Expression) , mentre getLeft() chiamata visit(this) risulterà in un’altra, più appropriata, chiamata di visita. Quindi, accept() esegue la conversione del tipo (aka casting).

PS3 Scala Pattern Matching = Visitor Pattern su Steroid mostra quanto è più semplice il pattern Visitor senza il metodo accept. Wikipedia aggiunge a questa affermazione : collegando un documento che mostra “che i metodi accept() non sono necessari quando la riflessione è disponibile, introduce il termine ‘Walkabout’ per la tecnica.”

I costrutti visit / accept del modello di visit sono un male necessario a causa della semantica dei linguaggi C (come C #, Java, ecc.). L’objective del modello di visitatore consiste nell’utilizzare il doppio invio per instradare la chiamata come ci si aspetterebbe dalla lettura del codice.

Normalmente quando viene utilizzato il pattern visitor, viene coinvolta una gerarchia di oggetti in cui tutti i nodes derivano da un tipo di Node base, indicato come Node . Istintivamente, lo scriveremmo in questo modo:

 Node root = GetTreeRoot(); new MyVisitor().visit(root); 

Qui sta il problema. Se la nostra class MyVisitor è stata 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 effettivo di root , la nostra chiamata passerebbe alla visit(Node node) overload visit(Node node) . Questo sarebbe vero per tutte le variabili dichiarate di tipo Node . Perchè è questo? Poiché Java e altri linguaggi simili a C considerano solo il tipo statico o il tipo dichiarato come variabile, del parametro quando si decide quale sovraccarico chiamare. Java non fa il passo in più per chiedere, per ogni chiamata al metodo, in fase di esecuzione, “Ok, qual è il tipo dinamico di root ? Oh, capisco. È un TrainNode . Vediamo se c’è un metodo in MyVisitor che accetta un parametro di tipo TrainNode … “. Il compilatore, in fase di compilazione, determina quale è 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 tenere conto del tipo di runtime (cioè dinamico) di un object quando viene chiamato un metodo – dispacciamento del metodo virtuale . Quando chiamiamo un metodo virtuale, la chiamata passa effettivamente a una tabella in memoria costituita da puntatori di funzione. Ogni tipo ha un tavolo. Se un metodo particolare viene sovrascritto da una class, la voce della tabella di funzione di quella class conterrà l’indirizzo della funzione sostituita. Se la class non sovrascrive un metodo, conterrà un puntatore all’implementazione della class base. Ciò comporta ancora un overhead delle prestazioni (ogni chiamata al metodo sarà fondamentalmente dereferenziata a due puntatori: una che punta alla tabella delle funzioni del tipo e un’altra alla stessa funzione), ma è ancora più veloce di dover ispezionare i tipi di parametri.

L’objective del modello di visitatore è di realizzare un doppio invio : non solo il tipo di target di chiamata considerato ( MyVisitor , tramite metodi virtuali), ma anche il tipo di parametro (che tipo di Node stiamo osservando)? Il pattern Visitor ci consente di farlo con la combinazione visit / accept .

Modificando la nostra linea in questo modo:

 root.accept(new MyVisitor()); 

Possiamo ottenere ciò che vogliamo: tramite la spedizione del metodo virtuale, inseriamo la chiamata accept () corretta implementata dalla sottoclass – nel nostro esempio con TrainElement , entreremo TrainElement di TrainElement di TrainElement accept() :

 class TrainNode extends Node implements IVisitable { void accept(IVisitor v) { v.visit(this); } } 

Cosa sa a questo punto il compilatore, nell’ambito TrainNode di TrainNode ? Sa che il tipo statico di this è un TrainNode . Questo è un importante brandello di informazioni di cui il compilatore non era a conoscenza nel campo del nostro chiamante: lì, tutto ciò che sapeva di root era che si trattava di un Node . Ora il compilatore sa che this ( root ) non è solo un Node , ma in realtà è un TrainNode . Di conseguenza, l’unica riga trovata all’interno di accept() : v.visit(this) , significa qualcos’altro interamente. Il compilatore cercherà ora un overload di visit() che prende un TrainNode . Se non riesce a trovarne uno, compila la chiamata a un sovraccarico che accetta un Node . Se nessuno dei due esiste, si otterrà un errore di compilazione (a meno che non si abbia un sovraccarico che richiede l’ object ). L’esecuzione entrerà quindi in ciò che avevamo sempre inteso: l’implementazione della visit(TrainNode e) . Non sono stati necessari lanci e, cosa più importante, non era necessario alcun riflesso. Quindi, il sovraccarico di questo meccanismo è piuttosto basso: consiste solo di riferimenti puntatore e nient’altro.

Hai ragione nella tua domanda: possiamo usare un cast e ottenere il comportamento corretto. Tuttavia, spesso, non sappiamo nemmeno che tipo di nodo sia. 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 conforms alle specifiche di cui sopra. Se stessimo scrivendo un interprete per la gerarchia implementato come visitatore:

 class Interpreter implements IVisitor { 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 metodi visit() . Molto probabilmente il nostro parser restituirebbe anche un object di tipo Node che puntava alla radice della gerarchia, quindi non possiamo nemmeno lanciarlo 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 modello di visitatore ci consente di fare qualcosa di molto potente: data una gerarchia di oggetti, ci permette di creare operazioni modulari che operano sulla gerarchia senza che sia necessario inserire il codice nella class stessa della gerarchia. Il pattern visitor è ampiamente utilizzato, ad esempio, nella costruzione del compilatore. Dato l’albero della syntax di un particolare programma, vengono scritti molti visitatori che operano su quell’albero: controllo del tipo, ottimizzazioni, emissione del codice macchina sono tutti di solito implementati come visitatori diversi. Nel caso del visitatore dell’ottimizzazione, può anche emettere un nuovo albero di syntax dato l’albero di input.

Ovviamente ha i suoi svantaggi: se aggiungiamo un nuovo tipo nella gerarchia, dobbiamo anche aggiungere un metodo visit() per quel nuovo tipo nell’interfaccia IVisitor e creare implementazioni stub (o complete) in tutti i nostri visitatori . Abbiamo anche bisogno di aggiungere anche il metodo accept() , per le ragioni sopra descritte. Se le prestazioni non significano tanto per te, ci sono soluzioni per scrivere i visitatori senza bisogno accept() , ma normalmente implicano una riflessione e quindi possono comportare un sovraccarico piuttosto ampio.

Naturalmente sarebbe sciocco se quello fosse l’ unico modo in cui Accept è implementato.

Ma non è.

Ad esempio, i visitatori sono davvero molto utili quando si tratta di gerarchie, nel qual caso l’implementazione di un nodo non terminale potrebbe essere qualcosa del genere

 interface IAcceptVisitor { void Accept(IVisit visitor); } class HierarchyNode : IAcceptVisitor { public void Accept(IVisit visitor) { visitor.visit(this); foreach(var n in this.children) n.Accept(visitor); } private IEnumerable children; .... } 

Vedi? Quello che descrivi come stupido è la soluzione per attraversare le gerarchie.

Ecco un articolo molto più lungo e approfondito che mi ha fatto capire il visitatore .

Modifica: per chiarire: il metodo Visit del visitatore contiene la logica da applicare a un nodo. Il metodo Accept del nodo contiene la logica su come navigare verso nodes adiacenti. Il caso in cui si effettua una doppia spedizione è un caso speciale in cui semplicemente non ci sono nodes adiacenti a cui navigare.

Lo scopo del pattern Visitor è quello di assicurare che gli oggetti sappiano quando il visitatore ha finito con loro e se ne sono andati, così le classi possono eseguire qualsiasi pulizia necessaria in seguito. Permette inoltre alle classi di esporre i loro interni “temporaneamente” come parametri “ref” e di sapere che gli interni non saranno più esposti una volta che il visitatore sarà scomparso. Nei casi in cui non è necessaria alcuna pulizia, lo schema del visitatore non è molto utile. Le classi che non fanno nessuna di queste cose potrebbero non beneficiare del pattern del visitatore, ma il codice che viene scritto per utilizzare il pattern del visitatore sarà utilizzabile con classi future che potrebbero richiedere una pulizia dopo l’accesso.

Ad esempio, supponiamo che uno abbia una struttura dati contenente molte stringhe che dovrebbero essere aggiornate atomicamente, ma la class che detiene la struttura dati non sa con precisione quali tipi di aggiornamenti atomici dovrebbero essere eseguiti (ad esempio se un thread vuole sostituire tutte le occorrenze di ” X “, mentre un altro thread vuole sostituire qualsiasi sequenza di cifre con una sequenza numericamente più alta, entrambe le operazioni dei thread dovrebbero avere successo, se ogni thread legge semplicemente una stringa, esegue gli aggiornamenti e la ripete, il secondo thread per riscrivere la sua stringa sovrascriverebbe la prima). Un modo per ottenere ciò sarebbe ottenere che ogni thread acquisisca un lock, esegua il suo funzionamento e rilasci il lock. Sfortunatamente, se i blocchi vengono esposti in questo modo, la struttura dei dati non avrebbe modo di impedire a qualcuno di acquisire un lucchetto e di non rilasciarlo mai.

Il pattern Visitor offre (almeno) tre approcci per evitare questo problema:

  1. Può bloccare un record, chiamare la funzione fornita e quindi sbloccare il record; il record potrebbe essere bloccato per sempre se la funzione fornita cade in un ciclo infinito, ma se la funzione fornita restituisce o genera un’eccezione, il record verrà sbloccato (potrebbe essere ragionevole contrassegnare il record non valido se la funzione genera un’eccezione; probabilmente non è una buona idea). Si noti che è importante che se la funzione chiamata tenta di acquisire altri blocchi, potrebbe verificarsi un deadlock.
  2. Su alcune piattaforms, può passare una posizione di archiviazione mantenendo la stringa come parametro ‘ref’. Quella funzione potrebbe quindi copiare la stringa, calcolare una nuova stringa in base alla stringa copiata, tentare di confrontareExchange la vecchia stringa in quella nuova e ripetere l’intero processo in caso di fallimento di CompareExchange.
  3. Può eseguire una copia della stringa, chiamare la funzione fornita sulla stringa, quindi utilizzare CompareExchange stesso per tentare di aggiornare l’originale e ripetere l’intero processo in caso di fallimento di CompareExchange.

Senza il modello di visitatore, l’esecuzione di aggiornamenti atomici richiederebbe l’esposizione di blocchi e il rischio di errori se la chiamata al software non seguisse un rigido protocollo di blocco / sblocco. Con il pattern Visitor, gli aggiornamenti atomici possono essere eseguiti in modo relativamente sicuro.

Le classi che richiedono modifiche devono tutte implementare il metodo “accetta”. I client chiamano questo metodo accept per eseguire qualche nuova azione su quella famiglia di classi estendendo così le loro funzionalità. I client sono in grado di utilizzare questo metodo di accettazione per eseguire una vasta gamma di nuove azioni passando in una class di visitatori diversa per ogni azione specifica. Una class visitatore contiene più metodi di visita sovrascritti che definiscono come ottenere la stessa azione specifica per ogni class all’interno della famiglia. Questi metodi di visita ricevono un’istanza su cui lavorare.

I visitatori sono utili se si aggiungono, si alterano o rimuovono frequentemente funzionalità in una famiglia di classi stabili perché ogni elemento di funzionalità è definito separatamente in ogni class di visitatore e le classi stesse non hanno bisogno di essere modificate. Se la famiglia di classi non è stabile, lo schema del visitatore potrebbe essere meno utile, poiché molti visitatori devono cambiare ogni volta che una class viene aggiunta o rimossa.

Un buon esempio è nella compilazione del codice sorgente:

 interface CompilingVisitor { build(SourceFile source); } 

I client possono implementare un JavaBuilder , RubyBuilder , XMLValidator , ecc. E l’implementazione per la raccolta e la visita di tutti i file di origine in un progetto non deve essere modificata.

Questo sarebbe un cattivo schema se hai classi separate per ogni tipo di file sorgente:

 interface CompilingVisitor { build(JavaSourceFile source); build(RubySourceFile source); build(XMLSourceFile source); } 

Dipende dal contesto e da quali parti del sistema si desidera essere estensibili.