Come non farsi odiare in un team: i principi SOLID

C

Introduzione

Hola Coders,

in questo articolo descriveremo i SOLID principles, un insieme di regole molto utili ad ogni developer.

Quanti di voi non si sono trovati in situazioni scomode con gli altri membri del team per via di codice incomprensibile o difficile da capire? Anche i migliori nei loro primi passi hanno scritto delle righe di codice di cui non vanno fieri!

Prima ancora di preoccuparsi dei differenti punti di opinione sulla scrittura del codice, ci sono ragioni più importanti per cui è meglio seguire le direttive che verranno spiegate in seguito per l’architettura complessiva: all’inizio ci sono poche classi e funzioni, le connessioni sono chiare e semplici, il software è pulito, elegante ed efficace. Andando avanti nel tempo, il codice però inizia a marcire: un piccolo hack qui, una feature messa lì in tutta fretta per consegnare in tempo, ed ecco che in men che non si dica che il software diventa ingestibile. Alla fine, anche aggiungere una piccola funzionalità diventa un compito così difficile da portare a termine che si perde molto tempo e denaro fin quando non si ricrea da zero il progetto.

Per ovviare a questo problema, ci sono vari metodi e tecniche personali per cercare di ovviare a questo problema, ma la migliore soluzione a mio avviso sono i SOLID Principles.

Questi principi furono ideati da Robert C. Martin (conosciuto per i suoi divertenti video educativi sotto il nome di “Uncle Bob”) all’incirca nel 2000 e fanno parte di un più grande insieme di principi che hanno lo scopo di avere codice pulito, resiliente e facilmente estendibile.

I principi sono:

  • Single Responsability Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

 

single responsability principle

Il primo dei principi SOLID afferma che:

"Non ci dovrebbe essere più di una ragione per una classe per cambiare"

Cosa vuol dire questa frase? Che ogni classe, o struttura dello stesso tipo, dovrebbe eseguire solamente una ed una sola operazione. Ciò non implica che dovrebbe essere presente in una classe un solo metodo, ma che tutti i metodi e le variabili hanno lo scopo di portare a termine una singola funzionalità. In questo modo, le classi diventeranno molto più piccole e chiare, sebbene il loro numero aumenterà considerevolmente.

Come esempio, prendiamo due classi che definiscono due forme geometriche: quadrato e rettangolo.

public interface GeometricShape {

  public double perimeter();

}


public class Square implements GeometricShape
{
  private long side;

  public Square(long side)
  {
    this.side = side;
  }

  public double perimeter() {
    return square * 4;
  }
}

public class Rectangle implements GeometricShape
{
  private long length;
  private long width;

  public Rectangle(long length, long width)
  {
    this.length = length;
    this.width = width;
  }

  public double perimeter() {
    return ((length * 2) + (width * 2));
  }
}

Queste due classi, vengono usate da un’altra classe PerimeterCalculator formata nel seguente modo:

public class PerimeterCalculator
{

  public void calculateTotalPerimeter(List<GeometricShape> shapes) {
    
    double totalPerimeter = 0;
    for (GeometricShape shape :shapes)
    {
      totalPerimeter += shape.perimeter();
    }
    System.out.println("Il perimetro totale degli oggetti è: " + totalPerimeter);
  }

}

Come possiamo vedere, la classe PerimeterCalculator, esegue due funzionalità: calcolare il perimetro degli oggetti e stampare il risultato. In caso in futuro si volesse restituire il risultato in un altro formato (JSON, XML, Stringa), allora bisognerebbe modificare la classe PerimeterCalculator sebbene il calcolo del perimetro non ne sia affetto. Per tale motivo è meglio separare in due classi separate così che una modifica ad una delle due operazioni, avvenga limitatamente ad una delle due classi:

public class PerimeterCalculator
{

  public double calculateTotalPerimeter(List<GeometricShape> shapes) {

    double totalPerimeter = 0;
    for (GeometricShape shapes : shape)
    {
      totalPerimeter += shape.perimeter();
    }
    return totalPerimeter;
  }

}

public class PerimeterPrinter
{

  public void printTotalPerimeter(double totalPerimeter) {

    System.out.println("Il perimetro totale degli oggetti è: " + totalPerimeter);
  }

}

