Jaki jest sens metody accept () we wzorcu Visitor?

88

Dużo się mówi o oddzieleniu algorytmów od klas. Ale jedna rzecz pozostaje na uboczu, nie została wyjaśniona.

Używają gościa w ten sposób

abstract class Expr {
  public <T> T accept(Visitor<T> 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);
  }

Zamiast bezpośrednio wywoływać visit (element), Visitor prosi element o wywołanie metody wizyty. Zaprzecza deklarowanej idei klasowej nieświadomości odwiedzających.

PS1 Proszę wyjaśnić własnymi słowami lub wskazać dokładne wyjaśnienie. Ponieważ dwie odpowiedzi, które otrzymałem, dotyczą czegoś ogólnego i niepewnego.

PS2 Moje przypuszczenie: Ponieważ getLeft()zwraca basic Expression, wywołanie visit(getLeft())spowoduje visit(Expression), podczas gdy getLeft()wywołanie visit(this)spowoduje inne, bardziej odpowiednie, wywołanie wizyty. Tak więc accept()wykonuje konwersję typu (inaczej rzutowanie).

PS3 Scala's Pattern Matching = Visitor Pattern on Steroid pokazuje, o ile prostszy jest wzorzec Visitor bez metody akceptacji. Wikipedia dodaje do tego stwierdzenia : łącząc artykuł pokazujący, że „ accept()metody są niepotrzebne, gdy dostępna jest refleksja; wprowadza termin„ Walkabout ”dla techniki”.

Val
źródło
Mówi się, że „gdy odwiedzający odbiera połączenie, połączenie jest wysyłane na podstawie typu wywoływanego. Następnie wywoływany oddzwania z powrotem zgodnie z metodą odwiedzin specyficzną dla typu odwiedzającego, a to połączenie jest wysyłane na podstawie rzeczywistego typu gościa”. Innymi słowy, stwierdza to, co mnie wprawia w zakłopotanie. Z tego powodu czy możesz podać bardziej szczegółowe informacje?
Val

Odpowiedzi:

155

