Cum se aplică principiile SOLID în Python pas cu pas

  • Principiile SOLID oferă o bază clară pentru proiectarea unui cod Python orientat pe obiecte mai ușor de lizit, de întreținut și scalabil.
  • Fiecare principiu (SRP, OCP, LSP, ISP și DIP) abordează un tip specific de problemă de proiectare, de la responsabilități prost separate până la dependențe rigide.
  • Aplicarea SOLID cu clase, abstracțiuni și injecție de dependențe în Python reduce cuplarea, îmbunătățește testabilitatea și facilitează evoluția sistemului.

Solid în Python

Când începi să lucrezi la proiecte Python mari, unul dintre primele lucruri pe care le observi este că Codul devine dificil de înțeles, testat și extins. Dacă nu respecți câteva reguli de design de bază. Aici intervin celebrele principii SOLID: o colecție de bune practici concepute pentru a face viața echipei mult mai ușoară.

Aceste principii își au originea în domeniul programare clasică orientată pe obiecte (Java, C++, C# etc.)Dar se potrivesc perfect cu Python, atâta timp cât folosești clasele și obiectele într-un mod mai mult sau mai puțin serios. Să analizăm în detaliu ce sunt, de unde provin, de ce contează și, mai presus de toate, cum... Aplicați SOLID cu exemple clare în Python pentru a face codul tău mai ușor de întreținut, scalabil și plăcut de utilizat.

Ce este SOLID și de unde provine totul?

Termenul SOLID este un acronim popularizat de Michael Feathers pentru a grupa cinci principii de design propuse inițial de Robert C. Martin, mai cunoscut sub numele de Unchiul Bob. Acest inginer software american, unul dintre semnatarii Manifestului Agile, a publicat articolul „Principiile OOD” la mijlocul anilor '90 și mai târziu „Principii de design și modele de design”, unde a pus multe dintre bazele designului orientat pe obiecte modern.

De-a lungul timpului, alți autori precum Barbara Liskov și Bertrand Meyer De asemenea, au contribuit cu idei care au fost integrate în acest set de principii. Michael Feathers a avut pur și simplu ideea (foarte perspicace) de a le rearanja astfel încât inițialele să formeze cuvântul SOLID, ceea ce le-a ajutat să se răspândească ca focul în comunitatea dezvoltatorilor.

Cele cinci litere ale cuvântului SOLID corespund acestor principii de design orientat pe obiecte, aplicabile și în Python:

  • S – Principiul responsabilității unice (Principiul responsabilității unice)
  • O – Principiul Deschis/Închis (Principiul Deschis/Închis)
  • Principiul substituției L – Liskov (Principiul substituției Liskov)
  • I – Principiul segregării interfețelor (Principiul segregării interfețelor)
  • D – Principiul inversiunii dependenței (Principiul inversării dependenței)

Ideea generală este că aceste cinci principii, utilizate împreună, Te ajută să scrii software flexibil, ușor de testat și de întreținutAcest lucru se traduce în implementări mai rapide, mai puține erori misterioase, o reutilizare mai bună a codului și mai puține bătăi de cap atunci când proiectul este în producție de câțiva ani.

La ce se folosesc principiile SOLID în Python?

Aplicarea principiilor SOLID în Python nu este doar un exercițiu academic; are un impact direct asupra muncii zilnice a echipei. Atunci când respecți aceste principii, Acestea reduc codul spaghetti, diminuează mirosul de cod și împiedică baza de cod să „miroasă a putred”.folosind faimoasa analogie „dacă miroase urât, ceva este prost conceput”. În Windows, mulți dezvoltatori aleg să Instalați și configurați WSL2 să avem un mediu Linux mai aproape de producție.

În mediile colaborative (echipe de dezvoltare backend, inginerie de date, produse cu cicluri lungi etc.), aceste principii sunt esențiale pentru Mai multe persoane pot lucra pe aceeași bază de cod fără a depăși limitele sau a încălca totul la cea mai mică atingere.În plus, Python, deși flexibil și dinamic, permite aplicarea fără probleme a abstracțiunilor tipice OOP: clase abstracte, ierarhii de moștenire, compoziție și interfețe prin intermediul abc, Etc

Pe scurt, SOLID vă ajută să realizați:

  • Cod mai curat și mai lizibilchiar și la ani de zile după ce a scris-o.
  • Testabilitate îmbunătățitădeoarece responsabilitățile sunt clar separate.
  • Reutilizabilitate și scalabilitate ridicate datorită numărului mai mic de dependențe rigide dintre module.
  • Mai puține erori colateraleCând modifici ceva într-un modul, nu strici accidental alte cinci lucruri.

S – Principiul responsabilității unice

Primul principiu afirmă că O clasă ar trebui să aibă un singur motiv pentru a se schimba.Cu alte cuvinte, trebuie să își asume o singură responsabilitate, bine definită. Aceasta nu înseamnă să aibă o singură metodă, ci mai degrabă ca toată logica sa să indice spre un scop unic, coerent.

Imaginați-vă o clasă Python care reprezintă un utilizator și, pe lângă stocarea datelor sale, gestionează și accesarea bazei de date și generarea de rapoarte:

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

Iată clasa combină trei responsabilități distincteReprezentarea utilizatorului, gestionarea persistenței și construirea de rapoarte. Modificările aduse bazei de date, formatului raportului sau atributelor utilizatorului necesită modificarea aceleiași clase, crescând riscul introducerii unor erori transversale.

Dacă separăm aceste aspecte, designul se îmbunătățește semnificativ:

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}"

