Kada počnete raditi na velikim Python projektima, jedna od prvih stvari koje primijetite je da Kod postaje teško razumjeti, testirati i proširiti. Ako ne slijedite nekoliko osnovnih pravila dizajna, tu dolaze do izražaja poznati SOLID principi: skup najboljih praksi osmišljenih da znatno olakšaju život timu.
Ovi principi su nastali u oblasti klasično objektno orijentisano programiranje (Java, C++, C#, itd.)Ali oni se savršeno uklapaju u Python sve dok koristite klase i objekte na manje-više ozbiljan način. Pogledajmo detaljno šta su, odakle dolaze, zašto su važni i, prije svega, kako Primijenite SOLID s jasnim primjerima u Pythonu kako bi vaš kod bio održiviji, skalabilniji i ugodniji za rad.
Šta je SOLID i odakle sve to dolazi?
Termin SOLID je akronim koji je popularizirao Michael Feathers. grupirati pet principa dizajna koje je prvobitno predložio Robert C. Martin, poznatiji kao Ujak Bob. Ovaj američki softverski inženjer, jedan od potpisnika Agilnog manifesta, objavio je članak "Principi objektno orijentisanog dizajna" sredinom 90-ih, a kasnije "Principi dizajna i obrasci dizajna", gdje je postavio mnoge temelje modernog objektno orijentisanog dizajna.
Vremenom su i drugi autori, kao što su Barbara Liskov i Bertrand Meyer Također su doprinijeli idejama koje su integrirane u ovaj skup principa. Michael Feathers je jednostavno imao (vrlo pronicljivu) ideju da ih preuredi tako da inicijali formiraju riječ SOLID, što im je pomoglo da se šire poput požara u razvojnoj zajednici.
Pet slova u SOLID-u odgovaraju ovim principima objektno orijentisanog dizajna, koji se mogu primijeniti i na Python:
- S – Princip jedinstvene odgovornosti (Princip jedinstvene odgovornosti)
- O – Princip otvoreno/zatvoreno (Princip otvorenog/zatvorenog)
- L – Liskovljev princip supstitucije (Liskovljev princip supstitucije)
- I – Princip segregacije interfejsa (Princip segregacije interfejsa)
- D – Princip inverzije zavisnosti (Princip obrnutog odnosa zavisnosti)
Opšta ideja je da ovih pet principa, korištenih zajedno, Pomažu vam da napišete fleksibilan, jednostavan za testiranje i održiv softverTo se prevodi u brže implementacije, manje misterioznih grešaka, bolju ponovnu upotrebu koda i manje glavobolja kada je projekat u produkciji nekoliko godina.
Za šta se koriste SOLID principi u Pythonu?
Primjena SOLID principa u Pythonu nije samo akademska vježba; ona ima direktan utjecaj na svakodnevni rad tima. Kada se pridržavate ovih principa, Oni smanjuju "špageti" kod, smanjuju miris koda i sprečavaju da vaša kodna baza "miriše trulo".koristeći poznatu analogiju, „ako nešto loše miriše, nešto je loše dizajnirano.“ U Windowsu, mnogi programeri se odlučuju za Instalirajte i konfigurirajte WSL2 imati Linux okruženje bliže produkcijskom.
U kolaborativnim okruženjima (timovi za razvoj pozadinskih sistema, inženjering podataka, proizvodi s dugim ciklusima itd.) ovi principi su ključni za Više ljudi može raditi na istoj kodnoj bazi bez prekoračenja svojih granica ili prekida bilo čega pri najmanjem dodiru.Nadalje, Python, iako fleksibilan i dinamičan, omogućava besprijekornu primjenu tipičnih OOP apstrakcija: apstraktnih klasa, hijerarhija nasljeđivanja, kompozicije i interfejsa putem... abc, Itd
Ukratko, SOLID vam pomaže da postignete:
- Čistiji, čitljiviji kodčak i godinama nakon što ga je napisao.
- Poboljšana mogućnost testiranjajer su odgovornosti jasno podijeljene.
- Visoka mogućnost ponovne upotrebe i skalabilnost zahvaljujući manjem broju krutih zavisnosti između modula.
- Manje kolateralnih grešakaKada nešto promijenite u jednom modulu, nećete slučajno pokvariti pet drugih stvari.
S – Princip jedinstvene odgovornosti
Prvi princip kaže da Razred treba imati samo jedan razlog za promjenu.Drugim riječima, mora preuzeti jednu, dobro definiranu odgovornost. To ne znači imati samo jednu metodu, već da sva njena logika treba da ukazuje na jedan, koherentan cilj.
Zamislite Python klasu koja predstavlja korisnika i, pored pohranjivanja njegovih podataka, također se bavi pristupom bazi podataka i generiranjem izvještaja:
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
Evo je razred miješa tri različite odgovornostiPredstavljanje korisnika, upravljanje perzistencijom i izrada izvještaja. Promjene u bazi podataka, formatu izvještaja ili atributima korisnika zahtijevaju modifikaciju iste klase, što povećava rizik od uvođenja unakrsnih grešaka.
Ako odvojimo ove probleme, dizajn se značajno poboljšava:
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}"
Sada razred Korisnik predstavlja korisnika samo kao entitetAko se način generiranja izvještaja promijeni, samo dodirnite UserReportGeneratorAko promijenite bazu podataka, samo dodirnete UserDBSvaka klasa ima jedan razlog za promjenu, što pojednostavljuje otklanjanje grešaka i evoluciju sistema.
SRP primijenjen na realističniji primjer: patke i komunikacija
Pogledajmo adaptirani klasični scenario: razred patka Kojem se, u početku, postepeno dodaju odgovornosti sve dok ne postane čudovište koje je teško održavati. Zamislite naivnu implementaciju:
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}")
Klasa Trebalo bi ga jednostavno definirati kao "patka"Ali također upravlja načinom na koji međusobno komuniciraju. Ako sutra promijenite logiku razgovora (više fraza, drugi jezici, različiti kanali), morate modificirati klasu duck, koja već dobro funkcionira kao entitet.
Rješenje koje poštuje SRP je izdvajanje te druge odgovornosti iz druge klase specijalizirane za komunikaciju:
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")
Zahvaljujući ovom odvajanju, Možete razviti logiku komunikacije bez dodirivanja definicije patkeNadalje, kod je lakše testirati: testirate ponašanje Duck a s druge strane, onaj od Communicatorbez miješanja odgovornosti.
O – Princip otvoreno/zatvoreno
Princip OCP-a kaže da Softverski entiteti trebaju biti otvoreni za proširenje svog ponašanja, ali zatvoreni za direktne modifikacije.Drugim riječima, kada želite dodati novu funkcionalnost, idealno bi bilo da ne morate prepisivati klase koje već rade i koriste ih drugi moduli.
Klasičan primjer je izračunavanje površina geometrijskih figura. Prvo ćemo pogledati verziju koja ne poštuje 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")
Ako sutra želite dodati trokut, bit ćete prisiljeni modificirati kod AreaCalculatordodavanje još jednog elifOvo krši OCP, jer klasa više nije "zatvorena" za promjene.
Ispravna verzija uključuje uvođenje apstrakcije Shape sa metodom area() koje svaka figura implementira na svoj način:
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()
Zahvaljujući ovom dizajnu, za dodajte trokut koji ne dodirujete AreaCalculatorJednostavno kreirate novu podklasu:
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
Princip otvoreno/zatvoreno se veoma dobro uklapa u ideju definirati jasne tačke proširenja putem apstrakcije: interfejsi, apstraktne klase, hookovi, itd. U Pythonu, modul abc Omogućava vam da ovo eksplicitno izrazite, čak i ako je jezik dinamičan.
OCP primijenjen na primjer komunikatora
Ako se vratimo na primjer CommunicatorMožemo ići korak dalje i pripremiti dizajn da podržava različite vrste razgovora bez prepisivanja komunikatora svaki put. Da bismo to uradili, definišemo apstrakciju razgovora i omogućavamo komunikatoru da koristi samo nju:
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")
U ovoj verziji, Ako želite dodati novi način razgovora (na primjer, agresivan razgovor, razgovor u kojem se naizmjenično odlučuje, itd.), samo stvarate još jednu podklasu AbstractConversation. Metoda communicate() de Communicator Ne mijenja se, u potpunosti se pridržava OCP-a.
L – Liskovljev princip supstitucije
Liskovljev princip supstitucije, koji je formulisala Barbara Liskov, tvrdi da Podklase bi trebale biti u stanju zamijeniti svoje osnovne klase bez promjene očekivanog ponašanja programa.U praksi, to znači da ako kod radi s jednom instancom osnovne klase, trebao bi jednako dobro raditi i s bilo kojom instancom podklase.
Tipičan primjer kršenja LSP-a je modeliranje svih ptica jednom metodom. fly()uključujući nojeve:
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")
Bilo koji kod koji pretpostavlja da Svaka ptica koja može letjeti propast će kada dobije noja.. To znači Ostrich Nije valjana zamjena za Bird, čime krši LSP.
Rješenje je prilagoditi hijerarhiju kako bi bolje odražavala stvarnost: ne lete sve ptice, tako da Samo dio ptica bi trebao imati ovu metodu 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
Ovim dizajnom, Bilo koja funkcija koja zahtijeva leteću pticu će izjaviti da joj je potrebna jedna. FlyingBirdi nikada neće dobiti noja. Na ovaj način se poštuje LSP i izbjegavaju se neočekivani izuzeci tokom izvršavanja.
LSP i razgovori o pticama
Vraćajući se na primjer razgovora, uobičajeno je da se kodiranje započne razmišljajući samo o patkama, a zatim se žele dodati vrane ili druge ptice. Ako klasa razgovora zavisi od Duck, Nećete ga moći ponovo koristiti s drugim vrstama ptica. bez diranja koda:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation Tipizirano je samo za patke; ne možete samo propustiti vranu bez da je modificirate. Ispravan pristup je kreiranje zajedničke apstrakcije. Bird i učinite da razgovor zavisi od te apstrakcije:
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
Na ovaj način, bilo koja podklasa Bird koji poštuje ugovor (do_sound()(ime, itd.) je valjana zamjena i neće poremetiti očekivano ponašanje SimpleConversation.
I – Princip segregacije interfejsa
Princip ISP-a tvrdi da Nijedan klijent ne bi trebao biti prisiljen oslanjati se na metode koje ne koristi.Prevedeno u apstraktne klase ili interfejse, ovo znači da je bolje imati nekoliko malih, specifičnih interfejsa nego jedan ogroman, generički interfejs.
Obratite pažnju na ovaj dizajn u kojem interfejs Worker To zahtijeva od svih koji ga primjenjuju da imaju specifične metode rada i prehrane:
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
Klasa Robot se oslanja na metodu eat() to ne trebaBilo kakva promjena vezana za hranu uticaće na robota, čak i ako nema nikakve veze sa tim ponašanjem.
Primjenom ISP-a, podijelili smo interfejs na dva manja, specifičnija:
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")
Sada, Svaka klasa implementira samo one metode koje su joj zaista potrebne.Ovo smanjuje spajanje, olakšava evoluciju dizajna i čini kod izražajnijim: postaje vrlo jasno ko šta može da uradi.
ISP u modeliranju ptica: letenje i plivanje
Nešto slično se dešava pri modeliranju ptica koje lete i plivaju. Ako osnovna apstrakcija Bird To zahtijeva implementaciju oba fly() como swim()Završit ćete s predavanjima poput Crow koji se moraju pretvarati da znaju plivati:
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
Rješenje prema ISP-u je odvojite interfejs na specifičnije mogućnosti:
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"
Ako ikada odlučite da budete model pingvina, jednostavno Natjeraš ga da naslijedi od SwimmingBird ali ne iz FlyingBirdI nećete morati implementirati prazne metode ili bacati vještačke izuzetke.
D – Princip inverzije zavisnosti
Posljednji princip, DIP, može se sažeti u dvije ključne ideje: Moduli visokog nivoa ne bi trebali zavisiti od modula niskog nivoa; oba bi trebala zavisiti od apstrakcija.I apstrakcije ne bi trebale zavisiti od detalja, već detalji bi trebali zavisiti od apstrakcija.
U praksi, to znači da vaša poslovna logika ne bi trebala biti vezana za specifične detalje poput "Korostim MySQL", "Pišem u lokalnu datoteku" ili "Šaljem SMS poruke s ovim provajderom". Umjesto toga, definirate apstraktni interfejsi (npr. Database, Channel, NotificationService) i natjerate svoj visokonivoski kod da komunicira samo s njima.
Dizajn koji prekid DIP-a Ovo bi bio korisnički repozitorij koji direktno instancira MySQL bazu podataka:
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")
Ako odlučite da sutra koristite PostgreSQL, morate modificirati klasu visokog nivoa UserRepositoryVezani ste za određeni detalj implementacije.
Primjenom DIP-a, prvo definiramo apstrakciju baze podataka, a zatim nasljeđujemo konkretne implementacije od nje:
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")
Na ovaj način, Možete ubrizgati bilo koju implementaciju Database prilikom kreiranja repozitorija, bez diranja njegovog internog koda:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
Ovaj obrazac je poznat kao Injekcija zavisnosti I to je najčešći način primjene DIP-a: klase ne kreiraju vlastite zavisnosti, već ih primaju izvana (kroz konstruktor ili putem specifičnih metoda), uvijek koristeći apstrakcije kao tip.
DIP primijenjen na kanale i komunikatore
U primjeru razgovora ptica, možemo poboljšati i upravljanje kanalima primjenom DIP-a. Pretpostavimo da definirate jednu apstrakciju za kanal, a drugu za komunikator:
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")
Prva, naivna implementacija bi mogla biti:
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
Iako se čini tačnim, Ovaj komunikator je i dalje direktno povezan sa SMSChannelPoboljšali smo dizajn tako što je komunikator primao kanal izvana (ubrizgavanje zavisnosti), te stoga zavisio samo od apstrakcije:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
S ovim pristupom, svaki novi kanal (e-pošta, push obavještenja itd.) implementira AbstractChannel y Može se koristiti bez promjene koda komunikatora.Opet, klase visokog nivoa zavise od apstrakcija, a ne od detalja.
Šta se dešava kada ignorišete SOLID?
Ako se ovi principi ne uzmu u obzir, kod obično pati od problema kao što su Miris koda, trulež koda i spojnice koje je nemoguće raspetljatiTo jest, ogromne klase sa hiljadu odgovornosti, podklase koje krše ugovore, ciklične zavisnosti i metode koje se mijenjaju svaki drugi dan jer rade previše stvari.
Posljedice su jasne i prilično bolne za bilo koji tim: Više ranjivosti, više grešaka, stalno refaktorisanje i, u najgorem slučaju, kod koji na kraju postane praktično neupotrebljiv.To je ono što se obično naziva "špageti kod": teško ga je pratiti, pun je zakrpa i gotovo ga je nemoguće proširiti bez oštećenja nečeg važnog.
SOLID principi nisu uklesani u kamenu i nije uvijek isplativo primjenjivati ih sve kruto, posebno kod brze izrade prototipa ili vrlo malih projekata. Uprkos tome, Imajte ih na umu i primijenite ih na većinu vašeg objektno orijentiranog dizajna u Pythonu. To pravi razliku između projekta koji se vremenom skalira i onog koji se raspada čim malo naraste.