Capire la necessità del pattern Visitor

3

Dopo aver visto un articolo sul modello dei visitatori, mi è chiaro come Funziona. E ho creato un programma di esempio per la mia comprensione;

main(){
    SortingAlgorithm bubbleSort;
    :
    intList.sort(bubbleSort);
}

class IntList implements Sortable{
    :

    public void sort(SortingAlgorithm algo){
        return algo.sort(this);
    }
}

interface SortingAlgorithm{
    public void sort(IntList list);
    public void sort(DoubleList list);
    public void sort(LongList list);
}

class BubbleSort implements SortingAlgorithm{

    public void sort(IntList list){
    :
    }
    public void sort(DoubleList list){
        :
    }
    public void sort(LongList list){
        :
    }
}

E il vantaggio che posso vedere è che se implemento un nuovo algoritmo, non ho bisogno di modificare IntList ;

main(){
    SortingAlgorithm heapSort;
    :
    intList.sort(heapSort);
}

class HeapSort implements SortingAlgorithm{

    public void sort(IntList arr){
    :
    }
    public void sort(DoubleList list){
        :
    }
    public void sort(LongList list){
        :
    }
}

L'altra cosa che posso vedere è che se creo un altro tipo di lista o mappa, diciamo IntMap , allora devo semplicemente aggiungere un metodo in Sortable interface e devo implementarlo in tutte le classi rilevanti: BubbleSort, HeapSort.

Ora quello che non riesco a capire è la necessità di modelli di visitatori, come posso semplificare al di sopra del programma come questo;

main(){
    BubbleSort.sort(intList);

}

Ora il mio elenco di primitivi e le mappe non devono implementare alcuna interfaccia e il suo metodo. E nemmeno l'interfaccia SortingAlgorithm non è più richiesta.

    
posta Amit Kumar Gupta 15.10.2016 - 15:45
fonte

2 risposte

13

Il pattern visitor è utile quando si desidera elaborare una struttura dati contenente diversi tipi di oggetti e si desidera eseguire un'operazione specifica su ciascuno di essi, a seconda del tipo.

Il tuo esempio non è il migliore, dal momento che passi un singolo elenco omogeneo come input, quindi non c'è davvero bisogno del pattern. Come fai notare, puoi semplicemente chiamare direttamente il metodo appropriato. (Inoltre un algoritmo di ordinamento non dovrebbe nemmeno preoccuparsene l'input è un elenco di interi o doppi, purché tutti gli elementi siano dello stesso tipo e il tipo abbia una funzione di confronto.)

Ma considera se hai un albero delle directory e vuoi visualizzare le prime righe di testo da ciascun file. Ciò richiederebbe una logica diversa se il file fosse in chiaro, HTML, Word o PDF. Quindi questo sarebbe l'uso appropriato del pattern visitor poiché vuoi un modo generico per attraversare l'albero, ma vuoi che l'anteprima di ogni foglia venga gestita a seconda del suo tipo.

Esempio

Abbiamo PdfFile , HtmlFile e TextFile che sono tutti discendenti della classe astratta File . Supponi che il metodo dir("path") abbia il tipo List<File> ma restituisca le istanze di PdfFile , HtmlFile ecc.

Creiamo una classe Head che dovrebbe generare i riepiloghi per questi diversi tipi di file, quindi avrà questa interfaccia:

class Head {
  static String of(PdfFile file) { ... }
  static String of(HtmlFile file) { ... }
  static String of(TextFile file) { ... }
}

Quindi potresti essere tentato di fare qualcosa del genere:

dir("path").forEach(file -> Head.of(file)))

Il problema è che questo non funziona! In Java e in linguaggi simili, gli overload dei metodi basati sul tipo di parametro vengono risolti in fase di compilazione, non in fase di runtime. Poiché il tipo dell'elenco è qualcosa come List<File> , il compilatore cercherà un singolo metodo con la firma of(File file) . Come risolviamo questo problema? Potremmo aggiungere un override a ogni sottoclasse di File che assomiglia a questo:

public override string acceptHead() { return Head.of(this); }

E poi fai questo:

dir("path").forEach(file -> file.acceptHead()))

Questo in realtà funziona, perché la spedizione sull'istanza (prima del punto) viene risolta in fase di esecuzione, quindi otteniamo l'override corretto che a sua volta chiama il sovraccarico corretto di of . Quindi puoi generalizzare il metodo acceptHead a solo accept e creare un'interfaccia FileVisitor generalizzata, che Head è solo un'istanza di, quindi puoi riutilizzare la logica di invio per altri scopi. E ora hai un modello di visitatore.

