Come applicare i principi SOLID in Python passo dopo passo

  • I principi SOLID forniscono una base chiara per la progettazione di codice Python orientato agli oggetti più leggibile, gestibile e scalabile.
  • Ogni principio (SRP, OCP, LSP, ISP e DIP) affronta un tipo specifico di problema di progettazione, da responsabilità scarsamente separate a dipendenze rigide.
  • L'applicazione di SOLID con classi, astrazioni e iniezione di dipendenza in Python riduce l'accoppiamento, migliora la testabilità e facilita l'evoluzione del sistema.

Solido in Python

Quando inizi a lavorare su grandi progetti Python, una delle prime cose che noti è che Il codice diventa difficile da comprendere, testare ed estendere. Se non si seguono alcune regole di progettazione di base, ecco che entrano in gioco i famosi principi SOLID: una raccolta di best practice pensate per semplificare notevolmente la vita del team.

Questi principi hanno avuto origine nel campo della programmazione classica orientata agli oggetti (Java, C++, C#, ecc.)Ma si adattano perfettamente a Python, a patto che si utilizzino classi e oggetti in modo più o meno serio. Vediamo in dettaglio cosa sono, da dove vengono, perché sono importanti e, soprattutto, come Applica SOLID con esempi chiari in Python per rendere il tuo codice più gestibile, scalabile e piacevole da usare.

Cos'è SOLID e da dove proviene?

Il termine SOLID è un acronimo reso popolare da Michael Feathers per raggruppare cinque principi di progettazione originariamente proposti da Robert C. Martin, meglio conosciuto come Uncle Bob. Questo ingegnere informatico americano, uno dei firmatari del Manifesto Agile, pubblicò l'articolo "The Principles of OOD" a metà degli anni '90 e successivamente "Design Principles and Design Patterns", dove pose molte delle basi della moderna progettazione orientata agli oggetti.

Nel corso del tempo, altri autori come Barbara Liskov e Bertrand Meyer Hanno anche contribuito con idee che sono state integrate in questo insieme di principi. Michael Feathers ha semplicemente avuto l'idea (molto astuta) di riorganizzarle in modo che le iniziali formassero la parola SOLID, il che ha contribuito a diffonderle a macchia d'olio nella comunità degli sviluppatori.

Le cinque lettere di SOLID corrispondono a questi principi di progettazione orientati agli oggetti, applicabili anche a Python:

  • S – Principio di responsabilità unica (Principio di responsabilità unica)
  • O – Principio aperto/chiuso (Principio aperto/chiuso)
  • L – Principio di sostituzione di Liskov (Principio di sostituzione di Liskov)
  • I – Principio di segregazione dell’interfaccia (Principio di segregazione dell'interfaccia)
  • D – Principio di inversione della dipendenza (Principio di inversione della dipendenza)

L'idea generale è che questi cinque principi, usati insieme, Ti aiutano a scrivere software flessibile, facile da testare e manutenibileCiò si traduce in distribuzioni più rapide, meno bug misteriosi, un migliore riutilizzo del codice e meno grattacapi quando il progetto è in produzione da alcuni anni.

A cosa servono i principi SOLID in Python?

Applicare i principi SOLID in Python non è solo un esercizio accademico; ha un impatto diretto sul lavoro quotidiano del team. Quando si aderiscono a questi principi, Riducono il codice spaghetti, diminuiscono l'odore di codice e impediscono che la tua base di codice "puzzi".usando la famosa analogia, "se ha un cattivo odore, qualcosa è mal progettato". In Windows, molti sviluppatori scelgono di Installa e configura WSL2 per avere un ambiente Linux più vicino alla produzione.

Negli ambienti collaborativi (team di sviluppo backend, ingegneria dei dati, prodotti con cicli lunghi, ecc.) questi principi sono fondamentali per più persone possono lavorare sullo stesso codice base senza oltrepassare i propri limiti o danneggiare tutto al minimo tocco.Inoltre, Python, sebbene flessibile e dinamico, consente l'applicazione senza soluzione di continuità delle tipiche astrazioni OOP: classi astratte, gerarchie di ereditarietà, composizione e interfacce tramite abc, ecc.

In sintesi, SOLID ti aiuta a raggiungere:

  • Codice più pulito e leggibileanche anni dopo averlo scritto.
  • Testabilità migliorataperché le responsabilità sono nettamente separate.
  • Elevata riutilizzabilità e scalabilità grazie a minori dipendenze rigide tra i moduli.
  • Meno errori collateraliQuando modifichi qualcosa in un modulo, non rompi accidentalmente altre cinque cose.

S – Principio di responsabilità unica

Il primo principio afferma che Una classe dovrebbe avere un solo motivo per cambiare.In altre parole, deve assumersi una responsabilità unica e ben definita. Ciò non significa avere un solo metodo, ma piuttosto che tutta la sua logica debba puntare verso un unico scopo coerente.

Immagina una classe Python che rappresenta un utente e, oltre a memorizzare i suoi dati, gestisce anche l'accesso al database e la generazione di report:

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

    def get_user_from_database(self, user_id: int) -> dict:
        # Recupera datos desde la base de datos
        # ...
        pass

    def save_user_to_database(self) -> None:
        # Persiste el usuario en la base de datos
        # ...
        pass

    def generate_user_report(self) -> str:
        # Genera un informe del usuario
        # ...
        pass

Ecco la classe mescola tre responsabilità distinteRappresentare l'utente, gestire la persistenza e creare report. Le modifiche al database, al formato del report o agli attributi utente richiedono la modifica della stessa classe, aumentando il rischio di introdurre bug trasversali.

Se separiamo queste preoccupazioni, il design migliora notevolmente:

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


class UserDB:
    @staticmethod
    def get_user(user_id: int) -> User:
        # Lógica para obtener usuarios de la base de datos
        # ...
        return User("John Doe")

    @staticmethod
    def save_user(user: User) -> None:
        # Lógica para guardar el usuario
        # ...
        pass


class UserReportGenerator:
    @staticmethod
    def generate_report(user: User) -> str:
        # Lógica para generar informes de usuario
        # ...
        return f"Report for user: {user.name}"

Ora la classe L'utente rappresenta solo l'utente come entitàSe cambia il modo in cui vengono generati i report, basta toccare UserReportGeneratorSe cambi il database, ti basta toccare UserDBOgni classe ha un unico motivo di modifica, il che semplifica il debug e l'evoluzione del sistema.

SRP applicato a un esempio più realistico: anatre e comunicazione

Diamo un'occhiata a uno scenario classico adattato: una classe Duck A cui, inizialmente, vengono aggiunte gradualmente responsabilità fino a trasformarlo in un mostro difficile da mantenere. Immaginate un'implementazione ingenua:

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

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, other_duck: "Duck") -> None:
        print(f"{self.name}: {self.do_sound()}, hello {other_duck.name}")

Classe Dovrebbe essere definito semplicemente come "un'anatra"Ma gestisce anche il modo in cui comunicano tra loro. Se domani si cambia la logica della conversazione (più frasi, altre lingue, canali diversi), si deve modificare la classe duck, che funziona già bene come entità.

La soluzione che rispetta l'SRP è quella di estrarre questa seconda responsabilità da un'altra classe specializzata nella comunicazione:

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

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    def communicate(self, duck1: Duck, duck2: Duck) -> None:
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = 
        print(*conversation, f"(via {self.channel})", sep="\n")

Grazie a questa separazione, È possibile evolvere la logica della comunicazione senza toccare la definizione dell'anatraInoltre, il codice è più facile da testare: si testa il comportamento di Duck e d'altra parte, quello di Communicatorsenza mescolare le responsabilità.

O – Principio aperto/chiuso

Il principio OCP afferma che Le entità software dovrebbero essere aperte all'estensione del loro comportamento, ma chiuse alle modifiche dirette.In altre parole, quando si desidera aggiungere nuove funzionalità, idealmente non si dovrebbero riscrivere classi che già funzionano e sono utilizzate da altri moduli.

Un esempio classico è il calcolo delle aree delle figure geometriche. Diamo prima un'occhiata a una versione che non rispetta l'OCP:

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


class Circle:
    def __init__(self, radius: float):
        self.radius = radius


class AreaCalculator:
    def calculate_area(self, shape) -> float:
        if isinstance(shape, Rectangle):
            return shape.width * shape.height
        elif isinstance(shape, Circle):
            return 3.14159 * shape.radius * shape.radius
        else:
            raise ValueError("Forma no soportada")

Se domani vuoi aggiungere un triangolo, sarai costretto a modificare il codice di AreaCalculatoraggiungendone un altro elifCiò viola l'OCP, perché la classe non è più "chiusa" alle modifiche.

La versione corretta prevede l'introduzione di un'astrazione Shape con un metodo area() che ogni figura implementa a modo suo:

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass


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

    def area(self) -> float:
        return self.width * self.height


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius * self.radius


class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area()

Grazie a questo design, per aggiungi un triangolo che non tocchi AreaCalculatorBasta creare una nuova sottoclasse:

class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height

Il principio Aperto/Chiuso si adatta molto bene all'idea di definire punti di estensione chiari attraverso astrazioni: interfacce, classi astratte, hook, ecc. In Python, il modulo abc Permette di esprimerlo in modo esplicito, anche se il linguaggio è dinamico.

OCP applicato all'esempio del comunicatore

Se torniamo all'esempio di CommunicatorPossiamo fare un ulteriore passo avanti e preparare il design per supportare diversi tipi di conversazione senza dover riscrivere il comunicatore ogni volta. Per fare ciò, definiamo un'astrazione della conversazione e facciamo in modo che il comunicatore la utilizzi esclusivamente:

from typing import final
from abc import ABC, abstractmethod


class AbstractConversation(ABC):
    @abstractmethod
    def do_conversation(self) -> list:
        pass


class SimpleConversation(AbstractConversation):
    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return 


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")

In questa versione, Se vuoi aggiungere un nuovo modo di parlare (ad esempio, una conversazione aggressiva, una conversazione a turno, ecc.), basta creare un'altra sottoclasse di AbstractConversation. Il metodo communicate() de Communicator Non cambia, rispettando alla lettera l'OCP.

L – Principio di sostituzione di Liskov

Il principio di sostituzione di Liskov, formulato da Barbara Liskov, afferma che Le sottoclassi dovrebbero essere in grado di sostituire le loro classi base senza alterare il comportamento previsto del programma.In pratica, ciò significa che se il codice funziona con un'istanza della classe base, dovrebbe funzionare altrettanto bene con qualsiasi istanza di una sottoclasse.

Un tipico esempio di violazione dell'LSP è la modellazione di tutti gli uccelli con un metodo fly()compresi gli struzzi:

class Bird:
    def fly(self) -> None:
        pass


class Duck(Bird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    def fly(self) -> None:
        # Las avestruces no vuelan
        raise NotImplementedError("Las avestruces no pueden volar")

Qualsiasi codice che presuppone che Ogni uccello che può volare fallirà quando riceverà uno struzzo. Voglio dire, Ostrich Non è un valido sostituto per Bird, violando così l'LSP.

La soluzione è adattare la gerarchia per riflettere meglio la realtà: non tutti gli uccelli volano, quindi Solo una parte degli uccelli dovrebbe avere il metodo fly():

class Bird:
    pass


class FlyingBird(Bird):
    def fly(self) -> None:
        pass


class Duck(FlyingBird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    # No vuela, así que no implementa fly()
    pass

Con questo design, Ogni funzione che richiede un uccello in volo dichiarerà di averne bisogno. FlyingBirde non riceverà mai un messaggio di errore. In questo modo, l'LSP viene rispettato e si evitano eccezioni impreviste durante l'esecuzione.

Conversazioni tra LSP e uccelli

Tornando all'esempio delle conversazioni, è comune iniziare a programmare pensando solo alle anatre e poi voler aggiungere corvi o altri uccelli. Se la classe di conversazione dipende da Duck, Non potrai riutilizzarlo con altri tipi di uccelli senza toccare il codice:

class Crow:
    # Implementación específica del cuervo
    ...

Si SimpleConversation È tipizzato solo per le anatre; non sarà possibile applicargli un corvo senza modificarlo. L'approccio corretto è creare un'astrazione comune. Bird e far dipendere la conversazione da tale astrazione:

from abc import ABC, abstractmethod


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

    @abstractmethod
    def do_sound(self) -> str:
        pass


class Crow(Bird):
    def do_sound(self) -> str:
        return "Caw"


class Duck(Bird):
    def do_sound(self) -> str:
        return "Quack"


class SimpleConversation(AbstractConversation):
    def __init__(self, bird1: Bird, bird2: Bird):
        self.bird1 = bird1
        self.bird2 = bird2

    def do_conversation(self) -> list:
        sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
        sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
        return 

In questo modo, qualsiasi sottoclasse di Bird che rispetta il contratto (do_sound()(nome, ecc.) è un sostituto valido e non interromperà il comportamento previsto di SimpleConversation.

I – Principio di segregazione dell’interfaccia

Il principio ISP sostiene che Nessun cliente dovrebbe essere costretto ad affidarsi a metodi che non utilizza.Tradotto in classi o interfacce astratte, ciò significa che è meglio avere diverse interfacce specifiche e di piccole dimensioni piuttosto che un'unica interfaccia generica e di grandi dimensioni.

Osserva questo design in cui un'interfaccia Worker Richiede a tutti coloro che lo mettono in pratica di avere metodi di lavoro e di alimentazione specifici:

from abc import ABC, abstractmethod


class Worker(ABC):
    @abstractmethod
    def work(self) -> None:
        pass

    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Worker):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Worker):
    def work(self) -> None:
        print("El robot está trabajando")

    def eat(self) -> None:
        # El robot no come, pero está obligado a declarar este método
        pass

Classe Il robot si basa su un metodo eat() che non ha bisognoQualsiasi cambiamento legato al cibo avrà ripercussioni sul robot, anche se non ha nulla a che fare con quel comportamento.

Applicando l'ISP, abbiamo diviso l'interfaccia in due più piccole e specifiche:

class Workable(ABC):
    @abstractmethod
    def work(self) -> None:
        pass


class Eatable(ABC):
    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Workable, Eatable):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Workable):
    def work(self) -> None:
        print("El robot está trabajando")

