Alla scoperta dei principi SOLID per il design di software robusto e manutenibile

I principi SOLID aiutano a progettare e sviluppare applicativi software a regola d’arte

SOLID è un acronimo mnemonico (la passione statunitense per gli acronimi meriterebbe un articolo a parte) creato per racchiudere quelli che sono stati definiti i cinque parametri fondanti della programmazione ad oggetti.

Quest’articolo può essere inteso come la terza parte della nostra serie sui paradigmi di programmazione. La lettura dei pregressi articoli aiuta a meglio contestualizzare anche questo approfondimento:

  1. Paradigmi di programmazione: storia e caratteristiche.
  2. OOP 101: Introduzione all’Object-Oriented Programming.

Come altre metodologie e pratiche riguardanti il design del software, anche i principi SOLID non ambiscono a creare una cortina impenetrabile e rigida.

Piuttosto vogliono fornire un contesto di riferimento per gli sviluppatori alle prese con problemi complessi, e con la necessità di progettare applicativi scalabili, solidi, sicuri.

Grazie ai principi SOLID essi possono trovare agilmente regole di design pronte all’uso, da mettere in pratica sia nella fase di sviluppo che di revisione.

SOLID – la storia

Il design del software ha un grande debito con Robert C. Martin aka Uncle Bob.

A lui dobbiamo la pietra miliare “Clean Code” e numerose altre opere inerenti la progettazione di software state-of-the-art.

Robert C. Martin ha introdotto per la prima volta l’acronimo SOLID nel 2000. Parliamo quindi di un aspetto teorico completamente maturo, che si avvia a compiere 30 anni di storia.

SOLID ha trovato spazio proprio nella prima opera testuale pubblicata da Uncle Bob, “Designing Object-Oriented C++ Applications Using the Booch Method”.

Successivamente i principi sono rimasti un punto conduttore della sua narrativa, ripresi nei suoi libri seguenti e in generale nella sua trattazione della progettazione software.

Robert C. Martin ha definito i principi SOLID espressamente per il paradigma della programmazione ad oggetti, tuttavia essi possono fornire la base in senso più esteso per altre metodologie, come la programmazione Agile e lo sviluppo di software adattivo o rapido (ASD & RAD).

SOLID – Finalità

Grazie all’applicazione dei principi SOLID è possibile:

  1. Migliorare la manutenibilità del codice: i principi SOLID promuovono la creazione di codice più pulito e organizzato, che risulta più facile da comprendere e modificare nel tempo. In tal modo si rende più agevole la manutenzione del software, consentendo agli sviluppatori di apportare modifiche con minor rischio di introduzione di errori.
  2. Ridurre l’accoppiamento tra le componenti: adottando i principi SOLID, si tende a ridurre l’accoppiamento tra le varie parti del sistema. Significa che le classi e i moduli sono meno dipendenti l’uno dall’altro, il che facilita la modifica di una parte del sistema senza dover modificare necessariamente altre parti correlate.
  3. Promuovere la riusabilità del codice: i principi SOLID incoraggiano la creazione di componenti software più modulari e indipendenti. Si favorisce quindi la riusabilità del codice, in quanto le componenti ben progettate possono essere utilizzate in diversi contesti senza la necessità di modifiche sostanziali.
  4. Facilitare l’estensione del software: i principi SOLID consentono di progettare il software in modo tale che sia possibile estenderlo con nuove funzionalità senza dover modificare il codice esistente. Si garantisce la scalabilità del software nel tempo e lo rende più adattabile ai cambiamenti dei requisiti.
  5. Migliorare la testabilità: scrivendo codice che rispetta i principi SOLID, si tende a creare componenti più piccole e isolate, che sono più facili da testare in modo unitario. Questo favorisce l’adozione di pratiche di sviluppo agile come il TDD (Test-Driven Development) e contribuisce a garantire una maggiore qualità del software attraverso una copertura più completa dei test.

I principi SOLID sono tra loro collegati e fanno parte di un contesto più ampio riguardante le pratiche di progettazione di applicativi software.

Non vanno quindi intesi come regole pratiche da adottare ad occhi chiusi ma piuttosto elementi di confronto ai quali fare riferimento durante il design dei prodotti tecnologici.

I principi in teoria e pratica

Vediamo ora i singoli principi partendo dalla loro definizione teorica e mostrando un semplice esempio pratico.

Iniziamo fornendo la definizione storica sancita proprio da Robert C. Martin, per poi spiegare più nel dettaglio i singoli principi nei seguenti sottocapitoli.

