Sådan anvender du SOLID-principper i Python trin for trin

  • SOLID-principperne giver et klart fundament for at designe mere læsbar, vedligeholdelig og skalerbar objektorienteret Python-kode.
  • Hvert princip (SRP, OCP, LSP, ISP og DIP) adresserer en specifik type designproblem, fra dårligt adskilte ansvarsområder til rigide afhængigheder.
  • Anvendelse af SOLID med klasser, abstraktioner og afhængighedsinjektion i Python reducerer kobling, forbedrer testbarhed og letter systemudvikling.

Solid i Python

Når du begynder at arbejde på store Python-projekter, er en af ​​de første ting, du bemærker, at Koden bliver vanskelig at forstå, teste og udvide. Hvis du ikke følger et par grundlæggende designregler. Det er her, de berømte SOLID-principper kommer ind i billedet: en samling af bedste praksisser designet til at gøre teamets liv meget lettere.

Disse principper stammer fra feltet klassisk objektorienteret programmering (Java, C++, C# osv.)Men de passer perfekt til Python, så længe du bruger klasser og objekter på en mere eller mindre seriøs måde. Lad os se nærmere på, hvad de er, hvor de kommer fra, hvorfor de er vigtige, og frem for alt hvordan Anvend SOLID med klare eksempler i Python for at gøre din kode mere vedligeholdelig, skalerbar og behagelig at arbejde med.

Hvad er SOLID, og ​​hvor kommer det hele fra?

Udtrykket SOLID er et akronym populariseret af Michael Feathers at gruppere fem designprincipper, der oprindeligt blev foreslået af Robert C. Martin, bedre kendt som Uncle Bob. Denne amerikanske softwareingeniør, en af ​​underskriverne af Agile Manifestet, udgav artiklen "The Principles of OOD" i midten af ​​90'erne og senere "Design Principles and Design Patterns", hvor han lagde mange af fundamenterne for moderne objektorienteret design.

Med tiden har andre forfattere, som f.eks. Barbara Liskov og Bertrand Meyer De bidrog også med idéer, der blev integreret i dette sæt af principper. Michael Feathers fik simpelthen den (meget skarpe) idé at omarrangere dem, så initialerne dannede ordet SOLID, hvilket hjalp dem med at sprede sig som en steppebrand i udviklingsmiljøet.

De fem bogstaver i SOLID svarer til disse objektorienterede designprincipper, som også gælder for Python:

  • S – Princippet om enkeltansvar (Princippet om enkeltansvar)
  • O – Åben/lukket princip (Åben/lukket princip)
  • L – Liskov-substitutionsprincippet (Liskov-substitutionsprincippet)
  • I – Princip for grænsefladeadskillelse (Princippet for grænsefladesegregering)
  • D – Afhængighedsinversionsprincippet (Princippet om tilbageførsel af afhængighed)

Den overordnede idé er, at disse fem principper, brugt sammen, De hjælper dig med at skrive fleksibel, let-at-test og vedligeholdelsesvenlig softwareDette resulterer i hurtigere implementeringer, færre mystiske fejl, bedre genbrug af kode og færre hovedpiner, når projektet har været i produktion i et par år.

Hvad bruges SOLID-principperne til i Python?

At anvende SOLID-principper i Python er ikke bare en akademisk øvelse; det har en direkte indflydelse på teamets daglige arbejde. Når du overholder disse principper, De reducerer spaghettikode, mindsker kodelugt og forhindrer din kodebase i at "lugte råddent".ved at bruge den berømte analogi: "Hvis det lugter dårligt, er noget dårligt designet." I Windows vælger mange udviklere at Installer og konfigurer WSL2 at have et Linux-miljø tættere på produktion.

I samarbejdsmiljøer (backend-udviklingsteams, data engineering, produkter med lange cyklusser osv.) er disse principper nøglen til Flere personer kan arbejde på den samme kodebase uden at overskride deres grænser eller ødelægge alt ved den mindste berøring.Derudover tillader Python, selvom den er fleksibel og dynamisk, problemfri anvendelse af typiske OOP-abstraktioner: abstrakte klasser, arvshierarkier, komposition og grænseflader via abcOsv

Kort sagt hjælper SOLID dig med at opnå:

  • Renere og mere læsbar kodeselv år efter at have skrevet den.
  • Bedre testbarhedfordi ansvarsområderne er tydeligt adskilt.
  • Høj genbrugelighed og skalerbarhed takket være færre rigide afhængigheder mellem moduler.
  • Færre fejl i forbindelse med sikkerhedsstillelseNår du ændrer noget i ét modul, ødelægger du ikke ved et uheld fem andre ting.

S – Princippet om enkeltansvar

Det første princip siger, at En klasse bør kun have én grund til at ændre sig.Med andre ord skal den påtage sig et enkelt, veldefineret ansvar. Dette betyder ikke kun at have én metode, men snarere at al dens logik skal pege mod et enkelt, sammenhængende formål.

Forestil dig en Python-klasse, der repræsenterer en bruger, og udover at gemme deres data, også håndterer adgang til databasen og genererer rapporter:

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

Her er klassen blander tre forskellige ansvarsområderRepræsentation af brugeren, styring af persistens og opbygning af rapporter. Ændringer i databasen, rapportformatet eller brugerattributter kræver ændring af den samme klasse, hvilket øger risikoen for at introducere tværgående fejl.

Hvis vi adskiller disse bekymringer, forbedres designet betydeligt:

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

Nu klassen Brugeren repræsenterer kun brugeren som en enhedHvis den måde, rapporter genereres på, ændres, skal du blot trykke på UserReportGeneratorHvis du ændrer databasen, skal du blot trykke på UserDBHver klasse har en enkelt årsag til ændring, hvilket forenkler fejlfinding og systemudvikling.

SRP anvendt på et mere realistisk eksempel: ænder og kommunikation

Lad os se på et klassisk scenarie, der er tilpasset: en klasse And Hvortil der i starten gradvist tilføjes ansvar, indtil det bliver et monster, der er svært at vedligeholde. Forestil dig en naiv implementering:

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

klasse Det bør defineres blot som "en and"Men den styrer også, hvordan de kommunikerer med hinanden. Hvis du i morgen ændrer samtalens logik (flere sætninger, andre sprog, forskellige kanaler), skal du ændre duck-klassen, som allerede fungerer godt som en enhed.

Løsningen, der respekterer SRP, er at udtrække det andet ansvar fra en anden klasse, der specialiserer sig i kommunikation:

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

Takket være denne adskillelse, Du kan udvikle kommunikationslogikken uden at røre definitionen af ​​andenDesuden er koden nemmere at teste: du tester adfærden hos Duck og på den anden side den ene af Communicatoruden at blande ansvarsområder.

O – Åben/lukket princip

OCP-princippet siger, at Softwareenheder bør være åbne for at udvide deres adfærd, men lukkede for direkte ændringer.Med andre ord, når du vil tilføje ny funktionalitet, bør du ideelt set ikke skulle omskrive klasser, der allerede fungerer og bruges af andre moduler.

Et klassisk eksempel er beregning af arealet af geometriske figurer. Lad os først se på en version, der respekterer ikke 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")

Hvis du vil tilføje en trekant i morgen, bliver du tvunget til det ændre koden for AreaCalculatortilføjer en anden elifDette overtræder OCP, fordi klassen ikke længere er "lukket" for ændringer.

Den korrekte version involverer introduktion af en abstraktion Shape med en metode area() som hver figur implementerer på sin egen måde:

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

Takket være dette design, for tilføj en trekant, du ikke rører ved AreaCalculatorDu opretter blot en ny underklasse:

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

Åben/lukket-princippet passer rigtig godt til ideen om definere klare forlængelsespunkter gennem abstraktioner: grænseflader, abstrakte klasser, hooks osv. I Python er modulet abc Det giver dig mulighed for at udtrykke dette eksplicit, selvom sproget er dynamisk.

OCP anvendt på kommunikatoreksemplet

Hvis vi går tilbage til eksemplet med CommunicatorVi kan gå et skridt videre og forberede designet til at understøtte forskellige typer samtaler uden at omskrive kommunikatoren hver gang. For at gøre dette definerer vi en samtaleabstraktion og lader kommunikatoren kun bruge den:

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

I denne version, Hvis du vil tilføje en ny måde at tale på (for eksempel en aggressiv samtale, en samtale hvor man tager tur osv.), opretter du blot en anden underklasse af AbstractConversation. Metoden communicate() de Communicator Det ændrer sig ikke, og overholder OCP til punkt og prikke.

L – Liskov-substitutionsprincippet

Liskov-substitutionsprincippet, formuleret af Barbara Liskov, siger, at Underklasser skal kunne erstatte deres basisklasser uden at ændre programmets forventede adfærd.I praksis betyder det, at hvis kode fungerer med én instans af basisklassen, burde den fungere lige så godt med enhver instans af en underklasse.

Et typisk eksempel på LSP-overtrædelse er modellering af alle fugle med én metode fly()inklusive strudse:

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

Enhver kode, der antager, at Enhver fugl, der kan flyve, vil fejle, når den får en struds. Jeg mener Ostrich Det er ikke en gyldig erstatning for Birdog dermed overtræde LSP.

Løsningen er at justere hierarkiet, så det bedre afspejler virkeligheden: ikke alle fugle flyver, så Kun en del af fuglene bør have metoden 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

Med dette design, Enhver funktion, der kræver en flyvende fugl, vil erklære, at den kræver en. FlyingBirdog vil aldrig modtage en struds. På denne måde respekteres LSP, og uventede runtime-undtagelser undgås.

LSP og fuglesamtaler

Tilbage til eksemplet med samtaler, er det almindeligt at starte kodning med kun at tænke på ænder og derefter tilføje krager eller andre fugle. Hvis samtaleklassen afhænger af Duck, Du vil ikke kunne genbruge den med andre typer fugle uden at røre koden:

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

Si SimpleConversation Det er kun skrevet til ænder; man kan ikke bare sende en krage igennem uden at ændre den. Den korrekte fremgangsmåde er at skabe en fælles abstraktion. Bird og få samtalen til at afhænge af den abstraktion:

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 

På denne måde enhver underklasse af Bird der respekterer kontrakten (do_sound()(navn osv.) er en gyldig erstatning og vil ikke bryde den forventede adfærd hos SimpleConversation.

I – Princip for grænsefladeadskillelse

ISP-princippet går ud på, at Ingen kunde bør tvinges til at stole på metoder, de ikke bruger.Oversat til abstrakte klasser eller grænseflader betyder det, at det er bedre at have flere små, specifikke grænseflader end én stor, generisk grænseflade.

Bemærk dette design, hvor en grænseflade Worker Det kræver, at alle, der implementerer det, har specifikke arbejds- og spisemetoder:

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

klasse Robotten er afhængig af en metode eat() det behøver ikkeEnhver ændring relateret til mad vil påvirke robotten, selvom det ikke har noget med den pågældende adfærd at gøre.

Ved at anvende ISP opdelte vi grænsefladen i to mindre, mere specifikke:

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

nu, Hver klasse implementerer kun de metoder, den rent faktisk har brug for.Dette reducerer kobling, letter designudvikling og gør koden mere udtryksfuld: det bliver meget tydeligt, hvem der kan gøre hvad.

ISP i fuglemodellering: flyvning og svømning

Noget lignende sker, når man modellerer fugle, der flyver og svømmer. Hvis den grundlæggende abstraktion Bird Det kræver implementering af begge dele fly() som swim()Du ender med kurser som Crow som må lade som om, de kan svømme:

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

Løsningen ifølge internetudbyderen er opdel grænsefladen i mere specifikke funktioner:

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"

Hvis du nogensinde beslutter dig for at modellere en pingvin, skal du blot du lader ham arve fra SwimmingBird men ikke fra FlyingBirdOg du behøver ikke at implementere tomme metoder eller kaste kunstige undtagelser.

D – Afhængighedsinversionsprincippet

Det sidste princip, DIP, kan opsummeres i to hovedideer: Højniveaumoduler bør ikke afhænge af lavniveaumoduler; begge bør afhænge af abstraktioner.Og abstraktioner bør ikke afhænge af detaljer, men detaljer bør snarere afhænge af abstraktioner.

I praksis betyder det, at din forretningslogik ikke bør være bundet til specifikke detaljer som "Jeg bruger MySQL", "Jeg skriver til en lokal fil" eller "Jeg sender SMS-beskeder med denne udbyder". I stedet definerer du abstrakte grænseflader (f.eks. Database, Channel, NotificationService) og du får din kode på højt niveau til kun at tale med dem.

Et design der bryd DIP Dette ville være et brugerlager, der direkte instantierer en MySQL-database:

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

Hvis du beslutter dig for at bruge PostgreSQL i morgen, skal du Ændr den høje klasse UserRepositoryDu er bundet til en specifik implementeringsdetalje.

Ved at anvende DIP definerer vi først en databaseabstraktion og får derefter de konkrete implementeringer til at arve fra den:

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

således Du kan injicere enhver implementering af Database når du opretter repository'et, uden at røre ved dets interne kode:

mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)

postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)

