I Principi SOLID: Fondamenti della Programmazione ad Oggetti

  



I principi SOLID sono un insieme di linee guida fondamentali per la progettazione di software robusto, scalabile e manutenibile. Questi principi, introdotti da Robert C. Martin (conosciuto come "Uncle Bob"), sono particolarmente utili nella programmazione orientata agli oggetti (OOP). L'applicazione corretta di questi principi aiuta a ridurre la complessità del codice, migliorare la riusabilità e facilitare la manutenzione del software nel tempo:

In questo articolo, esploreremo in dettaglio ciascun principio, illustrando prima un esempio di codice errato e poi la sua corretta applicazione in JavaScript.

🔗 Ti piace Techelopment? Dai un’occhiata al sito per tutti i dettagli!


1. Single Responsibility Principle (SRP) - Principio di Responsabilità Unica

Il principio di responsabilità unica afferma che una classe dovrebbe avere una sola ragione per cambiare, ovvero dovrebbe essere responsabile di un solo aspetto del software. In altre parole, una classe dovrebbe avere un solo scopo ben definito.

Se una classe assume più responsabilità, diventa più difficile da testare, manutenere e modificare. Questo porta a un codice rigido e fragile, in cui una modifica a una parte della classe può inaspettatamente influenzarne altre. Separare le responsabilità consente di ridurre il rischio di bug e migliorare la chiarezza e l'organizzazione del codice.

Codice errato (violazione di SRP)

class MySQLDatabase {
    save(data) {
        console.log("Saving data to MySQL database: ", data);
    }
}

class UserService {
    constructor() {
        this.database = new MySQLDatabase();
    }
    saveUser(user) {
        this.database.save(user);
    }
}


class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }

    save() {
        console.log(`Saving user ${this.name} to database.`);
    }

    sendEmail(message) {
        console.log(`Sending email to ${this.email}: ${message}`);
    }
}

Problema: La classe User ha più responsabilità: gestisce sia i dati dell'utente che la persistenza e l'invio di email. Se cambiamo la logica di salvataggio o di invio email, dovremo modificare questa classe, violando SRP.

Codice corretto (applicazione di SRP)

class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
}

class UserRepository {
    save(user) {
        console.log(`Saving user ${user.name} to database.`);
    }
}

class EmailService {
    sendEmail(user, message) {
        console.log(`Sending email to ${user.email}: ${message}`);
    }
}

Soluzione: Abbiamo separato le responsabilità tra tre classi: User, UserRepository e EmailService. Ora possiamo modificare il metodo di persistenza o l'invio di email senza alterare la logica dell'utente.


2. Open/Closed Principle (OCP) - Principio Aperto/Chiuso

Il principio aperto/chiuso afferma che un'entità software (classe, modulo, funzione, ecc.) dovrebbe essere aperta all'estensione ma chiusa alla modifica. Questo significa che dovremmo poter aggiungere nuove funzionalità senza dover modificare il codice esistente, evitando così di introdurre nuovi bug in codice già testato e funzionante.

Questo principio è particolarmente utile quando il software cresce nel tempo e dobbiamo aggiungere nuove funzionalità senza rischiare di rompere quelle esistenti. Il modo migliore per rispettare OCP è usare l'ereditarietà o il polimorfismo per estendere il comportamento delle classi senza modificarle direttamente.

Codice errato (violazione di OCP)


class Discount {
    calculate(amount, type) {
        if (type === "percentage") {
        return amount - (amount * 10) / 100;
        } else if (type === "fixed") {
        return amount - 20;
        }
        return amount;
    }
}

Problema: Se vogliamo aggiungere un nuovo tipo di sconto, dobbiamo modificare la classe Discount, violando OCP.

Codice corretto (applicazione di OCP)


class Discount {
    calculate(amount) {
        return amount;
    }
}

class PercentageDiscount extends Discount {
    constructor(percent) {
        super();
        this.percent = percent;
    }
    calculate(amount) {
        return amount - (amount * this.percent) / 100;
    }
}

class FixedDiscount extends Discount {
    constructor(amountOff) {
        super();
        this.amountOff = amountOff;
    }
    calculate(amount) {
        return amount - this.amountOff;
    }
}

Soluzione: Ora possiamo aggiungere nuovi tipi di sconto senza modificare Discount, rispettando OCP.


3. Liskov Substitution Principle (LSP) - Principio di Sostituzione di Liskov

Il principio di sostituzione di Liskov afferma che le classi derivate dovrebbero poter sostituire le loro classi base senza alterare il comportamento del programma. Se una sottoclasse viola questo principio, significa che non è realmente un sostituto valido della classe padre e potrebbe causare malfunzionamenti nel codice.

Una violazione tipica di LSP avviene quando una sottoclasse modifica o rimuove il comportamento ereditato, causando errori nelle parti del codice che si aspettano il comportamento originale.

Codice errato (violazione di LSP)


