
Ciao a tutti e benvenuti in questa guida dove andremo a vedere i cosiddetti principi SOLID della programmazione con linguaggi orientati agli oggetti. Questi principi, quando utilizzati correttamente, ci permettono di scrivere software più robusto, comprensibile e mantenibile. Software sviluppato ignorando questi principi, può risultare in ritardi di consegna e all'aumento del costo del progetto. In casi estremi, quando un software diventa di grandi dimensioni, questo può diventare talmente complicato e non mantenibile che viene totalmente scartato per ricominciare da capo.
Per questo motivo spesso questi principi vengono inseriti nei requisiti minimi delle candidature di lavoro nel campo del software engineering. Quindi essere a conoscenza di questi principi ed utilizzarli nel proprio codice è essenziale per uno sviluppatore software.
In questo post andiamo a spiegare in dettaglio quali sono questi principi utilizzando anche degli esempi con codice Java.
Che cos'è la programmazione orientata agli oggetti
Per capire al meglio i principi SOLID si ha prima bisogno di conoscere bene il concetto della programmazione orientata agli oggetti. La programmazione orientata gli oggetti, In inglese Object Oriented (abbreviata come OO), è uno dei paradigmi della programmazione più utilizzati dagli sviluppatori del software. Ma che cos'è? Per capire meglio proviamo a confrontare questo paradigma con un'altro modello. Quello della programmazione funzionale (functional programming). La programmazione funzionale, come fa intendere il nome, è un modello di programmazione che utilizza funzioni al posto degli oggetti.
Prendiamo come esempio una lampada. Questa lampada può assumere due stati: On e Off. Se si vuole modellare questo sistema usando la programmazione funzionale, invece di creare una Classe per la lampada si possono utilizzare una serie di funzioni che vanno ad alterare il suo stato. vediamo un esempio:
const lampada = {"stato": "Off", alimentata: true} function accendi(lampada){
return Object.assign({}, lampada, {"state": "On"}); } function spegni(lampada){
return Object.assign({}, lampada, {"state": "Off"}); }
Come potete vedere nell'esempio in alto vengono utilizzate due funzioni per modellare la lampada. Ogni funzione deve ricevere come argomento la lampada con lo stato iniziale e deve restituire la lampada con lo stato alterato. Nella programmazione OO invece, basta semplicemente creare una classe Lampada con le proprietà desiderate. Quando si vuole cambiare proprietà della lampada basta usare le funzioni interne alla classe che fanno da API. Vediamo un esempio in basso.
Come vedete sono due metodi completamente diversi per ottenere la stessa cosa. Esistono altri principi che permettono lo sviluppo corretto usando la programmazione funzionale, ma in questo post ci focalizzeremo sui principi per la programmazione OO.Class Lampada{
state = "Off";
accendi(){
this.state = "On";
}
spegni(){
this.state = "Off";
} }
I principi SOLID nella programmazione orientata gli oggetti
Vediamo quali sono questi principi!
SOLID: Single Responsibility
Il primo principio dei 5 è il principio di Single Responsibility (Responsabilità unica). Questo principio semplicemente ci invita a sviluppare più classi che hanno una unica funzionalità ben specifica anziché una singola classe che fa molteplici cose. Vediamo un esempio in basso:
Immaginiamo di avere un servizio "Pizzeria" modellato con la seguente classe.
Questo codice è molto semplice ma ha un grande difetto. Come vedete, questa classe Pizzeria ha molte responsabilità. La classe è in carica di gestire l'ordine, cucinare e spedire la pizza. In più queste tre azioni sono concatenate in una sequenza di call a funzioni. In questo modo il funzionamento della pizzeria è fisso. Se vogliamo cambiare la modalità di cottura o il servizio di spedizione dobbiamo andare a cambiare una classe centrale (pizzeria). È probabile che questa classe venga utilizzata da altre classi esterne. In questo caso dobbiamo assicurarci che i cambiamenti effettuati al codice non causino problemi di integrazione.Class Pizzeria{
public gestisciOrdine(ordine){
cucinaPizza(ordine);
}
public cucinaPizza(ordine){
tempDiCottura=ordine.pizza.tempoDiCottura;
cook(ordine.pizza, tempoDiCottura);
inviaPizza(ordine);
}
public inviaPizza(ordine){
indirizzo = ordine.indirizzo;
invia(ordine.pizza, indirizzo);
} }
Un metodo molto più efficace e mantenibile per ottenere la stessa classe Pizzeria consiste nel separare le varie responsabilità della classe in sotto-classi dove ogni classe è responsabile per una sola cosa. Vediamo un esempio.
Come potete vedere invece di avere una sola classe che fa tutto, abbiamo vari servizio dove ogni servizio è responsabile per una funzione specifica. In questo modo risulta più facile testare ogni classe in modo isolata ed orchestrare le varie classi insieme.Class PIzzeria{
public gestisciOrdine(ordine){
ServizioOrdini.ordine(ordine);
} } Class ServizioOrdini{
Queue
ordini;
public ordine(ordine){
ordini.add(ordine);
} } Class ServizioCucina{
Queue pizzePronte; public cucina(){ pizza = ServizioOrdini.ordini.pop().pizza; cucina(pizza); pizzePronte.add(pizza); } } Class ServizioSpedizione{ invia(){ ordine = SevizioCucina.pizzePronte.pop(); invia(ordine); } }
SOLID: Open-Closed principle
Il secondo principio dei 5 è il principio Open-Closed. Questo nome è derivato dalla frase "Open for extension, closed for modification". In parole semplici, questo principio ci invita a estendere la funzionalità di una classe/funzione invece di modificarla direttamente. Immaginiamo uno scenario dove stiamo sviluppando una calcolatrice per effettuare il calcolo di una figura geometrica. Cominciamo con una soluzione semplice:
Class Rettangolo{
double altezza;
double larghezza; } Class CalcolatoreArea{
public double area(Rettangolo r){
return r.altezza*r.larghezza;
} }
Riuscite a vedere qual'è il problema con il codice mostrato in alto? Come potete vedere, il calcolatore può calcolare l'area di una figura specifica: Il rettangolo. Ma se nel futuro vogliamo aggiungere al calcolatore la possibilità di calcolare l'area di un triangolo? Beh in questo caso dovremmo andare a modificare direttamente il codice della funzione area() violando il principio open-closed perché andiamo a modificare il codice anziché di estenderlo. La soluzione? Come sapete estendere codice in un linguaggio di programmazione Object Oriented è solitamente fatto attraverso l'uso dell'ereditarietà e di concetti come classi astratte ed interfacce. Vediamo un esempio di codice che rispetta meglio il principio open-closed.
Come potete vedere, abbiamo una classe Forma che specifica che qualsiasi classe che eredita da essa deve implementare una funzione chiamata area(). Ora possiamo creare varie forma dove ogni forma specifica come calcolare la propria area. Il calcolatore adesso può accettare qualsiasi classe che estende una Forma. Per esempio se vogliamo aggiungere la forma Cerchio, basta creare una nuova classe che estende Forma. Non serve assolutamente modificare la classe CalcolatoreArea (closed to modification) ma è molto facile creare nuove forme ed aggiungerle al codice esistente (open for extension).public abstract class Forma{
public abstract double area(); } public class Rettangolo extends Forma{
double altezza;
double larghezza;
public override double area(){
return altezza*larghezza;
} } public Class Triangolo extends Forma{
double base;
double altezza;
public override double area(){
return base*altezza/2;
} } Class CalcolatoreArea{
public void area(Forma[] forme){
for(Forma f : forme){
print(f.area());
}
} }
SOLID: Liskov Substitution
Il terzo principio: Sostituzione Liskov è quello con il nome più complicato ma il suo concetto è molto semplice. Questo principio afferma che in un buon codice OO si possono sostituire variabili con i loro sotto-tipi senza causare errori. Per capire meglio vediamo un esempio. Se prendiamo il codice in alto del calcolatore di area geometrica, vediamo che abbiamo una funzione chiamata print area che accetta un array di tipo Forma e per ogni forma stampa la sua area. Modifichiamo questa funzione per stampare la larghezza della forma anziché la sua area:Ora immaginiamo di avere un nuovo oggetto di tipo Forma: Cerchio:Class CalcolatoreArea{
public void area(Forma[] forme){
for(Forma f : forme){
print(f.larghezza);
}
} }
Come potete vedere la classe Cerchio non ha una proprietà larghezza. Quindi se andiamo a cambiare all'interno della funzione del calcolatoreArea il tipo dell'oggetto da Forma a Cerchio, verranno mostrati errori durante la compilazione. Questo codice quindi non aderisce al principio di sostituzione Liskov.public class Cerchio extends Forma{
public raggio;
public override double area(){
return raggio*raggio*Math.PI;
} }
SOLID: Interface segregation
Il quarto principio dei 5 principi SOLID é il principio di segregazione delle interfacce. Questo principio afferma che una classe non dovrebbe essere mai forzata ad implementare un metodo che non utilizza. Vediamo meglio con un esempio:
Come sappiamo, quando si eredita da una interfaccia, la sotto-classe deve specificare una implementazione dei metodi definiti nell'interfaccia. Abbiamo un'interfaccia che specifica due metodi: Area e Volume ma cosa succede se abbiamo due forme: Quadrato e Cubo. Queste due classi ereditano dalla interfaccia Forma, ma se ci pensiamo non ha senso che la classe Quadrato sia forzata ad implementare il metodo Volume (essendo 2D). Vediamo quindi che una classe è forzata ad implementare un metodo che non utilizzerà mai. Questo è una violazione del quarto principio SOLID: Segregazione delle interfacce.Interface Forma{
public double area();
public double volume(); }
Una soluzione migliore per ottenere lo stesso codice sarebbe creare due interfacce diverse: Forma e Forma3D. In questo modo possiamo implementare la funzione Volume solo quando si eredità da una Forma3D.
Interface Forma{
public double area; } Interface Forma3D{
public double volume; } Class Quadrato implements Forma{ } Class Cubo Implements Forma, Forma3D{ }
SOLID: Dependency Inversion
L'ultimo dei cinque principi per la programmazione SOLID è il principio di inversione delle dipendenze. Questo principio ci invita a creare classi che non dipendono da implementazioni concrete ma da interfacce o astrazioni. Come sempre vediamo un esempio per capire meglio: Immaginiamo di avere un servizio che permette a vari client di cambiare la loro password. Per questo abbiamo prima bisogno di collegarci ad un database. Possiamo simulare ciò con una classe:Come vediamo in alto, la classe CambiatorePassword dipende direttamente da una implementazione concreta di MYSQLDB. Immaginiamo se nel futuro vogliamo usare un nuovo database. Per esempio si va ad utilizzare un client MondoDB. In questo caso dobbiamo creare una nuova classe MongoDB ma dobbiamo anche andare a cambiare la classe CambiatorePassword. Per risolvere questo problema, basta assicurarsi che questa classe non dipendi da una implementazione concreta ma da una interfaccia.Class MYSQLDB{
public connect(){
// Simulazione connect
} } Class CambiatorePassword{
public void CambiaPassword(MYSQLDB db){
db.CambiaPassword();
} }
Interface Database{
public void connect(); } Class MYSQLDB implements Database{
public void connect(){ }; } Class MongoDB implements Database{
public void connect(){ }; } Class CambiatorePassword{
public void cambiaPassword(Database db){
db.cambiaPassword();
} }
Come vedete ora possiamo cambiare tranquillamente il Database usato senza dover modificare il codice del cambia password. Questo codice è molto più SOLID e rispetta il principio di dependency inversion. Ci siamo! Abbiamo visto quali sono i principi SOLID per la programmazione orientata agli oggetti.