Table of Contents

Laboratorul 7: Overriding, overloading & Visitor pattern

Video introductiv: link

Obiective

Polimorfismul

Polimorfismul reprezintă abilitatea unei clase să se comporte ca o altă clasă de pe lanțul de moștenire, și de aceea conceptul de suprascriere a metodelor este foarte strâns legat.

Mai precis, polimorfismul permite obiectelor de tipuri diferite să fie tratate folosind o interfață comună sau o clasă de bază. Există două tipuri principale de polimorfism în POO: polimorfismul prin suprascrierea metodelor (override) și polimorfismul prin supraincarcarea metodelor (overloading).

Overriding

Suprascrierea se referă la redefinirea metodelor existente în clasa părinte de către clasa copil în vederea specializării acestora.

Semnătura (signature) unei metode constă în:

  • numele metodei
  • numărul și tipul parametrilor

În cazul suprascrierii se determină ce metodă va fi apelată, în mod dinamic, la runtime. Explicația este că decizia se face pe baza tipului obiectului care apelează metoda, deci a instanței, care e cunoscută la runtime. Din acest motiv, suprascrierea este cunoscută și ca polimorfism dinamic (Runtime polymorphism).

La apelarea unei metode suprascrise, Java se uită la tipul intern al obiectului pentru care este apelată metoda, NU la referință. Astfel dacă referința are tipul clasei părinte, dar tipul obiectului este al clasei copil, JVM va apela metoda din clasa copil.

Câteva reguli pentru suprascriere sunt:

În exemplul de mai jos, metodele purr și getFeatures au fost suprascrise de tipul GrumpyCat.

class CatFeatures { }
class GrumpyCatFeatures extends CatFeatures { }
class GrumpyFeatures { }
 
class Cat {
 
	public void purr() {
		System.out.println("purrrr");
	}
 
	public CatFeatures getFeatures() {
		System.out.println("Cat getFeatures");
		return new CatFeatures();
	}
 
	public final void die() {
		System.out.println("Dying! frown emoticon");
	}
}
 
class GrumpyCat extends Cat {
        @Override
	public void purr() {
		System.out.println("NO!");
	}
 
        @Override
	public GrumpyCatFeatures getFeatures() {
		System.out.println("Grumpy getFeatures");
		return new GrumpyCatFeatures();
	}
 
        // compiler would complain if you included @Override here
        //@Override
	//public void die() { } // Cannot override the final method from Cat
 
	public static void main(String [] args) {
		ArrayList<Cat> cats = new ArrayList<Cat>();
		cats.add(new Cat());
		cats.add(new GrumpyCat());
 
		for (Cat c : cats) {
			c.purr();
			c.die();
			c.getFeatures();
		}
	}
}
 

Adnotarea (Annotation) @Override este complet opțională. Totuși este indicat să o includeți mereu când suprascrieți o metodă. Motivele sunt simple:

  • Compilatorul vă va anunța printr-o eroare dacă ați greșit numele metodei sau tipul parametrilor și această nouă metodă nu suprascrie de fapt o metodă a părintelui
  • Face codul vostru mai ușor de citit, pentru că devine evident când o metodă suprascrie o altă metodă

O metodă cu argumente de tip primitiv nu poate fi suprascrisă cu o metodă cu tip wrapper.

public void doSmth(int x) nu poate fi suprascrisă cu public void doSmth(Integer x)

Metoda cu argument de tip wrapper poate primi si null, insă cea cu tipul primitiv nu, de aceea, neputând să fie păstrată echivalența, nu este permisă aceasta suprascriere

Overloading

Supraîncarcarea se referă la posibilitatea de a avea într-o clasă mai multe metode cu același nume, dar implementari diferite. În Java, compilatorul poate distinge între metode pe baza semnăturii lor, acesta fiind mecanismul din spatele supraîncărcarii.

Opțional, pe lângă semnătura metodei poate fi menționat și tipul excepțiilor ce pot fi aruncate din codul acesteia.