class Rectangle {
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Rectangle {
    constructor(size) {
        super(size, size);
    }
    setWidth(width) {
        this.width = width;
        this.height = width;
    }
    setHeight(height) {
        this.width = height;
        this.height = height;
    }
}

Problema: Square cambia il comportamento di Rectangle in modo non previsto. Se una funzione si aspetta un Rectangle, potrebbe comportarsi in modo errato con uno Square.

Codice corretto (applicazione di LSP)


class Shape {
    getArea() {
        throw new Error("Method not implemented");
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Shape {
    constructor(size) {
        super();
        this.size = size;
    }
    getArea() {
        return this.size * this.size;
    }
}

Soluzione: Separiamo Rectangle e Square, facendo in modo che entrambe derivino da un'astrazione comune (Shape) senza violare LSP.


4. Interface Segregation Principle (ISP) - Principio di Segregazione delle Interfacce

Il principio di segregazione delle interfacce afferma che nessun client dovrebbe essere costretto a dipendere da metodi che non utilizza. In altre parole, è meglio avere più interfacce specifiche piuttosto che un'unica interfaccia generale con metodi non pertinenti per tutte le classi che la implementano.

Questo principio aiuta a evitare la creazione di interfacce troppo grandi e complesse, migliorando la manutenibilità e la chiarezza del codice. Se una classe è costretta a implementare metodi non rilevanti, può indicare che l'interfaccia è mal progettata.

Codice errato (violazione di ISP)

class Worker {
    work() {
        console.log("Working...");
    }

    eat() {
        console.log("Eating...");
    }
}

class Robot extends Worker {
    eat() {
        throw new Error("Robots don't eat!");
    }
}

class Human extends Worker {
    eat() {
        console.log("Human is eating...");
    }
}

Problema: La classe Worker ha un unico metodo eat() che è pertinente solo per i lavoratori umani, ma non per i robot. Costringere Robot ad implementare un metodo che non ha senso per la sua logica è una violazione di ISP.

Codice corretto (applicazione di ISP)

class Workable {
    work() {
        console.log("Working...");
    }
}

class Eatable {
    eat() {
        console.log("Eating...");
    }
}

class Robot extends Workable {
    work() {
        console.log("Robot is working...");
    }
}

class Human extends Workable {
    constructor() {
        super();
        this.eater = new Eatable();
    }

    work() {
        console.log("Human is working...");
    }

    eat() {
        this.eater.eat();
    }
}

Soluzione: Abbiamo separato le responsabilità in due interfacce distinte (Workable e Eatable). Ora, la classe Robot implementa solo Workable, senza essere costretta a implementare metodi inutili come eat(). D'altra parte, la classe Human può implementare entrambe le interfacce, beneficiando della separazione delle responsabilità.


5. Dependency Inversion Principle (DIP) - Principio di Inversione delle Dipendenze

Il principio di inversione delle dipendenze afferma che le classi ad alto livello non dovrebbero dipendere direttamente dalle classi a basso livello, ma entrambe dovrebbero dipendere da astrazioni (interfacce o classi astratte). Inoltre, le astrazioni non dovrebbero dipendere dai dettagli concreti, ma il contrario.

Seguire questo principio permette di ridurre il forte accoppiamento tra le classi, rendendo il codice più flessibile e testabile. Se una classe dipende direttamente da un'implementazione concreta, ogni modifica a quella implementazione potrebbe richiedere modifiche anche alla classe dipendente.

Codice errato (violazione di DIP)


class MySQLDatabase {
    save(data) {
        console.log("Saving data to MySQL database: ", data);
    }
}

class UserService {
    constructor() {
        this.database = new MySQLDatabase();
    }
    saveUser(user) {
        this.database.save(user);
    }
}

Problema: UserService dipende direttamente dalla classe concreta MySQLDatabase. Se volessimo cambiare database, dovremmo modificare UserService, violando DIP.

Codice corretto (applicazione di DIP)


class Database {
    save(data) {
        throw new Error("Method not implemented");
    }
}

class MySQLDatabase extends Database {
    save(data) {
        console.log("Saving data to MySQL database: ", data);
    }
}

class UserService {
    constructor(database) {
        this.database = database;
    }
    saveUser(user) {
        this.database.save(user);
    }
}

const database = new MySQLDatabase();
const userService = new UserService(database);
userService.saveUser({ name: "John Doe" });

Soluzione: UserService ora dipende da un'astrazione (Database) e non da un'implementazione concreta. Questo consente di sostituire MySQLDatabase con un'altra implementazione (es. PostgreSQLDatabase) senza modificare UserService.


Conclusione

I principi SOLID sono fondamentali per scrivere codice pulito, flessibile e manutenibile. Applicarli correttamente aiuta a ridurre l'accoppiamento tra le classi, migliorare la testabilità e rendere il software più adattabile ai cambiamenti.

Seguire questi principi consente agli sviluppatori di evitare molte delle problematiche comuni nella programmazione orientata agli oggetti, migliorando la qualità del codice e la sua longevità.

Se vuoi migliorare ulteriormente la tua comprensione di SOLID, prova a refattorizzare il codice esistente applicando questi principi e osserva come migliora la leggibilità e la modularità del tuo software.

 

Follow me #techelopment

Official site: www.techelopment.it
facebook: Techelopment
instagram: @techelopment
X: techelopment
Bluesky: @techelopment
telegram: @techelopment_channel
whatsapp: Techelopment
youtube: @techelopment