Wzorce visit
/ accept
konstrukcje 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 Node
typu 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 MyVisitor
klasa 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, MyVisitor
któ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 ( MyVisitor
za pomocą metod wirtualnych), ale także typ parametru (na jaki typ Node
patrzymy)? 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 TrainElement
implementację 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 this
to 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: MyVisitor
implementację 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 left
lub right
w tych visit()
metod. Nasz parser najprawdopodobniej zwróciłby również obiekt typu, Node
któ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 IVisitor
interfejsu 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.