Tipul de return al unei metode NU face parte din semnătura acesteia. Din acest motiv simpla modificare a tipului de return al unei metode nu este suficientă pentru supraîncărcare. Ceea ce vom primi este o eroare de compilare.

public class TRex {
    public void eat(Triceratops victim) {
        System.out.println("Take 5 huge bites");
    }
 
    public boolean eat(Triceratops victim) {
        boolean satisfaction = false;
        if (victim.isJuicy()) {
            System.out.println("Eat and be satisfied");
            satisfaction = true;
        }
        return satisfaction;
    }
 
    // Error "Duplicate method eat(Triceratops)" in type TRex

Observăm de asemenea că la compilare nu se ține cont de numele dat parametrilor. Astfel modificarea acestuia din victim în dino, spre exemplu, nu constituie o supraîncărcare validă.

Spre deosebire de suprascriere, supraîncărcarea are loc la compilare, motiv pentru care mai este numită și polimorfism static (compile time polymorphism). În aceasta fază compilatorul decide ce metodă este apelată pe baza tipului referinței și prin analiza numelui și a listei de parametri. La runtime, când este întalnit apelul unei metode supraîncărcate, deja se știe unde este codul care trebuie executat.

Mai jos avem un exemplu valid de supraîncărcare pentru metoda eat:

public class TRex {
 
    public void eat(Triceratops victim) {                   
        System.out.println("Take 5 huge bites");
    }
 
    public void eat(Dromaeosaurus victim) {                  // parametru cu tip diferit
        System.out.println("Take 1 single bite");
    }
 
    public void eat(Human firstCourse, Human secondCourse) { // numar si tipuri diferite de parametrii 
        System.out.println("No humans to eat at the time");
    }
 
    public int eat(Grass desert) {                           // parametru cu tip diferit, return type este irelevant
        System.out.println("Rather starve");
        return 0;
    }
 
    public static void main(String [] args) {
        TRex john = new TRex();
 
        john.eat(new Triceratops());                     // "Take 5 huge bites"
        john.eat(new Dromaeosaurus());                   // "Take 1 single bite"
        john.eat(new Human("Ana"), new Human("Andrei")); // "No humans to eat at the time"
        john.eat(new Grass());                           // "Rather starve" 
    }
}

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 bucata de cod ce poate fi aplicata 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 behavioral 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 behavioral (comportamental) pentru că definește modalități de comunicare între obiecte.

Aplicabilitate

Pattern-ul Visitor este util când:

Structură

 Componente pattern Visitor

Structura design pattern-ului “Visitor” este formată din următoarele componente:

Client:

Visitor:

ConcreteVisitor:

Visitable:

ConcreteVisitable:

Flowul aplicării acestui pattern:

  1. 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.
  2. 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ă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.

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ă:

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:

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 abordare bună ar fi:

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:

Î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-Manager, 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.

Tutorialul de double-dispatch oferă mai multe detalii legate de acest mecanism.

Cum implementăm?

  1. Se declară interfața care să reprezinte elementul nostru, care va conține și metoda public void accept(ElementVisitor elementVisitor);
  2. Se creează clasele concrete care implementează interfața declarată anterior. Body-ul pentru metoda accept va conține obligatoriu elementVisitor.visit(this);
  3. 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).
  4. 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())
  5. Î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:

Dezavantaje:

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 bucăți de text pe care să le convertim în diferite formate, momentan dokuwiki și markdown. Pentru un design decuplat între obiectele prelucrate și tipurile de formate dorite, implementați conversia folosind patternul Visitor.

Referințe

  1. Kathy Sierra, Bert Bates. SCJP Sun Certified Programmer for Java™ 6 - Study Guide. Chapter 2 - Object Orientation (available online) - moștenire, polimorfism, overriding, overloading + exemple de întrebări
  2. Vlissides, John, et al. Design patterns: Elements of reusable object-oriented software. Addison-Wesley (1995) (available online)