Principio di programmazione SOLID

Recentemente ho concluso la lettura di un libro molto interessante “Clean Architecture” di Robert C. Martin (conosciuto anche come Uncle Bob), che consiglio di leggere poiché ti permettere di crescere professionalmente pagina dopo pagina.

Il libro è scritto in modo molto semplice, capitolo dopo capitolo Uncle Bob ci spiega quali sono i principi e le strategie per arrivare ad avere un software il più possibile mantenibile, scalabile (dal punto di vista dell’architettura) e aperto ai cambiamentì, una sfida non così banale. Ho lavorato diversi anni in un’azienda dove mi trovato spesso a scrivere nuovi software (per lo più medio/piccole applicazioni web) ed una delle cose che tenevo sempre a mente era come fare a scrivere codice in modo tale che queste applicazioni si potessero “espandere” in un secondo momento senza dover intervenire in maniera “pesante”: i principi che oggi vediamo sono sicuramente alla base di questo obiettivo e per quanto possano sembrare complicati all’inizio diventeranno più comprensivi con la pratica e l’esercizio.

Il principio SOLID… Cos’è?

Il principio SOLID è un insieme di linee guida per la progettazione di software orientato agli oggetti che mira a rendere i sistemi più comprensibili, mantenibili e flessibili. È stato introdotto nel libro che vi ho citato poco fa e l’acronimo SOLID rappresenta cinque principi (sono le iniziali dei principi in inglese).

Vediamo ora nel dettaglio principio per principio, aggiungendo anche un esempio di codice per mostrare meglio il suo significato. La mia intenzione con questo articolo è quella di dare un’idea semplice e chiara dei principi, per ulteriori approfondimenti ti consiglio di fare riferimento al libro in quando ci sono dei capitoli dedicati con molti esempi.

SPrincipio della responsabilità singola (SRP)

Each class should have a single responsibility, meaning it should have only one reason to change.

Esempio: Una classe che gestisce la logica per salvare dati nel database non dovrebbe occuparsi anche della gestione della logica di presentazione dei dati. In questo esempio vediamo che sono state create due classi, una per il salvataggio (e che si occuperà quindi di gestire l’impaginazione e la grafica) e uno per estrarre i dati. Secondo questo principio sarebbe stato scorretto raggruppare tutto dentro una classe unica poiché avrebbe avuto due motivi per cambiare. Ipotizziamo che questo software abbia la possibilità di generare un report con grafiche ed impaginazioni differenti: seguendo questo principio lo sviluppo e la manutenibilità si semplifica poiché la classe che genera il report rimarrà sempre invariata mentre sarà sempre possibile aggiungere nuovi classi per le grafiche o modificare quelle già esistenti!

C#
public class ReportSaver
{
    public void SaveToFile(string report)
    {
        Console.WriteLine($"Report salvato su file: {report}");
    }
}

public class ReportGenerator
{
    public string GenerateReport()
    {
        return "Report generato";
    }
}

var generator = new ReportGenerator();
var report = generator.GenerateReport();

var saver = new ReportSaver();
saver.SaveToFile(report);

O Principio aperto/chiuso (OCP)

Il software dovrebbe essere aperto per estensioni ma chiuso per modifiche.

Esempio: Puoi aggiungere nuovi comportamenti o funzionalità attraverso l’ereditarietà o le interfacce, senza modificare il codice esistente. Nell’esempio vediamo come ci sia una classe base che implementa il calcolo delle tasse secondo una logica “standard”, in base al paese è poi stato eseguito un override della funzione andando a riscrivere la logica. Ipotizziamo che la classe TaxCalculator sia richiamata anche da altri 10 metodi: se ad un certo punto andassimo a modificarne il comportamento, riscrivendo una nuova logica potremmo trovarci nella condizione di introdurre nuovi bug senza accorgercene.

C#
// Classe base per il calcolo delle tasse
public abstract class TaxCalculator
{
    public abstract double CalculateTax(double income);
}

// Implementazione specifica per l'Italia
public class ItalianTaxCalculator : TaxCalculator
{
    public override double CalculateTax(double income)
    {
        return income * 0.22; // Aliquota fissa del 22%
    }
}

// Implementazione specifica per gli USA
public class USTaxCalculator : TaxCalculator
{
    public override double CalculateTax(double income)
    {
        return income * 0.30; // Aliquota fissa del 30%
    }
}

// Estendiamo senza modificare la logica del calcolo base
var calculator = new ItalianTaxCalculator();
Console.WriteLine(calculator.CalculateTax(1000)); // Output: 220

L Principio di sostituzione di Liskov (LSP)

Le classi derivate devono poter essere sostituite alle loro classi base senza alterare il comportamento del programma.

Esempio: Se una sottoclasse rompe le funzionalità del codice che si aspetta il comportamento della classe base, stai violando questo principio. Nell’esempio possiamo vedere come la classe PenguinWrong estenda la classe Bird in modo errato (esegue l’override della classe Fly ma cambiando la sua logica), mentre la classe PenguinCorrect aggiunge una nuova funzione alla classe, mantenendo Fly invariata

C#
// Classe base
public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Sto volando!");
    }
}

