Articolo originale: https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english/

I princìpi SOLID sono cinque principi della progettazione di classi orientate agli oggetti. Sono una serie di regole e buone prassi da seguire mentre si progetta la struttura di una classe.

Questi cinque princìpi ci aiutano a capire la necessità di seguire dei modelli di progettazione e architettura del software in generale. Quindi, credo che questo sia un argomento che ogni sviluppatore dovrebbe imparare.

Questo articolo ti insegnerà tutto ciò di cui hai bisogno per applicare ai tuoi progetti i principi SOLID.

Inizieremo dando un'occhiata alla storia di questo termine. Dopodiché andremo ad approfondire i dettagli più importanti - i perché ed i come di ogni principio - progettando una classe e migliorandola passo dopo passo.

Ora prendi una tazza di caffe o tè e iniziamo!

Contesto

I princìpi SOLID sono stati introdotti dal famoso informatico Robert J. Martin (o Uncle Bob) in un suo articolo del 2000. Ma l'acronimo SOLID venne introdotto successivamente da Michael Feathers.

Uncle Bob è anche l'autore dei bestseller Clean Code e Clean Architecture, ed è uno dei membri della "Alleanza Agile".

Quindi, non è una sorpresa che tutti questi concetti di pulizia del codice, architettura orientata agli oggetti e modelli di progettazione siano in qualche modo connessi e complementari tra loro.

Hanno tutti lo stesso scopo:

"Creare codice comprensibile, leggibile e testabile sul quale molti sviluppatori possano lavorare collaborativamente."

Vediamo i principi uno ad uno. Seguendo l'acronimo SOLID, sono:

Il principio di singola responsabilità (SRP)

Il principio di singola responsabilità stabilisce che una classe deve fare solo una cosa e quindi avere un solo motivo per cambiare.