Ora Ogni classe implementa solo i metodi di cui ha effettivamente bisogno.Ciò riduce l'accoppiamento, facilita l'evoluzione del design e rende il codice più espressivo: diventa molto chiaro chi può fare cosa.

ISP nella modellazione degli uccelli: volo e nuoto

Qualcosa di simile accade quando si modellano uccelli che volano e nuotano. Se l'astrazione di base Bird Richiede l'implementazione di entrambi fly() come swim()Finirai con classi come Crow che devono fingere di saper nuotare:

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

    @abstractmethod
    def fly(self) -> None:
        pass

    @abstractmethod
    def swim(self) -> None:
        pass

    @abstractmethod
    def do_sound(self) -> str:
        pass

La soluzione secondo l'ISP è separare l'interfaccia in capacità più specifiche:

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

    @abstractmethod
    def do_sound(self) -> str:
        pass


class FlyingBird(Bird):
    @abstractmethod
    def fly(self) -> None:
        pass


class SwimmingBird(Bird):
    @abstractmethod
    def swim(self) -> None:
        pass


class Crow(FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"


class Duck(SwimmingBird, FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

Se mai decidessi di modellare un pinguino, semplicemente gli fai ereditare da SwimmingBird ma non da FlyingBirdE non dovrai implementare metodi vuoti o generare eccezioni artificiali.

D – Principio di inversione della dipendenza

L'ultimo principio, DIP, può essere riassunto in due idee chiave: I moduli di alto livello non dovrebbero dipendere dai moduli di basso livello; entrambi dovrebbero dipendere dalle astrazioni.E le astrazioni non dovrebbero dipendere dai dettagli, ma piuttosto i dettagli dovrebbero dipendere dalle astrazioni.

In pratica, ciò significa che la logica aziendale non dovrebbe essere legata a dettagli specifici come "Uso MySQL", "Scrivo su un file locale" o "Invio messaggi SMS con questo provider". Invece, definisci interfacce astratte (per esempio, Database, Channel, NotificationService) e fai in modo che il tuo codice di alto livello parli solo con loro.

Un design che pausa DIP Questo sarebbe un repository utente che crea direttamente un'istanza di un database MySQL:

class MySQLDatabase:
    def connect(self) -> None:
        # Conectar a MySQL
        pass

    def query(self, sql: str) -> list:
        # Ejecutar consulta
        return []


class UserRepository:
    def __init__(self) -> None:
        self.database = MySQLDatabase()  # Dependencia directa

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

Se decidi di usare PostgreSQL domani, devi modificare la classe di alto livello UserRepositorySei vincolato a un dettaglio di implementazione specifico.

Applicando DIP, definiamo prima un'astrazione del database e poi facciamo in modo che le implementazioni concrete ereditino da essa:

from abc import ABC, abstractmethod


class Database(ABC):
    @abstractmethod
    def connect(self) -> None:
        pass

    @abstractmethod
    def query(self, sql: str) -> list:
        pass


class MySQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a MySQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en MySQL
        return []


class PostgreSQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a PostgreSQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en PostgreSQL
        return []


class UserRepository:
    def __init__(self, database: Database) -> None:
        self.database = database  # Depende de una abstracción

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

Così, È possibile iniettare qualsiasi implementazione di Database durante la creazione del repository, senza toccarne il codice interno:

mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)

postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)