SRP The Single Responsibility Principle A class should have one, and only one, reason to change.
OCP The Open Closed Principle You should be able to extend a classes behavior, without modifying it.
LSP The Liskov Substitution Principle Derived classes must be substitutable for their base classes.
ISP The Interface Segregation Principle Make fine grained interfaces that are client specific.
DIP The Dependency Inversion Principle Depend on abstractions, not on concretions.

S – Principio della singola responsabilità (SRP – Single Responsibility Principle)

Una classe dovrebbe avere solo una ragione per cambiare. Ossia, avere un solo scopo, e compiere solo quello.

Questo principio promuove la coesione all’interno delle classi, separando le diverse responsabilità in modo che ciascuna classe sia focalizzata su un compito specifico. Ciò rende il codice più chiaro, facile da comprendere e modificare.

Questo approccio può sembrare molto rigido, e il beneficio si comprende poco su progetti di modeste dimensioni o con codice molto lineare.

Ma SOLID genera un effetto domino: se le cose sono fatte bene fin da subito, anche quando poi il progetto cresce, tutto è lineare.

Viceversa, partire senza regole di design precise, e poi pensare di cambiarle in corsa, può generare brutti mal di testa.

Vogliamo creare un animale e poi salvarlo. La tentazione di fare una singola classe “create and save” sarebbe forte. Ma già dal nome capiamo che stiamo andando contro a un solido problema. Quindi ci fermiamo, e seguiamo il principio della singola responsabilità.

class Animal:
    def __init__(self, name: str):
        self.name = name

    def get_name(self):
        pass

class AnimalDB:
    def get_animal(self) -> Animal:
        pass

    def save(self, animal: Animal):
        pass

O – Principio di apertura e chiusura (OCP – Open Close Principle)

Il principio di apertura-chiusura stabilisce che le entità del software (classi, moduli, funzioni, ecc.) dovrebbero essere aperte per l’estensione (ossia, includendo nuove funzionalità) ma chiuse per la modifica, ossia non dovrebbe essere necessario cambiare il codice esistente per fare ciò.

Questo principio promuove la progettazione del software in modo tale che sia possibile estendere il comportamento degli oggetti senza dover modificare il codice esistente.

Talvolta le definizioni dei principi SOLID fanno apparire le cose più complesse di quello che sono in realtà.

Vediamo un esempio.

Saremmo tentati di definire subito una classe Shape e poi estenderla via via che aggiungiamo nuove figure geometriche.

Scegliamo invece di creare prima un’interfaccia e poi di implementarla con una classe per ogni singola figura geometrica.

Possiamo modificare il codice aggiungendo nuove figure, ma senza modificare il codice sorgente.

from abc import ABC, abstractmetho
from math import pi

class Shape(ABC):
    def __init__(self, shape_type):
        self.shape_type = shape_type

   @abstractmethod
   def calculate_area(self):
      pass

class Circle(Shape):
   def __init__(self, radius):
      super().__init__("circle")
      self.radius = radius

def calculate_area(self):
   return pi * self.radius**2

class Rectangle(Shape):
   def __init__(self, width, height):
      super().__init__("rectangle")
      self.width = width
      self.height = height

def calculate_area(self):
   return self.width * self.height

class Square(Shape):
   def __init__(self, side):
      super().__init__("square")
      self.side = side

def calculate_area(self):
   return self.side**2d

Principio di sostituzione di Liskov (LSP – Liskov Substitution Principle)

Il principio di sostituzione è stato per l’appunto introdotto da Barbara Liskov nel 1987, durante la sua conferenza dal titolo “Data Abstraction”.

Successivamente ne ha fornito una definizione formale:

“Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T”.

Proviamo a renderla un poco più palatabile.

Il principio di sostituzione di Liskov afferma che gli oggetti di una classe derivata dovrebbero essere utilizzabili al posto degli oggetti della classe base senza rompere l’applicativo o causare errori indesiderati.

Il desiderio è che gli oggetti delle sottoclassi si comportino come gli oggetti delle classi di ordine superiore (superclassi).

Torniamo sulle nostre figure geometriche.

Ancora una volta il principio SOLID ci dice di avere una base di astrazione comune, e successivamente lavorare sui singoli casi, questa volta seguendo la medesima logica implementativa.

class Rectangle:
    def __init__(self, width, height):
       self.width = width
       self.height = height

    def set_width(self, width):
       self.width = width

    def set_height(self, height):
       self.height = height

    def get_area(self):
       return self.width * self.height

class Square(Rectangle):
    def set_width(self, width):
       self.width = self.height = width

    def set_height(self, height):
       self.width = self.height = height

