Via Antonio Amato, 20/22 84131 Salerno (SA)

Ottimizzare lo sviluppo con i principi SOLID

principi solid programmazione

(articolo redatto da Luigi Pierri)

Ogni sviluppatore, prima o poi, nel corso della sua carriera, si troverà ad affrontare la seguente problematica:
“Come posso scrivere del codice pulito, comprensibile al mio team e facilmente aggiornabile?”.
Se anche tu ti sei posto questa domanda ed utilizzi un linguaggio di programmazione ad oggetti, puoi trovare una valida risposta ed un valido aiuto nei principi SOLID.

Che cosa sono i principi SOLID?

Introdotti all’inizio degli anni 2000 da Robert Cecil Martin (uno sviluppatore statunitense conosciuto con lo pseudonimo di “Zio Bob”), i principi SOLID sono una serie di linee guida, adattabili a qualsiasi linguaggio di programmazione ad oggetti, per lo sviluppo di codice leggibile, estendibile e mantenibile. Nel corso degli anni, questi principi sono stati adottati da moltissime aziende, divenendo uno standard alla base di progetti di dimensioni medio/grandi, migliorandone la scalabilità, velocizzandone i tempi di sviluppo e facilitandone i cambiamenti nel tempo.

La parola SOLID, in realtà, è un acronimo dei cinque principi fondamentali di cui si compone:

  • S – Single responsability principle (SRP) o principio di singola responsabilità
  • O – Open / Close principle (OCP) o principio aperto / chiuso
  • L – Liskov substitution principle (LSP) o principio di sostituzione di Liskov
  • I – Interface segregation principle (ISP) o principio di segregazione delle interfacce
  • D – Dependency inversion principle (DIP) o principio di inversione delle dipendenze

Andiamo ad analizzare singolarmente ogni principio, integrando con qualche esempio pratico.
Gli esempi sono scritti in linguaggio C#, ma sono facilmente comprensibili ed applicabili a qualsiasi altro linguaggio di programmazione ad oggetti.

Single responsability principle (SRP)

Il principio di singola responsabilità si basa sulla seguente affermazione:

“Ogni classe dovrebbe avere una ed una sola responsabilità, interamente incapsulata al suo interno”

Che cosa vuol dire? Secondo questo principio, è buona norma creare classi, strutture o funzioni che svolgano una sola operazione. Questo non implica che le classi debbano contenere un unico metodo, ma che i metodi servano a svolgere una singola e specifica funzionalità. In questo modo, le classi saranno più semplici e chiare e potranno combinarsi per svolgere compiti più complessi, rimanendo indipendenti l’una dall’altra.

L’applicazione di questo principio, inoltre, ci aiuta a strutturare meglio le classi per l’incapsulamento, in quanto le classi sono molto ridotte e semplici da gestire. Risulta più semplice, quindi, limitare l’accesso diretto ai dati dell’oggetto all’utilizzatore ed implementare getter e setter appropriati nelle varie classi.

Vediamo di seguito un esempio pratico.

Esempio

Prendiamo come esempio una semplice classe Utente.

Single responsability principle (SRP) esempio

Questa classe si occupa di due funzioni concettualmente distinte: salvare i dati dell’utente nel database e generare un report. In base al primo principio, le due funzioni dovrebbero essere suddivise in due classi separate, in quanto una funzione rappresenta una logica legata strettamente all’utente, mentre l’altra si occupa di una generazione di report. Applichiamo il primo principio e vediamo come risultano le classi separate.

Single responsability principle (SRP) esempio

Come puoi notare, separando le due classi, il codice risulta molto più leggibile, avendo un’idea ben chiara e distinta di quali sono i compiti delle classi Utente e GeneratoreReport.

Immaginiamo di dover aggiungere ulteriori report, per esempio per esportare il report in Excel o in altri formati: con due classi distinte dovremo modificare solamente la classe GeneratoreReport, lasciando inalterata la classe Utente. Nel prossimo paragrafo risolveremo proprio questo tipo di problematica.

Open / Close principle (OCP)

Il principio aperto / chiuso afferma che:

“Un’entità software dovrebbe essere aperta alle estensioni, ma chiusa alle modifiche”.

Forse il nome “aperto / chiuso” ti sembrerà una contraddizione, ma il concetto è molto semplice: per aggiungere nuove funzionalità ad una classe, questo principio consiglia di utilizzare il polimorfismo, cioè la possibilità di creare una nuova classe che estenda le funzionalità di una classe di base, lasciando inalterata la classe base. In questo modo la classe base è sia “aperta” a nuove funzionalità tramite estensione e sia “chiusa” alle modifiche.

Dov’è il vantaggio? Invece di modificare la classe base per aggiungere le nuove funzionalità, crei una nuova classe che derivi dalla classe base e sei sicuro di non intaccare il funzionamento sia della classe base sia delle altre classi derivate.

Ovviamente ciò non esclude la possibilità di modificare una classe base nel caso in cui siano presenti bug.

Vediamo un esempio pratico.

Esempio

Prendendo spunto dalla precedente classe GeneratoreReport, aggiungiamo la possibilità di generare un report in diversi formati. D’istinto utilizzeremmo un approccio del genere:

Open / Close principle (OCP)

Ciò che si nota immediatamente è la presenza di più if all’interno del generatore, uno per ogni tipo di formato del report. Il metodo proposto funziona correttamente, ma cosa succede se dobbiamo aggiungere ulteriori due o tre formati di report? Siamo costretti ad aggiungere altrettanti if con altrettante logiche di generazione, modificando continuamente la stessa classe. Applichiamo il secondo principio e vediamo come rendere più flessibile e leggibile questo generatore.

Open / Close principle (OCP)

Il codice risultante è più lungo rispetto al precedente, non ci sono dubbi, ma abbiamo il vantaggio di avere una classe che si occupa di generare un report in un formato specifico (rispettando anche il primo principio di singola responsabilità) e di poter creare infinite classi che estendano una funzionalità della classe base senza che questa o le altre derivate vengano modificate.

Liskov substitution principle (LSP)

Il terzo principio è il principio di sostituzione di Liskov, il quale afferma che:

“Gli oggetti dovrebbero poter essere sostituiti con dei loro sottotipi, senza alterare il comportamento del software che li utilizza”.

In base a questo principio, una classe dovrebbe poter essere sostituita da qualsiasi classe derivata, mantenendo il codice perfettamente funzionante. In altre parole, una classe derivata che mantiene tutte le caratteristiche della classe base (eccetto le sue funzioni specifiche) può essere utilizzata anche in sostituzione della classe base da cui deriva, senza ulteriori modifiche.

Il vantaggio, anche in questo caso, è enorme: puoi utilizzare le nuove classi derivate al posto delle classi base senza dover modificare il codice esistente.

Di seguito un esempio pratico.

Esempio

Prendiamo come esempio una classe SommaNumeri (che esegue la somma di tutti i numeri presenti in un array) e una classe derivata SommaNumeriPari (che esegue la somma dei soli numeri pari presenti in un array).

Liskov substitution principle (LSP)

L’esempio è perfettamente funzionante: le somme restituite saranno rispettivamente 10 nel primo caso e 6 per il secondo. Ma cosa succede se applichiamo il principio di sostituzione di Liskov e sostituiamo la classe base SommaNumeri con la classe derivata SommaNumeriPari? Forse ti stupirà, ma il seguente codice non restituisce nessun errore e dà come risultato 6, quindi nettamente diverso dal risultato atteso.

Liskov substitution principle (LSP)

Vediamo, invece, come la seguente soluzione può risolvere il problema.

Liskov substitution principle (LSP)

Introduciamo una nuova classe astratta Sommatore che viene estesa sia dalla classe SommaNumeri sia dalla classe SommaNumeriPari, divenendo una classe base. In questo modo entrambe le sottoclassi possono referenziare la nuova classe base Sommatore senza cambiare il risultato finale, rispettando il principio di sostituzione di Liskov.

Interface segregation principle (ISP)

In base al principio di segregazione delle interfacce:

“Sarebbero preferibili più interfacce specifiche, che una singola generica”.

In altre parole, l’errore che viene commesso più spesso è quello di creare interfacce con una vasta varietà di metodi, per cui le classi che implementano tali interfacce, sono costrette ad implementare anche metodi che in realtà non vengono utilizzati. È consigliabile, invece, suddividere le interfacce per scopi specifici (come suggerito anche nel primo principio). In tal modo, le classi possono implementare solo le interfacce di cui hanno necessariamente bisogno, evitando la dichiarazione di metodi vuoti e inutili.

I vantaggi di questa pratica non sono da sottovalutare, in quanto il codice viene ridotto notevolmente, riducendo, di conseguenza, i tempi di sviluppo e la probabilità di presenza di bug.

Vediamo un esempio pratico.

Esempio

Supponiamo di avere a disposizione la seguente interfaccia:

Interface segregation principle (ISP)

Proviamo ad utilizzarla in un contesto di veicolo multifunzione.

Interface segregation principle (ISP)

In questo caso la nostra interfaccia copre interamente tutti i metodi previsti dalla classe VeicoloMultifunzione. Se, invece, volessimo utilizzare l’interfaccia per implementare le classi Barca e Aeroplano, il risultato sarebbe il seguente:

Interface segregation principle (ISP)

Come puoi notare, ognuna delle due classi implementa un solo metodo, mentre l’altro non è richiesto, per cui restituisce un’eccezione. Il suddetto codice funziona, ma ci pone davanti degli obblighi onerosi e inutili, come, ad esempio, aggiungere un’eccezione per ogni metodo non utilizzato, aggiungere documentazione o commenti per non far utilizzare all’utente alcuni metodi perché restituirebbero eccezioni. Il principio di segregazione delle interfacce ci risolve proprio questo problema.

Interface segregation principle (ISP)

Innanzitutto abbiamo creato due interfacce separate per le classi Barca e Aeroplano, limitate ai soli metodi utilizzati da ognuna, e abbiamo adattato le classi alle nuove interfacce. Cosa succede ora con la classe VeicoloMultifunzione? Semplicemente implementerà entrambe le interfacce.

Interface segregation principle (ISP)

Abbiamo visto come, dividendo e strutturando le interfacce per utilizzare solo i metodi effettivamente necessari, il codice risultante è molto più pulito ed efficace. Il principio di base da seguire è: creare più interfacce piccole e specifiche piuttosto che interfacce grandi con metodi variegati, in modo da combinarle più efficacemente all’occorrenza.

Dependency inversion principle (DIP)

Il quinto principio di inversione delle dipendenze afferma che:

“I moduli di alto livello non devono dipendere da quelli di basso livello, entrambi devono dipendere da astrazioni; Le astrazioni non devono dipendere dai dettagli, sono i dettagli che dipendono dalle astrazioni”.

In un approccio convenzionale, i moduli di alto livello sono le classi che realizzano le proprie funzioni facendo uso dei moduli di basso livello, attraverso le interfacce esposte da questi ultimi. Ciò implica una dipendenza dei moduli di alto livello da quelli di basso livello, realizzando il cosiddetto “accoppiamento stretto”.

Il principio di inversione delle dipendenze inverte questo classico approccio di programmazione ad oggetti, cercando di disaccoppiare quanto più possibile i due moduli, connettendoli entrambi tramite astrazioni. In questo modo, i moduli di alto livello usano determinate astrazioni, mentre i moduli di basso livello le implementano.

È ancora poco chiaro? Facciamo un esempio pratico.

Esempio

Immaginiamo una semplice classe Utente che si connette ad un database per il salvataggio dei dati.

Dependency inversion principle (DIP)

Come si può notare, per la scrittura su database, la classe Utente utilizza la libreria SqlServer, quindi è “strettamente accoppiata” alla suddetta libreria. Cosa succederebbe se cambiasse il tipo di database, per esempio, in MySql? Saremmo costretti a riscrivere la classe per adattarla alla nuova libreria. Nel caso in cui fossero previste entrambe le possibilità? Dovremmo inserire innumerevoli if.