Il link all’articolo originale sul principio

Open/Closed principle

Passiamo ora al secondo dei principi che afferma:

"Le entità del software (classi, moduli, funzioni, etc) dovrebbe essere aperte per le estensioni e chiuse per le modifiche"

Esso intende che ogni classe può essere estesa ma non deve essere modificata. Per poter spiegare il principio in maniera chiara, consideriamo nuovamente l’esempio delle forme geometriche. Ripartendo dalle classi Quadrato e Rettangolo definite sopra, cambiamo l’entità FormaGeometrica da interfaccia a classe astratta e definiamo solamente i loro campi ed il metodo di accesso get():

public abstract class GeometricShape { 
  
} 

public class Square implements GeometricShape
{
  private long side;

  public Square(long side)
  {
    this.side = side;
  }

  public double getSide() {
    return side;
  }
}

public class Rectangle implements GeometricShape
{
  private long length;
  private long width;

  public Rectangle(long length, long width)
  {
    this.length = length;
    this.width = width;
  }

  public double getLength() {
    return length;
  }

  public double getWidth() { 
    return width; 
  }
}

Le due classi sono molto semplici ed intuitive, inseriamo tramite costruttore gli elementi base dei due oggetti e li recuperiamo tramite i metodi get…().

Consideriamo nuovamente una classe che calcola il perimetro degli oggetti e in prima istanza assumiamo che questi oggetti sono solamente quadrati:

public class PerimeterCalculator
{

  public double calculateTotalPerimeter(List<Square> shapes) {

    double totalPerimeter = 0;
    for (Square square : shapes)
    {
      totalPerimeter += (square.getSide() * 4);
    }
    return totalPerimeter;
  }

}

Assumiamo ora che la classe PerimetroOggetti debba calcolare il perimetro anche per i rettangoli, il codice potrebbe diventare:

public class PerimetroOggetti
{

  public double calculateTotalPerimeter(List<GeometricShape> shapes) {

    double totalPerimeter = 0;
    for (GeometricShape shape : shapes)
    {
      if (shape instanceof Square) {
        Square square = (Square) shape;
        totalPerimeter += (square.getSide() * 4);
      } else {
        Rectangle rectangle = (Rectangle) shape;
        totalPerimeter += (rectangle.getLength() + rectangle.getWidth()) * 2;
      }
    }
    return totalPerimeter;
  }

}

In questo modo, sebbene la soluzione funzioni, ogni volta che viene aggiunta una nuova classe che estende FormaGeometrica, bisognerà aggiungere un nuovo ramo all’if dentro il ciclo for. Una migliore soluzione consiste come primo passo nella (ri)creazione del metodo calcolaPerimetro() all’interno dell’interfaccia FormaGeometrica:

public abstract class GeometricShape {

  public abstract double perimeter();

}

public class Square implements GeometricShape
{
  private long side;

  public Square(long side)
  {
    this.side = side;
  }

  public double perimeter() {
    return side * 4;
  }
}

public class Rectangle implements GeometricShape
{
  private long length;
  private long width;

  public Rectangle(long length, long width)
  {
    this.length = length;
    this.width = width;
  }

  public double perimeter() {
    return ((length * 2) + (width * 2));
  }
}

Come passo finale, possiamo utilizzare (nuovamente) la classe PerimetroOggetti come nella soluzione del precedente principio:

public class PerimeterCalculator
{

  public double calculateTotalPerimeter(List<GeometricShape> shapes) {

    double totalPerimeter = 0;
    for (GeometricShape shape : shapes)
    {
      totalPerimeter += shape.perimeter();
    }
    return totalPerimeter;

  }

}

Sebbene possa sembrare una rifattorizzazione con poco valore, ogni qual volta aggiungiamo un nuovo oggetto, non dobbiamo cambiare la classe PerimetroOggetti, ma solamente implementare correttamente il metodo calcolaPerimetro() nel nuovo oggetto.

Il link all’articolo originale sul principio

Liskov substitution principle

Il terzo dei principi è il Principio di Sostituzione di Liskov che afferma:

"Funzioni che usano puntatori o riferimenti alle classi base, devono essere in grado di utilizzare le classi derivate senza saperlo"

Il messaggio che trasmette questo principio si può riassumere: se una classe usa un tipo di oggetti, deve poterne usarne anche i suoi derivati.

Prendiamo come esempio i soliti due oggetti, un rettangolo ed un quadrato. Definiamo dapprima un rettangolo:

public class Rectangle
{
  private double length;
  private double width;
  
  public void setLength(double length) {
    this.length = length;
  }

  public void setWidth(double width) {
    this.width = width;
  }

}

Consideriamo ora una classe che definisce un quadrato. Quest’ultimo si può facilmente considerare come un caso particolare di un rettangolo con base e altezza uguali tra loro. Creiamo quindi la classe Quadrato che estende Rettangolo, in cui nei metodi setBase() e setAltezza() vengono impostati i due campi con la variabile in ingresso poiché i lati devono essere uguali:

public class Quadrato extends Rectangle
{
  private double length;
  private double width;
  
  public void setLength(double length) {
    this.length = length;
    this.width = width;
  }

  public void setWIdth(double width) {
    this.width = width;
    this.length = length;
  }
}

se ora prendiamo come esempio un metodo che imposta base ed altezza di un rettangolo:

public void setSides(Rectangle rectangle, double length, double width) {
  rectangle.setWidth(width);
  rectangle.setLength(length);
}

Come possiamo vedere dal codice precedente, al metodo viene passato un rettangolo, un valore per la base e uno per l’altezza. Poi, i due valori sono impostati nell’oggetto. Se però l’oggetto passato al metodo fosse di tipo Square, in caso di valori di base e altezza pari rispettivamente a 2 e 3 per esempio,  il valore dell’altezza sarebbe 2 e non 3. Infatti, il metodo setLength() imposterebbe l’altezza a 2 andando a sovrascrivere l’effetto del metodo setWIdth(). Tale inconsistenza  è la violazione che viene evitata rispettando il principio di sostituzione di Liskov.

Il link all’articolo originale sul principio

Interface segregation principle

Questo principio afferma:

"I clients non dovrebbero essere obbligati a dipendere da interfacce che non usano"

La più grande violazione di tale principio si riscontra in classi che implementano un gran numero  di interfacce, ma utilizzano solamente un sottoinsieme di metodi. Per poter mostrare un esempio, consideriamo gli uccelli nel regno animale. La maggior parte di loro può volare, (come l’aquila, il falco, etc.) mentre altri come struzzo o pinguino possono solo correre. Per poter rappresentare lato codice questo dominio, consideriamo un’interfaccia Bird che contiene quindi un metodo walk()  ed uno fly():

public interface Bird
{
  void fly();
  void walk();
}

Creiamo ora due implementazioni, Eagle e Penguin:

public class Penguin implements Bird {

  @Override
  public void fly()
  {
    return;
  }

  @Override
  public void walk()
  {
    //logic for walk
  }
}

public class Eagle implements Bird {

  @Override
  public void fly()
  {
    // logic for flying
  }

  @Override
  public void walk()
  {
    //logic for walk
  }
}

Come possiamo notare, la classe Penguin ha un metodo fly() non utilizzato in cui si esce immediatamente. La cosa che denota un bad design è il metodo walk() nei pinguini che non fa nulla. Per poter risolvere questo smell, possiamo lasciare il metodo walk() nella class Bird e creare una seconda interfaccia FlyingBird:

public interface Bird
{
  void walk();
}

public interface FlyingBird
{
  void fly();
}

A questo punto, possiamo modificare la classe Penguin per fargli implementare la classe Bird e la classe Eagle per fargli implementare sia la classe Bird che FlyingBird:

public class Penguin implements Bird {

  @Override
  public void walk()
  {
    //logic for walk
  }
}

public class Eagle implements Bird, FlyingBird {

  @Override
  public void fly()
  {
    // logic for flying
  }

  @Override
  public void walk()
  {
    //logic for walk
  }
}

Sebbene il numero di interfacce implementate nella classe Eagle aumenti, ora possiamo essere sicuri che ogni classe implementa solo le interfacce di cui hanno necessariamente bisogno, facendo risparmiare tempo ai developer nello scrivere il codice di metodi vuoti ed inutili.