In un certo senso, il modello di visitatore è semplicemente una soluzione per il fatto che Java (e lingue simili) sono di sola spedizione, il che significa che le sostituzioni sono selezionate in fase di esecuzione solo in base all'istanza prima del punto. Se i sovraccarichi sono stati selezionati anche in fase di esecuzione in base ai tipi di parametri, non sarebbe necessario questo modello.

    
risposta data 15.10.2016 - 16:23
fonte
14

Il modello di visitatore è una soluzione a un problema di progettazione più generale:

Ho una gerarchia di classi diverse. Ogni classe supporta varie operazioni comuni. Vorremmo ora estendere quella gerarchia, senza dover modificare la gerarchia esistente (ad esempio perché è definita da una libreria a cui non è stato assegnato il codice sorgente).

  • Possiamo aggiungere più classi, che devono supportare tutte le operazioni richieste.
  • Possiamo aggiungere più operazioni, che devono essere supportate da tutte le classi nella gerarchia.

Quando progettiamo una gerarchia, possiamo scegliere quale delle due direzioni di estensione vogliamo semplificare. (Cercare di fare entrambe le cose è chiamato il Problema di espressione ed è estremamente complicato.)

Poiché questo è un po 'teorico, usiamo un esempio: vari tipi di animali. Ogni animale ha un name() e crea un sound() .

Codice libreria:

interface Animal {
  String name();
  String sound();
}

class Dog implements Animal {
  public String name() { return "Dog"; }
  public String sound() { return "Woof"; }
}

class Cat implements Animal {
  public String name() { return "Cat"; }
  public String sound() { return "Meow"; }
}

Codice utente:

Animal[] animals = new Animal[] { new Dog(), new Cat() };
for (Animal animal : animals)
  System.out.println(animal.name() + " goes " + animal.sound());

Output:

Dog goes Woof
Cat goes Meow

Estensione aggiungendo classi

Possiamo estendere la gerarchia Animal aggiungendo nuove classi che implementano tutti i metodi richiesti.

Codice utente:

class Bird implements Animal {
  public String name() { return "Bird"; }
  public String sound() { return "Tweet"; }
}

...


Animal[] animals = new Animal[] { new Dog(), new Cat(), new Bird() };
for (Animal animal : animals)
  System.out.println(animal.name() + " goes " + animal.sound());

Output:

Dog says Woof
Cat says Meow
Bird says Tweet

Consentendo la creazione di nuove sottoclassi di Animal , abbiamo anche reso impossibile aggiungere nuove operazioni alla gerarchia Animal . Dopotutto, quell'operazione dovrebbe essere implementata per tutte le classi sia dalla libreria che da qualsiasi codice utente - un'impresa impossibile poiché ci possono essere infinitamente molte sottoclassi.

Estensione aggiungendo nuove operazioni

Non possiamo aggiungere nuove operazioni senza modificare le definizioni di tutte le classi. Questo è piuttosto inelegante. Bene, potremmo controllando con instanceof , ma è piuttosto fragile:

public static String favouriteToy(Animal animal) {
  if (animal instanceof Dog)
    return "chewing bone";
  if (animal instanceof Cat)
    return "yarn ball";
  throw new UnsupportedOperationException();
}

Questo è problematico perché è facile dimenticare un animale. Come possiamo controllare staticamente che abbiamo coperto tutti i casi?

Il modello di visitatore offre una via d'uscita, purché il progettista della gerarchia di classi originale abbia anticipato la necessità di operazioni aggiuntive. Immagino ora che l'interfaccia originale abbia un metodo acceptVisitor aggiuntivo:

Codice libreria:

interface Animal {
  String name();
  String sound();
  <R> R acceptVisitor(AnimalVisitor<R> visitor);
}

interface AnimalVisitor<R> {
  R visitDog(Dog dog);
  R visitCat(Cat cat);
}

class Dog implements Animal {
  public String name() { return "Dog"; }
  public String sound() { return "Woof"; }
  public <R> R acceptVisitor(AnimalVisitor<R> visitor) {
    return visitor.visitDog(this);
  }
}

class Cat implements Animal {
  public String name() { return "Cat"; }
  public String sound() { return "Meow"; }
  public <R> R acceptVisitor(AnimalVisitor<R> visitor) {
    return visitor.visitCat(this);
  }
}

Codice utente:

class FavouriteToy implements AnimalVisitor<String> {
  public String visitDog(Dog dog) { return "chewing bone"; }
  public String visitCat(Cat cat) { return "yarn ball"; }
  public static String get(Animal animal) {
    FavouriteToy visitor = new FavouriteToy();
    return animal.acceptVisitor(visitor);
  }
}

...

Animal[] animals = new Animal[] { new Dog(), new Cat() };
for (Animal animal : animals)
  System.out.println(animal.name()
      + " goes " + animal.sound()
      + ", plays with " + FavouriteToy.get(animal));

Output:

Dog goes Woof, plays with chewing bone
Cat goes Meow, plays with yarn ball

