Table of Contents

Visitor pattern

Obiective

Scopul acestui laborator este prezentarea design pattern-ului Visitor și familiarizarea cu situațiile în care acesta este util de aplicat.

Design Patterns

Design pattern-urile reprezintă soluții generale și reutilizabile ale unei probleme comune în design-ul software. Un design pattern nu este un design în forma finală, ceea ce înseamnă ca nu poate fi transformat direct în cod. Acesta este o descriere a soluției sau un template ce poate fi aplicat pentru rezolvarea problemei. In general pattern-urile orientate obiect arată relațiile și interacțiunile dintre clase sau obiecte, fără a specifica însă forma finală a claselor sau obiectelor implicate.

Design Pattern-urile fac parte din domeniul modulelor și interconexiunilor. La un nivel mai înalt se găsesc pattern-urile arhitecturale (Architectural Patterns) ce descriu structura întregului sistem.

Se consideră că exista aproximativ 2000 de design patterns [2], iar principalul mod de a le clasifica este următorul:

O carte de referință pentru design patterns este “Design Patterns: Elements of Reusable Object-Oriented Software” [1], denumită și “Gang of Four”. Aceasta definește 23 de design patterns, foarte cunoscute și utilizate în prezent. Aplicațiile pot încorpora mai multe pattern-uri pentru a reprezenta legături dintre diverse componente (clase, module). În afară de GoF, și alți autori au adus în discuție pattern-uri orientate în special pentru aplicațiile enterprise și cele distribuite.

Pattern-urile GoF sunt clasificate după următoarele tipuri:

Design pattern-urile nu trebuie privite ca niște rețete care pot fi aplicate direct pentru a rezolva o problemă din design-ul aplicației, pentru că de multe ori pot complica inutil arhitectura. Trebuie întâi înțeles dacă este cazul să fie aplicat un anumit pattern, si de-abia apoi adaptat pentru situația respectivă. Este foarte probabil chiar să folosiți un pattern (sau o abordare foarte similară acestuia) fără să vă dați seama sau să îl numiți explicit. Ce e important de reținut după studierea acestor pattern-uri este un mod de a aborda o problemă de design.

Visitor

Design pattern-ul Visitor oferă o modalitate de a separa un algoritm de structură pe care acesta operează. Avantajul constă în faptul că putem adauga noi posibilităţi de prelucrare a structurii, fără să o modificăm. Extrapolând, folosind Visitor, putem adăuga noi funcţii care realizează prelucrări asupra unei familii de clase, fără a modifica efectiv structura claselor.

Acest pattern este comportamental (behavioral) pentru că definește modalități de comunicare între obiecte.

Cum recunoaștem o situație în care Visitor e aplicabil?

Decizia de utilizare a pattern-ului Visitor este în strânsă legătură cu stabilitatea ierarhiilor de clase prelucrate: dacă noi clase copil sunt adăugate rar, atunci se poate aplica acest pattern (într-o manieră eficientă), altfel nu este indicat.

Structură

 Fig. 1: Componente pattern Visitor

Visitor - o interfață pentru operația aplicată Visitable - o interfață pentru obiecte pe care pot aplicate operațiile (în diagramă este numită Element)

Pentru fiecare algoritm/operație ce trebuie aplicată, se implementează clase de tip Visitor. În fiecare obiect de tip Visitor trebuie să implementăm metode care aplică operația pentru fiecare tip de element vizitabil.

În figure 2 este reprezentat flow-ul aplicării acestui pattern:
  1. Clientul este cel care folosește o colecție de obiecte de unul sau mai multe tipuri, și dorește să aplice pe acestea diferite operații (în exercițiile din laborator clientul este practic programul vostru de test - main-ul). Clientul folosește obiecte Visitor create pentru fiecare operație necesară.
  2. Clientul parcurge colecția și în loc să aplice direct pe fiecare obiect operația, îi oferă acestuia un obiect de tip Visitor.
  3. Obiectul apelează metoda de “vizitare” oferită de Visitor.
  4. Pe obiectul Visitor se apelează metoda visit corespunzătoare obiectului, iar în ea se efectuează operația. (!!! în Visitor folosim conceptul de overloading pentru fiecare metodă visit)