Applicando la prima parte del principio (“I moduli di alto livello non devono dipendere da quelli di basso livello, entrambi devono dipendere da astrazioni”), introduciamo classi più generiche per separare la relazione tra Utente ed il driver di connessione al database.

Dependency inversion principle (DIP)

La differenza è evidente, ora la classe Utente (alto livello) è molto più flessibile e può utilizzare qualsiasi implementazione della classe ConnettoreDB per interagire con il database. Dall’altro lato, le classi driver (basso livello) possono essere aggiunte facilmente nel progetto senza causare modifiche alla classe Utente. E non solo: in questo modo abbiamo anche applicato la seconda parte del principio, in quanto la classe di astrazione DatabaseDriver non dipende dalla classe dettaglio SqlServerDriver, ma è quest’ultima a dipendere dalla classe astratta.

Non ne vedi il vantaggio? Aggiungiamo altri due driver per i rispettivi tipi di database e vediamo come utilizzare questa nuova implementazione.

Dependency inversion principle (DIP)

Ora il vantaggio ti sembrerà più evidente: è bastato aggiungere due classi driver per estendere la classe DatabaseDriver ed implementare il relativo metodo di scrittura su database, tutto qui. L’utilizzo di queste classi è molto semplice, in quanto la classe Utente richiede solo il tipo di driver da utilizzare nel costruttore, mentre il suo funzionamento rimane inalterato.

Conclusioni

Come hai potuto constatare, i principi SOLID possono semplificare enormemente la vita dello sviluppatore (e del suo team) in termini di velocità di sviluppo, pulizia e manutenzione del codice. Forse all’inizio ti sembreranno un po’ ostici, ma con un po’ di pratica riuscirai a padroneggiarli.

Riepilogando, ogni principio afferma che:

  • S – Single responsibility

Ogni classe dovrebbe avere una ed una sola responsabilità, interamente incapsulata al suo interno.

  • O – Open / Closed

Un’entità software dovrebbe essere aperta alle estensioni, ma chiusa alle modifiche.

  • L – Liskov substitution

Gli oggetti dovrebbero poter essere sostituiti con dei loro sottotipi, senza alterare il comportamento del software che li utilizza.

  • I – Interface segregation

Sarebbero preferibili più interfacce specifiche, che una singola generica.

  • D – Dependency inversion

I moduli di alto livello non devono dipendere da quelli di basso livello, entrambi devono dipendere da astrazioni; Le astrazioni non devono dipendere dai dettagli, sono i dettagli che dipendono dalle astrazioni.

Da qui puoi anche scaricare il progetto, completo di tutti gli esempi indicati in questo articolo.


Se anche tu vuoi occuparti di sviluppo software di qualità
dai un’occhiata alle nostre opportunità di lavoro e conosciamoci subito!

Questo sito utilizza cookies propri e si riserva di utilizzare anche cookie di terze parti per garantire la funzionalità del sito e per tenere conto delle scelte di navigazione.
Per maggiori dettagli e sapere come negare il consenso a tutti o ad alcuni cookie è possibile consultare la Cookie Policy.

USO DEI COOKIE

Se abiliti i cookie nella tabella sottostante, ci autorizzi a memorizzare i tuoi comportamenti di utilizzo sul nostro sito web. Questo ci consente di migliorare il nostro sito web e di personalizzare le pubblicità. Se non abiliti i cookie, noi utilizzeremo solo cookies di sessione per migliorare la facilità di utilizzo.

Cookie tecnicinon richiedono il consenso, perciò vengono installati automaticamente a seguito dell’accesso al Sito.

Cookie di statisticaVengono utilizzati da terze parti, anche in forma disaggregata, per la gestione di statistiche

Cookie di social networkVengono utilizzati per la condivisione di contenuti sui social network.

Cookie di profilazione pubblicitariaVengono utilizzati per erogare pubblicità basata sugli interessi manifestati attraverso la navigazione in internet.

AltriCookie di terze parti da altri servizi di terze parti che non sono cookie di statistica, social media o pubblicitari.