Per chiarire questo principio in modo più tecnico: le specifiche della classe dovrebbero essere influenzate da una sola potenziale modifica (logica del database, logica d'accesso, e così via.) nelle specifiche del software.

Questo significa che se una classe è un contenitore di dati, come una classe Libro o una classe Studente, e ha alcuni campi riguardanti tale entità, questa deve cambiare solo quando cambiamo quel modello di dati.

Seguire il principio di singola responsabilità è importante. Prima di tutto, molti team differenti possono lavorare sullo stesso progetto e modificare le stesse classi per motivi diversi, e ciò potrebbe portare a moduli incompatibili.

Secondo, rende il controllo delle versioni più semplice. Per esempio, ipotizziamo di avere una classe di persistenza che gestisce le operazioni sul database, e vediamo una modifica apportata su quel file dai commit di GitHub. Seguendo l'SRP, sapremo che il file è inerente alla memorizzazione o gestione del database.

I conflitti di merge sono un altro esempio. Appaiono quando team diversi modificano lo stesso file. Ma se l'SRP viene seguito, ci saranno meno conflitti - i file avranno solo un motivo di essere modificati, e i conflitti che esisteranno saranno più semplici da risolvere.

Errori comuni e anti-pattern

In questa sezione, vedremo alcuni errori comuni che violano il principio di responsabilità singola. Poi vedremo come correggere il codice.

Daremo un'occhiata al codice di un semplice programma di fatturazione di una libreria. Iniziamo definendo la classe Libro.

class Libro {
    String nome;
    String nomeAutore;
    int anno;
    int prezzo;
    String isbn;

    public Libro(String nome, String nomeAutore, int anno, int prezzo, String isbn) {
        this.nome = nome;
        this.nomeAutore = nomeAutore;
        this.anno = anno;
        this.prezzo = prezzo;
        this.isbn = isbn;
    }
}

Questa è una semplice classe Libro con qualche campo, nulla di che. Non sto rendendo i campi privati in modo da non dover avere a che fare con getter e setter e concentrarmi più sulla logica.

Ora creiamo la classe di fatturazione che conterrà la logica di creazione della ricevuta e del calcolo del prezzo totale. Per ora, ipotizziamo che la nostra libreria venda solo libri e null'altro.

public class Ricevuta {

	private Libro libro;
	private int quantita;
	private double scontoPercentuale;
	private double tassaPercentuale;
	private double totale;

	public Invoice(Libro libro, int quantita, double scontoPercentuale, double tassaPercentuale) {
		this.libro = libro;
		this.quantita = quantita;
		this.scontoPercentuale = scontoPercentuale;
		this.tassaPercentuale = tassaPercentuale;
		this.totale = this.calcolaTotale();
	}

	public double calcolaTotale() {
	        double prezzo = ((libro.prezzo - libro.prezzo * scontoPercentuale) * this.quantita);

		double prezzoConTasse = prezzo * (1 + tassaPercentuale);

		return prezzoConTasse;
	}

	public void stampaRicevuta() {
            System.out.println(quantita + "x " + libro.nome + " " +          libro.prezzo + "€");
            System.out.println("Percentuale di sconto: " + scontoPercentuale);
            System.out.println("Percentuale di tasse: " + tassaPercentuale);
            System.out.println("Totale: " + totale);
	}

        public void salvaNelFile(String nomefile) {
	// Crea un file con il nome fornito e scrive la ricevuta
	}

}

Ed ecco la classe di fatturazione. Contiene anche alcuni campi riguardanti la ricevuta e 3 metodi:

  • Metodo calcolaTotale, che calcola il prezzo totale,
  • Metodo stampaRicevuta, che stampa la ricevuta sulla console,
  • Metodo salvaNelFile, che scrive la ricevuta su un file.

Prenditi un attimo per pensare a cosa è sbagliato nel modello di questa classe prima di passare al prossimo paragrafo.

Ok, quindi qual è il problema? La nostra classe infrange il principio di singola responsabilità in diversi modi.

La prima violazione è il metodo stampaRicevuta, che contiene la nostra logica di stampa. L'SRP afferma che la nostra classe dovrebbe avere un solo motivo per cambiare, e in questo caso quella ragione dovrebbe essere il calcolo della ricevuta.

Ma con questa architettura, se volessimo cambiare il formato di stampa, avremmo bisogno di modificare la classe. Ecco perché non dovremmo mischiare la logica di stampa con la logica di business nella stessa classe.

C'è anche un altro metodo che viola l'SRP nella nostra classe: il metodo salvaNelFile. Mischiare la logica di persistenza dei dati e la logica di business è un errore estremamente comune.

Non pensare solo alla scrittura su file - potrebbe essere il salvataggio in un database, fare una chiamata a un'API, o altre cose legate alla persistenza dei dati.

Quindi ti chiederai come possiamo sistemare questa funzione di stampa.

Possiamo creare nuove classi per la nostra logica di stampa e di persistenza dei dati, in modo da non dover più modificare la classe di fatturazione per quegli scopi.

Creiamo 2 classi, StampaFattura e MemorizzaFattura, e spostiamo i metodi.

public class stampaFattura {
    private Fattura fattura;

    public stampaFattura(Fattura fattura) {
        this.fattura = fattura;
    }

    public void print() {
        System.out.println(fattura.quantita + "x " + fattura.libro.nome + " " + fattura.libro.prezzo + " €");
        System.out.println("Percentuale di sconto: " + fattura.scontoPercentuale);
        System.out.println("Percentuale di tasse: " + fattura.tassaPercentuale);
        System.out.println("Totale: " + fattura.totale + " €");
    }
}
public class MemorizzaFattura {
    Fattura fattura;

    public MemorizzaFattura(Fattura fattura) {
        this.fattura = fattura;
    }

    public void salvaNelFile(String nomefile) {
        // Crea un file con il nome fornito e scrive la ricevuta
    }
}

Ora la struttura della nostra classe rispetta il principio di responsabilità singola e ogni classe è responsabile di una sola porzione della nostra applicazione. Grandioso!

Il principio aperto/chiuso (OCP)

Il principio aperto/chiuso stabilisce che le classi debbano essere aperte alle estensioni e chiuse alle modifiche.

Per modifica si intende il cambiamento del codice di una classe esistente, ed estensione significa aggiungere nuove funzionalità.

Questo principio sta a significare che dovremmo essere in grado di aggiungere nuove funzionalità senza toccare il codice attuale della classe. Questo perché ogni volta che modifichiamo il codice, rischiamo di dare vita a potenziali bug. Quindi dovremmo evitare di toccare un codice di produzione testato e affidabile dove possibile.

Potresti chiederti come fare ad aggiungere nuove funzionalità senza modificare la classe. Di solito, questo avviene con l'aiuto di interfacce e classi astratte.

Ora che abbiamo coperto le basi del principio, applichiamolo alla nostra applicazione di fatturazione.

Ipotizziamo che il nostro capo sia venuto da noi chiedendoci di salvare le fatture su un database, in modo che possano essere ricercate facilmente. Pensiamo: ok, facilissimo capo, dammi un secondo!

Creiamo il database, ci connettiamo e aggiungiamo un metodo di salvataggio alla nostra classe MemorizzaFattura:

public class MemorizzaFattura {
    Fattura fattura;

    public MemorizzaFattura(Fattura fattura) {
        this.fattura = fattura;
    }

    public void salvaNelFile(String nomefile) {
        // Crea un file con il nome fornito e scrive la ricevuta
    }

    public void salvaNelDatabase() {
        // Salva la fattura nel database
    }
}

Sfortunatamente, proprio come lo sviluppatore pigro della libreria, non abbiamo costruito la classe in modo che sia facilmente estendibile nel futuro. Quindi per aggiungere questa funzionalità, abbiamo modificato la classe MemorizzaFattura.

Se il modello della nostra classe seguisse il principio aperto/chiuso non avremmo avuto bisogno di modificare questa classe.

Quindi, come farebbe lo sviluppatore pigro ma intelligente della libreria, notiamo il problema d'architettura e decidiamo di riscrivere il codice per fare in modo che rispetti il principio.

interface MemorizzaFattura {

    public void salva(Fattura fattura);
}

Cambiamo il tipo di MemorizzaFattura in Interfaccia e aggiungiamo il metodo salva. Ogni classe di persistenza implementerà il metodo salva.

public class PersistenzaDatabase implements MemorizzaFattura {

    @Override
    public void salva(Fattura fattura) {
        // Salva sul DB
    }
}
public class PersistenzaFile implements MemorizzaFattura {

    @Override
    public void salva(Fattura fattura) {
        // Salva su file
    }
}

Quindi la struttura della nostra classe appare così:

SOLID-Tutorial-1-1024x554

Ora la nostra logica di persistenza è facilmente estendibile. Se il nostro capo ci chiede di aggiungere un altro database e avere 2 diversi tipi di database come MySQL e MongoDB, possiamo farlo facilmente.

Potresti pensare che potremmo semplicemente creare classi multiple senza interfacce e aggiungere un metodo salva a tutte queste classi.

Ma immaginiamo di voler estendere la nostra app e avere classi di persistenza multiple come MemorizzaFattura, MemorizzaLibro e creiamo la classe GestionePersistenza che gestisce tutte le classi di memorizzazione:

public class GestionePersistenza {
    MemorizzaFattura memorizzaFattura;
    MemorizzaLibro memorizzaLibro;
    
    public GestionePersistenza(MemorizzaFattura memorizzaFattura,
                              MemorizzaLibro memorizzaLibro) {
        this.memorizzaFattura = memorizzaFattura;
        this.memorizzaLibro = memorizzaLibro;
    }
}

Ora possiamo passare qualsiasi classe che implementa l'interfaccia MemorizzaFattura a questa classe con l'aiuto del polimorfismo. Questa è la flessibilità che ci offrono le interfacce.

Il principio di sostituzione di Liskov (LSP)

Il principio di sostituzione di Liskov stabilisce che le sottoclassi debbano essere sostituibili dalle loro classi d'origine.

Questo significa che, data una classe B, sottoclasse di una classe A, dobbiamo essere in grado di passare un oggetto della classe B a qualsiasi metodo che si aspetti un oggetto della classe A, senza che il metodo restituisca un output inatteso.

Questo è il comportamento previsto, perché quando usiamo l'ereditarietà diamo per scontato che la classe figlia erediti tutto quello che ha la superclasse. La classe figlia estende il comportamento senza mai limitarlo.

Quindi, quando una classe non rispetta questo principio, porta ad alcuni sgradevoli bug difficili da identificare.

Il principio di Liskov è facile da capire ma difficile da trovare nel codice. Quindi diamo un'occhiata a un esempio.

class Rettangolo {
	protected int larghezza, altezza;

	public Rettangolo() {
	}

	public Rettangolo(int larghezza, int altezza) {
		this.larghezza = larghezza;
		this.altezza = altezza;
	}

	public int getLarghezza() {
		return larghezza;
	}

	public void setLarghezza(int larghezza) {
		this.larghezza = larghezza;
	}

	public int getAltezza() {
		return altezza;
	}

	public void setAltezza(int altezza) {
		this.altezza = altezza;
	}

	public int getArea() {
		return larghezza * altezza;
	}
}

Abbiamo una semplice classe Rettangolo, e una funzione getArea che restituisce l'area del rettangolo.

Ora decidiamo di creare un'altra classe per i quadrati. Come saprai, il quadrato è solamente un rettangolo con altezza e larghezza uguali.

class Quadrato extends Rettangolo {
	public Quadrato() {}

	public Quadrato(int lato) {
		larghezza = altezza = lato;
	}

	@Override
	public void setLarghezza(int larghezza) {
		super.setLarghezza(larghezza);
		super.setAltezza(larghezza);
	}

	@Override
	public void setAltezza(int altezza) {
		super.setAltezza(altezza);
		super.setLarghezza(altezza);
	}
}

La nostra classe Quadrato estende la classe Rettangolo. Impostiamo l'altezza e la larghezza con lo stesso valore nel costruttore, ma non vogliamo che un utente cambi altezza o larghezza in modo da violare le proprietà del quadrato.

Quindi sovrascriviamo i setter in modo da impostare entrambe le proprietà, ogni volta che ne viene modificata una. Facendo questo violiamo il principio di sostituzione di Liskov.

Creiamo una classe main per effettuare dei test sulla funzione getArea.

class Test {

   static void getAreaTest(Rettangolo r) {
      int larghezza = r.getLarghezza();
      r.setAltezza(10);
      System.out.println("Risultato previsto " + (larghezza * 10) + ", ottenuto " + r.getArea());
   }

   public static void main(String[] args) {
      Rettangolo rc = new Rettangolo(2, 3);
      getAreaTest(rc);

      Rettangolo sq = new Quadrato();
      sq.setLarghezza(5);
      getAreaTest(sq);
   }
}

Il tester del tuo team ha appena creato la funzione di test getAreaTest e ti dice che la tua funzione getArea non ha passato il test per i quadrati.

Nel primo test, creiamo un rettangolo di larghezza 2 ed altezza 3 e chiamiamo getAreaTest. L'output è 20 come previsto, ma le cose vanno male se passiamo un quadrato. Questo perché la chiamata della funzione setAltezza del test sta impostando anche la larghezza, restituendoci un risultato inaspettato.

Il principio di segregazione delle interfacce (ISP)

Segregare significa tenere delle cose separate, e il principio di segregazione delle interfacce si occupa di separare le interfacce.

Il principio stabilisce che avere interfacce specifiche per ogni client è meglio di avere un'interfaccia di uso generico. I client non dovrebbero essere forzati a implementare funzioni di cui non hanno bisogno.

Questo è un principio semplice da capire e applicare, vediamo un esempio.

public interface Parcheggio {

	void parcheggiaAuto();	// Diminuisce parcheggi vuoti di 1
	void esceAuto(); // Aumenta parcheggi vuoti di 1
	void getCapienza();	// Restituisce capienza auto
	double calcolaQuota(Auto auto); // Restituisce il prezzo in base al numero di ore
	void faiPagamento(Auto auto);
}

class Auto {

}

Abbiamo modellato un parcheggio molto semplificato. Questo è un tipo di parcheggio nel quale paghi una quota oraria. Ora immagina di voler implementare un parcheggio gratuito.

public class ParcheggioGratis implements Parcheggio {

	@Override
	public void parcheggiaAuto() {
		
	}

	@Override
	public void esceAuto() {

	}

	@Override
	public void getCapienza() {

	}

	@Override
	public double calcolaQuota(Auto auto) {
		return 0;
	}

	@Override
	public void faiPagamento(Auto auto) {
		throw new Exception("Il parcheggio è libero");
	}
}

Il nostro parcheggio è composto da 2 cose: la logica relativa al parcheggio (auto parcheggia, auto esce, ottieni capienza) e logica relativa al pagamento.

Ma è troppo specifico e a causa di questo, la classe ParcheggioGratis ha dovuto implementare i metodi relativi al pagamento che sono irrilevanti. Quindi separiamo/segreghiamo le interfacce.

SOLID-Tutorial-1024x432

Ora abbiamo separato i parcheggi. Con questo nuovo modello, possiamo anche andare oltre e dividere ParcheggioPagamento per supportare diverse modalità di pagamento.

Ora il nostro modello è molto più flessibile, estendibile e i client non hanno bisogno di implementare alcuna logica irrilevante visto che forniamo solo funzionalità relative al tipo specifico di parcheggio nelle varie interfacce.

Il principio di inversione delle dipendenze (DIP)

Il principio di inversione delle dipendenze stabilisce che le classi debbano dipendere da interfacce o classi astratte invece che da classi concrete e funzioni.

Nel suo articolo (2000), Uncle Bob riassume il principio così:

"Se l' OCP stabilisce l'obiettivo dell'architettura orientata agli oggetti, il DIP ne stabilisce il meccanismo primario".

Questi due principi sono legati ed abbiamo applicato questo schema mentre stavamo discutendo del principio aperto/chiuso.

Vogliamo che le nostre classi siano aperte all'estensione, quindi abbiamo riorganizzato le dipendenze in modo da dipendere da interfacce invece che da classi concrete. La nostra classe GestionePersistenza dipende da MemorizzaFattura, e non dalle classi che implementano questa interfaccia.

Conclusione

In questo articolo, abbiamo iniziato con la storia dei principi SOLID, per poi tentare di acquisire una chiara comprensione delle ragioni dietro a ogni principio e come implementarli. Abbiamo addirittura riscritto una semplice applicazione di fatturazione per farle rispettare i principi SOLID.

Grazie per aver speso il tuo tempo leggendo l'intero articolo. Spero che i concetti espressi siano chiari.

Ti consiglio di tenere questi principi a mente mentre progetti, scrivi e riscrivi i tuoi codici in modo da avere un codice molto più pulito, estendibile e testabile.