Articolo originale: https://www.freecodecamp.org/news/the-basic-design-patterns-all-developers-need-to-know/
Cos'è un Design Pattern?
I design pattern (schemi progettuali) sono soluzioni a livello di progettazione per problemi ricorrenti con i quali gli ingegneri informatici hanno spesso a che fare. Non è codice. Lo ripeto, ❌ CODICE. È come una descrizione di come affrontare questi problemi e progettare una soluzione.
L'uso di questi pattern è considerato una buona pratica, visto che la progettazione della soluzione è stata abbondantemente provata e testata, risultando in una maggiore leggibilità del codice finale. I design pattern sono creati piuttosto spesso per e usati da linguaggi di programmazione orientati agli oggetti (OOP), come Java, linguaggio nel quale sarà scritta la maggior parte degli esempi da qui in avanti.
Tipi di design pattern
Ci sono circa 26 design pattern attualmente noti (dubito fortemente che li esaminerò tutti...).
Questi 26 possono essere classificati in 3 tipi:
1. Creazionali: progettati per creare un'istanza di una classe. Possono essere sia pattern di creazione di classi che di creazione di oggetti.
2. Strutturali: progettati in relazione alla struttura e la composizione di una classe. L'obiettivo principale della maggior parte di questi schemi è aumentare la funzionalità della classe (o classi) coinvolta, senza cambiare più di tanto la sua composizione.
3. Comportamentali: progettati a seconda di come una classe comunica con le altre.
In questo post, esamineremo un design pattern di base per ciascun tipo di classificazione.
Tipo 1: Creazionali - Il Design Pattern Singleton
Il design pattern Singleton ha l'obiettivo di creare una sola istanza di una classe e di fornire un solo punto di accesso globale a quell'oggetto. Un esempio comunemente citato di tale classe in Java è Calendar, dove non puoi creare un'istanza di quella classe. Utilizza anche il suo metodo getInstance()
per ottenere l'oggetto da usare.
Una classe che usa il design pattern Singleton includerà:
- Una variabile privata statica, associata all'istanza della classe.
- Un costruttore privato, in modo che non possa essere istanziato da nessun'altra parte.
- Un metodo statico pubblico, per ritornare la singola istanza della classe.
Ci sono molte diverse implementazioni di questo pattern. Oggi esamineremo le seguenti:
1. Creazione immediata (Eager)
2. Creazione differita (Lazy)
3. Creazione Thread-safe
Creazione Immediata (Eager)
public class EagerSingleton {
// crea un'istanza della classe.
private static EagerSingleton instance = new EagerSingleton();
// costruttore privato, in modo che non possa essere istanziato al di fuori di questa classe.
private EagerSingleton() { }
// ottiene l'unica istanza dell'oggetto creato.
public static EagerSingleton getInstance() {
return instance;
}
}
La creazione dell'istanza avviene durante il caricamento della classe, visto che l'istanza della classe viene associata alla variabile al di fuori di qualsiasi metodo. Questo comporta un pesante svantaggio se questa classe non viene mai usata dall'applicazione client. Il piano di emergenza, se questa classe non viene usata, è usare il metodo di creazione differita (Lazy).
Creazione Differita (Lazy)
Non c'è molta differenza rispetto all'implementazione qui sopra. Le differenze principali sono che la variabile statica viene dichiarata inizialmente null
e viene istanziata solo all'interno del metodo getInstance()
se, e solo se, la variabile di istanza risulta null
al tempo della verifica.
public class LazySingleton {
// inizializza l'istanza come null.
private static LazySingleton instance = null;
// costruttore privato, in modo che non possa essere istanziato al di fuori di questa classe.
private LazySingleton() { }
// verifica se l'istanza è null, e in questo caso, crea l'oggetto.
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
Questo risolve un problema, ma ne esiste ancora un altro. Cosa succede se due client diversi accedono alla classe Singleton allo stesso tempo, al millisecondo?. Entrambi verificheranno se l'istanza è null
allo stesso tempo e ciò sarà vero, pertanto verranno create due istanze della classe, una per richiesta fatta da ciascun client. Per risolvere questo problema si deve implementare un modo di istanziare di tipo Thread Safe.
Creazione Thread-safe - La sicurezza è la Chiave
In Java, la parola chiave synchronized
viene usata su metodi o oggetti per implementare la thread-safety, in modo che solo un thread abbia accesso a una particolare risorsa a un dato momento. La creazione dell'istanza della classe viene posta all'interno di un blocco synchronized
in modo che un solo client alla volta possa accedere a quel metodo in un dato momento.
public class ThreadSafeSingleton {
// inizializza l'istanza come null.
private static ThreadSafeSingleton instance = null;
// costruttore privato, in modo che non possa essere istanziato al di fuori di questa classe.
private ThreadSafeSingleton() { }
// verifica se l'istanza è null, all'interno di un blocco synchronized block. Se vero, crea l'oggetto
public static ThreadSafeSingleton getInstance() {
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new ThreadSafeSingleton();
}
}
return instance;
}
}
Il sovraccarico per il metodo synchronized
è elevato e riduce le prestazioni dell'intera operazione.
Per esempio, se la variabile di istanza è già stata istanziata, ogni volta che un qualunque client accede al metodo getInstance()
, viene eseguito il metodo synchronized
e le prestazioni calano. Questo succede in quanto si deve verificare se il valore della variabile instance
è null
. Se la verifica è vera, si esce dal metodo.
Per ridurre questo sovraccarico, viene usato un sistema chiamato double locking (doppio blocco). La verifica viene effettuata anche prima della chiamata del metodo synchronized
, e solo se il valore è null
il metodo synchronized
viene eseguito.
// Il doppio blocco viene utilizzato per ridurre il sovraccarico del metodo sincronizzato
public static ThreadSafeSingleton getInstanceDoubleLocking() {
if (instance == null) {
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new ThreadSafeSingleton();
}
}
}
return instance;
}
Passiamo alla prossima classificazione.
Tipo 2: Strutturale - Il Design Pattern Decorator
Ti prospetterò un piccolo scenario per offrirti un miglior contesto per capire perché e dove dovresti usare il pattern Decorator.
Diciamo che possiedi un coffee shop, e come qualunque altro che parta con un'attività, inizi con solo due tipi di semplice caffè: la miscela della casa e il tostato scuro. Nel software che gestisce il tuo listino prezzi, c'è solo una classe per diverse miscele di caffè, che eredita dalla classe astratta Beverage
(bevanda). I clienti iniziano ad affluire e consumano il tuo meraviglioso (anche se un po' amaro?) caffè. Poi c'è chi ha appena iniziato a bere caffè che vorrebbe aggiungere, Dio ce ne scampi, zucchero o latte. Che obbrobrio!!
Ora devi creare anche queste due aggiunte, inserendole sfortunatamente sia nel menù che nel listino prezzi. All'inizio il tuo tecnico informatico ha creato una sottoclasse per entrambi i tipi di caffè, una che include lo zucchero, e un'altra che include il latte. Poi visto che il cliente ha sempre ragione, uno pronuncia questa temuta frase:
“Posso avere un caffè con latte, e zucchero per favore?”
???
Ecco il tuo listino prezzi che ti ride in faccia nuovamente. Va bene, torniamo alla lavagna….
Il tuo programmatore aggiunge caffè con latte e zucchero come sottoclasse di ciascuna classe genitore che rappresenta un tipo di caffè. Il resto del mese va via che è un piacere, con i clienti in coda per consumare il tuo caffè, stai addirittura facendo soldi. ??
Un momento, c'è dell'altro!
Ancora una volta il mondo cospira ai tuoi danni. Un concorrente apre dall'altra parte della strada, non solo con 4 tipi di caffè, ma anche con più di 10 aggiunte!
Adotti anche tu queste aggiunte, per vendere un caffè migliore, e ti sei appena ricordato che hai dimenticato di aggiornare il tuo maledetto listino prezzi. Non è certamente possibile creare un numero infinito di sottoclassi per qualunque combinazione di tutte le integrazioni, includendo anche le nuove miscele di caffè. Senza contare la dimensione finale del sistema.
È ora di investire in un sistema di listino prezzi appropriato. Trovi dei nuovi programmatori, che in effetti sanno cosa stanno facendo e ti dicono:
“Sarebbe stato molto più facile e ridotto se si fosse usato il pattern decorator.”
Cosa diamine è?
Il design pattern decorator rientra nella categoria strutturale, che ha a che fare con l'effettiva struttura di una classe, sia per ereditarietà che per composizione o entrambe. Lo scopo è di modificare la funzionalità di un oggetto in fase di esecuzione. Questo è uno di molti altri design pattern che utilizzano classi astratte e interfacce con la composizione per ottenere il risultato desiderato.
Diamo una possibilità alla matematica (hai i brividi?) per portare tutto questo in prospettiva.
Ipotizziamo 4 miscele di caffè e 1o aggiunte. Se continuiamo con la tattica della generazione di sottoclassi per ciascuna diversa combinazione di tutte le aggiunte per ciascun tipo di miscela avremo:
(10–1)² = 9² = 81 sottoclassi
Ne togliamo una dalle 1o in quanto non puoi combinare un'aggiunta con un'altra dello stesso tipo, zucchero con zucchero sembra stupido. E questo è per solo una miscela. Moltiplica 81 per 4 e ottieni un mostruoso totale di 324 sottoclassi diverse! Pensa a tutto il codice che si dovrebbe scrivere…
Tuttavia con il pattern decorator ti serviranno solo 16 classi in questo scenario. Scommettiamo?
Se mappiamo il nostro scenario in base all'ipotesi sopra citata, abbiamo 4 classi per le quattro miscele di caffè, 10 per ciascuna aggiunta (AddOn
), 1 per il componente astratto e una ulteriore per il decorator astratto. Vedi! 16! Ora dammi $100 (sto scherzando, ma se me li dai non li rifiuto... giusto per dire).
Come puoi vedere qui sopra, così come le classi concrete che rappresentano le miscele di caffè (HouseBlend
e DarkRoast
ad esempio) sono sottoclassi della classe astratta Beverage
, anche la classe astratta AddOn
(aggiunta) eredita da essa i suoi metodi. Le aggiunte (le classi Milk
e Sugar
nel diagramma), che sono sottoclassi di AddOn
, a loro volta erediteranno qualsiasi nuovo metodo creato per aggiungere funzionalità all'oggetto base quando necessario.
Scriviamo un po' di codice per vedere il pattern in azione.
Per prima cosa creiamo la classe astratta Beverage
, dalla quale erediteranno tutte le classi che rappresentano le diverse miscele di caffè:
public abstract class Beverage {
private String description;
public Beverage(String description) {
super();
this.description = description;
}
public String getDescription() {
return description;
}
public abstract double cost();
}
Poi aggiungiamo le due classi concrete per le miscele di caffè.
public class HouseBlend extends Beverage {
public HouseBlend() {
super(“House blend”);
}
@Override
public double cost() {
return 250;
}
}
public class DarkRoast extends Beverage {
public DarkRoast() {
super(“Dark roast”);
}
@Override
public double cost() {
return 300;
}
}
Anche la classe astratta AddOn
eredita da Beverage
(maggiori dettagli qui sotto).
public abstract class AddOn extends Beverage {
protected Beverage beverage;
public AddOn(String description, Beverage bev) {
super(description);
this.beverage = bev;
}
public abstract String getDescription();
}
Ora le implementazioni concrete di questa classe astratta:
public class Sugar extends AddOn {
public Sugar(Beverage bev) {
super(“Sugar”, bev);
}
@Override
public String getDescription() {
return beverage.getDescription() + “ with Mocha”;
}
@Override
public double cost() {
return beverage.cost() + 50;
}
}
public class Milk extends AddOn {
public Milk(Beverage bev) {
super(“Milk”, bev);
}
@Override
public String getDescription() {
return beverage.getDescription() + “ with Milk”;
}
@Override public double cost() {
return beverage.cost() + 100;
}
}
Come puoi vedere qui sopra, possiamo passare qualsiasi sottoclasse di Beverage
a qualsiasi sottoclasse di AddOn
e ottenere il costo aggiuntivo assieme alla descrizione aggiornata. Poi, visto che la classe AddOn
è essenzialmente di tipo Beverage
, possiamo passare un AddOn
in un altro AddOn
. In questo modo possiamo usare un qualsiasi numero di aggiunte (AddOn
) per una specifica miscela di caffè.
Ora scriviamo del codice per testare tutto quanto.
public class CoffeeShop {
public static void main(String[] args) {
HouseBlend houseblend = new HouseBlend();
System.out.println(houseblend.getDescription() + “: “ + houseblend.cost());
Milk milkAddOn = new Milk(houseblend);
System.out.println(milkAddOn.getDescription() + “: “ + milkAddOn.cost());
Sugar sugarAddOn = new Sugar(milkAddOn);
System.out.println(sugarAddOn.getDescription() + “: “ + sugarAddOn.cost());
}
}
Il risultato finale è:
Funziona! Siamo stati in grado di aggiungere più di una aggiunta a una miscela di caffè e aggiornare con successo il suo costo e descrizione finale, senza dove creare un numero infinito di sottoclassi per ciascuna combinazione di aggiunte per tutte le miscele di caffè.
Infine, passiamo all'ultima categoria.
Tipo 3: Comportamentale - Il Design Pattern Command
Un design pattern di tipo comportamentale si focalizza su come le classi e gli oggetti comunicano tra loro. Lo scopo principale del pattern command è di inculcare un alto grado di loose coupling tra le parti coinvolte (leggi: classi).
Uhhhh… Cos'è?
L'accoppiamento (coupling) è il modo nel quale due (o più) classi interagiscono tra loro. Lo scenario ideale sarebbe quello nel quale le classi che interagiscono non debbano dipendere molto strettamente le une dalle altre. Questo è il loose coupling. Quindi una definizione migliore di loose coupling sarebbe, classi che sono interconnesse, facendo il minor uso possibile le une delle altre.
La necessità di questo pattern nacque quando occorreva inviare delle richieste senza conoscere esplicitamente cosa stavi chiedendo e chi fosse il destinatario.
In questo modello, la classe chiamante è disaccoppiata dalla classe che esegue realmente un'azione. La classe chiamata deve avere solo un metodo (in genere chiamato execute()
) che esegua i comandi necessari, quando il client lo richiede.
Facciamo un semplice esempio reale, ordinare un pasto in un bel ristorante. Seguendo il flusso delle azioni, tu ordini un piatto (dai un comando) al cameriere (il chiamante), il quale a sua volta lo passa allo chef (il destinatario), in modo che tu possa avere quanto ordinato. Potrebbe sembrare semplice... ma così così da programmare.
L'idea è piuttosto semplice, ma il codice non tanto.
Dal punto di vista tecnico, il flusso delle operazioni è: tu, il cliente, crei un comando concreto, che implementa l'interfaccia Command
, chiedendo al destinatario (lo chef) di completare un'azione, e invii il comando al chiamante (il cameriere). Il chiamante è la persona che sa quando impartire questo comando. Lo chef è l'unico che sa cosa fare quando riceve uno specifico comando/ordine. Quindi quando il metodo execute
del chiamante viene eseguito, a sua volta fa si che il metodo execute
degli oggetti derivati dalla classe Command
venga eseguito sul destinatario, pertanto completando le azioni necessarie.
Quello che serve implementare è;
- Una interfaccia
Command
- Una classe
Order
- ordine - che implementi l'interfacciaCommand
- Una classe
Waiter
- cameriere - (chiamante) - Una classe
Chef
(destinatario)
Quindi il codice è il seguente:
Chef, il destinatario
public class Chef {
public void cookPasta() {
System.out.println(“Chef is cooking Chicken Alfredo…”);
}
public void bakeCake() {
System.out.println(“Chef is baking Chocolate Fudge Cake…”);
}
}
Command, l'interfaccia
public interface Command {
public abstract void execute();
}
Order, il comando concreto
public class Order implements Command {
private Chef chef;
private String food;
public Order(Chef chef, String food) {
this.chef = chef;
this.food = food;
}
@Override
public void execute() {
if (this.food.equals(“Pasta”)) {
this.chef.cookPasta();
} else {
this.chef.bakeCake();
}
}
}
Waiter, il chiamante
public class Waiter {
private Order order;
public Waiter(Order ord) {
this.order = ord;
}
public void execute() {
this.order.execute();
}
}
Tu, il cliente
public class Client {
public static void main(String[] args) {
Chef chef = new Chef();
Order order = new Order(chef, “Pasta”);
Waiter waiter = new Waiter(order);
waiter.execute();
order = new Order(chef, “Cake”);
waiter = new Waiter(order);
waiter.execute();
}
}
Come puoi vedere qui sopra, il cliente (Client
) dà un ordine (Order
) e imposta il destinatario (Chef
) L'ordine è inviato al cameriere (Waiter
), che saprà quando eseguirlo, vale a dire quando dare allo chef l'ordine di preparare il piatto. Quando viene eseguito il chiamante, viene invocato il metodo execute
dell'ordine (Order
) sul destinatario (vale a dire che Chef
riceve il comando di cucinare la pasta - cookPasta()
- oppure di infornare un dolce - bakeCake()
).
Veloce riepilogo
In questo post abbiamo esaminato:
- Cosa è un design pattern,
- I diversi tipi di design pattern e perché sono differenti
- Un design pattern comune o di base per ciascun tipo
Spero che questo sia stato utile.
Puoi trovare il repository del codice per questo post qui.