Ancora una volta, il punto importante è che questo in effetti ci consente di definire nuovi metodi per tutte le classi in alcune gerarchie senza modificare quelle classi. Questo è utile in due casi:

  • Non possiamo modificare la gerarchia originale perché è una libreria esterna.

  • Abbiamo bisogno di molte operazioni non correlate e non vogliamo mettere diverse operazioni nella stessa classe. Ciò è indicato dal principio di responsabilità singola.

Lo svantaggio del pattern Visitor è che abbiamo perso la possibilità di aggiungere nuove sottoclassi di Animal . Dovremmo aggiungerli anche all'interfaccia AnimalVisitor e ciò interromperà tutti i visitatori esistenti.

In pratica, lo schema visitatore è spesso usato nei compilatori. Qui, la sintassi di un programma viene solitamente rappresentata tramite una struttura dati ad albero, in cui ogni elemento dell'albero può avere un tipo diverso. Diverse parti del compilatore fanno cose molto diverse con questo albero: una parte mostra il codice. Una parte ottimizza il codice. Un'altra parte compila il codice in un'altra lingua e un altro visitatore potrebbe essere un interprete.

Tutte queste operazioni non correlate possono essere scritte come visitatori, in modo che le definizioni dei vari tipi negli alberi non contengano nient'altro che i campi dati e un po 'di informazione del visitatore.

Esempio di compilatore semplice:

interface Expression { <R> R acceptVisitor(Visitor<R> v); }

interface Visitor<R> {
  R visitConstant(Constant c);
  R visitVariable(Variable v);
  R visitAddition(Addition a);
}

class Constant implements Expression {
  public final int value;
  public Constant(int value) { this.value = value; }
  public <R> R acceptVisitor(Visitor<R> v) { return v.visitConstant(this); }
}

class Variable implements Expression {
  public final String name;
  public Variable(String name) { this.name = name; }
  public <R> R acceptVisitor(Visitor<R> v) { return v.visitVariable(this); }
}

class Addition implements Expression {
  public final Expression left;
  public final Expression right;
  public Addition(Expression left, Expression right) {
    this.left = left;
    this.right = right;
  }
  public <R> R acceptVisitor(Visitor<R> v) { return v.visitAddition(this); }
}

Esempio di albero della sintassi:

Expression example = new Addition(
  new Variable("x"),
  new Addition(new Constant(2), new Constant(3)));

Stampante carina:

class PrettyPrinting implements Visitor<Void> {
  private StringBuffer sb = new StringBuffer();

  public static String of(Expression e) {
    PrettyPrinting pp = new PrettyPrinting();
    e.acceptVisitor(pp);
    return pp.sb.toString();
  }

  public Void visitConstant(Constant c) {
    sb.append(c.value);
    return null;
  }

  public Void visitVariable(Variable v) {
    sb.append(v.name);
    return null;
  }

  public Void visitAddition(Addition add) {
    sb.append('(');
    add.left.acceptVisitor(this);
    sb.append(" + ");
    add.right.acceptVisitor(this);
    sb.append(')');
    return null;
  }
}

Ottimizzazione della piegatura costante:

class ConstantFolding implements Visitor<Expression> {
  public Expression visitConstant(Constant c) { return c; }

  public Expression visitVariable(Variable v) { return v; }

  public Expression visitAddition(Addition add) {
    Expression left = add.left.acceptVisitor(this);
    Expression right = add.right.acceptVisitor(this);

    if (left instanceof Constant && right instanceof Constant) {
      int leftValue = ((Constant) left).value;
      int rightValue = ((Constant) right).value;
      return new Constant(leftValue + rightValue);
    }

    return new Addition(left, right);
  }
}

Interprete:

class Interpreter implements Visitor<Integer> {
  private final Map<String, Integer> env;
  private Interpreter(Map<String, Integer> env) { this.env = env; }

  public static int eval(Expression e, Map<String, Integer> env) {
    return e.acceptVisitor(new Interpreter(env));
  }

  public Integer visitConstant(Constant c) { return c.value; }
  public Integer visitVariable(Variable v) { return env.get(v.name); }
  public Integer visitAddition(Addition add) {
    return add.left.acceptVisitor(this) + add.right.acceptVisitor(this);
  }
}

Programma di esempio:

System.out.println("Expression is: " + PrettyPrinting.of(example));
example = example.acceptVisitor(new ConstantFolding());
System.out.println("Optimized expression is: " + PrettyPrinting.of(example));
Map<String, Integer> variables = new HashMap<>();
variables.put("x", 37);
int result = Interpreter.eval(example, variables);
System.out.println("result is: " + result);

Output:

Expression is: (x + (2 + 3))
Optimized expression is: (x + 5)
result is: 42
    
risposta data 15.10.2016 - 18:12
fonte

Leggi altre domande sui tag