def use_it(rc):
    w = rc.width
    rc.set_height(10)
    assert rc.get_area() == w * 10

rc = Rectangle(2, 3)
    use_it(rc)

sq = Square(5)
    use_it(sq)

I – Principio della segregazione delle interfacce (ISP – Interface Segregation Principle)

Il principio della segregazione delle interfacce sancisce che un client non dovrebbe mai essere costretto a implementare un’interfaccia che non usa, o i client non dovrebbero essere costretti a dipendere da metodi che non usano.

A questo punto si riesce a intuire il filo logico: manteniamo le componenti del software quanto più indipendenti e separate (o per meglio dire: coese e incapsulate), così che non ci siano fardelli e eredità che risultano ingombranti.

In quest’esempio la tentazione sarebbe di creare una interfaccia “Worker” che gestisca tutte le attività.

Ma così creeremmo un fardello non necessario specificatamente ad altre classi che potremmo voler sviluppare in seguito.

Meglio essere più puntuali, così da non dover correggere in seguito.

interface Workable {
    void work();
}

interface Feedable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class Programmer implements Workable, Feedable, Sleepable {
    public void work() {
    }

public void eat() {

    }

public void sleep() {

    }
}

D – Principio della inversione di dipendenza (DIP – Dependency Inversion Principle)

Il principio della inversione di dipendenza stabilisce che le classi di alto livello non dovrebbero dipendere dalle classi di basso livello, ma entrambe dovrebbero dipendere da astrazioni.

  • I moduli di alto livello non dovrebbero importare nulla dai moduli di basso livello. Entrambi dovrebbero dipendere da astrazioni (ad esempio, interfacce).
  • Le astrazioni devono essere indipendenti dai dettagli. I dettagli (implementazioni concrete) dovrebbero dipendere dalle astrazioni.

Questo principio promuove il disaccoppiamento tra le classi, consentendo una maggiore flessibilità e facilitando la sostituzione delle dipendenze senza modificare il codice sorgente.

Supponiamo di avere una classe Project che dipende direttamente da una classe BackendDeveloper per implementare nuove funzionalità. Utilizzeremo il principio DIP per invertire questa dipendenza, in modo che Project dipenda da un’interfaccia astratta anziché da una classe concreta.

class Developer:
def code(self):
pass

class BackendDeveloper(Developer):
def code(self):
print("Stiamo studiando SOLID.")

class Project:
def __init__(self, developer):
self.developer = developer

def implement_feature(self):
self.developer.code()

developer = BackendDeveloper()
project = Project(developer)
project.implement_feature()

Principi SOLID in sintesi

I principi SOLID a prima vista possono apparire respingenti. Troppo astratti. Troppo teorici. Lontani dall’utilizzo pratico. Confusi nelle definizioni. Poco netti tra di loro.

Tutto ciò è comprensibile. Investire nel design SOLID è come mettere da parte una somma in denaro, farla fruttare, e poi riscuotere il tutto con gli interessi al momento opportuno.

SOLID è astratto e teorico? Si, è vero, proprio come dovrebbe essere il codice ben scritto. Astratto, e riutilizzabile.

SOLID è lontano dall’utilizzo pratico? Sì fino a che scriviamo codice per il nostro tool in cameretta. Ma quando passiamo a lavorare a progetti di grandi dimensioni, con molte entità coinvolte e molti sviluppatori che lavorano sul progetto, avere delle regole chiare, condivise e note nella industry è un valore inestimabile.

Le definizioni SOLID sembrano confuse tra loro? Sì, perché i principi SOLID sono tra loro interdipendenti con un certo grado di sovrapposizione. ISP richiede certamente anche OCP. Sono principi che non si escludono a vicenda, ma anzi si arricchiscono vicendevolmente.

Forse i principi SOLID non sono immediati, è vero, ma è proprio studiando verticalmente la teoria che governa il design, la progettazione del software, che possiamo ambire a diventare programmatori migliori.

Gli esempi in codice utilizzati in quest’articolo sono stati attinti o ispirati dal repository open source dedicato proprio alla divulgazione dei principi SOLID di D. Sichkar, disponibile a questo link.

Non ne hai avuto abbastanza?

Leggi tutti i nostri articoli tecnici nella sezione Tech, o contattaci per scoprire di più sul mondo Aziona!

aziona risorse ebook guida al debito tecnico

Scarica l’ebook “La guida definitiva alla comprensione del Debito Tecnico”

Iscriviti alla newsletter e scarica l’ebook.

Ricevi aggiornamenti, tips e approfondimenti su tecnologia, innovazione e imprenditoria.