Якщо ви програмуєте на Python і хочете вивести організацію свого коду на новий рівень, принципи SOLID – це революційні зміни. Вони не є примхою чи академічною примхою: вони… п'ять практичних правил які допомагають вам писати зрозуміліше програмне забезпечення, яке легше тестувати та набагато простіше підтримувати з часом.
Хоча вони виникли у світі класичного об'єктно-орієнтованого програмування (Java, C++, C#…), їх можна без проблем застосовувати до Python та його об'єктна модельУ цьому посібнику ми крок за кроком, використовуючи приклади, розглянемо, як розуміти та застосовувати SOLID у Python: що означає кожен принцип, звідки він походить, які реальні проблеми він вирішує та як він перекладається на повсякденні класи, методи та інтерфейси.
Що таке принципи SOLID і звідки вони взялися?
Термін ТВЕРДИЙ – це добре відома абревіатура у розробці програмного забезпечення. Його популяризував Майкл Фезерс на основі набору принципів, визначених, серед інших, Робертом К. Мартіном (дядько Боб), Бертраном Мейєром та Барбарою Лісков. Ідея полягала в тому, щоб стиснути до п'яти літер мінімальний посібник зі створення об'єктно-орієнтованих проектів, які не перетворювалися б на кошмар у міру їх розвитку.
Ці принципи стали відомими завдяки есе дядька Боба «Принципи проектування та шаблони проектування» і з таких книг, як Чистий код o Чиста архітектураКрім того, вони дуже добре поєднуються з іншими правилами чистого кодування, такими як СУХИ (не повторюйся) o KISS (Keep It Simple, Stupid)всі вони були спрямовані на зменшення складності та непотрібних залежностей.
Назва SOLID походить від першої літери кожного принципу англійською мовою, які вам слід запам'ятати, бо ви зустрінете їх у будь-якій більш-менш серйозній розмові про об'єктно-орієнтовану архітектуру:
- S – Принцип єдиної відповідальності (SRP)принцип одноосібної відповідальності.
- O – Принцип відкритого/закритого положення (OCP)принцип «відкрито/закрито».
- L – Принцип заміни Ліскова (LSP)Принцип заміщення Ліскова.
- I – Принцип розділення інтерфейсу (ISP)принцип сегрегації інтерфейсів.
- D – Принцип інверсії залежності (DIP)принцип зворотної залежності.
Їхня спільна мета — зробити ваші заняття узгоджений, роз'єднаний та передбачуванийДотримання їх не лише допомагає вам писати кращий новий код, але й значно спрощує підтримку проекту, який зростає з роками або в якому задіяно кілька команд, що працюють одночасно.
Чому варто застосовувати SOLID у Python?
Хоча Python є гнучкою мовою, яка дозволяє використовувати дуже різні стилі, принципи SOLID забезпечують міцна основа конструкції для будь-якого середнього чи великого проекту. Їх впровадження зазвичай призводить до більш гнучкого розгортання, меншої кількості регресій при роботі зі старим кодом та меншого «запаху коду» (того дивного запаху, який підказує вам, що щось не так, навіть якщо це працює).
Серед найочевидніших переваг застосування SOLID у ваших розробках на Python є… більша чіткість та розбірливістьта використання Основні IDE та редактори Це допомагає підтримувати його, оскільки класи мають чітко визначені обов'язки, інтерфейси невеликі, а модулі не дуже добре знають один одного. Через це відкриття чужого файлу не схоже на читання ієрогліфів, а є чимось досить зрозумілим.
Це також дуже помітно в обслуговування та розвиток системиНадійний дизайн (буквально) дозволяє додавати нові функції, розширюючи існуючі моделі поведінки, а не порушуючи те, що вже є. Це зменшує кількість травматичних рефакторингів та жахливого «спагетті-коду», до якого ніхто не наважується торкатися, не порушивши три речі, і дозволяє працювати з найкращі IDE для програмування Це полегшує ці рефакторинги.
Іншим ключовим моментом є зменшення помилок та вразливостейМенше перехресних залежностей означає менше місць, де безглузда зміна в одному класі впливає на зовсім інший. А коли вам потрібно виправити помилки, знайти джерело проблеми в модулі з однією відповідальністю набагато легше.
Зрештою, дотримання SOLID допомагає зберегти такі явища, як запах коду (неприємні дизайнерські запахи), гниття коду (код, який з часом «гниє») або поява «спагеті-коду», повного прихованих залежностей. У гнучких контекстах, де багато людей працюють над одним і тим самим кодом, ці правила відрізняють живу систему від тієї, яку зрештою майже неможливо змінити.
S – Принцип єдиної відповідальності (SRP) у Python