Fig. 2: Interacțiunile dintre componentele pattern-ului Visitor

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? Ce se intamplă însă, când dorim să vizităm o structură complexă de obiecte? (listă, arbore, graf etc):

  • declanşarea vizitării se va face printr-un apel accept pe un prim obiect (e.g. rădacina 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.

Traversarea structurii poate fi realizată in 3 moduri:

  • de către structură
  • în cadrul vizitatorului, în cazul unor parcurgeri cu o logică mai complexă
  • în conjuncţie cu un iterator, care dictează ordinea de vizitare

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 (Boss), 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 Boss extends Employee {        
        float bonus;
        public Boss(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) {
                Boss boss;
                List<Employee> employees = new LinkedList<Employee>();                
                employees.add(new Employee("Alice", 20));
                employees.add(boss = new Boss("Bob", 1000));
                boss.setBonus(100);
        }
}

Ne interesează să interogăm toţi angajaţii noştri asupra venitului lor total. Observăm că:

Varianta la indemâ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 Boss extends Employee {
        ...       
        public float getTotalRevenue() {
                return salary + bonus;
        }
}

Acum ne interesează să calulăm procentul mediu pe care îl reprezintă bonusul din venitul şefilor, luându-se în considerare doar bonusurile pozitive. Avem două posibilităţi:

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 final, tragem concluzia că este de dorit să izolăm algoritmii de clasele pe care le prelucrează. O primă idee se referă la utilizarea metodelor statice. Dezavantajul acestora este că nu pot reţine, într-un mod elegant, informaţie de stare din timpul prelucrării. De exemplu, dacă structura noastră ar fi arborescentă (recursivă), în sensul că o instanţă Boss ar putea ţine referinţe la alte instanţe Boss, ce reprezintă şefii ierarhic inferiori, o funcţie de prelucrare ar trebui să menţină o informaţie parţială de stare (precum suma procentelor calculate până într-un anumit moment) sub forma unor parametri furnizaţi apelului recursiv:

class Boss extends Employee {
        ...
        public float getPercentage(float sum, int n) {
                float f = bonus / getTotalRevenue();
                if (f > 0)
                        return inferiorBoss.getPercentage(sum + f, n + 1); // trimite mai departe cererea catre nivelul inferior                
                return inferiorBoss.getPercentage(sum, n);
        }        
}

O abordare mai bună ar fi:

After

Conform obsrevațiilor precedente, structura programului Employee-Boss devine:

interface Visitor {
        public void visit(Employee e);
        public void visit(Boss b);
}
interface Visitable {
        public void accept(Visitor v);
}
class Employee implements Visitable {
        ...      
        public void accept(Visitor v) {
                v.visit(this);          
        }
}
class Boss 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 e) {
                System.out.println(e.getName() + " " + e.getSalary());                
        }        
        public void visit(Boss b) {
                System.out.println(b.getName() + " " + (b.getSalary() + b.getBonus()));                
        }       
}

Secvenţele de cod de mai sus definesc:

În exemplul de mai sus, putem identifica :

Double-dispatch

Mecanismul din spatele pattern-ului Visitor poartă numele de double-dispatch. Acesta este un concept raspândit, şi se referă la faptul că metoda apelată este determinată la runtime de doi factori. În exemplul Employee-Boss, efectul vizitarii, solicitate prin apelul e.accept(v), depinde de:

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.

Aplicabilitate

Pattern-ul Visitor este util când:

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:

Exerciţii

  1. (1p) Scheletul de laborator conține implementarea folosind Visitor a scenariului Employee-Boss descris mai sus. Rulați codul și observați comportamentul și interacțiunea dintre obiectele vizitate și obiectul de tip Visitor.
  2. (6p) Obiectele Employee-Boss pot fi reprezentate printr-o structură arborescentă, ce are ca rădăcină un Boss (ceo-ul). Creați un Visitor care să permită parcurgerea ierarhiei și efectuarea unei acțiuni pe fiecare nod. Acea acțiune este practic o operație, implementată într-o altă clasă de tip Visitor, deci TreeVisitor-ul va primi un obiect de tip Visitor pe care să îl aplice pe nodurile parcurse.
    • fiecare Boss va ţine referinţe către angajaţii aflaţi sub răspunderea lui directă (ce pot fi alţi sefi la rândul lor, sau salariaţi obişnuiţi)
    • implementați un TreeVisitor care pentru:
      • Employee - aplică operația primită
      • Boss - parcurge subordonații și apoi aplică operația primită pe Boss
    • implementați un AverageIncomeVisitor care calculează venitul mediu pe toate compania (sum_salary/num_employees)
  3. (3p) Adăugați încă un tip de obiect vizitabil - Intern. Acesta nu are salariu, doar nume și durata (în luni) a internship-ului.
    • modificați clasele existente deja, pentru a lua în considerare și obiectele Intern
    • testați operațiile de la exercițiile anterioare pe o colecție care conține și obiecte Intern
    • :!: Observați modificările pe care le-ați efectuat pentru a adăuga o nouă operație (ex. 2) și pe cele pentru a adăuga un nou tip de obiect în colecție. Ca să merite să aplicăm pattern-ul Visitor, ce situație ar trebui să fie evitată?
  4. (bonus - 2p) Calculați recursiv dimensiunea în bytes a unui director folosind java.nio.

Resurse

Referințe

  1. Vlissides, John, et al. Design patterns: Elements of reusable object-oriented software. Addison-Wesley (1995).
  2. Smith, Jason. Elemental Design Patterns. Addison-Wesley, 2012.