Acum clasa Utilizatorul îl reprezintă doar ca entitateDacă modul în care sunt generate rapoartele se schimbă, atingeți pur și simplu UserReportGeneratorDacă modificați baza de date, trebuie doar să atingeți UserDBFiecare clasă are un singur motiv pentru schimbare, ceea ce simplifică depanarea și evoluția sistemului.

SRP aplicat unui exemplu mai realist: rațele și comunicarea

Să ne uităm la un scenariu clasic adaptat: o clasă Raţă La care, inițial, se adaugă treptat responsabilități, până când devine un monstru greu de întreținut. Imaginați-vă o implementare naivă:

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}")

Clasă Ar trebui definit simplu ca „o rață”.Dar gestionează și modul în care comunică între ele. Dacă mâine schimbi logica conversației (mai multe fraze, alte limbi, canale diferite), va trebui să modifici clasa „duck”, care deja funcționează bine ca entitate.

Soluția care respectă SRP este de a extrage acea a doua responsabilitate de la o altă clasă specializată în comunicare:

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")

Datorită acestei despărțiri, Poți evolua logica comunicării fără a atinge definiția raței.În plus, codul este mai ușor de testat: testezi comportamentul Duck și, pe de altă parte, cea a Communicatorfără a amesteca responsabilitățile.

O – Principiul Deschis/Închis

Principiul OCP prevede că Entitățile software ar trebui să fie deschise la extinderea comportamentului lor, dar închise la modificări directe.Cu alte cuvinte, atunci când doriți să adăugați funcționalități noi, în mod ideal, nu ar trebui să rescrieți clase care funcționează deja și sunt utilizate de alte module.

Un exemplu clasic este calcularea ariilor figurilor geometrice. Să ne uităm mai întâi la o versiune care nu respectă OCP-ul:

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")

Dacă vrei să adaugi un triunghi mâine, vei fi obligat să modificați codul de AreaCalculatoradăugând un altul elifAceasta încalcă OCP, deoarece clasa nu mai este „închisă” la modificări.

Versiunea corectă implică introducerea unei abstractizări Shape cu o metodă area() pe care fiecare figură o implementează în felul său:

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()

Datorită acestui design, pentru adaugă un triunghi pe care nu îl atingi AreaCalculatorPur și simplu creați o nouă subclasă:

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

Principiul Deschis/Închis se potrivește foarte bine cu ideea de definiți puncte de extensie clare prin abstracțiuni: interfețe, clase abstracte, hook-uri etc. În Python, modulul abc Îți permite să exprimi acest lucru explicit, chiar dacă limbajul este dinamic.