Questo modello è noto come Iniezione di dipendenza Ed è il modo più comune di applicare DIP: le classi non creano le proprie dipendenze, ma le ricevono dall'esterno (attraverso il costruttore o tramite metodi specifici), utilizzando sempre le astrazioni come tipo.

DIP applicato ai canali e ai comunicatori

Nell'esempio delle conversazioni tra uccelli, possiamo anche migliorare la gestione del canale applicando DIP. Supponiamo di definire un'astrazione per il canale e un'altra per il comunicatore:

class AbstractChannel(ABC):
    @abstractmethod
    def get_channel_message(self) -> str:
        pass


class AbstractCommunicator(ABC):
    @abstractmethod
    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep="\n")

Una prima, ingenua implementazione potrebbe essere:

class SMSChannel(AbstractChannel):
    def get_channel_message(self) -> str:
        return "(via SMS)"


class SMSCommunicator(AbstractCommunicator):
    def __init__(self) -> None:
        self._channel = SMSChannel()  # Depende de detalle concreto

    def get_channel(self) -> AbstractChannel:
        return self._channel

Sebbene sembri corretto, Questo comunicatore è ancora direttamente accoppiato a SMSChannelAbbiamo migliorato il design facendo in modo che il comunicatore riceva il canale dall'esterno (iniezione di dipendenza) e dipenda quindi solo dall'astrazione:

class SimpleCommunicator(AbstractCommunicator):
    def __init__(self, channel: AbstractChannel) -> None:
        self._channel = channel

    def get_channel(self) -> AbstractChannel:
        return self._channel

Con questo approccio, qualsiasi nuovo canale (e-mail, notifiche push, ecc.) implementa AbstractChannel y Può essere utilizzato senza modificare il codice del comunicatore.Ancora una volta, le classi di alto livello dipendono dalle astrazioni, non dai dettagli.

Cosa succede se ignori SOLID?

Se questi principi non vengono presi in considerazione, il codice tende a soffrire di problemi quali: odore di codice, codice corrotto e accoppiamenti impossibili da districareVale a dire, classi enormi con mille responsabilità, sottoclassi che rompono i contratti, dipendenze cicliche e metodi che cambiano a giorni alterni perché svolgono troppe attività.

Le conseguenze sono chiare e piuttosto dolorose per qualsiasi squadra: Più vulnerabilità, più bug, refactoring costante e, nel peggiore dei casi, codice che finisce per essere praticamente inutilizzabile.È quello che comunemente viene chiamato "spaghetti code": difficile da seguire, pieno di patch e quasi impossibile da estendere senza rompere qualcosa di importante.

I principi SOLID non sono scolpiti nella pietra e non sempre vale la pena applicarli rigidamente, soprattutto nella prototipazione rapida o in progetti molto piccoli. Ciononostante, Teneteli a mente e applicateli alla maggior parte dei vostri progetti orientati agli oggetti in Python. Fa la differenza tra un progetto che cresce nel tempo e uno che crolla non appena cresce un po'.

I migliori IDE per la programmazione di Windows 11
Articolo correlato:
I migliori IDE per la programmazione su Windows 11