VisitorWzór.25 Drogi czytelniku omówiliśmy prawie wszystkie wzorce projektowe z "Gang of Four". Do skończenia tej kolekcji wpisów pozostało nam omówić ostatni wzorzec projektowy, a jest nim wzorzec projektowy "Visitor".
Jak najlepiej wyjaśnić ten wzorzec?
Najlepiej jest od razu przeskoczyć do przykładu.
Powiedzmy, że mamy następujące wyrażenie matematyczne, które dla ułatwienia składa się tylko z liczb (możliwie ułamkowych) i operatora odejmowania.
Oto przykład takiego wyrażenia : (1.0 - (2.0 - 3.0))
Chcemy teraz zapisać te wyrażenie matematyczne w sposób obiektowy.
Na początku stwórzmy klasę abstrakcyjną, która będzie nam określała wszystkie możliwe wyrażenia.
public abstract class Expression { }
//na razie puste
Teraz tworzymy klasę, która reprezentuje wyrażenie liczbowe.
public class DoubleExpression : Expression
{
private double value;
public DoubleExpression(double value) { this.value = value; }
}
Oto klasa, która określa wyrażenie odejmowania. Przyjmuje ona wartości po lewej i po prawej stronie.
public class SubtractionExpression : Expression
{
private Expression left, right;
public SubtractionExpression
(Expression left, Expression right)
{
this.left = left;
this.right = right;
}
}
Wiemy, do czego chcemy dążyć. Interesują nasz teraz dwie rzeczy:
- Jak wydrukować te wyrażenie obiektowe jako tekst?
- Jak wykonać dane wyrażenie obiektowe, aby otrzymać jego wynik
Istnieje wiele sposobów na rozwiązanie tych problemów.
Natrętny odwiedzający inaczej Intrusive Visitor
Najłatwiej by było dodać metodę drukującą do każdego wyrażenia. Tworząc metodę abstrakcyjną wymusimy takie zachowanie na każdym wyrażeniu zapisanym przy pomocy klas.
public abstract class Expression
{
public abstract void Print(StringBuilder sb);
}
Pozostało nam teraz do klas dodać implementacje tej metod.
public class DoubleExpression : Expression
{
public override void Print(StringBuilder sb)
{
sb.Append(value.ToString());
}
private double value;
public DoubleExpression(double value) { this.value = value; }
}
Rekurencyjnie i poliformicznie wywołamy metodę "Print()" która ostatecznie stworzy nam kompletne wyrażenie w formie tekstowej.
public class SubtractionExpression : Expression
{
public override void Print(StringBuilder sb)
{
sb.Append(value: "(");
left.Print(sb);
sb.Append(value: "-");
right.Print(sb);
sb.Append(value: ")");
}
private Expression left, right;
public SubtractionExpression
(Expression left, Expression right)
{
this.left = left;
this.right = right;
}
}
Sprawdźmy jak nasz kod działa.
var expression = new SubtractionExpression(
left: new DoubleExpression(1),
right: new SubtractionExpression(
left: new DoubleExpression(2),
right: new DoubleExpression(3)));
var sb = new StringBuilder();
expression.Print(sb);
Console.WriteLine(sb);
Wydaje się to łatwe, ale wyobraź sobie, że w tej hierarchii wyrażenia masz z 10 klas, które albo dziedziczą po sobie, albo są mniejszą częścią większego wyrażenia. Każda modyfikacji w tych klasach może wywołać reakcję łańcuchową, która przejdzie na wszystkie inne klasy co łamię zasadę "Otwarte-Zamknięte" .
To jednak niejedyny problem
Kolejnym problemem jest złamana zasada "Pojedynczej odpowiedzialności" . W sumie każda klasa teraz odpowiada za drukowanie całego wyrażenia.
Powinniśmy przedstawić oddzielną klasę, która skupi się tylko na drukowaniu. Potem też możemy stworzyć oddzielną klasę, która skupi się na rozwiązaniu całego wyrażenia.
Drukarka w stylu Reflective
Mamy więc swoje wyrażenie w postaci klasy abstrakcyjnej. Tym razem ona nic w sobie nie zawiera.
public abstract class Expression2
{
}
Nasze klasy wyglądają tak samo jak w pierwszym przykładzie.
public class SubtractionExpression : Expression2
{
public Expression2 Right {get;set;}
public Expression2 Left { get; set; }
public SubtractionExpression
(Expression2 left, Expression2 right)
{
this.Left = left;
this.Right = right;
}
}
public class DoubleExpression : Expression2
{
public double Value { get; set; }
public DoubleExpression(double value) { this.Value = value; }
}
Gdybyś chciał napisać swoją implementację drukowania to zapewne zrobiłbyś to tak
public static class ExpressionPrinter
{
public static void Print(DoubleExpression e, StringBuilder sb)
{
sb.Append(e.Value);
}
public static void Print(SubtractionExpression ae, StringBuilder sb)
{
sb.Append("(");
Print(ae.Left, sb); // to się nie kompiluje
sb.Append("-");
Print(ae.Right, sb); // to się nie kompiluje
sb.Append(")");
}
}
Aby jednak ten kod działał musielibyśmy ustalić, że wewnątrz wyrażenia odejmowania zawsze będą liczby ułamkowe. Co, jeśli będę chciał rozwijać swój program i będę tam w tym wyrażeniu odejmowania umieszczał jeszcze inne wyrażenia.
Nie możemy więc zmodyfikować klasy "SubtractionExpression"
Możemy stworzyć metodę drukującą wszystko i w niej będziemy sprawdzać, z jakim typem wyrażenia mamy do czynienia.
public static class ExpressionPrinter
{
public static void Print(Expression2 e, StringBuilder sb)
{
if (e is DoubleExpression de)
{
sb.Append(de.Value);
}
else if (e is SubtractionExpression ae)
{
sb.Append("(");
Print(ae.Left, sb);
sb.Append("+");
Print(ae.Right, sb);
sb.Append(")");
}
}
}
Jest to jakieś rozwiązanie.
var expression = new SubtractionExpression(
left: new DoubleExpression(1),
right: new SubtractionExpression(
left: new DoubleExpression(2),
right: new DoubleExpression(3)));
var sb = new StringBuilder();
ExpressionPrinter.Print(expression, sb);
Console.WriteLine(sb);
Te rozwiązanie oczywiście ma swoją wadę. Bez użycia refleksji w sumie nie masz jak sprawdzić, czy wszystkie możliwe warunki if-else dla wszystkich typów wyrażenia w programie biorą udział w drukowaniu.
Jeśli dojdzie nowe wyrażenie to oczywiście klasę "ExpressionPrinter" musisz zmodyfikować.
Czy można to zrobić lepiej?
Dynamiczny Odwiedzający : Dynamic Visitor
Na ratunek przychodzi słowo kluczowe "dynamic", które sprawia, że dany fragment kodu zostanie sprawdzony, dopiero gdy ten kod się uruchomi.
Tradycyjnie sprawdzanie typów odbywa się w trakcie kompilacji programu.
Ten fragment kodu działa.
public class ExpressionPrinter2
{
public void Print(SubtractionExpression se, StringBuilder sb)
{
sb.Append("(");
Print((dynamic)se.Left, sb);
sb.Append("+");
Print((dynamic)se.Right, sb);
sb.Append(")");
}
public void Print(DoubleExpression de, StringBuilder sb)
{
sb.Append(de.Value);
}
}
Dalej już nic nie musimy zmieniać.
var sb = new StringBuilder();
ExpressionPrinter2 expressionPrinter2
= new ExpressionPrinter2();
expressionPrinter2.Print(expression, sb);
Console.WriteLine(sb);
Jakie są problemy z tym rozwiązaniem ?
- Program działa trochę wolniej niż wcześniej wynika to z działania kodu sposób dynamiczny
- Jeśli danej metody lub właściwości nie ma to oczywiście dostaniesz wyjątek RunTime Exception
- Mogą pojawić się problemy, gdy jeszcze nam dojdzie mechanizm dziedziczenia
Dynamiczny odwiedzający ma sens, gdy wiesz, że twoje wyrażenia nie będą aż tak skomplikowane oraz gdy wiesz, że dana metoda jak "Print" nie będzie wywoływana za często.
Przeciwnym wypadku twój system może mieć zbyt duże czkawki.
Klasyczny Odwiedzający : Classic Visitor
Jak wygląda klasyczne podejście do wzorca "Visitor". Otóż potrzebne są następujące metody
- Metoda Visit() wykona operacje wydruku albo liczenia. Nie chcemy mieć tej metod w każdym elemencie wyrażenie. Trzeba to jakoś wydzielić i zaraz Ci pokaże jak
- Metoda Accept() do sprawdzenia, czy metoda Visit() powinna się uruchomić. Ta metoda powinna się znajdować w każdym elemencie wyrażenia.
Tworzymy więc kolejną wersję naszej klasy abstrakcyjnej.
Tym razem każde wyrażenie musi mieć metodę "Accept()", która przyjmuje do siebie klasę, która będzie implementować interfejs "IExpressionVisitor".
public abstract class Expression3
{
public abstract void Accept(IExpressionVisitor visitor);
}
Ten interfejs będzie miał w swoim kontrakcie wszystkie metody odwiedzające dla każdego wyrażenia.
public interface IExpressionVisitor
{
void Visit(DoubleExpression de);
void Visit(SubtractionExpression se);
}
Oto użycie naszej klasy abstrakcyjnej
public class SubtractionExpression : Expression3
{
public Expression3 Right { get; set; }
public Expression3 Left { get; set; }
public SubtractionExpression
(Expression3 left, Expression3 right)
{
this.Left = left;
this.Right = right;
}
public override void Accept(IExpressionVisitor visitor)
{
visitor.Visit(this);
}
}
public class DoubleExpression : Expression3
{
public double Value { get; set; }
public DoubleExpression(double value) { this.Value = value; }
public override void Accept(IExpressionVisitor visitor)
{
visitor.Visit(this);
}
}
O ile kod się powtarza teraz to też mamy możliwość dodania logiki blokującej wizytę danego elementu.
Teraz pozostało napisać nam trzecią wersję naszej drukarki, która będzie implementować interfejs "IExpressionVisitor"
public class ExpressionPrinter3 : IExpressionVisitor
{
StringBuilder sb = new StringBuilder();
public void Visit(DoubleExpression de)
{
sb.Append(de.Value);
}
public void Visit(SubtractionExpression se)
{
sb.Append("(");
se.Left.Accept(this);
sb.Append("+");
se.Right.Accept(this);
sb.Append(")");
}
public override string ToString() => sb.ToString();
}
Nie musimy też teraz korzystać ze słowa kluczowego dynamic, dzięki potędze słowa "this", która przekaże instancje naszej drukarki do innych metod.
var expression = new SubtractionExpression(
new DoubleExpression(1),
new SubtractionExpression(
new DoubleExpression(2),
new DoubleExpression(3)));
var ep = new ExpressionPrinter3();
Console.WriteLine($"{ep}");
Visitor / Odwiedzający do kalkulacji matematycznej
Zrobiliśmy przed chwilą drukowanie, a co z liczeniem danego wyrażenia? Nic nie stoi na przeszkodzie, aby napisać innego odwiedzającego, który właśnie to zrobi.
public class ExpressionCalculator : IExpressionVisitor
{
public double Result;
public void Visit(DoubleExpression de)
{
Result = de.Value;
}
public void Visit(SubtractionExpression se)
{
//za chwile
}
}
Dla odejmowania zrobimy operacje odejmowania. Metoda Visit niczego nigdy nie powinna zwracać więc rezultat odejmowania idzie do innej właściwości.
public void Visit(SubtractionExpression se)
{
se.Left.Accept(this);
var a = Result;
se.Right.Accept(this);
var b = Result;
Result = a - b;
}
Oto przykład naszego odwiedzającego, który umie interpretować wyrażenia matematyczne.
var expression = new SubtractionExpression(
new DoubleExpression(1),
new SubtractionExpression(
new DoubleExpression(2),
new DoubleExpression(3)));
var ep = new ExpressionPrinter3();
var calc = new ExpressionCalculator();
calc.Visit(expression);
Console.WriteLine($"{ep} = {calc.Result}");
Teraz możesz stworzyć według tego wzoru kolejnych odwiedzających, którzy będą operować na tych samym cegiełkach danego wyrażenia.
Co ciekawe każdy nowy odwiedzający nie będzie robił zmian w tych klasach reprezentujących dany elementy wyrażenia.
W ten sposób wiemy ,że zasada "Otwarte-Zamknięte" oraz zasada "Pojedynczej odpowiedzialności" jest spełniona. Co czyni kod czytelniejszym i łatwym do zrozumienia.
Acykliczny Odwiedzający : Acyclic Visitor
Wzorzec projektowy Visitor, czyli odwiedzający ma jeszcze swoje dwa odłamy.
- Cykliczny odwiedzający : polega na technice przeciążania metody/funkcji. Z tej techniki skorzystaliśmy do tej pory. Jednakże ten styl ma sens, gdy mamy styczność z hierarchią, która jest stabilna i się nie zmienia.
- Acykliczny odwiedzający : polega na rzutowaniu. Limitacje związane ze znajomością typu odwiedzającego znikają, ale idzie za tym koszt wydajności.
Zobaczmy jak możemy napisać Acyklicznego odwiedzającego:
W naszej drukarce nie potrzebujemy już metody Visit dla każdego typu. Możemy zrobić jedną metodę Visit, która rozmnoży się na inne metody generyczne w zależności od typu wyrażenia.
public interface IVisitor<TVisitable>
{
void Visit(TVisitable obj);
}
Potrzebujemy też pusty interfejs, który określi nam to, że działamy ze wzorcem odwiedzającego. Poza oznaczeniem ten Interfejs nie robi nic.
public interface IVisitor { }
Tworzymy czwartą wersję klasy abstrakcyjne dla wszystkich wyrażeń. Jak widzisz poniższa metoda Accept jest wirtualna, a nie abstrakcyjna. Znaczy, to ,że patrzymy na domyślną implementację tej metody, chociaż możemy to zmienić poprzez przeciążenie tej metody
public abstract class Expression4
{
public virtual void Accept(IVisitor visitor)
{
if (visitor is IVisitor<Expression4> typed)
typed.Visit(this);
}
}
Co tutaj się dzieje w tej nowej metodzie Accept ? Nasz parametr w tej metodzie musi implementować interfejs ogólny IVisitor.
Później ten parametr spróbujemy z rzutować na interfejs IVisitor<T>, gdzie T będzie obecnym typem, w którym się znajdujemy.
Jeśli rzutowanie wykona się poprawnie wtedy wywołamy na tym typie metodę Visit().
Nasze elementy wyglądają tak samo. Pamiętaj, że każdy z nich ma tę domyślną implementację metody Visit() z możliwością przeciążenia.
public class SubtractionExpression : Expression4
{
public Expression4 Right { get; set; }
public Expression4 Left { get; set; }
public SubtractionExpression
(Expression4 left, Expression4 right)
{
this.Left = left;
this.Right = right;
}
}
public class DoubleExpression : Expression4
{
public double Value { get; set; }
public DoubleExpression(double value) { this.Value = value; }
}
Do sklejenia tego podejścia "Acyklicznego" potrzebujmy jeszcze drukarki, która nam wyświetli te wyrażenie w formie tekstowej.
public class ExpressionPrinter : IVisitor,
IVisitor<Expression4>,
IVisitor<DoubleExpression>,
IVisitor<SubtractionExpression>
{
StringBuilder sb = new StringBuilder();
public void Visit(DoubleExpression de) { }
public void Visit(SubtractionExpression ae) { }
public void Visit(Expression4 obj)
{
// domyślne zachowanie if i else
}
public override string ToString() => sb.ToString();
}
Nasza drukarka musi implementować "IVisitor" i wszystkie generyczne pod typy interfejsu "IVisitor<T>" dla każdego wyrażenia, które istnieje systemu.
Niestety i tutaj pojawia się problem, z tym że nasze złożone wyrażenia zawierają po prostu definicję wyrażenia ogólnego "Expression4".
public class ExpressionPrinter4 : IVisitor,
IVisitor<Expression4>,
IVisitor<DoubleExpression>,
IVisitor<SubtractionExpression>
{
StringBuilder sb = new StringBuilder();
public void Visit(Expression4 obj)
{
if (obj is DoubleExpression)
Visit(obj as DoubleExpression);
if (obj is SubtractionExpression)
Visit(obj as SubtractionExpression);
}
public void Visit(DoubleExpression de)
{
sb.Append(de.Value);
}
public void Visit(SubtractionExpression se)
{
sb.Append("(");
se.Left.Accept(this);
sb.Append("-");
se.Right.Accept(this);
sb.Append(")");
}
public override string ToString() => sb.ToString();
}
Znowu albo napiszemy if i else, aby obsłużyć taki problem ,albo skorzystam ze słowa kluczowego dynamic kosztem wydajności.
public void Visit(Expression4 obj)
{
Visit((dynamic)obj);
}
Sprawdźmy, czy podejście acykliczne działa pisząc taki kod:
var expression4 = new SubtractionExpression(
left: new DoubleExpression(1),
right: new SubtractionExpression(
left: new DoubleExpression(2),
right: new DoubleExpression(3)));
ExpressionPrinter4 expressionPrinter4
= new ExpressionPrinter4();
expressionPrinter4.Visit(expression4);
Console.WriteLine(expressionPrinter4);
Pamiętaj też, że w metodzie ogólnej Visit() w naszej drukarce możemy także wyrzucać wyjątki, gdy trafi się metoda Visit, którą nie obsługujemy.
Podsumowanie
Wzorzec projektowy Visitor pozwala ci dodawać unikatowe zachowania dla każdego elementu danego wyrażenia, które też znajduje się w jakieś hierarchii.
Oto na ile sposobów możesz skorzystać z tego wzorca :
- Intrusive : Dodajemy metodę do każdego elementu określającego cegiełki naszego wyrażenia. Później zgodnie z hierarchią wykonasz zbiór operacji na tych cegiełkach. Te podejście łamie zasadę otwarte-zamknięte oraz zasadę pojedynczej odpowiedzialności.
- Reflective : Dodajemy oddzielną klasę w tym przypadku klasę operacji drukowania tak, aby nie robić zmian w naszych element. Takich oddzielnych klas możesz mieć wiele. Przykładowo stworzyliśmy inną klasę, która wykonywała operacje matematyczne na naszym wyrażeniu.
- Dynamic : Zamiast pisać if i else możesz rzutować dany pod element na typ dynamiczny. To wszystko się stanie kosztem szybkości działania programu.
- Classic : Cała hierarchia elementów ulega zmianie tylko raz. Każdy element ma metodę Accept(), która przyjmuje danego odwiedzającego.
- Acyclic : Daje nam możliwość bardziej elastycznej relacji pomiędzy odwiedzającym a odwiedzanym. Dzięki temu możemy mieć zbiór odwiedzających względem jednego zadania.
Wzorzec projektowy Visitor łączy się ze wzorcem Interpreter. Dlatego ta dwa wzorce zostawiłem sobie na koniec tego cyklu.
Warto poznać podstawy tego wzorca, zanim skoczysz do wzorca Visitor stworzonych dla kompilatora Roslyn, który potrafi analizować składnie napisanego kodu, jak i ją modyfikować.
Ja mogę sobie gratulować, ponieważ w tym cyklu właśnie omówiliśmy wszystkie wzorce projektowe z Gang of Four. Pozostało mi przygotować jedno wielkie repozytorium GitHub z tymi wszystkim przykładami.