Wzorce visit/ acceptkonstrukcje gości są złem koniecznym z powodu semantyki języków podobnych do C (C #, Java itp.). Celem wzorca odwiedzającego jest użycie podwójnego wysyłania do kierowania połączenia zgodnie z oczekiwaniami po odczytaniu kodu.

Zwykle, gdy używany jest wzorzec gościa, zaangażowana jest hierarchia obiektów, w której wszystkie węzły pochodzą z Nodetypu podstawowego , określanego odtąd jako Node. Instynktownie napisalibyśmy to tak:

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

W tym tkwi problem. Jeśli nasza MyVisitorklasa została zdefiniowana w następujący sposób:

class MyVisitor implements IVisitor {
  void visit(CarNode node);
  void visit(TrainNode node);
  void visit(PlaneNode node);
  void visit(Node node);
}

Jeśli w czasie wykonywania, niezależnie od rzeczywistego typu root, nasze wywołanie przejdzie do przeciążenia visit(Node node). Byłoby to prawdą dla wszystkich zmiennych zadeklarowanych typu Node. Dlaczego to? Ponieważ Java i inne języki podobne do języka C uwzględniają tylko typ statyczny lub typ, jako zadeklarowana zmienna, parametru przy podejmowaniu decyzji, które przeciążenie należy wywołać. Java nie wykonuje dodatkowego kroku, aby przy każdym wywołaniu metody pytać „OK, jaki jest typ dynamiczny root? O, rozumiem. To jest a TrainNode. Sprawdźmy, czy jest jakaś metoda, MyVisitorktóra akceptuje parametr typuTrainNode... ”. Kompilator w czasie kompilacji określa, która metoda zostanie wywołana. (Gdyby Java rzeczywiście zbadała typy dynamiczne argumentów, wydajność byłaby fatalna).

Java daje nam jedno narzędzie do uwzględnienia typu runtime (tj. Dynamicznego) obiektu podczas wywoływania metody - wirtualnego wysyłania metody . Kiedy wywołujemy metodę wirtualną, wywołanie w rzeczywistości trafia do tabeli w pamięci, która składa się ze wskaźników funkcji. Każdy typ ma tabelę. Jeśli określona metoda jest zastępowana przez klasę, pozycja tablicy funkcji tej klasy będzie zawierała adres przesłoniętej funkcji. Jeśli klasa nie przesłania metody, będzie zawierać wskaźnik do implementacji klasy bazowej. Wciąż powoduje to narzut wydajności (każde wywołanie metody będzie w zasadzie wyłuskiwać dwa wskaźniki: jeden wskazujący tabelę funkcji typu, a drugi samą funkcję), ale nadal jest szybszy niż konieczność sprawdzania typów parametrów.

Celem wzorca odwiedzającego jest wykonanie podwójnej wysyłki - nie tylko jest brany pod uwagę typ celu połączenia ( MyVisitorza pomocą metod wirtualnych), ale także typ parametru (na jaki typ Nodepatrzymy)? Wzorzec Visitor pozwala nam to zrobić za pomocą kombinacji visit/ accept.

Zmieniając naszą linię na tę:

root.accept(new MyVisitor());

Możemy dostać to, czego chcemy: poprzez wywołanie metody wirtualnej wpisujemy poprawne wywołanie accept () zaimplementowaną przez podklasę - w naszym przykładzie z TrainElement, wprowadzimy TrainElementimplementację accept():

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

Co know kompilatora w tym momencie wewnątrz zakresu TrainNode„s accept? Wie, że typ statyczny thisto aTrainNode . Jest to ważny dodatkowy strzęp informacji, o którym kompilator nie był świadomy w zakresie naszego wywołującego: wiedział tylko root, że był to plik Node. Teraz kompilator wie, że this( root) to nie tylko a Node, ale w rzeczywistości jest TrainNode. W konsekwencji ta jedna linia znajdująca się w środku accept(): v.visit(this)oznacza coś zupełnie innego. Kompilator będzie teraz szukał przeciążenia, visit()które zajmuje plik TrainNode. Jeśli nie może go znaleźć, skompiluje wywołanie do przeciążenia, które przyjmuje rozszerzenieNode. Jeśli żadne z nich nie istnieje, pojawi się błąd kompilacji (chyba że masz przeciążenie, które wymaga object). W ten sposób wykonanie wejdzie w to, co przez cały czas mieliśmy na myśli: MyVisitorimplementację visit(TrainNode e). Nie były potrzebne żadne odlewy i, co najważniejsze, żadne refleksje nie były potrzebne. Zatem narzut tego mechanizmu jest raczej niski: składa się tylko z odniesień do wskaźników i nic więcej.

Masz rację w swoim pytaniu - możemy użyć obsady i uzyskać prawidłowe zachowanie. Jednak często nawet nie wiemy, jakiego typu jest Node. Weźmy przykład następującej hierarchii:

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; }

Pisaliśmy też prosty kompilator, który analizuje plik źródłowy i tworzy hierarchię obiektów zgodną z powyższą specyfikacją. Gdybyśmy pisali tłumacza dla hierarchii zaimplementowanej jako Gość:

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;
  }
}

Casting by nie dostać nam bardzo daleko, ponieważ nie wiemy, typy leftlub rightw tych visit()metod. Nasz parser najprawdopodobniej zwróciłby również obiekt typu, Nodektóry wskazywał również na korzeń hierarchii, więc nie możemy też tego bezpiecznie rzutować. Tak więc nasz prosty tłumacz może wyglądać następująco:

Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);

Wzorzec gościa pozwala nam zrobić coś bardzo potężnego: biorąc pod uwagę hierarchię obiektów, pozwala nam tworzyć operacje modularne, które działają w hierarchii bez konieczności umieszczania kodu w samej klasie hierarchii. Wzorzec odwiedzających jest szeroko stosowany, na przykład w konstrukcji kompilatora. Biorąc pod uwagę drzewo składniowe określonego programu, wielu odwiedzających jest napisanych, które działają na tym drzewie: sprawdzanie typów, optymalizacje, emisja kodu maszynowego są zwykle implementowane jako różni użytkownicy. W przypadku gościa optymalizacji może nawet wyprowadzić nowe drzewo składni, biorąc pod uwagę drzewo wejściowe.

Ma to oczywiście swoje wady: jeśli dodamy nowy typ do hierarchii, musimy również dodać visit()metodę dla tego nowego typu do IVisitorinterfejsu i utworzyć implementacje pośredniczące (lub pełne) u wszystkich naszych odwiedzających. Musimy również dodać accept()metodę z powodów opisanych powyżej. Jeśli wydajność nie znaczy dla ciebie zbyt wiele, istnieją rozwiązania umożliwiające pisanie do odwiedzających bez potrzeby accept(), ale zwykle wymagają one refleksji i dlatego mogą powodować dość duże koszty.