Dette mønster er kendt som Afhængighedsindsprøjtning Og det er den mest almindelige måde at anvende DIP på: klasser opretter ikke deres egne afhængigheder, men modtager dem udefra (gennem konstruktøren eller gennem specifikke metoder), og bruger altid abstraktioner som en type.

DIP anvendt på kanaler og kommunikatører

I eksemplet med fuglesamtaler kan vi også forbedre kanalstyringen ved at anvende DIP. Antag, at du definerer én abstraktion for kanalen og en anden for kommunikatoren:

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

En første, naiv implementering kunne være:

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

Selvom det virker korrekt, Denne kommunikator er stadig direkte koblet til SMSChannelVi forbedrede designet ved at lade kommunikatoren modtage kanalen udefra (afhængighedsinjektion) og dermed kun afhænge af abstraktionen:

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

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

Med denne tilgang implementerer enhver ny kanal (e-mail, push-notifikationer osv.) AbstractChannel y Den kan bruges uden at ændre kommunikatorkoden.Igen afhænger klasser på højt niveau af abstraktioner, ikke detaljer.

Hvad sker der, når man ignorerer SOLID?

Hvis disse principper ikke tages i betragtning, har koden en tendens til at lide af problemer som f.eks. Kodelugt, koderåd og koblinger, der er umulige at udredeDet vil sige enorme klasser med tusind ansvarsområder, underklasser, der bryder kontrakter, cykliske afhængigheder og metoder, der ændrer sig hver anden dag, fordi de laver for mange ting.

Konsekvenserne er tydelige og ret smertefulde for ethvert hold: Flere sårbarheder, flere fejl, konstant refactoring og i værste fald kode, der ender med at være praktisk talt ubrugelig.Det er det, der almindeligvis kaldes "spaghettikode": svær at følge, fuld af programrettelser og næsten umulig at udvide uden at ødelægge noget vigtigt.

SOLID-principperne er ikke mejslet i sten, og det er ikke altid umagen værd at anvende dem alle stift, især ikke i forbindelse med hurtig prototyping eller meget små projekter. Alligevel, Husk dem, og anvend dem på det meste af dit objektorienterede design i Python. Det gør forskellen på et projekt, der skalerer over tid, og et, der smuldrer, så snart det vokser lidt.

De bedste IDE'er til programmering af Windows 11
relateret artikel:
De bedste IDE'er til programmering på Windows 11

Tilføj som foretrukken kilde