Laboratorul 6: Visitor pattern
Obiective
Visitor Design Pattern
Design pattern-urile reprezintă soluții generale și reutilizabile ale unei probleme comune în design-ul software. Un design pattern este o descriere a soluției sau un template ce poate fi aplicat pentru rezolvarea problemei, nu o bucată de cod ce poate fi aplicată direct. În general, pattern-urile orientate pe obiect arată relațiile și interacțiunile dintre clase sau obiecte, fără a specifica însă forma finală a claselor sau a obiectelor implicate.
Visitor este un behavioural design pattern ce oferă posibilitatea de a adăuga în mod extern funcționalități pe o întreagă ierarhie de clase, fără să fie nevoie să modificăm efectiv structura acestora.
Acest pattern este behavioural (comportamental) pentru că definește modalități de comunicare între obiecte.
Aplicabilitate
Pattern-ul Visitor este util când:
se dorește prelucrarea unei structuri complexe, ce cuprinde mai multe obiecte de tipuri diferite
se dorește definirea de operații specifice pentru aceeași structură, fără a polua interfețele claselor implicate, cu multe detalii specifice algoritmilor. Vizitatorul centralizează logica comună, păstrând în același timp detaliile specifice în interiorul acestuia.
clasele ce se doresc prelucrate se modifică rar, în timp ce operațiile de prelucrare se definesc des. Vizitatorul permite adăugarea de noi funcționalități fără modificarea claselor existente.
Structură
Structura design pattern-ului “Visitor” este formată din următoarele componente:
Client:
Este clasa consumatoare a design pattern-ului “Visitor”.
Are acces la obiectele din structura de date și poate instrui aceste obiecte să accepte un “Visitor” pentru a realiza prelucrările corespunzătoare.
Exemplu: O aplicație care procesează diferite tipuri de elemente într-o structură de date complexă.
Visitor:
Este o interfață sau o clasă abstractă folosită pentru a declara operațiile de vizitare pentru toate tipurile de clase vizitabile.
Conține metode de vizitare corespunzătoare fiecărui tip de clasă vizitabilă.
Exemplu: Interfața Visitor cu metodele visit(ElementA elementA), visit(ElementB elementB), etc.
ConcreteVisitor:
Pentru fiecare tip de “Visitor”, toate metodele de vizitare definite în “Visitor” trebuie implementate.
Fiecare “Visitor” este responsabil pentru diferite operații.
Exemplu: Clasa ConcreteVisitorA implementând interfața Visitor cu metodele sale specifice pentru tratarea diferitelor tipuri de elemente.
Visitable:
Este o interfață pentru obiecte pe care pot fi aplicate operațiile.
Această operație permite unui obiect să fie “vizitat” de către un obiect “Visitor”.
Exemplu: Interfața Visitable cu metoda accept(Visitor visitor).
ConcreteVisitable:
Aceste clase implementează interfața sau clasa Visitable și definesc operația accept.
Prin intermediul acestei operații, obiectul “Vizitabil” primește un obiect “Visitor”.
Exemplu: Clasele ConcreteElementA, ConcreteElementB, etc., care implementează interfața Visitable și definesc metoda accept.
Flow-ul aplicării acestui pattern:
Când un client dorește să efectueze operații pe obiectele vizitabile, el creează un obiect vizitator corespunzător, le “vizitează” apelând metoda accept, iar fiecare obiect vizitabil interacționează cu vizitatorul prin intermediul metodelor visit.
Acest pattern oferă o modalitate de a separa algoritmii de obiectele pe care operează, facilitând extinderea și adăugarea de noi operații fără a modifica clasele obiectelor vizitabile.
Visitor și structurile de date
Aparent, folosirea lui accept este artificială. De ce nu declanșăm vizitarea unui obiect, apelând direct v.visit(e) atunci când dorim vizitarea unui obiect oarecare? Răspunsul vine însă chiar din situațiile în care vrem să folosim pattern-ul; vrem să lăsăm structura internă a colecţiei să facă aplicarea vizitatorilor. Cu alte cuvinte, vizitatorul se ocupă de fiecare obiect în parte, iar colecţia îl “plimbă” prin elementele sale. De exemplu, când dorim să vizităm un arbore:
declanşarea vizitării se va face printr-un apel accept
pe un prim obiect (e.g. rădăcina arborelui)
elementul curent este vizitat prin apelul v.visit(this)
pe lângă vizitarea elementului curent, este necesar sa declanşăm vizitarea tuturor elementelor accesibile din elementul curent (e.g. nodurile-copil din arbore etc). Realizăm acest lucru apelând accept
pe fiecare dintre aceste elemente. Acest comportament depinde de logica structurii.
Scenariu Visitor
Pentru a înţelege mai bine motivaţia din spatele design-pattern-ului Visitor, să considerăm următorul exemplu.
Before
Fie ierarhia de mai jos, ce defineşte un angajat (Employee) şi un şef (Manager), văzut, de asemenea, ca un angajat:
- Test.java
class Employee {
String name;
float salary;
public Employee(String name, float salary) {
this.name = name;
this.salary = salary;
}
public String getName() {
return name;
}
public float getSalary() {
return salary;
}
}
class Manager extends Employee {
float bonus;
public Manager(String name, float salary) {
super(name, salary);
bonus = 0;
}
public float getBonus() {
return bonus;
}
public void setBonus(float bonus) {
this.bonus = bonus;
}
}
public class Test {
public static void main(String[] args) {
Manager manager;
List<Employee> employees = new LinkedList<Employee>();
employees.add(new Employee("Alice", 20));
employees.add(manager= new Manager("Bob", 1000));
manager.setBonus(100);
}
}
Ne interesează să interogăm toţi angajaţii noştri asupra venitului lor total. Observăm că:
angajaţii obişnuiţi au salariul ca unic venit
şefii posedă, pe lângă salariu, un posibil bonus
Varianta la îndemână ar fi să definim în fiecare din cele doua clase, câte o metodă, getTotalRevenue(), care întoarce salariul pentru angajaţi, respectiv suma dintre salariu şi bonus pentru şefi:
class Employee {
...
public float getTotalRevenue() {
return salary;
}
}
class Manager extends Employee {
...
public float getTotalRevenue() {
return salary + bonus;
}
}
Acum ne interesează să calculăm procentul mediu pe care îl reprezintă bonusul din venitul şefilor, luându-se în considerare doar bonusurile pozitive. Avem două posibilităţi:
Definim câte o metodă, getBonusPercentage(), care în Employee întoarce mereu 0, iar în Manager raportul real. Dezavantajul constă în adăugarea în interfeţe a unor funcţii prea specializate, de detalii ce ţin doar de unele implementări ale acestora.
Parcurgem lista de angajaţi, testăm, la fiecare pas, tipul angajatului, folosind instanceof
, şi calculăm, doar pentru şefi, raportul solicitat. Dezavantajul este tratarea într-o manieră neuniformă a structurii noastre, cu evidenţierea particularităţilor fiecărei clase.
Datorită acestor particularităţi (în cazul nostru, modalităţile de calcul al venitului, respectiv procentului mediu), constatăm că ar fi foarte utilă izolarea implementărilor specifice ale algoritmului (în cazul nostru, scrierea unei funcţii în fiecare clasă). Acest lucru conduce, însă, la introducerea unei metode noi în fiecare din clasele antrenate in prelucrări, de fiecare dată cand vrem să punem la dispoziţie o nouă operaţie. Obţinem următoarele dezavantaje:
în cazul unui număr mare de operaţii, interfeţele claselor se aglomerează excesiv şi se ascunde funcţionalitatea de bază a acestora
codul din interiorul clasei (care servea functionalităţii primare a acesteia) va fi amestecat cu cel necesar algoritmilor de prelucrare, devenind mai greu de parcurs şi întreţinut
în cazul în care nu avem acces la codul claselor, singura modalitate de adăugare de funcţionalitate este extinderea
În final, tragem concluzia că este de dorit să izolăm algoritmii de clasele pe care le prelucrează.
O abordare bună ar fi:
conceperea claselor cu posibilitatea de primire/ataşare a unor obiecte-algoritm, care definesc operaţiile dorite
definirea unor clase algoritm care vor vizita structura noastră de date, vor efectua prelucrările specifice fiecărei clase, având, totodată, posibilitatea de încapsulare a unor informaţii de stare (cum sunt suma şi numărul din exemplul anterior)
After
Conform observațiilor precedente, structura programului Employee-Manager devine:
- Test.java
interface Visitor {
public void visit(Employee employee);
public void visit(Manager manager);
}
interface Visitable {
public void accept(Visitor v);
}
class Employee implements Visitable {
...
public void accept(Visitor v) {
v.visit(this);
}
}
class Manager extends Employee {
...
public void accept(Visitor v) {
v.visit(this);
}
}
public class Test {
public static void main(String[] args) {
...
Visitor v = new SomeVisitor(); // creeaza un obiect-vizitator concret
for (Employee e : employees)
e.accept(v);
}
}
Iată cum poate arăta un vizitator ce determină venitul total al fiecărui angajat şi îl afişează:
- RevenueVisitor.java
public class RevenueVisitor implements Visitor {
public void visit(Employee employee) {
System.out.println(employee.getName() + " " + employee.getSalary());
}
public void visit(Manager manager) {
System.out.println(manager.getName() + " " + (manager.getSalary() + manager.getBonus()));
}
}
Secvenţele de cod de mai sus definesc:
o interfaţă, Visitor, ce reprezintă un algoritm oarecare, ce va putea vizita orice clasă. Observaţi definirea câte unei metode visit(…) pentru fiecare clasă ce va putea fi vizitată.
o interfaţă, Visitable, a carei metodă accept(Visitor)
permite rularea unui algoritm pe structura curentă.
implementări ale metodei accept(Visitor)
, în cele două clase, care, pur şi simplu, solicită vizitarea instanţei curente de către vizitator.
o implementare a unei operații aplicabilă pe obiectele de tip Visitable.
În exemplul de mai sus, putem identifica :
Double-dispatch
Mecanismul din spatele pattern-ului Visitor poartă numele de double-dispatch. Acesta este un concept răspândit, şi se referă la faptul că metoda apelată este determinată la runtime de doi factori. În exemplul Employee-Manager, efectul vizitării, solicitate prin apelul e.accept(v)
, depinde de:
tipul elementului vizitat, e
(Employee sau Manager), pe care se invocă metoda
tipul vizitatorului, v
(RevenueVisitor), care conţine implementările metodelor visit
Acest lucru contrastează cu un simplu apel e.getTotalRevenue(), pentru care efectul este hotărât doar de tipul anagajatului. Acesta este un exemplu de single-dispatch.
Cum implementăm?
Se declară interfața care să reprezinte elementul nostru, care va conține și metoda public void accept(ElementVisitor elementVisitor);
Se creează clasele concrete care implementează interfața declarată anterior. Body-ul pentru metoda accept va conține obligatoriu elementVisitor.visit(this);
Se definește o interfață care reprezintă Visitor-ul nostru și care va conține atâtea metode de visit câte clase concrete am creat la pasul anterior (câte o metodă de visit pentru fiecare tip de element).
Se creează o clasă concretă care implementează interfața de Visitor, unde vom adăuga implementările pentru fiecare tip de element în parte. (ex: ElementDisplayVisitor()
)
În main putem testa dacă funcționează așa cum ne dorim iterând print-un arraylist/vector de obiecte de tip Element astfel: elementIterator.accept(new ElementDisplayVisitor());
Avantaje și dezavantaje
Avantaje:
Decuplarea datelor de operațiile aplicate pe acestea
Ușurează adăugarea unor noi operații/algortimi. Se creează o implementare a unui obiect de tip Visitor și nu se schimbă nimic în obiecte vizitate.
Spre deosebire de Iterator poate gestiona elemente de tipuri diferite
Poate menține informații de stare pe măsură ce vizitează obiectele
Dezavantaje:
Depinde de stabilitatea ierarhiei de obiecte vizitate. Adăugarea de obiecte vizitabile rezultă în schimbarea implementării obiectelor Visitor.
obiecte de noi tipuri adăugate des + multe operații aplicabile = NU folosiți Visitor
Expune metode publice care folosesc informații de stare ale obiectelor. Nu se pot accesa membrii privați ai claselor, necesitatea expunerii acestor informaţii (in forma publică) ar putea conduce la ruperea încapsulării
Exemple din API-uri
Visitor este de obicei utilizat pentru structuri arborescente de obiecte:
Summary
Visitor = behavioral design pattern
Exerciţii
Dorim să prelucrăm forme geometrice, pe care să le afișăm în diverse formate: text și JSON https://datatracker.ietf.org/doc/html/rfc8259. Pentru un design decuplat între formele prelucrate și tipurile de formate dorite, implementați conversia folosind patternul Visitor.
Problema de pe DevMind va avea două task-uri, corespunzătoare celor două tipuri de Visitor. Pentru simplitatea implementării acestor Visitors, vă sugerăm să urmăriți TODO-urile din schelet.
Vom avea trei tipuri de forme geometrice care implementează interfața comună “Shape”: Dot, Circle, Rectangle. Aceste tipuri de forme vor accepta obiecte Visitor pentru a putea permite afișarea lor în cele două formate.
Vom avea două tipuri de Visitor care implementează interfața comună “Visitor”: TextVisitor și JsonVisitor. Fiecare Visitor va implementa metoda visit(), care va aplica modalitatea de afișare specifică pe obiectul primit ca parametru.
Scheletul conține în fiecare clasă copil a tipului Shape, câmpuri specifice formei geometrice. Pentru acestea, va trebui să creați getters și setters.
Tips for faster coding:
atunci când creați o clasă care implementează o interfață sau o clasă cu metode abstracte, nu scrieți de mână antetul fiecărei metode, ci folosiți-vă de IDE.
în Intellij va apărea cu roșu imediat după ce scrieți extends…/implements… Dați alt-enter sau option-enter (pe mac), și vi se vor genera metodele pe care trebuie să le implementați, voi completând apoi conținutul lor.
generați getters/setters folosind IDE-ul
Exemplu de format text
Circle - radius = 30
Exemplu de format JSON
{
"Circle": {
"radius": 30
}
}
Referințe
Vlissides, John, et al.
Design patterns: Elements of reusable object-oriented software. Addison-Wesley (1995) (
available online)
-
-