atanamir
źródło
5
Efektywny element Java 41 zawiera następujące ostrzeżenie: „ unikaj sytuacji, w których ten sam zestaw parametrów może zostać przekazany do różnych przeciążeń przez dodanie rzutów ”. accept()Metoda staje się konieczna, gdy ostrzeżenie zostanie naruszone w programie Visitor.
jaco0646
Zwykle, gdy używany jest wzorzec gości, zaangażowana jest hierarchia obiektów, w której wszystkie węzły pochodzą z podstawowego typu węzła ”, w C ++ nie jest to absolutnie konieczne. Zobacz Boost.Variant, Eggs.Variant
Jean-Michaël Celerier
Wydaje mi się, że w javie tak naprawdę nie potrzebujemy metody accept, ponieważ w javie zawsze wywołujemy metodę najbardziej szczegółowego typu
Gilad Baruchian
1
Wow, to było niesamowite wyjaśnienie. Oświecające, gdy widzisz, że wszystkie cienie wzorca wynikają z ograniczeń kompilatora, a teraz ujawnia wyraźne podziękowania.
Alfonso Nishikawa
@GiladBaruchian, kompilator generuje wywołanie najbardziej określonej metody typu, którą kompilator może określić.
mmw
15

Oczywiście byłoby to głupie, gdyby był to jedyny sposób wdrożenia akceptacji.

Ale to nie jest.

Na przykład odwiedzający są naprawdę przydatni, gdy zajmują się hierarchiami, w którym to przypadku implementacja węzła nieterminalnego może wyglądać mniej więcej tak

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

  private IEnumerable<HierarchyNode> children;
  ....
}

Zobaczysz? Co można opisać jako głupi jest rozwiązanie dla przejeżdżające hierarchie.

Oto znacznie dłuższy i dogłębny artykuł, dzięki któremu zrozumiałem gościa .

Edycja: wyjaśnienie: Visitmetoda odwiedzającego zawiera logikę, którą należy zastosować do węzła. Metoda węzła Acceptzawiera logikę dotyczącą sposobu nawigacji do sąsiednich węzłów. Przypadek, w którym wykonujesz tylko podwójną wysyłkę, jest szczególnym przypadkiem, w którym po prostu nie ma sąsiednich węzłów, do których można nawigować.

George Mauer
źródło
7
Twoje wyjaśnienie nie wyjaśnia, dlaczego iteracja dzieci powinna leżeć w gestii węzła, a nie odpowiedniej metody visit () gościa? Czy masz na myśli to, że główną ideą jest udostępnianie kodu przechodzenia hierarchii, gdy potrzebujemy tych samych wzorów odwiedzin dla różnych odwiedzających? Nie widzę żadnych wskazówek z zalecanego papieru.
Val
1
Stwierdzenie, że akceptacja jest dobra do rutynowego przemierzania, jest rozsądne i warte dla ogólnej populacji. Ale wziąłem mój przykład z czyjegoś „Nie mogłem zrozumieć wzorca gości, dopóki nie przeczytałem andymaleh.blogspot.com/2008/04/… ”. Ani ten przykład, ani Wikipedia, ani inne odpowiedzi nie wspominają o przewadze nawigacji. Niemniej jednak wszyscy żądają tego głupiego accept (). Dlatego zadaj mi pytanie: dlaczego?
Val
1
@Val - co masz na myśli? Nie jestem pewien, o co pytasz. Nie mogę wypowiadać się w imieniu innych artykułów, ponieważ ci ludzie mają różne poglądy na te rzeczy, ale wątpię, czy się nie zgadzamy. Ogólnie w obliczeniach wiele problemów można przypisać do sieci, więc użycie może nie mieć nic wspólnego z wykresami na powierzchni, ale jest w rzeczywistości bardzo podobnym problemem.
George Mauer,
1
Podanie przykładu, w którym jakaś metoda może być przydatna, nie odpowiada na pytanie, dlaczego metoda jest obowiązkowa. Ponieważ nawigacja nie zawsze jest potrzebna, metoda accept () nie zawsze jest dobra do odwiedzania. Dlatego bez tego powinniśmy być w stanie osiągnąć nasze cele. Niemniej jednak jest to obowiązkowe. Oznacza to, że istnieje silniejszy powód, aby wprowadzić metodę accept () do wzoru każdego odwiedzającego niż „czasami się przydaje”. Co nie jest jasne w moim pytaniu? Jeśli nie próbujesz zrozumieć, dlaczego Wikipedia szuka sposobów na pozbycie się akceptacji, nie jesteś zainteresowany zrozumieniem mojego pytania.
Val
1
@Val Artykuł, do którego odnoszą się „The Essence of the Visitor Pattern”, zwraca uwagę na to samo oddzielenie nawigacji i obsługi w swoim streszczeniu, co podałem. Mówią po prostu, że implementacja GOF (o co pytasz) ma pewne ograniczenia i uciążliwości, które można usunąć za pomocą refleksji - więc wprowadzają wzorzec Walkabout. Jest to z pewnością przydatne i może zrobić wiele takich samych rzeczy, które mogą zrobić odwiedzający, ale zawiera dużo dość wyrafinowanego kodu i (przy pobieżnym czytaniu) traci pewne korzyści z bezpieczeństwa typów. To narzędzie do skrzynki z narzędziami, ale cięższe niż gość
George Mauer
0