Il link all’articolo originale sul principio

dependency inversion principle

L’ultimo dei principi afferma:

I moduli di alto livello non devono dipendere da quelli di basso livello, entrambi dovrebbero dipendere da astrazioni;
Le astrazioni non dovrebbero dipende dai dettagli, i dettagli dovrebbero dipendere da astrazioni

Sebbene il principio possa sembrare  molto lungo e articolato, in realtà esso è semplice e di fondamentale importanza. Secondo Martin, il principio impone che tutti i moduli di alto livello dovrebbero avere dipendenze da astrazioni e non da classi concrete. Ciò è per evitare che un modulo dipenda da un’implementazione specifica di una classe in quanto se in futuro l’implementazione dovesse evolvere o cambiare, bisognerebbe modificare anche il modulo. Per poter mostrare un esempio, consideriamo una classe ReportGenerator contenente un collaboratore EmailSender per poter inviare il report generato via mail:

public class ReportGenerator {

  public final EmailSender emailSender;

  public ReportGenerator(EmailSender emailSender)
  {
    this.emailSender = emailSender;
  }

  public void sendReport() {
    // stuff

    emailSender.send(data);

    // other stuff
  }
}

public class EmailSender {

  public void send(Data data) {
    // logic to send email
  }
}

Analizzando il codice, vediamo che il ReportGenerator ad un certo momento richiama il metodo send() dell’EmailSender per eseguire il compito. Se in caso in futuro i report dovessero essere inviati per esempio via JSON, bisognerebbe cambiare la classe ReportSender e ricompilare il tutto.

Per ovviare a questo problema, la migliore soluzione consiste semplicemente nel modificare il ReportSender modificando il suo collaboratore con un’interfaccia Sender con un metodo send():

public class ReportGenerator {

  public final Sender sender;

  public ReportGenerator(Sender sender)
  {
    this.sender = sender;
  }

  public void sendReport() {
    // stuff

    sender.send(data);

    // other stuff
  }
}

public interface Sender {

  void send(Data data);
}

public class EmailSender {

  public void send(Data data) {
    // logic to send email
  }
}

Infine, richiamando il costruttore del ReportGenerator con un oggetto di EmailSender, otteniamo lo stesso risultato. Quali sono i vantaggi di tale modifica? Se in futuro ci fosse la necessità di modificare il metodo di invio da Email a REST call per esempio, basterà creare una nuova classe HttpRestClientSender che implementa l’interfaccia Sender e sostituire in fase di configurazione l’oggetto passato nel costruttore del ReportGenerator. In questo modo, in caso ReportGenerator sia definita in un modulo diverso dalla libreria dai mezzi per inviare i report, basterà solamente ricompilare la libreria e il file di configurazione, senza toccare il modulo.

Il link all’articolo originale sul principio

conclusione

E finalmente, con grande sollievo degli occhi, questo lungo articolo è finito! Lo scopo di questo articolo era fornire una panoramica sui SOLID principles e degli esempi per riconoscerli. Sebbene la trattazione possa sembrare semplice e i principles anche banali, la loro importanza non deve essere sottovalutata. In progetti di piccole dimensioni, violare i principi SOLID può non comportare una grande perdita di denaro o tempo per gestirli. Al contrario in progetti più grandi, violare in maniera continuativa questi principi può creare un design architetturale che nel lungo tempo non permette di integrare nuove funzionalità o rallentare il business, arrivando nel peggiore dei casi a creare seri problemi all’azienda.

A proposito di me

manuel mandatori

Manuel è un ex studente/lavoratore con la passione per la programmazione in generale. Il suo percorso lavorativo è iniziato come Malware Analyst per poi creare tool per l'analisi dei malware in C (in kernel mode). Dopo un progetto in Java SE, ha deciso di intraprendere la strada in Java EE per dare una svolta alla sua carriera professionale. Oggi lavora in una grande OTA e studia nel tempo libero altri linguaggi e le tecniche di Clean Code, per aumentare le sue abilità di Software Craftmanship

Gli articoli più letti

Articoli recenti

Commenti recenti