Коли ви починаєте працювати над великими проектами на 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 ближче до продакшену.
У середовищах спільної роботи (команди розробників бекенду, інженерія даних, продукти з тривалими циклами тощо) ці принципи є ключовими для Кілька людей можуть працювати над однією кодовою базою, не переступаючи своїх меж і не порушуючи нічого через найменший дотик.Крім того, 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. Це робить різницю між проєктом, який масштабується з часом, і тим, який руйнується, щойно трохи виросте.