Принцип єдиної відповідальності стверджує, що Кожен клас повинен мати лише одну причину для змін.Це не означає, що клас може мати лише один метод, і що він виконує лише «одну мікрозавдання», а радше те, що він має відповідати за єдину, чітко визначену вісь змін.
На практиці це означає, що клас повинен представляти чітка сутність або тип послуги: користувач системи, замовлення, сховище даних, генератор звітів, валідатор тощо. Коли ви починаєте змішувати бізнес-логіку, доступ до бази даних, форматування звітів та надсилання електронної пошти в одному класі, ви перетинаєте межу.
Наприклад, уявіть собі клас Python, який представляє користувача та виконує трохи всього:
class User:
def __init__(self, name: str):
self.name = name
def get_user_from_database(self, user_id: int) -> dict:
# Lógica de acceso a base de datos
pass
def save_user_to_database(self) -> None:
# Lógica de persistencia
pass
def generate_user_report(self) -> str:
# Lógica de generación de informes
pass
Тут клас змішаний модель домену, персистентність та звітністьЗміни в будь-якій з цих областей вимагають модифікації того самого класу, що множить ризик побічних ефектів і ускладнює ізольовані модульні тести.
Більш розумне застосування SRP передбачає чіткий розподіл обов'язків на кілька скоординованих класів:
class User:
def __init__(self, name: str):
self.name = name
class UserDB:
@staticmethod
def get_user(user_id: int) -> User:
# Obtiene usuarios de la BBDD
return User("John Doe")
@staticmethod
def save_user(user: User) -> None:
# Guarda un usuario
pass
class UserReportGenerator:
@staticmethod
def generate_report(user: User) -> str:
return f"Report for user: {user.name}"
Тепер кожен клас має один чіткий обов'язокЯкщо спосіб збереження користувачів зміниться, вам просто потрібно буде натиснути UserDBЯкщо формат звіту зміниться, ви внесете корективи UserReportGenerator без ризику порушення стійкості.
Ще один поширений приклад у Python – це коли клас домену перевантажений взаємодією з об'єктами. Розглянемо цей клас качки з кількома можливостями:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
def greet(self, duck2: "Duck"):
print(f"{self.name}: {self.do_sound()}, hello {duck2.name}")
Тут у класі розглядаються обидва дати визначення качки а також логіку спілкування між качками. Якщо змінити спосіб, яким вони вітають одне одного, доведеться торкнутися самого визначення Duck, що вводить другу причину для переходу.
Рішення, сумісне з SRP, передбачає переміщення логіки розмовної обробки до окремого, виділеного класу:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
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):
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.
O – Принцип відкритого/закритого положення (OCP) у розширюваних конструкціях
Принцип відкритості/закритості стверджує, що програмна сутність повинна бути Відкритий для розширення, але закритий для модифікаційІншими словами, ви повинні мати можливість додавати нові функції, не торкаючись коду, який вже протестовано та знаходиться у виробництві.
У Python це зазвичай досягається шляхом поєднання Спадкування, абстрактні класи та композиціяХитрощі полягають у тому, щоб розробляти класи таким чином, щоб нові функції з'являлися у вигляді розширень (нових підкласів, нових впроваджених об'єктів), а не як каскад... if/elif про певні типи, які змушують вас редагувати один і той самий модуль щоразу, коли ви щось додаєте.
Класичним прикладом є погано розроблений калькулятор площі, який перевіряє певні типи:
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")
Цей дизайн порушує OCP, оскільки кожна нова геометрична фігура передбачає дотик AreaCalculator і поставте ще один elifМодуль більше не є "закритим" для модифікацій.
Застосовуючи OCP, ми переносимо логіку області до кожної фігури та працюємо зі спільною абстракцією:
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()
Якщо вам знадобляться трикутники завтра, вам просто потрібно буде створити ще один підклас Shape що реалізує area(), не торкаючись калькулятора:
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
Цю ідею також можна застосувати до прикладу качок та комунікації. Уявіть, що ви хочете використовувати різні типи розмови, не змінюючи базовий комунікативний клас. Ви можете ввести абстракція розмови і щоб комунікатор виконав це автоматично:
from typing import final
class AbstractConversation:
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:
s1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
s2 = 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):
print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")
Метод communicate залишається Закрито для змін і нові форми розмови додаються шляхом створення більшої кількості підкласів AbstractConversationВам не потрібно щоразу повертатися до занять з комунікації, коли ви хочете почути інший діалог.
L – Принцип заміщення Ліскова (LSP) та добре зрозуміле успадкування
Принцип підстановки Ліскова стверджує, що підклас повинен мати можливість безперешкодно замінити ваш суперклас будь-де, де використовується останній, без зміни очікуваної поведінки програми.
Формально, контракт базового класу (типи повернення, передумови, постумови, інваріанти) має продовжувати виконуватися в похідних класах. Якщо підклас порушує цей контракт, наприклад, викидаючи неочікувані винятки або надмірно обмежуючи можливості виконання, код, що залежить від базового класу, почне непомітно давати збої.
Типовим прикладом порушення LSP є наївне моделювання літаючих та нелітаючих птахів:
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 pueden volar
raise NotImplementedError("Las avestruces no pueden volar")
Будь-яка функція, яка отримує Bird і дзвонити fly() сподіваючись, що це спрацює, воно вийде з ладу, якщо з ним щось трапиться. OstrichПідклас більше не є дійсна заміна від твого батька, тож ти порушив LSP.
Правильний спосіб моделювання полягає у введенні проміжна абстракція Для птахів, які літають:
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 implementa fly() porque no vuela
pass
Тепер будь-яка функція, яка працює з FlyingBird знає, що може зателефонувати fly() без несподіванок, тоді як функція, яка приймає Bird generic не повинен вважати, що всі літають.
Щось подібне відбувається і з класичними прикладами, такими як прямокутник і квадратНа математичному рівні квадрат є прямокутником, але на рівні об'єктно-орієнтованого проектування таке успадкування часто створює проблеми, оскільки обмеження квадрата (всі сторони рівні) змушують методи встановлення висоти та ширини поводитися інакше, ніж очікується кодом, який знає лише прямокутники.
Поширена стратегія в цих випадках полягає Уникайте нав'язування спадкових відносин лише тому, що вони «здаються» природними У реальному світі, в багатьох областях, краще мати окремі класи без прямого успадкування та, за необхідності, більш загальну спільну абстракцію.
I – Принцип розділення інтерфейсів (ISP) та малі інтерфейси

