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.