Pe parcursul laboratoarelor și temelor ați folosit entități și v-ați definit propriile tipuri de interacțiuni. În cadrul acestui laborator vom aprofunda câteva tipare eficiente și utilizate în industrie.
Aspectele urmărite sunt:
Aspectele bonus urmărite sunt:
Design pattern-urile reprezintă soluții generale și reutilizabile pentru probleme comune de design software. Ele nu oferă cod gata de folosit, ci șabloane și relații între clase și obiecte pentru a rezolva probleme recurente.
Design pattern-urile se împart în 3 familii:
Pattern-urile incluse în aceste două laboratoare sunt printre cele mai frecvent utilizate, însă vă recomandăm să continuați studiul și asupra altora pentru o înțelegere completă.
Singleton este un design pattern creational care se asigură că o clasă are:
Practic, atunci când există ceva ce are sens să existe o singură dată (un logger, un config manager, o conexiune, un registry global) folosim Singleton pentru a evita crearea mai multor obiecte.
Să ne imaginăm o aplicație care folosește o clasă Config pentru a încărca setări importante, citite dintr-un fișier config.properties.
De asemenea, avem trei clase ale aplicației unde fiecare își ia configurația în felul ei pentru a citi anumite proprietăți din fișier:
public class LoginPage { void render() { Config config = new Config(); // încărcare 1 System.out.println(config.get("page.theme")); } } public class DashboardPage { void render() { Config config = new Config(); // încărcare 2 System.out.println(config.get("panel.size")); } } public class ReportGenerator { void run() { Config config = new Config(); // încărcare 3 System.out.println(config.get("report.max_length")); } }
Apoi aplicația rulează:
new LoginPage().render(); new DashboardPage().render(); new ReportGenerator().run();
Rezultat: Fișierul config.json este citit de trei ori, deși conținutul este identic.
1. Multiplicarea inutilă a obiectelor
Ajungem să avem 3, 5 sau 20 de instanțe ale aceleiași configurații, toate identice.
2. Cost mare de inițializare
Fișierul poate avea multe date în el, astfel citirea lui devine scumpă. De ce să o facem de 20 de ori?
3. Inconsistență
Dacă fișierul se schimbă între două instanțieri, unele clase lucrează cu valori noi, altele cu valori vechi, ceea ce rezultă în bug-uri greu de depistat.
4. Lipsa controlului asupra ciclului de viață
Orice developer poate scrie:
new Config();
și creează o nouă instanță fără să știe.
5. Avem nevoie de acces global, dar „sigur”
Vrem să putem accesa configurația de oriunde, dar fără riscurile variabilelor globale care pot fi suprascrise accidental, ceea ce poate afecta clasele care folosesc această variabilă globală.
Singleton rezolvă problema printr-un mecanism clar:
1. Constructor privat
2. Metodă statică getInstance()
3. Instanță statică stocată intern
4. Oferă un punct global de acces
Astfel, clasa își controlează propriul ciclu de viață și asigură existența unei singure instanțe, oriunde în program.
Una dintre cele mai simple și sigure implementări, instanța este creată când e încărcată clasa, nu când este folosită.
public class Singleton { // Instanțiem direct obiectul la declarare private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } }
Avantaje:
Dezavantaje:
Instanță este creată doar când este necesară.
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); // creat DOAR la prima utilizare } return instance; } }
Problemă:
if (instance == null) și crea două instanțe.Se poate rezolva cu:
Aceasta este considerată cea mai sigură implementare a unui Singleton în Java:
public enum Singleton { INSTANCE; public void doSomething() { System.out.println("Working..."); } }
Avantaje:
| Avantaj | De ce e util |
|---|---|
| O singură instanță | Evită duplicarea resurselor |
| Acces global | Cod simplu, accesibil de oriunde |
| Inițializare controlată | Poate încărca resurse la nevoie |
| Bun pentru resurse costisitoare | DB connection manager, config, cache |
| Problemă | Explicație |
|---|---|
| Violare SRP | Clasa controlează logica + ciclul de viață |
| Global state | Duce la dependențe ascunse, greu de urmărit |
| Testare dificilă | Starea globală afectează testele |
| Maschează design prost | Devine „soluția rapidă” la orice, dar greșit |
O alternativă foarte bună la Singleton este agregarea: creezi o singură instanță a clasei (ex: Config) și o transmiți în constructorul claselor care au nevoie de ea.
Avantaje:
Există totuși câteva probleme cu această abordare:
1. Pasarea responsabilității
Dacă folosim agregare, trebuie ca cineva să gestioneze:
De obicei, acest „cineva” este un container de dependențe sau o clasă compoziție (ex. App).
Fără un framework (Spring, Micronaut, Guice), trebuie să gestionezi tu manual acest „compozitor”.
În aplicațiile mai mari, asta devine rapid greu de întreținut și poate deveni o sursă de bug-uri (ex. un programator reinițializează referința care a fost pasată, deci clasele care o foloseau vor lucra cu obiectul vechi).
2. Se pot crea oricâte obiecte
Agregarea nu garantează unicitatea.
Dacă un developer scrie:
new Config();
nimic nu îl oprește.
Putem ajunge, accidental, în aceeași situație pentru care Singleton a fost inventat:
Singleton, în schimb, controlează strict numărul instanțelor.
Situație comună într-o aplicație reală:
public class ConfigManager { private static ConfigManager instance; private Properties props; private ConfigManager() { props = new Properties(); try(FileInputStream input = new FileInputStream("app.config")) { props.load(input); } catch (IOException e) { throw new RuntimeException("Cannot load config"); } } public static ConfigManager getInstance() { if (instance == null) { instance = new ConfigManager(); } return instance; } public String get(String key) { return props.getProperty(key); } }
Utilizare:
String dbUrl = ConfigManager.getInstance().get("db.url");
Tot codul din aplicație citește configurația din aceeași instanță, fără duplicări, fără costuri mari la fiecare apel.
Factory Method este un pattern creational care rezolvă problema creării obiectelor atunci când nu vrem ca logica principală a aplicației să depindă de clasele concrete. În loc să instanțiem obiecte cu new direct în codul client, delegăm această responsabilitate unei metode “fabrică”. Această metodă poate fi suprascrisă de subclase pentru a controla tipul exact de obiect construit.
Scopul este să obținem un cod flexibil, ușor de extins și structurat astfel încât modificările să nu afecteze restul sistemului. Factory Method păstrează codul curat și respectă principiile OCP (Open-closed Principle) și DIP (Dependency Inversion Principle).
Presupune că ai o aplicație de livrare care trebuie să poată trimite pachete fie pe uscat (camion), fie pe mare (navă). Codul client (procesul de planificare) nu ar trebui să depindă de tipul concret de transport.
Dacă ai scrie ceva de genul:
if (type.equals("road")) { return new Truck(); } else { return new Ship(); }
codul ar deveni încărcat, greu de întreținut și greu de extins.
Factory Method evită această problemă prin mutarea instanțierii în subclase.
Structura se bazează pe 3 elemente:
Această structură permite extinderea aplicației fără a modifica codul deja scris, doar adăugăm noi creatori.
1. Produsul
interface Transport { void deliver(); }
2. Implementări concrete
class Truck implements Transport { @Override public void deliver() { System.out.println("Delivery by truck via road."); } } class Ship implements Transport { @Override public void deliver() { System.out.println("Delivery by ship on sea."); } }
3. Creatorul abstract cu factory method
abstract class Logistics { public abstract Transport createTransport(); public void planDelivery() { Transport t = createTransport(); t.deliver(); } }
4. Creatorii concreți
class RoadLogistics extends Logistics { @Override public Transport createTransport() { return new Truck(); } } class SeaLogistics extends Logistics { @Override public Transport createTransport() { return new Ship(); } }
5. Codul client
public class Main { public static void main(String[] args) { Logistics road = new RoadLogistics(); road.planDelivery(); Logistics sea = new SeaLogistics(); sea.planDelivery(); } }
Factory Method oferă flexibilitate: fiecare subclasă de fabrică decide ce obiecte creează, fără ca restul aplicației să fie afectat. Clientul folosește doar metoda create() și nu trebuie să știe cum sau ce tip concret este instanțiat.
Acest pattern devine valoros atunci când sistemul trebuie extins cu noi tipuri de produse. Adăugarea unui nou tip implică doar crearea unei noi fabrici sau a unei noi implementări a metodei, fără modificarea codului existent, ceea ce îmbunătățește izolarea, testarea și mentenanța.
Abstract Factory este un pattern creational folosit atunci când avem nevoie să creem familii întregi de obiecte compatibile între ele, fără ca logica clientului să depindă de implementările concrete.
În loc să creem „un singur obiect” ca în Factory Method, Abstract Factory creează grupuri de obiecte care trebuie să funcționeze împreună.
Imaginează-ți că scrii o aplicație grafică care poate rula pe Windows și pe macOS. Fiecare platformă are propria estetică, propriul set de widget-uri și propriul comportament.
Putem asuma că vrem să creăm mai multe tipuri de butoane (ex. checkbox, clickable button, dropdown etc.). Dacă am combina manual obiectele, am risca să creem combinații incompatibile:
Abstract Factory garantează consistența interfeței și separă clar logica aplicației de logica de randare pentru exemplul dat.
Patternul are 4 componente principale:
1. Produsele abstracte
interface Button { void paint(); } interface Checkbox { void render(); }
2. Implementări concrete — Windows
class WindowsButton implements Button { @Override public void paint() { System.out.println("Painting Windows button."); } } class WindowsCheckbox implements Checkbox { @Override public void render() { System.out.println("Rendering Windows checkbox."); } }
3. Implementări concrete — macOS
class MacButton implements Button { @Override public void paint() { System.out.println("Painting macOS button."); } } class MacCheckbox implements Checkbox { @Override public void render() { System.out.println("Rendering macOS checkbox."); } }
4. Fabrica abstractă
interface GUIFactory { Button createButton(); Checkbox createCheckbox(); }
5. Fabrica Windows
class WindowsFactory implements GUIFactory { public Button createButton() { return new WindowsButton(); } public Checkbox createCheckbox() { return new WindowsCheckbox(); } }
6. Fabrica macOS
class MacFactory implements GUIFactory { public Button createButton() { return new MacButton(); } public Checkbox createCheckbox() { return new MacCheckbox(); } }
7. Codul client
class Application { private final Button button; private final Checkbox checkbox; public Application(GUIFactory factory) { this.button = factory.createButton(); this.checkbox = factory.createCheckbox(); } public void start() { button.paint(); checkbox.render(); } }
Principalul câștig este consistența: dacă alegem WindowsFactory, toate obiectele sunt Windows; dacă alegem MacFactory, totul rămâne macOS. Codul client nici nu știe ce platformă folosește.
Acest pattern devine esențial în aplicații mari, cross-platform sau cu teme diferite, când coerența între componente este critică.
Visitor este un behavioural design pattern care permite adăugarea de funcționalități la o ierarhie de clase fără a modifica structura acestora.
Să ne imaginăm o aplicație care gestionează noduri într-o structură geografică sau industrială: orașe (City), obiective turistice (SightSeeing) și facilități industriale (Industry). Dorim să procesăm aceste noduri diferit, de exemplu să le exportăm în diferite formate sau să le afișăm într-un anumit fel.
Prima soluție folosește o clasă ExportVisitor cu metode separate pentru fiecare tip de nod. Clientul trebuie să testeze tipul obiectului cu instanceof și să apeleze metoda corespunzătoare.
interface Node {} class City implements Node {} class Industry implements Node {} class SightSeeing implements Node {} // Clasa care conține operațiile de procesare class ExportVisitor { void doForCity(City c) { System.out.println("Export City"); } void doForIndustry(Industry f) { System.out.println("Export Industry"); } void doForSightSeeing(SightSeeing ss) { System.out.println("Export SightSeeing"); } } class Main { public static void main(String[] args) { List<Node> nodes = new ArrayList<>(); nodes.add(new City()); nodes.add(new Industry()); nodes.add(new SightSeeing()); ExportVisitor exportVisitor = new ExportVisitor(); // NOT GOOD: folosim instanceof pentru a selecta metoda corectă for (Node node: nodes) { if (node instanceof City) { exportVisitor.doForCity((City) node); } else if (node instanceof Industry) { exportVisitor.doForIndustry((Industry) node); } else if (node instanceof SightSeeing) { exportVisitor.doForSightSeeing((SightSeeing) node); } } } }
Probleme:
instanceof.
Încercăm să folosim supraîncărcare, definind o metodă export pentru fiecare tip de nod în clasa Exporter. Teoretic, aceasta ar trebui să aleagă metoda corectă automat.
interface Node { void show(); } class City implements Node { @Override public void show() { System.out.println("City display"); } } class Industry implements Node { @Override public void show() { System.out.println("Industry display"); } } class SightSeeing implements Node { @Override public void show() { System.out.println("SightSeeing display"); } } // Clasa care încearcă să folosească overloading class Exporter { void export(Node node) { System.out.println("Export Node"); } void export(City city) { System.out.println("Export City"); } void export(Industry industry) { System.out.println("Export Industry"); } void export(SightSeeing sightSeeing) { System.out.println("Export SightSeeing"); } } class Main { public static void main(String[] args) { Exporter exporter = new Exporter(); Node city = new City(); Node industry = new Industry(); Node sightseeing = new SightSeeing(); // Problema apare aici: overloading se rezolvă la compile-time exporter.export(city); // Va apela export(Node) ! exporter.export(industry); // Va apela export(Node) exporter.export(sightseeing);// Va apela export(Node) } }
Probleme:
city.show(); // va apela metoda suprascrisă din City industry.show(); // va apela metoda suprascrisă din Industry
Însă această soluție permite un singur comportament de afișare per clasă. Dacă vrem să afișăm același obiect în mai multe formate (TEXT, JSON, XML), nu putem suprascrie show() de trei ori. Ar trebui să delegăm afișarea către clase externe (ExporterText, ExporterJson, ExporterXml), iar dacă folosim overloading pentru acestea, revenim la aceeași problemă: overloading-ul se decide la compile-time, ignorând tipul real al obiectului.
Concluzie:
Avem nevoie de un mecanism care să determine metoda corectă la runtime, în funcție de tipul real al obiectului. Aceasta se face prin double dispatch, ceea ce duce la iterația 3 (Visitor).
| Componentă | Rol | Exemplu |
|---|---|---|
| Visitor | Interfață care definește metodele de vizitare | NodeVisitor cu visit(City), visit(Industry) etc. |
| ConcreteVisitor | Implementarea operațiilor pentru fiecare tip de obiect | NodePrintVisitor, NodeJsonVisitor |
| Visitable (Node) | Interfața pentru obiecte care pot fi vizitate | Node cu accept(Visitor v) |
| ConcreteVisitable | Implementarea obiectelor vizitabile | City, Industry, SightSeeing |
Când încercăm să apelăm metode diferite în funcție de tipul unui obiect, avem două mecanisme în Java:
Reluăm exemplul de problemă de mai sus:
class Exporter { void export(Node node) { System.out.println("Export Node"); } void export(City city) { System.out.println("Export City"); } } Node city = new City(); Exporter exporter = new Exporter(); exporter.export(city); // Apelează export(Node) !!!
Explicație:
Node, deci compilatorul alege metoda export(Node) la compilare.City, dar metoda corectă nu este apelată.City, Industry, SightSeeing).
Prin double dispatch, vrem ca metoda apelată să depindă de tipul obiectului vizitabil și de tipul vizitatorului.
accept(visitor) pe obiectul vizitabil.visit(this) din interiorul accept, unde this este tipul real al obiectului.Exemplu simplu:
interface Node { void accept(Visitor v); } class City implements Node { @Override public void accept(Visitor v) { v.visit(this); // tipul real al obiectului City este acum cunoscut } } interface Visitor { void visit(City city); }
node.accept(visitor) → depinde de tipul nodului (City).v.visit(this) → depinde de tipul vizitatorului și tipul concret al nodului.
Astfel, metoda corectă este aleasă la runtime, fără instanceof și fără cod duplicat.
1. Definirea nodurilor vizitabile
interface Node { void accept(Visitor v); } class City implements Node { @Override public void accept(Visitor v) { v.visit(this); } } class Industry implements Node { @Override public void accept(Visitor v) { v.visit(this); } } class SightSeeing implements Node { @Override public void accept(Visitor v) { v.visit(this); } }
2. Definirea vizitatorului
interface Visitor { void visit(City city); void visit(Industry industry); void visit(SightSeeing sightSeeing); }
3. Implementarea unui vizitator concret
class NodeVisitor implements Visitor { public void visit(City city) { System.out.println("Export City"); } public void visit(Industry industry) { System.out.println("Export Industry"); } public void visit(SightSeeing sightSeeing) { System.out.println("Export SightSeeing"); } }
4. Utilizarea pattern-ului
List<Node> nodes = new ArrayList<>(); nodes.add(new City()); nodes.add(new Industry()); nodes.add(new SightSeeing()); Visitor visitor = new NodeVisitor(); for (Node node : nodes) { node.accept(visitor); // double dispatch aplicat }
TL;DR: Folosim Visitor când vrem să avem mai mulți algoritmi (ex. ExporterPDF, ExporterXML, ExporterJSON) pentru mai multe tipuri de entități (ex. SeightSeeing, Industry, City).
Proxy este un structural design pattern care oferă un înlocuitor sau un intermediar pentru un alt obiect, controlând accesul la acesta. Obiectul proxy poate adăuga logica suplimentară, cum ar fi caching, logging, securitate sau încărcare întârziată (lazy loading), fără a modifica obiectul real.
Imaginează-ți o aplicație care lucrează cu un obiect costisitor de creat, de exemplu un fișier mare, o conexiune la rețea sau o imagine de înaltă rezoluție.
Exemplu simplu fără proxy
class RealImage { private String filename; public RealImage(String filename) { this.filename = filename; loadFromDisk(); } private void loadFromDisk() { System.out.println("Loading " + filename); } public void display() { System.out.println("Displaying " + filename); } } public class Main { public static void main(String[] args) { RealImage img1 = new RealImage("photo1.jpg"); img1.display(); RealImage img2 = new RealImage("photo1.jpg"); // se reîncarcă imaginea! img2.display(); } }
Probleme:
Structura pattern-ului Proxy implică 4 elemente principale:
1. Interfața Subject
interface Image { void display(); }
2. RealSubject
class RealImage implements Image { private String filename; public RealImage(String filename) { this.filename = filename; loadFromDisk(); } private void loadFromDisk() { System.out.println("Loading " + filename); } @Override public void display() { System.out.println("Displaying " + filename); } }
3. Proxy
class ProxyImage implements Image { private RealImage realImage; private String filename; public ProxyImage(String filename) { this.filename = filename; } @Override public void display() { if (realImage == null) { // lazy loading realImage = new RealImage(filename); } realImage.display(); } }
4. Clientul
public class Main { public static void main(String[] args) { Image image1 = new ProxyImage("photo1.jpg"); Image image2 = new ProxyImage("photo2.jpg"); // Prima afișare => încarcă obiectul real image1.display(); // A doua afișare => folosește obiectul deja creat image1.display(); image2.display(); } }
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.
Exemplu de format text
Circle - radius = 30
Exemplu de format JSON
{ "Circle": { "radius": 30 } }
Într-un joc futurist, creaturile sunt generate procedural (automat), iar pentru a evita haosul, jocul folosește un singur „Generator Universal de Creaturi”.
Acest generator trebuie să creeze mai multe tipuri de creaturi:
În joc trebuie să existe o singură instanță a generatorului (Singleton), altfel apar buguri în simulare.
Generatorul trebuie să includă un Factory Method pentru crearea creaturilor pe baza tipului lor.
Urmăriți scheletul pentru a putea implementa generatorul universal de creaturi.