OCP aplicat exemplului comunicatorului

Dacă ne întoarcem la exemplul de CommunicatorPutem merge mai departe și pregăti designul pentru a suporta diferite tipuri de conversații fără a rescrie comunicatorul de fiecare dată. Pentru a face acest lucru, definim o abstracție a conversației și facem ca comunicatorul să o folosească doar pe aceasta:

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")

În această versiune, Dacă vrei să adaugi un nou mod de a vorbi (de exemplu, o conversație agresivă, o conversație pe rând etc.), pur și simplu creați o altă subclasă de AbstractConversation. Metoda communicate() de Communicator Nu se schimbă, respectând întocmai OCP-ul.

Principiul substituției L – Liskov

Principiul substituției Liskov, formulat de Barbara Liskov, afirmă că Subclasele ar trebui să poată înlocui clasele lor de bază fără a altera comportamentul așteptat al programului.În practică, aceasta înseamnă că, dacă un cod funcționează cu o instanță a clasei de bază, ar trebui să funcționeze la fel de bine cu orice instanță a unei subclase.

Un exemplu tipic de încălcare a LSP este modelarea tuturor păsărilor cu o singură metodă fly()inclusiv struți:

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")

Orice cod care presupune că Orice pasăre care poate zbura va eșua când va primi un struț. Vreau să spun, Ostrich Nu este un înlocuitor valid pentru Bird, încălcând astfel LSP.

Soluția este ajustarea ierarhiei pentru a reflecta mai bine realitatea: nu toate păsările zboară, așa că Doar o parte din păsări ar trebui să aibă metoda 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

Cu acest design, Orice funcție care necesită o pasăre zburătoare va declara că necesită una. FlyingBirdși nu va primi niciodată un struț. În acest fel, LSP este respectat și se evită excepțiile neașteptate la execuție.

LSP și conversații cu păsări

Revenind la exemplul conversațiilor, este obișnuit să începi să programezi gândindu-te doar la rațe și apoi să vrei să adaugi ciori sau alte păsări. Dacă clasa de conversație depinde de Duck, Nu îl veți putea reutiliza cu alte tipuri de păsări. fără a atinge codul:

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

Si SimpleConversation Este tipizat doar pentru rațe; nu vei putea să-i aplici o cioară fără să o modifici. Abordarea corectă este de a crea o abstracție comună. Bird și fă ca conversația să depindă de acea abstracție:

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 

