Když začnete pracovat na velkých projektech v Pythonu, jednou z prvních věcí, kterých si všimnete, je, že Kód se stává obtížně pochopitelným, testovatelným a rozšiřitelným. Pokud nedodržujete několik základních pravidel designu, pak přicházejí na řadu slavné principy SOLID: soubor osvědčených postupů navržených tak, aby týmu výrazně usnadnily život.
Tyto principy vznikly v oblasti klasické objektově orientované programování (Java, C++, C# atd.)Ale perfektně se hodí do Pythonu, pokud třídy a objekty používáte víceméně vážným způsobem. Pojďme se podrobně podívat na to, co jsou zač, odkud pocházejí, proč jsou důležité a především jak... Aplikace SOLID v Pythonu s jasnými příklady aby byl váš kód lépe udržovatelný, škálovatelný a příjemnější na práci.
Co je SOLID a odkud se to všechno bere?
Termín SOLID je zkratka, kterou zpopularizoval Michael Feathers. seskupit pět principů designu, které původně navrhl Robert C. Martin, lépe známý jako Strýček Bob. Tento americký softwarový inženýr, jeden ze signatářů Agilního manifestu, publikoval v polovině 90. let článek „Principy objektově orientovaného designu“ a později „Principy designu a vzory designu“, kde položil mnoho základů moderního objektově orientovaného designu.
Postupem času se objevili i další autoři, jako např. Barbara Liskov a Bertrand Meyer Také přispěli nápady, které byly integrovány do této sady principů. Michael Feathers měl jednoduše (velmi chytrý) nápad přeskupit je tak, aby iniciály tvořily slovo SOLID, což jim pomohlo šířit se jako požár v komunitě vývojářů.
Pět písmen SOLID odpovídá těmto principům objektově orientovaného návrhu, které lze použít i v Pythonu:
- S – Princip jediné odpovědnosti (Zásada jediné odpovědnosti)
- O – Princip otevřeno/zavřeno (Princip otevřeno/zavřeno)
- L – Liskovův substituční princip (Liskovův substituční princip)
- I – Princip segregace rozhraní (Princip segregace rozhraní)
- D – Princip inverze závislostí (Princip obrácení závislosti)
Obecná myšlenka je, že těchto pět principů, použitých společně, Pomáhají vám psát flexibilní, snadno testovatelný a udržovatelný software.To se promítá do rychlejšího nasazení, menšího počtu záhadných chyb, lepšího opětovného použití kódu a menšího počtu problémů, když je projekt v produkci již několik let.
K čemu se v Pythonu používají principy SOLID?
Aplikace principů SOLID v Pythonu není jen akademické cvičení; má přímý dopad na každodenní práci týmu. Když se těchto principů budete držet, Snižují „špagetový“ kód, snižují zápach kódu a zabraňují tomu, aby vaše kódová základna „zapáchala hnilobou“.pomocí známé analogie: „Pokud to špatně zapáchá, je něco špatně navrženo.“ Ve Windows se mnoho vývojářů rozhodne Instalace a konfigurace WSL2 mít linuxové prostředí blíže produkčnímu.
V kolaborativních prostředích (backendové vývojové týmy, datové inženýrství, produkty s dlouhými cykly atd.) jsou tyto principy klíčové pro Více lidí může pracovat na stejné kódové základně, aniž by překročili své meze nebo cokoli narušili sebemenším dotykem.Python, ačkoli je flexibilní a dynamický, umožňuje bezproblémové použití typických abstrakcí OOP: abstraktních tříd, hierarchií dědičnosti, kompozice a rozhraní prostřednictvím... abc, Etc.
Stručně řečeno, SOLID vám pomůže dosáhnout:
- Čistší a čitelnější kódi roky po jeho napsání.
- Vylepšená testovatelnostprotože odpovědnosti jsou jasně oddělené.
- Vysoká opakovaná použitelnost a škálovatelnost díky menšímu počtu rigidních závislostí mezi moduly.
- Méně chyb v dodavatelském řetězciKdyž něco změníte v jednom modulu, nenecháte náhodou poškodit pět dalších věcí.
S – Princip jediné odpovědnosti
První princip říká, že Třída by měla mít pouze jeden důvod ke změně.Jinými slovy, musí převzít jedinou, jasně definovanou odpovědnost. To neznamená mít pouze jednu metodu, ale spíše to, že veškerá její logika by měla směřovat k jedinému, ucelenému cíli.
Představte si třídu v Pythonu, která reprezentuje uživatele a kromě ukládání jeho dat se také stará o přístup k databázi a generování reportů:
class User:
def __init__(self, name: str):
self.name = name
def get_user_from_database(self, user_id: int) -> dict:
# Recupera datos desde la base de datos
# ...
pass
def save_user_to_database(self) -> None:
# Persiste el usuario en la base de datos
# ...
pass
def generate_user_report(self) -> str:
# Genera un informe del usuario
# ...
pass
Tady je třída kombinuje tři odlišné odpovědnostiReprezentace uživatele, správa perzistence a vytváření sestav. Změny v databázi, formátu sestavy nebo atributech uživatele vyžadují úpravu stejné třídy, což zvyšuje riziko zavedení chyb v rámci jednotlivých sestav.
Pokud tyto obavy oddělíme, design se výrazně zlepší:
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}"
Teď třída Uživatel představuje uživatele pouze jako entitu.Pokud se změní způsob generování reportů, stačí klepnout UserReportGeneratorPokud změníte databázi, stačí se dotknout UserDBKaždá třída má jeden důvod pro změnu, což zjednodušuje ladění a vývoj systému.
SRP aplikovaná na realističtější příklad: kachny a komunikace
Podívejme se na adaptovaný klasický scénář: třída Kachna Ke kterému se zpočátku postupně přidávají povinnosti, až se z něj stane monstrum, které je obtížné udržovat. Představte si naivní implementaci:
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}")
Třída Mělo by být definováno jednoduše jako „kachna“Ale také řídí, jak spolu komunikují. Pokud zítra změníte logiku konverzace (více frází, jiné jazyky, různé kanály), musíte upravit třídu duck, která už jako entita funguje dobře.
Řešením, které respektuje SRP, je vyčlenit tuto druhou odpovědnost z jiné třídy specializující se na komunikaci:
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")
Díky tomuto oddělení, Můžete vyvíjet komunikační logiku, aniž byste se dotkli definice kachny.Navíc je kód snadněji testovatelný: otestujete chování Duck a na druhé straně ten z Communicatorbez míchání odpovědností.
O – Princip otevřeno/zavřeno
Princip OCP říká, že Softwarové entity by měly být otevřené rozšiřování svého chování, ale uzavřené pro přímé úpravy.Jinými slovy, když chcete přidat novou funkcionalitu, v ideálním případě byste neměli muset přepisovat třídy, které již fungují a používají je jiné moduly.
Klasickým příkladem je výpočet ploch geometrických útvarů. Nejprve se podívejme na verzi, která nerespektuje 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")
Pokud chcete zítra přidat trojúhelník, budete nuceni upravit kód AreaCalculatorpřidání dalšího elifToto porušuje OCP, protože třída již není „uzavřená“ pro změny.
Správná verze zahrnuje zavedení abstrakce Shape s metodou area() které každá postava implementuje svým vlastním způsobem:
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()
Díky tomuto designu, přidejte trojúhelník, kterého se nedotýkáte AreaCalculatorJednoduše vytvoříte novou podtřídu:
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 otevřeno/zavřeno velmi dobře odpovídá myšlence definovat jasné body rozšíření pomocí abstrakcí: rozhraní, abstraktní třídy, hooky atd. V Pythonu modul abc Umožňuje to explicitně vyjádřit, i když je jazyk dynamický.
OCP aplikovaný na příklad komunikátoru
Pokud se vrátíme k příkladu SdělovatelMůžeme jít ještě o krok dál a připravit design tak, aby podporoval různé typy konverzací, aniž bychom museli komunikátor pokaždé přepisovat. Abychom toho dosáhli, definujeme abstrakci konverzace a komunikátor ji bude používat pouze:
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")
V této verzi Pokud chcete přidat nový způsob mluvení (například agresivní konverzace, konverzace, ve které se účastníci střídají atd.), pouze vytvoříte další podtřídu AbstractConversation. Metoda communicate() de Communicator Nemění se, přesně dodržuje OCP.
L – Liskovův substituční princip
Liskovův substituční princip, formulovaný Barbarou Liskovovou, uvádí, že Podtřídy by měly být schopny nahradit své základní třídy, aniž by se změnilo očekávané chování programu.V praxi to znamená, že pokud kód funguje s jednou instancí základní třídy, měl by stejně dobře fungovat s jakoukoli instancí podtřídy.
Typickým příkladem porušení LSP je modelování všech ptáků jednou metodou. fly()včetně pštrosů:
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")
Jakýkoli kód, který předpokládá, že Každý pták, který umí létat, selže, když dostane pštrosa.. Myslím Ostrich Není to platná náhrada za Bird, čímž porušuje LSP.
Řešením je upravit hierarchii tak, aby lépe odrážela realitu: ne všichni ptáci létají, takže Metodu by měla mít pouze část ptáků 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
S tímto designem, Jakákoli funkce, která vyžaduje letícího ptáka, to deklaruje. FlyingBirda nikdy neobdrží pštrosa. Tímto způsobem je respektován LSP a zabráněno neočekávaným výjimkám za běhu.
LSP a konverzace s ptáky
Zpět k příkladu konverzací. Je běžné, že se při psaní kódu myslí pouze na kachny a pak se chce přidat vrány nebo jiné ptáky. Pokud třída konverzací závisí na Duck, Nebudete jej moci znovu použít s jinými druhy ptáků. bez dotyku kódu:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation Je to typované pouze pro kachny; vrány nemůžete jen tak propustit, aniž byste je upravili. Správný přístup je vytvořit společnou abstrakci. Bird a založit konverzaci na této abstrakci:
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
Tímto způsobem jakákoli podtřída Bird který respektuje smlouvu (do_sound()(jméno atd.) je platná náhrada a nenaruší očekávané chování SimpleConversation.
I – Princip segregace rozhraní
Princip ISP tvrdí, že Žádný zákazník by neměl být nucen spoléhat se na metody, které nepoužíval.Přeloženo do abstraktních tříd nebo rozhraní to znamená, že je lepší mít několik malých, specifických rozhraní než jedno obrovské, generické rozhraní.
Všimněte si tohoto návrhu, ve kterém rozhraní Worker Vyžaduje to od všech, kteří ji implementují, specifické pracovní a stravovací metody:
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
Třída Robot se spoléhá na metodu eat() to nepotřebujeJakákoli změna týkající se jídla ovlivní robota, i když s tímto chováním nemá nic společného.
Použitím ISP jsme rozdělili rozhraní na dvě menší, specifičtější části:
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")
Nyní, Každá třída implementuje pouze metody, které skutečně potřebuje.To snižuje propojení, usnadňuje vývoj návrhu a činí kód výraznějším: je velmi jasné, kdo co může dělat.
ISP v modelování ptáků: létání a plavání
Něco podobného se děje při modelování ptáků, kteří létají a plavou. Pokud základní abstrakce Bird Vyžaduje to implementaci obou fly() jak swim()Skončíte s kurzy jako Crow kteří musí předstírat, že umí plavat:
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
Řešení podle poskytovatele internetových služeb je rozdělit rozhraní na specifičtější funkce:
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"
Pokud se někdy rozhodnete modelovat tučňáka, jednoduše necháš ho zdědit SwimmingBird ale ne od FlyingBirdA nebudete muset implementovat prázdné metody ani vyvolávat umělé výjimky.
D – Princip inverze závislostí
Poslední princip, DIP, lze shrnout do dvou klíčových myšlenek: Moduly vysoké úrovně by neměly záviset na modulech nízké úrovně; oba by měly záviset na abstrakcích.A abstrakce by neměly záviset na detailech, ale detaily by měly záviset na abstrakcích.
V praxi to znamená, že vaše obchodní logika by neměla být vázána na konkrétní detaily, jako například „Používám MySQL“, „Zapisuji do lokálního souboru“ nebo „S tímto poskytovatelem odesílám SMS zprávy“. Místo toho definujete abstraktní rozhraní (například Database, Channel, NotificationService) a váš kód na vysoké úrovni bude komunikovat pouze s nimi.
Design, který přerušit DIP Toto by byl uživatelský repozitář, který přímo vytváří instanci databáze 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")
Pokud se rozhodnete zítra používat PostgreSQL, musíte upravit třídu vysoké úrovně UserRepositoryJste vázáni na konkrétní detail implementace.
Použitím DIP nejprve definujeme abstrakci databáze a poté z ní dědíme konkrétní implementace:
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")
To znamená, Můžete vložit libovolnou implementaci Database při vytváření repozitáře, bez zásahu do jeho interního kódu:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
Tento vzorec je známý jako Injekce závislosti A je to nejběžnější způsob použití DIP: třídy si nevytvářejí vlastní závislosti, ale přijímají je zvenčí (prostřednictvím konstruktoru nebo specifických metod), vždy s použitím abstrakcí jako typu.
DIP aplikovaný na kanály a komunikátory
V příkladu ptačí konverzace můžeme také vylepšit správu kanálů aplikací DIP. Předpokládejme, že definujete jednu abstrakci pro kanál a druhou pro komunikátor:
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")
První, naivní implementace by mohla být:
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
I když se to zdá správné, Tento komunikátor je stále přímo propojen s SMSChannelVylepšili jsme návrh tím, že komunikátor přijímá kanál zvenčí (vkládání závislostí), a je tedy závislý pouze na abstrakci:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
S tímto přístupem implementuje jakýkoli nový kanál (e-mail, push notifikace atd.) AbstractChannel y Lze jej použít beze změny kódu komunikátoru.Opět platí, že třídy vysoké úrovně závisí na abstrakcích, nikoli na detailech.
Co se stane, když ignorujete SOLID?
Pokud se tyto principy neberou v úvahu, kód má tendenci trpět problémy, jako například zápach kódu, hniloba kódu a nerozmotatelné spojkyTedy obrovské třídy s tisíci zodpovědnostmi, podtřídy, které porušují smlouvy, cyklické závislosti a metody, které se mění obden, protože dělají příliš mnoho věcí.
Důsledky jsou jasné a pro jakýkoli tým docela bolestivé: Více zranitelností, více chyb, neustálé refaktorování a v nejhorším případě kód, který se nakonec stane prakticky nepoužitelným.Je to to, čemu se běžně říká „špagetový kód“: obtížné ho sledovat, je plný záplat a téměř nemožné ho rozšířit, aniž by se porušilo něco důležitého.
Principy SOLID nejsou neměnné a ne vždy se vyplatí je všechny striktně aplikovat, zejména u rychlého prototypování nebo velmi malých projektů. I tak ale... Mějte je na paměti a aplikujte je na většinu svého objektově orientovaného návrhu v Pythonu. To dělá rozdíl mezi projektem, který se časem škáluje, a projektem, který se rozpadne, jakmile trochu vyroste.