Введение
Открываешь чужой код на Python, а там — Java. Абстрактные базовые классы в местах, где хватило бы простой функции, фабрики фабрик и нагромождение паттернов, усложняющих чтение бизнес-логики. Это классическая проблема: многие разработчики приходят из C# или Java и механически переносят свои архитектурные привычки в новую среду. То, что в строго типизированных языках было технической необходимостью, в Python часто превращается в переусложненный и совершенно неидиоматичный код.
От автора: Если вы только начинаете погружаться в объектно-ориентированный дизайн и хотите заложить правильный фундамент, рекомендую пройти мой бесплатный курс ООП Python: Часть 1 на Stepik. Он поможет разобраться с базовыми концепциями без лишней воды, прежде чем переходить к сложной архитектуре.
Наша цель — адаптировать строгие академические правила под реалии Python. Мы не будем сбрасывать SOLID со счетов, но переведем его на язык динамической типизации и функций первого класса (Pythonic way).
Главный тезис, который стоит держать в голове: SOLID — это компас, а не смирительная рубашка. Дальше мы на конкретных примерах разберем, в каких ситуациях эти пять принципов действительно спасают архитектуру проекта, а где слепое следование книжным догмам скатывается в карго-культ и только вредит поддержке кода.
1. S: Single Responsibility Principle (Принцип единственной ответственности)
Формальное определение гласит: у класса должна быть только одна причина для изменения. На практике это значит, что компонент должен решать ровно одну задачу.
В Python разработчики часто нарушают этот принцип, когда пытаются засунуть весь процесс в один класс, создавая типичный God Object. Давайте посмотрим на код, который регулярно встречается в продакшене.
Как не надо (Антипаттерн):
class ReportGenerator: def __init__(self, db_connection): self.db = db_connection def generate_and_save(self, user_id: int, filepath: str): # 1. Извлечение данных (работа с БД) query = f"SELECT price FROM sales WHERE user_id = {user_id}" raw_data = self.db.execute(query) # 2. Бизнес-логика (вычисления) total_sales = sum(row['price'] for row in raw_data) # 3. Форматирование и экспорт (ввод-вывод) with open(filepath, 'w') as f: f.write(f"User ID,Total Sales\n{user_id},{total_sales}\n")
У этого класса три причины для изменения:
-
Мы перешли с SQL на HTTP API — меняем метод.
-
Изменилась формула подсчета суммы (например, добавились налоги) — меняем метод.
-
Бизнес попросил выгрузку в JSON вместо CSV — снова лезем в этот же класс.
Pythonic way: Меньше классов, больше функций
Разработчики с бэкграундом в Java в ответ на проблему выше создали бы три новых класса: DataFetcher, SalesCalculator и CsvExporter. В Python это лишнее. Нам не нужно плодить классы-обертки для действий (глаголов).
Идиоматичный подход — использовать функции для логики, dataclasses для данных, а модули (файлы .py) — как естественные границы пространства имен.
Как надо:
from dataclasses import dataclassfrom typing import List# Структуры данных изолированы@dataclassclass SaleItem: price: float@dataclassclass ReportData: user_id: int total_sales: float# 1. Слой данных (может жить в модуле db.py)def fetch_user_sales(db_connection, user_id: int) -> List[SaleItem]: raw_data = db_connection.execute(...) return [SaleItem(price=row['price']) for row in raw_data]# 2. Слой бизнес-логики (модуль analytics.py)def calculate_sales_report(user_id: int, sales: List[SaleItem]) -> ReportData: total = sum(item.price for item in sales) return ReportData(user_id=user_id, total_sales=total)# 3. Слой представления (модуль export.py)def save_report_to_csv(report: ReportData, filepath: str) -> None: with open(filepath, 'w') as f: f.write(f"User ID,Total Sales\n{report.user_id},{report.total_sales}\n")
Что мы получили: Мы разнесли ответственность по независимым функциям. Если нужно добавить экспорт в JSON, мы просто напишем новую функцию save_report_to_json, не трогая логику расчетов или запросов к базе. Код стал легко тестировать: в функцию расчета можно передать мок-данные из списка, вообще не поднимая тестовую БД. И мы не написали ни одного лишнего класса с методами execute().
2. O: Open/Closed Principle (Принцип открытости/закрытости)
Код должен быть открыт для расширения, но закрыт для модификации. Если бизнес просит добавить новую фичу, вы должны писать новый код, а не переписывать и ломать старый, уже оттестированный.
Как не надо (Антипаттерн):
Самое частое нарушение OCP в процедурном стиле — это бесконечные простыни if/elif. Допустим, у нас есть функция расчета итоговой цены с учетом скидки:
def calculate_total(price: float, client_type: str) -> float: if client_type == "regular": return price elif client_type == "student": return price * 0.9 elif client_type == "vip": return price * 0.8 # Завтра маркетинг придумает "black_friday", и нам придется снова лезть сюда else: raise ValueError("Unknown client type")
Каждый раз, когда появляется новый тип скидки, разработчик вынужден открывать эту базовую функцию и дописывать очередной elif. Это прямой путь к конфликтам при слиянии веток (merge conflicts) и багам в ядре системы.
Pythonic way: Функции высшего порядка вместо иерархии классов
В классическом ООП для решения этой проблемы применяют паттерн «Стратегия»: создают базовый интерфейс DiscountStrategy и от него наследуют классы VipDiscount, StudentDiscount и так далее.
В Python функции являются объектами первого класса (first-class citizens). Нам не нужно выстраивать тяжелую иерархию классов, чтобы передать кусок логики. Мы можем просто передавать одну функцию в качестве аргумента для другой.
Как надо:
from typing import Callable# Определяем сигнатуру нашей "стратегии" для тайп-хинтингаDiscountStrategy = Callable[[float], float]# Расширяем функционал, просто добавляя новые функции (код открыт для расширения)def regular_discount(price: float) -> float: return pricedef student_discount(price: float) -> float: return price * 0.9def vip_discount(price: float) -> float: return price * 0.8# Ядро системы (код закрыт для модификации)def calculate_total(price: float, apply_discount: DiscountStrategy = regular_discount) -> float: return apply_discount(price)# Использованиеfinal_price = calculate_total(1000.0, apply_discount=vip_discount)
Что мы получили: Если завтра появится скидка black_friday, ядро системы (calculate_total) вообще не изменится. Мы напишем новую короткую функцию и будем передавать её там, где это нужно. Базовый код остается закрытым для изменений, но мы можем расширять его поведение как угодно.
Бонус: Декораторы как идеальное воплощение OCP
Еще один мощный инструмент Python для соблюдения OCP — декораторы. Если вам нужно добавить логирование, кэширование или проверку прав доступа к существующей функции, вам не нужно менять её исходный код. Вы просто оборачиваете её:
def log_execution(func): def wrapper(*args, **kwargs): print(f"Вызов функции {func.__name__} с аргументами {args}") return func(*args, **kwargs) return wrapper@log_executiondef process_payment(amount: float): ...
3. L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Формально: функции, использующие базовый тип, должны иметь возможность использовать его подтипы, не зная об этом. На практике это означает, что наследующий класс обязан строго соблюдать «контракт» базового класса. Он не должен менять типы входящих аргументов, тип возвращаемого значения или выбрасывать непредусмотренные исключения (например, всеми любимую заглушку raise NotImplementedError).
Специфика Python: В статически типизированных языках компилятор бьет по рукам за нарушение LSP. В Python правит утиная типизация: интерпретатору абсолютно всё равно, что вы вернете из переопределенного метода — словарь, список или объект сессии базы данных. Из-за этого нарушения LSP в Python выстреливают не на этапе сборки, а прямо в рантайме в виде падающих исключений.
Как не надо (Антипаттерн):
Часто разработчики наследуются от класса просто ради переиспользования куска логики, забывая про то, что они ломают интерфейс для клиентского кода.
class ConfigParser: def parse(self, raw_data: str) -> dict: # Парсит строку и возвращает словарь return {"status": "ok", "data": raw_data}class LegacyConfigParser(ConfigParser): def parse(self, raw_data: str) -> list: # В старой системе почему-то отдавали список. # Мы переопределили метод, но сломали контракт (возвращаем list вместо dict). return [raw_data]def process_configuration(parser: ConfigParser, data: str): result = parser.parse(data) # Клиентский код ждет словарь. Если передать LegacyConfigParser, # приложение упадет с AttributeError: 'list' object has no attribute 'get' print(result.get("status"))
Здесь класс-наследник сломал ожидаемое поведение. Функция process_configuration не может безопасно работать с подтипом LegacyConfigParser, хотя наследование обещает, что может.
Pythonic way: abc и статический анализ (mypy)
Так как интерпретатор нас не защитит, мы обязаны переложить эту работу на модуль typing и статические анализаторы. Чтобы зафиксировать контракт, идиоматично применять абстрактные базовые классы (abc.ABC).
Как надо:
from abc import ABC, abstractmethodfrom typing import Dict, Any# Фиксируем жесткий контракт с помощью абстрактного класса и аннотаций типовclass BaseConfigParser(ABC): @abstractmethod def parse(self, raw_data: str) -> Dict[str, Any]: """Парсит данные и всегда возвращает словарь.""" passclass JSONConfigParser(BaseConfigParser): def parse(self, raw_data: str) -> Dict[str, Any]: # Реализация строго следует контракту return {"status": "ok", "data": raw_data}class XMLConfigParser(BaseConfigParser): def parse(self, raw_data: str) -> Dict[str, Any]: # Даже если внутри сложный парсинг XML, наружу мы обязаны отдать словарь return {"status": "parsed", "data": raw_data}def process_configuration(parser: BaseConfigParser, data: str): result = parser.parse(data) print(result.get("status"))
Что мы получили: Мы создали предсказуемую систему. Теперь, если какой-то разработчик попытается написать класс YamlParser и вернуть из него список, статический анализатор (например, mypy) подсветит ошибку еще до запуска кода, сообщив о несовпадении типов (Incompatible return value type). Клиентский код больше не нужно обвешивать параноидальными проверками isinstance и блоками try/except на случай, если наследник поведет себя неадекватно.
4. I: Interface Segregation Principle (Принцип разделения интерфейса)
Суть принципа проста: клиенты не должны зависеть от методов, которые они не используют. Если у вас есть один огромный интерфейс, его нужно раздробить на несколько мелких и узкоспециализированных.
Как не надо (Антипаттерн):
В Python нет ключевого слова interface. Исторически разработчики имитировали интерфейсы через абстрактные классы (abc.ABC). Проблема начинается, когда создается один «толстый» базовый класс на все случаи жизни.
Допустим, у нас есть абстрактный UserStorage.
from abc import ABC, abstractmethodclass UserStorage(ABC): @abstractmethod def get_user(self, user_id: int) -> dict: pass @abstractmethod def save_user(self, user: dict) -> None: pass @abstractmethod def delete_user(self, user_id: int) -> None: pass
Кажется, всё логично. Но что, если мы хотим добавить слой кэширования на чтение (например, через Redis), который вообще не должен уметь сохранять или удалять пользователей напрямую?
class ReadOnlyUserCache(UserStorage): def get_user(self, user_id: int) -> dict: # Логика извлечения из кэша return {"id": user_id, "name": "Cached John"} def save_user(self, user: dict) -> None: # Нам приходится писать заглушки или кидать исключения raise NotImplementedError("Кэш только для чтения") def delete_user(self, user_id: int) -> None: raise NotImplementedError("Кэш только для чтения")
Это прямое нарушение: мы заставили ReadOnlyUserCache реализовать методы, которые ему не нужны, просто чтобы удовлетворить контракту родителя.
**Pythonic way: Структурная типизация и typing.Protocol**
Начиная с Python 3.8, у нас есть идеальный инструмент для разделения интерфейсов — typing.Protocol. Он реализует структурную типизацию (duck typing с проверкой типов). Нам больше не нужно жестко наследоваться от базового класса, чтобы доказать, что наш объект подходит.
Как надо:
Мы дробим наш толстый интерфейс на два маленьких протокола: один для чтения, другой для записи.
from typing import Protocol# 1. Определяем узконаправленные интерфейсы (протоколы)class UserReader(Protocol): def get_user(self, user_id: int) -> dict: ...class UserWriter(Protocol): def save_user(self, user: dict) -> None: ... def delete_user(self, user_id: int) -> None: ...# 2. Конкретные реализации просто делают свою работу. # Им не нужно наследоваться от протоколов!class DatabaseStorage: def get_user(self, user_id: int) -> dict: return {"id": user_id, "name": "John"} def save_user(self, user: dict) -> None: print("Сохранено в БД") def delete_user(self, user_id: int) -> None: print("Удалено из БД")class ReadOnlyCache: def get_user(self, user_id: int) -> dict: return {"id": user_id, "name": "Cached John"}# 3. Клиентский код просит ровно то, что ему нужноdef send_marketing_email(user_id: int, storage: UserReader): # Этой функции нужно только чтение. Мы указываем зависимость от UserReader. user = storage.get_user(user_id) print(f"Отправляем email пользователю {user['name']}")
Что мы получили: Теперь мы можем передать в send_marketing_email и полноценную БД (DatabaseStorage), и легковесный кэш (ReadOnlyCache). Статический анализатор (mypy) будет доволен, так как оба класса имеют метод get_user с нужной сигнатурой. Мы избавились от NotImplementedError, мертвых методов и жесткой иерархии наследования. Код стал модульным и абсолютно безопасным.
5. D: Dependency Inversion Principle (Принцип инверсии зависимостей)
Суть принципа: бизнес-логика (верхний уровень) не должна зависеть от инфраструктуры (нижнего уровня — базы данных, внешние API, файловая система). Оба уровня должны зависеть от абстракций.
Как не надо (Антипаттерн):
Самая частая ошибка — жесткое создание конкретных объектов прямо внутри функций или методов бизнес-логики.
import sqlite3class OrderProcessor: def __init__(self): # Жесткая привязка к конкретной БД и библиотеке self.db = sqlite3.connect("production.db") def process(self, order_id: int): # Бизнес-логика намертво склеена с SQL-запросами data = self.db.execute(f"SELECT * FROM orders WHERE id={order_id}") # ... обработка заказа
Этот код невозможно нормально протестировать. Чтобы написать unit-тест для метода process, вам придется создавать реальный файл базы данных на диске. Вы прибиты гвоздями к sqlite3. Если завтра архитектура изменится и заказы нужно будет тянуть из микросервиса по HTTP, придется переписывать класс OrderProcessor, хотя сами правила обработки заказа остались прежними.
Pythonic way: Внедрение зависимостей через аргументы
В строго типизированных языках для решения этой проблемы строят громоздкие DI-контейнеры. В Python всё гораздо проще. Благодаря утиной типизации и typing.Protocol, зачастую достаточно самого простого варианта внедрения зависимостей — передачи готового объекта через параметры. Это иногда называют «DI для бедных» (Poor man’s DI), но в реалиях Python это самый читаемый и надежный подход.
Как надо:
from typing import Protocol# Абстракция, от которой будут зависеть оба уровняclass OrderStorage(Protocol): def get_order(self, order_id: int) -> dict: ...# Бизнес-логика ничего не знает про SQL, Postgres или HTTP.# Она просто принимает объект, который умеет отдавать заказы.class OrderProcessor: def __init__(self, storage: OrderStorage): self.storage = storage def process(self, order_id: int): data = self.storage.get_order(order_id) # ... обработка заказа
Теперь в боевом коде вы передаете в конструктор инстанс реальной БД (processor = OrderProcessor(PostgresStorage())), а в тестах — простой класс-заглушку, который возвращает захардкоженный словарь. И всё это работает без единого стороннего фреймворка.
Когда нужны фреймворки?
Ручное внедрение работает отлично, пока проект не разрастается до десятков компонентов, которые нужно прокидывать друг в друга при старте приложения. Когда передача аргументов по цепочке становится головной болью, в дело вступают специализированные инструменты.
Например, библиотека dependency-injector позволяет вынести сборку зависимостей в отдельный слой конфигурации. А фреймворк FastAPI со своей системой Depends вообще сделал внедрение зависимостей стандартом де-факто для веб-разработки на Python, позволив элегантно связывать эндпоинты с базой данных или проверкой авторизации.
Где фанатизм ведет к катастрофе (Антипаттерны “Энтерпрайз Python”)
SOLID — это отличный инструмент, но если применять его без оглядки на специфику Python, можно превратить проект в нечитаемое месиво. Вот три признака того, что вы свернули не туда:
-
Over-engineering в простых скриптах. Если ваш скрипт на 200 строк занимается парсингом одного сайта по крону, ему не нужны абстрактные фабрики и протоколы. Не усложняйте то, что должно быть простым.
-
Игнорирование встроенных структур данных. Начинающие «архитекторы» любят создавать классы
UserDataCollectionтам, где отлично справился бы обычный встроенныйdictили список. Python проектировался так, чтобы базовые структуры данных решали 90% задач. -
Нарушение YAGNI (You Aren’t Gonna Need It). Закладывание абстракций под изменения, которые может быть когда-нибудь произойдут. «Давайте сделаем интерфейс для смены СУБД, вдруг мы уйдем с Postgres на MongoDB» — спойлер: вы не уйдете, а поддерживать мертвый абстрактный слой придется годами.
Заключение
SOLID в Python работает. Но он требует адаптации. Нам не нужно плодить классы-пустышки, потому что в Python есть функции. Нам не нужны тяжелые интерфейсы, потому что есть протоколы и утиная типизация.
Принципы SOLID должны помогать читать и изменять код, а не заставлять разработчика продираться через пять файлов, чтобы понять, где именно выполняется один SELECT к базе данных. Используйте здравый смысл, придерживайтесь PEP 20 (Zen of Python), и тогда SOLID станет вашим союзником, а не бюрократической преградой.
Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram‑сообществе. Смело заходите, если что‑то пойдет не так, — постараемся разобраться вместе.
ссылка на оригинал статьи https://habr.com/ru/articles/1045294/