// Classe derivata che rispetta il comportamento base
public class Sparrow : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Il passero vola basso.");
    }
}

// Classe derivata che viola il principio (es. un pinguino non vola)
public class PenguinWrong : Bird
{
    public override void Fly()
    {
        throw new NotImplementedException("I pinguini non possono volare!");
    }
}

// Corretto: Modifica la gerarchia per gestire eccezioni come il pinguino
public class PenguinCorrect : Bird
{
    public void Swim()
    {
        Console.WriteLine("Il pinguino nuota!");
    }
}

I Principio di segregazione delle interfacce (ISP)

Le interfacce devono essere specifiche e adattate alle necessità dei client, piuttosto che generiche e onnicomprensive.

Esempio: È meglio avere più interfacce piccole e focalizzate (ad es. IPrintable e IScannable) piuttosto che un’unica interfaccia enorme (IMultifunctionalDevice) che costringa i client a implementare metodi inutilizzati. Nell’esempio vediamo quindi che sono state create due classi, una per ogni “contesto” anziché una classe unica con entrambi i metodi.

C#
// Interfacce specifiche
public interface IPrinter
{
    void Print();
}

public interface IScanner
{
    void Scan();
}

// Classe multifunzione
public class MultifunctionDevice : IPrinter, IScanner
{
    public void Print()
    {
        Console.WriteLine("Stampa in corso...");
    }

    public void Scan()
    {
        Console.WriteLine("Scansione in corso...");
    }
}

// Classe solo stampante
public class SimplePrinter : IPrinter
{
    public void Print()
    {
        Console.WriteLine("Stampa semplice...");
    }
}

public class SimpleScanner : IScanner
{
    public void Scan()
    {
        Console.WriteLine("Scansione in corso...");
    }
}

DPrincipio di inversione delle dipendenze (DIP)

I moduli di alto livello non devono dipendere da moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni (interfacce). Inoltre, le astrazioni non devono dipendere dai dettagli; i dettagli devono dipendere dalle astrazioni.

Esempio: Un modulo non deve dipendere da una classe di più basso libello (es. un servizio di logica di business), ma dovrebbe dipendere da un’interfaccia. La classe di basso livello invece dovrebbe essere un’implementazione delle interfacce. Nell’esempio qui sotto è stata creata un’interfaccia per inviare un generico messaggio e due classi la implementano: una per gli sms e una per le email. Nei moduli dove necessitiamo di inviare una notifica andremo ad iniettare, tramite la dependency injection, l’implementazione dell’interfaccia (magari tramite un factory pattern). Un domani, se cambierà il provider con il quale inviamo le email ci basterà creare una nuova classe che implementi IMessageSender, sostituirla dove inizializziamo la dependency injection e tutto continuerà a funzionare come prima!

C#
// Interfaccia astratta
public interface IMessageSender
{
    void SendMessage(string message);
}

// Implementazione concreta: invio email
public class EmailSender : IMessageSender
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"Email inviata: {message}");
    }
}

// Implementazione concreta: invio SMS
public class SmsSender : IMessageSender
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"SMS inviato: {message}");
    }
}

// Classe dipendente dall'astrazione
public class NotificationService
{
    private readonly IMessageSender _messageSender;

    public NotificationService(IMessageSender messageSender)
    {
        _messageSender = messageSender;
    }

    public void Notify(string message)
    {
        _messageSender.SendMessage(message);
    }
}

// Uso
var emailService = new NotificationService(new EmailSender());
emailService.Notify("Notifica via Email");

var smsService = new NotificationService(new SmsSender());
smsService.Notify("Notifica via SMS");

Conclusioni

In conclusione, i principi SOLID rappresentano un insieme di linee guida fondamentali per progettare software robusto, modulare e di facile manutenzione. L’applicazione di questi principi non solo migliora la qualità del codice, ma consente anche di affrontare la complessità dei progetti software in modo più efficace. Ogni principio offre una prospettiva su come gestire le responsabilità, le dipendenze e l’estensibilità delle applicazioni, rendendo il sistema più flessibile e adattabile ai cambiamenti futuri.

Sebbene l’adozione di SOLID richieda una certa disciplina e pratica, il valore che ne deriva supera di gran lunga il tempo investito per comprenderli e applicarli. È importante ricordare che, come in ogni aspetto della programmazione, questi principi non devono essere applicati in modo rigido o dogmatico: devono servire come guida, adattandosi al contesto e alle specifiche necessità del progetto.

Nel complesso, integrare i principi SOLID nel proprio flusso di lavoro aiuta a creare software che non solo soddisfa le esigenze attuali, ma è anche pronto per affrontare le sfide di domani, in un mondo in continua evoluzione tecnologica ed è ciò che può fare la differenza tra un ottimo ed un pessimo software!

In un prossimo articolo affronteremo come andare a strutturare un progetto con la Clean Architecture, adottando anche i principi visti in questo articoli, restate sintonizzati!

Condividi questo articolo
Shareable URL
Post precedente

Come creare un extension method per LINQ

Prosimo post

HashSet<T> in .NET 9

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Leggi il prossimo articolo