Принцип розділення інтерфейсів стверджує, що Клієнта не слід змушувати покладатися на методи, якими він не користується.Іншими словами: краще мати кілька маленьких, специфічних інтерфейсів, ніж один великий, універсальний інтерфейс, повний методів, які деяким класам не потрібні.
У Python, хоча у вас немає інтерфейсів, як у Java, ви можете використовувати абстрактні класи (ABC) або протоколи (з typing.Protocol) для досягнення того ж ефекту: опишіть мінімальні можливості, які кожна реалізація фактично використовуватиме.
Спочатку розглянемо приклад, який порушує правила інтернет-провайдера з надмірно широким інтерфейсом:
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:
# Los robots no comen, pero se ven obligados a implementar este método
pass
клас Robot Це залежить від методу не має відношення до його природиХоча ми залишили це поле порожнім, у складніших конструкціях це призводить до винятків, несумісних станів або хибної логіки лише для дотримання інтерфейсу.
Версія, зручна для інтернет-провайдерів, розділяє можливості на менші інтерфейси, які потім об'єднуються за потреби:
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() y swim()Ви можете розділити ієрархію:
from abc import ABC, abstractmethod
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):
pass
class SwimmingBird(Bird):
@abstractmethod
def swim(self):
pass
class Crow(FlyingBird):
def fly(self):
print(f"{self.name} is flying high and fast!")
def do_sound(self) -> str:
return "Caw"
class Duck(SwimmingBird, FlyingBird):
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
Якби ви хотіли змоделювати пінгвіна, вам просто потрібно було б розширити SwimmingBird без впровадження fly(), чому Цей інтерфейс більше не нав'язується штучно..
D – Принцип інверсії залежностей (DIP) та залежність абстракцій
Принцип інверсії залежностей можна підсумувати двома реченнями: модулі високого рівня не повинні залежати від модулів низького рівня; обидва повинні залежати від абстракційІ абстракції не повинні залежати від деталей, а деталі повинні залежати від абстракцій.
На практиці це означає, що ваша (високорівнева) бізнес-логіка не повинна бути безпосередньо пов'язана з конкретними реалізаціями баз даних, зовнішніх API, файлових систем тощо. Натомість визначте інтерфейси (або абстрактні класи), які описують ваші потреби, і зробіть конкретні реалізації залежними від цих абстракцій.
Типовим прикладом порушення DIP є репозиторій, який безпосередньо створює екземпляр своєї конкретної бази даних:
class MySQLDatabase:
def connect(self) -> None:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
# Ejecutar consulta
return []
class UserRepository:
def __init__(self):
self.database = MySQLDatabase() # Dependencia directa
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
Ось репозиторій. прив'язаний до MySQLЯкщо ви хочете перейти на PostgreSQL або використовувати базу даних в оперативній пам'яті для тестування, вам доведеться змінювати продакшн-код у репозиторії, що суперечить OCP та робить систему менш гнучкою.
Застосовуючи DIP, ми вводимо абстракцію Database і ми змушуємо MySQL та PostgreSQL реалізовувати це. Репозиторій тоді залежатиме виключно від цієї абстракції:
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:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
return []
class PostgreSQLDatabase(Database):
def connect(self) -> None:
# Conectar a PostgreSQL
pass
def query(self, sql: str) -> list:
return []
class UserRepository:
def __init__(self, database: Database):
self.database = database # Depende de una abstracción
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
Такий спосіб побудови об'єктів, де Ви вводите залежності ззовні (конструктор, сеттер або параметр методу), відоме як ін'єкція залежностей і є звичайним способом реалізації DIP у чистих архітектурах.
Такий самий підхід можна застосувати до інших контекстів, таких як канал зв'язку наших птахів. Замість того, щоб певний комунікатор створював власне повідомлення внутрішньо, SMSChannelВи можете зробити будь-який комунікатор залежним від абстракції каналу:
from abc import ABC
from typing import final
class AbstractChannel(ABC):
def get_channel_message(self) -> str:
pass
class AbstractCommunicator(ABC):
def get_channel(self) -> AbstractChannel:
pass
@final
def communicate(self, conversation: AbstractConversation):
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):
self._channel = SMSChannel()
def get_channel(self) -> AbstractChannel:
return self._channel
У вас все ще є прямий зв'язок між SMSCommunicator y SMSChannelЩоб повністю дотримуватися DIP, ви можете створити простий комунікатор, який отримує канал як зовнішню залежність:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel):
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
Таким чином, будь-яке нове впровадження AbstractChannel (електронна пошта, push-повідомлення, внутрішні сповіщення…) можна використовувати без зміни комунікатора. Як високорівневі, так і низькорівневі модулі залежать від абстракцій., як зазначено в принципі.
Коли має сенс зробити перерву або розслабитися?
Хоча ми зобразили тут принципи SOLID як своєрідний компас для об'єктно-орієнтованого проектування, важливо розуміти, що вони є путівники, а не незмінні догмиУ невеликих скриптах, швидких прототипах або простих завданнях автоматизації на Python застосування всіх цих шарів може бути схожим на використання гармати для знищення мух.
Де вони справді сяють, так це в великі, довготривалі або тісно пов'язані з співпрацею проектиМікросервіси з кількома командами, складні платформи даних, серверні частини, що розвиватимуться протягом багатьох років, або програмне забезпечення, де якість та безпека є критично важливими — це ситуації, коли початкові зусилля з ретельного розгляду абстракцій, розподілу відповідальності, проектування з урахуванням розширюваності та інвертування залежностей справді окупаються.
Зрештою, інтернаціоналізація SOLID дає вам дуже корисний словник для міркувань про дизайн та для виявлення ранніх ознак коду: класи, які роблять усе, роздуті інтерфейси, погано сплановані ієрархії успадкування або високорівневі модулі, приклеєні до деталей інфраструктури.
Якщо ви будете використовувати ці принципи розумно та зі здоровим глуздом, ваш код на Python буде чистішим, професійнішим та легшим у підтримці, навіть коли проєкт зростатиме, а люди, які над ним працюють, змінюватимуться. Поділіться цим посібником з програмування на Python, щоб інші користувачі могли дізнатися про принципи SOLID.