Когато започнете да работите по големи Python проекти, едно от първите неща, които забелязвате, е, че Кодът става труден за разбиране, тестване и разширяване. Ако не спазвате няколко основни правила за дизайн, тогава се намесват известните принципи SOLID: колекция от най-добри практики, предназначени да улеснят значително живота на екипа.
Тези принципи са възникнали в областта на класическо обектно-ориентирано програмиране (Java, C++, C# и др.)Но те се вписват идеално в Python, стига да използвате класове и обекти по един или друг сериозен начин. Нека разгледаме подробно какво представляват, откъде идват, защо са важни и най-вече как... Приложете SOLID с ясни примери в Python за да направите кода си по-лесно поддържаем, мащабируем и приятен за работа.
Какво е SOLID и откъде идва всичко това?
Терминът SOLID е акроним, популяризиран от Майкъл Федърс. да се групират пет принципа на дизайна, първоначално предложени от Робърт К. Мартин, по-известен като Чичо Боб. Този американски софтуерен инженер, един от подписалите Agile манифеста, публикува статията „Принципите на обектно-ориентирания дизайн“ в средата на 90-те години, а по-късно и „Принципи на дизайна и модели на дизайн“, където полага много от основите на съвременния обектно-ориентиран дизайн.
С течение на времето други автори, като например Барбара Лисков и Бертран Майер Те също допринесоха с идеи, които бяха интегрирани в този набор от принципи. Майкъл Федърс просто имаше (много проницателната) идея да ги пренареди, така че инициалите да образуват думата SOLID, което им помогна да се разпространят като горски пожар в общността на разработчиците.
Петте букви на SOLID съответстват на тези принципи на обектно-ориентиран дизайн, приложими и за Python:
- S – Принцип на единната отговорност (Принцип на еднолична отговорност)
- O – Принцип на отворено/затворено (Принцип „отворено/затворено“)
- L – Принцип на заместване на Лисков (Принцип на заместване на Лисков)
- I – Принцип на разделяне на интерфейсите (Принцип на сегрегация на интерфейсите)
- D – Принцип на инверсия на зависимостите (Принцип на обръщане на зависимостта)
Общата идея е, че тези пет принципа, използвани заедно, Те ви помагат да пишете гъвкав, лесен за тестване и поддържан софтуерТова се изразява в по-бързо внедряване, по-малко мистериозни грешки, по-добра повторна употреба на код и по-малко главоболия, когато проектът е в производство от няколко години.
За какво се използват SOLID принципите в Python?
Прилагането на принципите на SOLID в Python не е просто академично упражнение; то има пряко въздействие върху ежедневната работа на екипа. Когато се придържате към тези принципи, Те намаляват „спагети“ кода, намаляват миризмата на код и предотвратяват кодовата ви база да „мирише на гнило“.използвайки известната аналогия, „ако мирише лошо, нещо е лошо проектирано“. В Windows много разработчици избират да Инсталиране и конфигуриране на WSL2 да има Linux среда, по-близка до производствена.
В съвместни среди (екипи за разработка на backend, инженерство на данни, продукти с дълги цикли и др.) тези принципи са ключови за Много хора могат да работят върху една и съща кодова база, без да превишават границите си или да нарушават всичко при най-малкото докосване.Освен това, Python, макар и гъвкав и динамичен, позволява безпроблемното прилагане на типични обектно-ориентирани абстракции: абстрактни класове, йерархии на наследяване, композиция и интерфейси чрез... abcИ др
В обобщение, SOLID ви помага да постигнете:
- По-чист, по-четлив коддори години след написването му.
- Подобрена тестваемостзащото отговорностите са ясно разпределени.
- Висока възможност за многократна употреба и мащабируемост благодарение на по-малко твърди зависимости между модулите.
- По-малко странични грешкиКогато промените нещо в един модул, не повредите случайно пет други неща.
S – Принцип на единната отговорност
Първият принцип гласи, че Един клас трябва да има само една причина за промяна.С други думи, то трябва да поеме една-единствена, добре дефинирана отговорност. Това не означава да има само един метод, а по-скоро цялата му логика да сочи към една-единствена, последователна цел.
Представете си Python клас, който представлява потребител и освен че съхранява неговите данни, обработва и достъпа до базата данни и генерира отчети:
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
Ето го класът смесва три различни отговорностиПредставяне на потребителя, управление на постоянството и изграждане на отчети. Промените в базата данни, формата на отчета или атрибутите на потребителя изискват модифициране на един и същ клас, което увеличава риска от въвеждане на кръстосани грешки.
Ако разделим тези притеснения, дизайнът се подобрява значително:
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}"
Сега класът Потребителят представлява потребителя само като обектАко начинът, по който се генерират отчетите, се промени, просто докоснете UserReportGeneratorАко промените базата данни, просто докоснете UserDBВсеки клас има една единствена причина за промяна, което опростява отстраняването на грешки и еволюцията на системата.
SRP, приложен към по-реалистичен пример: патици и комуникация
Нека разгледаме един адаптиран класически сценарий: един клас патица Към което първоначално постепенно се добавят отговорности, докато то се превърне в чудовище, което е трудно за поддържане. Представете си наивна имплементация:
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}")
клас Трябва да се определи просто като „патица“Но той също така управлява начина, по който те комуникират помежду си. Ако утре промените логиката на разговора (повече фрази, други езици, различни канали), ще трябва да модифицирате класа duck, който вече функционира добре като цялост.
Решението, което уважава SRP, е да се извлече тази втора отговорност от друг клас, специализиран в комуникацията:
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")
Благодарение на това разделение, Можете да развиете комуникационната логика, без да докосвате определението за патица.Освен това, кодът е по-лесен за тестване: тествате поведението на Duck и от друга страна, този на Communicatorбез смесване на отговорности.
O – Принцип на отворено/затворено
Принципът OCP гласи, че Софтуерните обекти трябва да бъдат отворени за разширяване на поведението си, но затворени за директни модификации.С други думи, когато искате да добавите нова функционалност, в идеалния случай не би трябвало да пренаписвате класове, които вече работят и се използват от други модули.
Класически пример е изчисляването на площите на геометрични фигури. Нека първо разгледаме версия, която не спазва 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")
Ако искате да добавите триъгълник утре, ще бъдете принудени да модифицирайте кода на AreaCalculatorдобавяне на още един elifТова нарушава OCP, защото класът вече не е „затворен“ за промени.
Правилната версия включва въвеждане на абстракция Shape с метод area() които всяка фигура прилага по свой собствен начин:
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()
Благодарение на този дизайн, за добавете триъгълник, който не докосвате AreaCalculatorПросто създавате нов подклас:
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
Принципът „Отворено/Затворено“ се вписва много добре в идеята за дефинирайте ясни точки на разширение чрез абстракцииинтерфейси, абстрактни класове, куки и др. В Python, модулът abc Това ви позволява да изразите това изрично, дори ако езикът е динамичен.
OCP, приложен към примера с комуникатора
Ако се върнем към примера на комуникаторМожем да направим още една крачка напред и да подготвим дизайна, който да поддържа различни видове разговори, без да пренаписваме комуникатора всеки път. За да направим това, дефинираме абстракция на разговора и комуникаторът използва само нея:
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")
В тази версия, Ако искате да добавите нов начин на говорене (например, агресивен разговор, разговор с поемане на отговор и т.н.), просто създавате друг подклас от AbstractConversation. Методът communicate() de Communicator Не се променя, спазвайки OCP дословно.
L – Принцип на заместване на Лисков
Принципът на заместването на Лисков, формулиран от Барбара Лисков, гласи, че Подкласовете трябва да могат да заменят базовите си класове, без да променят очакваното поведение на програмата.На практика това означава, че ако кодът работи с един екземпляр на базовия клас, той би трябвало да работи също толкова добре и с всеки екземпляр на подклас.
Типичен пример за нарушение на LSP е моделирането на всички птици с един метод. fly()включително щрауси:
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")
Всеки код, който приема, че Всяка птица, която може да лети, ще се провали, когато получи щраус., Тоест, Ostrich Това не е валиден заместител на Bird, като по този начин нарушава LSP.
Решението е да се коригира йерархията, за да отразява по-добре реалността: не всички птици летят, така че Само част от птиците трябва да имат метода 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
С този дизайн, Всяка функция, която изисква летяща птица, ще декларира, че изисква такава. FlyingBirdи никога няма да получи щраус. По този начин LSP се спазва и се избягват неочаквани изключения по време на изпълнение.
LSP и разговори с птици
Връщайки се към примера с разговорите, често се случва да започнете да програмирате, мислейки само за патици, а след това да искате да добавите врани или други птици. Ако класът за разговори зависи от Duck, Няма да можете да го използвате повторно с други видове птици. без да докосвам кода:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation Той е типизиран само за патици; няма да можете да приложите врана към него, без да го модифицирате. Правилният подход е да се създаде обща абстракция. Bird и да накараме разговора да зависи от тази абстракция:
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
По този начин, всеки подклас на Bird който спазва договора (do_sound()(име и т.н.) е валиден заместител и няма да наруши очакваното поведение на SimpleConversation.
I – Принцип на разделяне на интерфейсите
Принципът на ISP твърди, че Никой клиент не трябва да бъде принуждаван да разчита на методи, които не използва.Преведено в абстрактни класове или интерфейси, това означава, че е по-добре да имате няколко малки, специфични интерфейса, отколкото един огромен, обобщен интерфейс.
Обърнете внимание на този дизайн, в който интерфейсът Worker Това изисква от всички, които го прилагат, да имат специфични методи на работа и хранене:
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
клас Роботът разчита на метод eat() това не се нуждаеВсяка промяна, свързана с храната, ще повлияе на робота, дори и да няма нищо общо с това поведение.
Чрез прилагане на ISP разделихме интерфейса на два по-малки, по-специфични:
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")
сега, Всеки клас имплементира само методите, от които действително се нуждае.Това намалява свързването, улеснява еволюцията на дизайна и прави кода по-изразителен: става много ясно кой какво може да прави.
Интернет доставчик в моделирането на птици: летене и плуване
Нещо подобно се случва при моделирането на птици, които летят и плуват. Ако основната абстракция Bird Това изисква прилагането и на двете fly() като swim()Ще завършите с класове като Crow които трябва да се преструват, че знаят да плуват:
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
Решението според интернет доставчика е разделяне на интерфейса на по-специфични възможности:
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"
Ако някога решите да моделирате пингвин, просто караш го да наследи от SwimmingBird но не от FlyingBirdИ няма да се налага да внедрявате празни методи или да генерирате изкуствени изключения.
D – Принцип на инверсия на зависимостите
Последният принцип, DIP, може да се обобщи в две ключови идеи: Модулите от високо ниво не трябва да зависят от модулите от ниско ниво; и двата трябва да зависят от абстракции.И абстракциите не трябва да зависят от детайлите, а по-скоро детайлите трябва да зависят от абстракциите.
На практика това означава, че вашата бизнес логика не трябва да бъде обвързана с конкретни детайли като „Използвам MySQL“, „Пиша в локален файл“ или „Изпращам SMS съобщения с този доставчик“. Вместо това, вие дефинирате абстрактни интерфейси (например Database, Channel, NotificationService) и карате кода си от високо ниво да общува само с тях.
Дизайн, който прекъсване на DIP Това би било потребителско хранилище, което директно създава инстанции на 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")
Ако решите да използвате PostgreSQL утре, трябва модифицирайте класа от високо ниво UserRepositoryВие сте обвързани с конкретен детайл от изпълнението.
Чрез прилагане на DIP, първо дефинираме абстракция на база данни и след това наследяваме конкретните имплементации от нея:
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")
По този начин, Можете да инжектирате всяка имплементация на Database при създаването на хранилището, без да се докосва вътрешният му код:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
Този модел е известен като Инжектиране на зависимост И това е най-разпространеният начин за прилагане на DIP: класовете не създават свои собствени зависимости, а ги получават отвън (чрез конструктора или чрез специфични методи), винаги използвайки абстракции като тип.
DIP, приложен към канали и комуникатори
В примера с разговорите на птиците, можем също да подобрим управлението на каналите, като приложим DIP. Да предположим, че дефинираме една абстракция за канала и друга за комуникатора:
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")
Първа, наивна имплементация може да бъде:
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
Въпреки че изглежда правилно, Този комуникатор все още е директно свързан с SMSChannelПодобрихме дизайна, като накарахме комуникатора да получава канала отвън (инжектиране на зависимости) и по този начин да зависи само от абстракцията:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
С този подход, всеки нов канал (имейл, push известия и др.) внедрява AbstractChannel y Може да се използва без промяна на кода на комуникатора.Отново, класовете от високо ниво зависят от абстракции, а не от детайли.
Какво се случва, когато игнорирате SOLID?
Ако тези принципи не се вземат предвид, кодът е склонен да страда от проблеми като например миризма на код, гниене на код и невъзможни за разплитане връзкиТоест, огромни класове с хиляда отговорности, подкласове, които нарушават договори, циклични зависимости и методи, които се променят през ден, защото правят твърде много неща.
Последиците са ясни и доста болезнени за всеки отбор: Повече уязвимости, повече грешки, постоянно рефакториране и, в най-лошия случай, код, който в крайна сметка става практически неизползваем.Това е, което обикновено се нарича „спагети код“: труден за следване, пълен с корекции и почти невъзможно за разширяване, без да се наруши нещо важно.
Принципите на SOLID не са неотменни и не винаги си струва да се прилагат стриктно, особено при бързо прототипиране или много малки проекти. Въпреки това, Имайте ги предвид и ги прилагайте към по-голямата част от вашия обектно-ориентиран дизайн в Python. Това прави разликата между проект, който се мащабира с времето, и такъв, който се разпада веднага щом порасне малко.