Quando si dice che bisogna avere solidi principi nella vita questo vale anche per gli sviluppatori. E no, non parliamo di ideali o convinzioni o dottrine strane.
Ok. basta con questo stupido umorismo e veniamo al dunque.
I principi S.O.L.I.D. sono un insieme di linee guida per migliorare la progettazione e la manutenzione del software e sono stati introdotti da Robert C. Martin, noto anche come “Uncle Bob”, all’inizio degli anni 2000.
Questi principi aiutano a creare codice più flessibile, comprensibile e manutenibile e la parola SOLID è un acronimo che racchiude le iniziali di questi principi.
In questo articolo, esploreremo i cinque principi SOLID con esempi pratici in C#.
Partiamo con elencare quali sono questi principi
S – Single Responsibility Principle (SRP)
O – Open/Closed Principle (OCP)
L – Liskov Substitution Principle (LSP)
I – Interface Segregation Principle (ISP)
D – Dependency Inversion Principle (DIP)
vediamo dunque di che si tratta.
Single Responsibility Principle (SRP)
Il principio di singola responsabilità afferma che una classe dovrebbe avere una sola responsabilità o, in altre parole, una sola ragione per cambiare o meglio dovrebbe avere una sola responsabilità o compito.
L’idea è di mantenere il codice semplice e facile da mantenere, riducendo la complessità e migliorando la coesione poiché ogni classe ha un ruolo ben definito.
Quando una classe ha una sola responsabilità infatti è più facile capire e modificare il codice.
Se una classe ha molte responsabilità, diventa difficile capire quale parte del codice fa cosa, rendendo più complicato apportare modifiche senza introdurre bug.
Le classi con una sola responsabilità sono più facili da testare. Se una classe fa troppe cose, i test diventano più complessi e meno affidabili.
Le classi con una sola responsabilità inoltre sono più riutilizzabili. Se una classe è progettata per fare una cosa sola, è più probabile che possa essere riutilizzata in diversi contesti senza modifiche.
Esempio Pratico
Consideriamo una classe Invoice che gestisce diverse operazioni relative alle fatture:
public class Invoice { public void AddInvoice() { // Logica per aggiungere una fattura } public void DeleteInvoice() { // Logica per eliminare una fattura } public void GenerateReport() { // Logica per generare un report } public void EmailReport() { // Logica per inviare un report via email } }
In questo esempio, la classe Invoice ha più responsabilità: aggiungere ed eliminare fatture, generare report e inviare report via email. Questo viola il principio SRP perché la classe ha più di una ragione per cambiare.
Per rispettare il SRP, possiamo suddividere queste responsabilità in classi separate:
public class InvoiceManager { public void AddInvoice() { // Logica per aggiungere una fattura } public void DeleteInvoice() { // Logica per eliminare una fattura } } public class ReportGenerator { public void GenerateReport() { // Logica per generare un report } } public class EmailService { public void EmailReport() { // Logica per inviare un report via email } }
Ora, ogni classe ha una sola responsabilità: InvoiceManager gestisce le operazioni sulle fatture, ReportGenerator genera report e EmailService invia report via email. Questo rende il codice più facile da comprendere, mantenere e testare.
Open/Closed Principle (OCP)
Il principio Open/Close introdotto da Bertrand Meyer afferma che le entità software (classi, moduli, funzioni, ecc.) dovrebbero essere aperte per estensione, ma chiuse per modifica.
Aperte per estensione: Significa che dovremmo essere in grado di aggiungere nuove funzionalità al nostro sistema senza dover modificare il codice esistente. Questo permette di estendere il comportamento del software in modo sicuro e controllato.
Chiuse per modifica: Significa che una volta che una classe o un modulo è stato scritto e testato, non dovrebbe essere modificato. Questo riduce il rischio di introdurre nuovi bug nel sistema quando si aggiungono nuove funzionalità.
Seguire il principio OCP rende il codice più facile da mantenere. Poiché non è necessario modificare il codice esistente per aggiungere nuove funzionalità, si riduce il rischio di introdurre bug.
Il codice che segue l’OCP è più facile da scalare. Possiamo aggiungere nuove funzionalità senza dover riscrivere o modificare il codice esistente.
Le classi e i moduli progettati secondo l’OCP sono più riutilizzabili. Possiamo estendere il loro comportamento senza doverli modificare, rendendoli più versatili.
Esempio Pratico
Consideriamo una classe Shape che calcola l’area di diverse forme geometriche:
public abstract class Shape { public abstract double Area(); } public class Circle : Shape { public double Radius { get; set; } public override double Area() { return Math.PI * Radius * Radius; } } public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public override double Area() { return Width * Height; } }
In questo esempio, la classe Shape è aperta per estensione ma chiusa per modifica. Possiamo aggiungere nuove forme geometriche (come Triangle, Square, ecc.) estendendo la classe Shape senza dover modificare il codice esistente.
Per implementare l’OCP, possiamo utilizzare l’ereditarietà e le interfacce. Ecco un esempio di come possiamo aggiungere una nuova forma geometrica senza modificare il codice esistente:
public class Triangle : Shape { public double Base { get; set; } public double Height { get; set; } public override double Area() { return 0.5 * Base * Height; } }
In questo modo, abbiamo esteso il comportamento del nostro sistema aggiungendo una nuova forma geometrica senza modificare il codice esistente.
Liskov Substitution Principle (LSP)
Il Liskov Substitution Principle (LSP) è stato introdotto da Barbara Liskov e afferma che gli oggetti di una classe derivata dovrebbero poter sostituire gli oggetti della loro classe base senza alterare il comportamento del programm
In pratica, il principio LSP stabilisce che una classe derivata deve essere in grado di essere utilizzata al posto della sua classe base senza che il programma si comporti in modo inaspettato. Questo significa che le classi derivate devono aderire al contratto definito dalla classe base.
Seguire il LSP garantisce che le classi derivate non introducano comportamenti indesiderati o errori quando vengono utilizzate al posto delle classi base.
Inoltre se le classi derivate rispettano il contratto della classe base, il codice diventa più facile da mantenere e aggiornare, poiché le modifiche possono essere fatte in modo isolato senza influenzare altre parti del sistema.
Ed ancora, le classi che rispettano il LSP sono più riutilizzabili, poiché possono essere sostituite senza problemi in diversi contesti.
Esempio Pratico
Consideriamo una classe Bird e due classi derivate Sparrow e Ostrich:
public class Bird { public virtual void Fly() { // Implementazione del volo } } public class Sparrow : Bird { public override void Fly() { // Implementazione del volo del passero } } public class Ostrich : Bird { public override void Fly() { throw new NotImplementedException("Gli struzzi non volano"); } }
In questo esempio, Ostrich viola il principio LSP perché non può essere sostituito con la classe base Bird senza causare errori. Per rispettare il LSP, dovremmo rivedere la gerarchia delle classi in modo che solo le classi che possono volare estendano Bird.
Per implementare correttamente il LSP, possiamo utilizzare l’ereditarietà in modo più appropriato. Ecco un esempio di come possiamo ristrutturare le classi:
public abstract class Bird { // Proprietà e metodi comuni a tutti gli uccelli } public abstract class FlyingBird : Bird { public abstract void Fly(); } public class Sparrow : FlyingBird { public override void Fly() { // Implementazione del volo del passero } } public class Ostrich : Bird { // Gli struzzi non volano, quindi non implementano Fly }
In questo modo, Ostrich non estende più FlyingBird ma solo Bird, rispettando così il principio LSP.
Interface Segregation Principle (ISP)
Questo principio afferma che i client non dovrebbero essere costretti a dipendere da interfacce che non utilizzano. In altre parole, è meglio avere più interfacce specifiche e piccole piuttosto che una singola interfaccia generale e grande.
Il principio ISP suggerisce che le interfacce dovrebbero essere progettate in modo tale che i client non siano obbligati a implementare metodi che non utilizzano. Questo aiuta a mantenere il codice più pulito e modulare, riducendo la complessità e migliorando la manutenibilità.
Le interfacce più piccole e specifiche rendono il codice più modulare. Ogni modulo può essere sviluppato, testato e mantenuto indipendentemente dagli altri.
Quando le interfacce sono specifiche, è più facile apportare modifiche senza influenzare altre parti del sistema. Questo riduce il rischio di introdurre bug.
Inolte le interfacce specifiche sono più riutilizzabili. I client possono implementare solo le interfacce di cui hanno bisogno, rendendo il codice più flessibile.
Esempio Pratico
Consideriamo una stampante multifunzione che può stampare, scansionare e inviare fax. Se utilizziamo una singola interfaccia per tutte queste funzionalità, potremmo avere qualcosa del genere:
public interface IMultiFunctionDevice { void Print(); void Scan(); void Fax(); } public class MultiFunctionPrinter : IMultiFunctionDevice { public void Print() { // Implementazione della stampa } public void Scan() { // Implementazione della scansione } public void Fax() { // Implementazione del fax } }
In questo esempio, se abbiamo un dispositivo che può solo stampare, dovrà comunque implementare i metodi Scan e Fax, anche se non li utilizza.
Questo viola il principio ISP.
Per rispettare l’ISP, possiamo suddividere l’interfaccia IMultiFunctionDevice in interfacce più piccole e specifiche:
public interface IPrinter { void Print(); } public interface IScanner { void Scan(); } public interface IFax { void Fax(); } public class MultiFunctionPrinter : IPrinter, IScanner, IFax { public void Print() { // Implementazione della stampa } public void Scan() { // Implementazione della scansione } public void Fax() { // Implementazione del fax } } public class SimplePrinter : IPrinter { public void Print() { // Implementazione della stampa } }
In questo modo, un dispositivo che può solo stampare implementerà solo l’interfaccia IPrinter, rispettando il principio ISP.
Per implementare correttamente l’ISP, è importante identificare le responsabilità specifiche e creare interfacce che riflettano queste responsabilità.
Ecco alcuni passaggi per farlo:
- Analisi delle Responsabilità: Identifica le diverse responsabilità che una classe potrebbe avere.
- Creazione di Interfacce Specifiche: Crea interfacce separate per ciascuna responsabilità.
- Implementazione delle Interfacce: Implementa solo le interfacce necessarie per ciascuna classe.
Dependency Inversion Principle (DIP)
Questo principio afferma che i moduli di alto livello non dovrebbero dipendere dai moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni. Inoltre, le astrazioni non dovrebbero dipendere dai dettagli, ma i dettagli dovrebbero dipendere dalle astrazioni.
In pratica, il DIP suggerisce che le dipendenze tra i moduli di un sistema dovrebbero essere invertite rispetto a come vengono tradizionalmente progettate. Invece di avere moduli di alto livello che dipendono direttamente da moduli di basso livello, entrambi dovrebbero dipendere da interfacce o astrazioni. Questo rende il sistema più flessibile e facile da mantenere.
Seguire il DIP rende il codice più flessibile. Possiamo cambiare le implementazioni dei moduli di basso livello senza modificare i moduli di alto livello.
Inoltre le dipendenze invertite facilitano il testing. Possiamo sostituire i moduli di basso livello con mock o stub durante i test.
Per finire il codice che segue il DIP è più facile da mantenere. Le modifiche ai dettagli di implementazione non richiedono modifiche ai moduli di alto livello.
Esempio Pratico
Consideriamo un esempio di una classe Notification che invia messaggi utilizzando una classe EmailSender:
public class EmailSender { public void SendEmail(string message) { // Logica per inviare un'email } } public class Notification { private EmailSender _emailSender; public Notification() { _emailSender = new EmailSender(); } public void Send(string message) { _emailSender.SendEmail(message); } }
In questo esempio, la classe Notification dipende direttamente dalla classe EmailSender, violando il DIP. Per rispettare il DIP, possiamo introdurre un’interfaccia IMessageSender e fare in modo che Notification dipenda da questa interfaccia anziché dalla classe concreta EmailSender.
Ecco come possiamo ristrutturare il codice per rispettare il DIP:
public interface IMessageSender { void SendMessage(string message); } public class EmailSender : IMessageSender { public void SendMessage(string message) { // Logica per inviare un'email } } public class SmsSender : IMessageSender { public void SendMessage(string message) { // Logica per inviare un SMS } } public class Notification { private readonly IMessageSender _messageSender; public Notification(IMessageSender messageSender) { _messageSender = messageSender; } public void Send(string message) { _messageSender.SendMessage(message); } }
In questo esempio, Notification dipende dall’interfaccia IMessageSender anziché dalla classe concreta EmailSender. Questo ci permette di sostituire EmailSender con qualsiasi altra implementazione di IMessageSender, come SmsSender, senza modificare la classe Notification.
Sono laureato in Ingegneria Informatica presso l’Univertità degli Studi di Lecce (2008).
Attualmente mi occupo di Sviluppo C#.Net / Mono in ambiente Linux
Sviluppo da anni applicativi ANSI C per terminali POS basati su processori ARM Risc 32Bit con 2-4 MB di Ram.