Celem wzorca Visitor jest upewnienie się, że obiekty wiedzą, kiedy odwiedzający skończył z nimi i wyszedł, aby klasy mogły później przeprowadzić niezbędne czyszczenie. Pozwala również klasom na „tymczasowe” wystawianie swoich elementów wewnętrznych jako parametrów „ref” i wie, że elementy wewnętrzne nie będą już ujawniane, gdy odwiedzający nie będzie. W przypadkach, gdy czyszczenie nie jest konieczne, wzorzec odwiedzających nie jest zbyt przydatny. Klasy, które nie robią żadnej z tych rzeczy, mogą nie korzystać z wzorca gości, ale kod, który został napisany w celu wykorzystania wzorca gościa, będzie używany w przyszłych klasach, które mogą wymagać czyszczenia po uzyskaniu dostępu.

Na przykład, załóżmy, że mamy strukturę danych zawierającą wiele ciągów, które powinny być aktualizowane atomowo, ale klasa przechowująca strukturę danych nie wie dokładnie, jakie typy atomowych aktualizacji powinny być wykonywane (np. Jeśli jeden wątek chce zastąpić wszystkie wystąpienia " X ”, podczas gdy inny wątek chce zastąpić dowolną sekwencję cyfr sekwencją, która jest numerycznie wyższa o jeden, operacje obu wątków powinny się powieść; jeśli każdy wątek po prostu odczytuje ciąg, wykonuje aktualizacje i zapisuje go z powrotem, drugi wątek zapisanie z powrotem jego ciągu spowoduje nadpisanie pierwszego). Jednym ze sposobów osiągnięcia tego byłoby doprowadzenie do blokady każdego wątku, wykonanie jego operacji i zwolnienie blokady. Niestety, jeśli zamki zostaną w ten sposób odsłonięte,

Wzorzec Odwiedzający oferuje (co najmniej) trzy podejścia, aby uniknąć tego problemu:

  1. Może zablokować rekord, wywołać dostarczoną funkcję, a następnie odblokować rekord; rekord może zostać zablokowany na zawsze, jeśli dostarczona funkcja wpadnie w nieskończoną pętlę, ale jeśli podana funkcja zwróci lub zgłosi wyjątek, rekord zostanie odblokowany (może być rozsądne oznaczenie rekordu jako nieważnego, jeśli funkcja zgłosi wyjątek; pozostawienie to prawdopodobnie nie jest dobry pomysł). Zauważ, że ważne jest, aby jeśli wywoływana funkcja próbowała uzyskać inne blokady, może to spowodować zakleszczenie.
  2. Na niektórych platformach może przekazać miejsce przechowywania przechowujące łańcuch jako parametr „ref”. Ta funkcja może następnie skopiować ciąg, obliczyć nowy ciąg na podstawie skopiowanego ciągu, podjąć próbę CompareExchange starego ciągu na nowy i powtórzyć cały proces, jeśli CompareExchange nie powiedzie się.
  3. Może wykonać kopię ciągu, wywołać podaną funkcję w ciągu, a następnie użyć samego CompareExchange, aby spróbować zaktualizować oryginał, i powtórzyć cały proces, jeśli CompareExchange nie powiedzie się.

Bez wzorca odwiedzających, wykonanie atomowych aktualizacji wymagałoby ujawnienia blokad i ryzyka niepowodzenia, jeśli wywołujące oprogramowanie nie będzie przestrzegać ścisłego protokołu blokowania / odblokowywania. Dzięki wzorowi Visitor atomowe aktualizacje można przeprowadzać stosunkowo bezpiecznie.

supercat
źródło
2
1. odwiedzanie oznacza, że ​​masz dostęp tylko do publicznych metod odwiedzania, więc musisz udostępnić publiczne zamki wewnętrzne, aby były użyteczne dla Gościa. 2 / Żaden z przykładów, które widziałem wcześniej, nie sugeruje, że odwiedzający powinien być używany do zmiany statusu odwiedzonego. 3. „W przypadku tradycyjnego VisitorPattern można tylko określić, kiedy wchodzimy do węzła. Nie wiemy, czy opuściliśmy poprzedni węzeł przed wejściem do bieżącego”. Jak odblokować tylko wizytę zamiast wizytyWejdź i odwiedźLeave? Na koniec zapytałem raczej o aplikacje accpet () niż Visitor.
Val
Być może nie jestem do końca na bieżąco z terminologią dotyczącą wzorców, ale „wzorzec gości” wydaje się przypominać podejście, którego użyłem, w którym X przekazuje Y delegatowi, do którego Y może następnie przekazać informacje, które muszą być ważne tylko jako tak długo, jak działa delegat. Może ten wzór ma inną nazwę?
supercat
2
Jest to ciekawe zastosowanie wzorca gości do konkretnego problemu, ale nie opisuje samego wzorca ani nie odpowiada na pierwotne pytanie. „W przypadkach, gdy porządkowanie nie jest konieczne, wzorzec odwiedzających nie jest szczególnie przydatny”. To twierdzenie jest zdecydowanie fałszywe i odnosi się tylko do konkretnego problemu, a nie ogólnie do wzorca.
Tony O'Hagan
0

Wszystkie klasy, które wymagają modyfikacji, muszą implementować metodę „accept”. Klienci nazywają tę metodę accept, aby wykonać nową akcję na tej rodzinie klas, rozszerzając tym samym ich funkcjonalność. Klienci mogą używać tej jednej metody akceptowania do wykonywania szerokiego zakresu nowych działań, przekazując inną klasę odwiedzających dla każdego konkretnego działania. Klasa gościa zawiera wiele nadpisanych metod odwiedzin, definiujących sposób wykonania tej samej określonej czynności dla każdej klasy w rodzinie. Te metody odwiedzin otrzymują instancję, na której mają działać.

Odwiedzający są przydatni, jeśli często dodajesz, zmieniasz lub usuwasz funkcjonalność w stabilnej rodzinie klas, ponieważ każdy element funkcjonalności jest definiowany oddzielnie w każdej klasie odwiedzających, a same klasy nie wymagają zmiany. Jeśli rodzina klas nie jest stabilna, wzorzec odwiedzających może być mniej przydatny, ponieważ wielu odwiedzających wymaga zmiany za każdym razem, gdy klasa jest dodawana lub usuwana.

pasztet andrewski
źródło
-1

Dobrym przykładem jest kompilacji kodu źródłowego:

interface CompilingVisitor {
   build(SourceFile source);
}

Klienci mogą zaimplementować JavaBuilder, RubyBuilder, XMLValidator, itd. I wdrożenie do zbierania i zwiedzanie wszystkich plików źródłowych w projekcie nie wymaga zmian.

Byłby to zły wzorzec, gdybyś miał oddzielne klasy dla każdego typu pliku źródłowego:

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

Wszystko sprowadza się do kontekstu i tego, które części systemu mają być rozszerzalne.

Garrett Hall
źródło
Ironia polega na tym, że VisitorPattern oferuje nam użycie złego wzorca. Mówi, że musimy zdefiniować metodę odwiedzin dla każdego rodzaju węzła, który ma zamiar odwiedzić. Po drugie, nie jest jasne, jakie przykłady są dobre, a jakie złe? Jak są one powiązane z moim pytaniem?
Val