În acest fel, orice subclasă a Bird care respectă contractul (do_sound()(nume etc.) este un/o înlocuitor valid și nu va încălca comportamentul așteptat al SimpleConversation.

I – Principiul segregării interfețelor

Principiul ISP susține că Niciun client nu ar trebui să fie obligat să se bazeze pe metode pe care nu le folosește.Tradus în clase sau interfețe abstracte, aceasta înseamnă că este mai bine să ai mai multe interfețe mici și specifice decât o interfață generică imensă.

Observați acest design în care o interfață Worker Necesită ca toți cei care îl implementează să aibă metode specifice de lucru și de alimentație:

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

Clasă Robotul se bazează pe o metodă eat() care nu are nevoieOrice schimbare legată de mâncare va afecta robotul, chiar dacă nu are nicio legătură cu comportamentul respectiv.

Prin aplicarea ISP, am împărțit interfața în două mai mici, mai specifice:

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")

acum, Fiecare clasă implementează doar metodele de care are nevoie efectiv.Acest lucru reduce cuplarea, facilitează evoluția designului și face codul mai expresiv: devine foarte clar cine ce poate face.

ISP în modelarea păsărilor: zbor și înot

Ceva similar se întâmplă și la modelarea păsărilor care zboară și înoată. Dacă abstractizarea de bază Bird Necesită implementarea ambelor fly() ca swim()Vei ajunge să ai cursuri precum Crow care trebuie să se prefacă că știu să înoate:

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

Soluția conform ISP-ului este segregă interfața în capabilități mai specifice:

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"

Dacă vreodată te hotărăști să modelezi un pinguin, pur și simplu îl faci să moștenească SwimmingBird dar nu din FlyingBirdȘi nu va trebui să implementați metode goale sau să aruncați excepții artificiale.

D – Principiul inversiunii dependenței

Ultimul principiu, DIP, poate fi rezumat în două idei cheie: Modulele de nivel înalt nu ar trebui să depindă de module de nivel scăzut; ambele ar trebui să depindă de abstracțiuni.Și abstracțiunile nu ar trebui să depindă de detalii, ci mai degrabă detaliile ar trebui să depindă de abstracțiuni.

În practică, aceasta înseamnă că logica ta de business nu ar trebui să fie legată de detalii specifice precum „Eu folosesc MySQL”, „Scriu într-un fișier local” sau „Trimit mesaje SMS cu acest furnizor”. În schimb, definești interfețe abstracte (de exemplu, Database, Channel, NotificationService) și faci ca codul tău de nivel înalt să comunice doar cu ele.

Un design care pauză DIP Acesta ar fi un depozit de utilizatori care instanțiază direct o bază de date 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")

Dacă te hotărăști să folosești PostgreSQL mâine, trebuie să modificarea clasei de nivel înalt UserRepositoryEști legat de un detaliu specific de implementare.

Aplicând DIP, definim mai întâi o abstractizare a bazei de date și apoi implementările concrete moștenesc de la aceasta:

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")

În acest fel, Puteți injecta orice implementare a Database la crearea repozitoriului, fără a atinge codul său intern:

mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)

postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)

Acest model este cunoscut sub numele de Injecția de dependență Și este cea mai comună modalitate de a aplica DIP: clasele nu își creează propriile dependențe, ci le primesc din exterior (prin constructor sau prin metode specifice), folosind întotdeauna abstracțiuni ca tip.

DIP aplicat canalelor și comunicatoarelor

În exemplul conversațiilor între păsări, putem îmbunătăți și gestionarea canalelor aplicând DIP. Să presupunem că definiți o abstracție pentru canal și o alta pentru comunicator:

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")

O primă implementare naivă ar putea fi:

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

Deși pare corect, Acest comunicator este încă conectat direct la SMSChannelAm îmbunătățit designul prin faptul că comunicatorul primește canalul din exterior (injecție de dependențe) și, prin urmare, depinde doar de abstractizare:

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

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

Cu această abordare, orice canal nou (e-mail, notificări push etc.) implementează AbstractChannel y Poate fi utilizat fără a schimba codul comunicatorului.Din nou, clasele de nivel înalt depind de abstracțiuni, nu de detalii.

Ce se întâmplă când ignori SOLID?

Dacă aceste principii nu sunt luate în considerare, codul tinde să sufere de probleme precum miros de cod, putregai de cod și cuplaje imposibil de descurcatAdică, clase uriașe cu o mie de responsabilități, subclase care încalcă contracte, dependențe ciclice și metode care se schimbă o dată la două zile pentru că fac prea multe lucruri.

Consecințele sunt clare și destul de dureroase pentru orice echipă: Mai multe vulnerabilități, mai multe erori, refactorizare constantă și, în cel mai rău caz, cod care ajunge să fie practic inutilizabil.Este ceea ce se numește în mod obișnuit „cod spaghete”: dificil de urmărit, plin de petice și aproape imposibil de extins fără a strica ceva important.

Principiile SOLID nu sunt bătute în cuie și nu merită întotdeauna să le aplici pe toate în mod rigid, mai ales în prototiparea rapidă sau în proiecte foarte mici. Chiar și așa, Țineți cont de ele și aplicați-le la majoritatea designului orientat pe obiecte în Python. Face diferența dintre un proiect care se echilibrează în timp și unul care se destramă imediat ce crește puțin.

Cele mai bune IDE-uri pentru programarea în Windows 11
Articol conex:
Cele mai bune IDE-uri pentru programare pe Windows 11

Adăugați ca sursă preferată în Google