Фундамент чистого кода: пять принципов SOLID, DRY, KISS, YAGNI. Как писать поддерживаемый и расширяемый код на Python.
Чистый код — это не роскошь, а необходимость для поддерживаемой системы
Представьте: вы добавляете новую фичу, и ломается код в трёх других местах. Вы исправляете одно — появляется другое. Знакомо? Это признак нарушения принципов проектирования.
SOLID — это пять принципов, которые помогают писать код, который:
Принцип единственной ответственности: класс должен иметь только одну причину для изменения.
class User:
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
def save(self, db_connection):
"""Сохраняет пользователя в БД"""
db_connection.execute(
"INSERT INTO users VALUES (?, ?, ?)",
(self.id, self.name, self.email)
)
def send_welcome_email(self, smtp_server):
"""Отправляет приветственное письмо"""
message = f"Welcome, {self.name}!"
smtp_server.send(self.email, message)
def validate_email(self):
"""Проверяет корректность email"""
return "@" in self.email and "." in self.emailПроблема: класс User отвечает за:
Изменение логики БД потребует изменения класса User, даже если бизнес-логика не менялась.
class User:
"""Модль данных — только хранение"""
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
class UserRepository:
"""Работа с БД — единственная ответственность"""
def __init__(self, db_connection):
self._db = db_connection
def save(self, user: User):
self._db.execute(
"INSERT INTO users VALUES (?, ?, ?)",
(user.id, user.name, user.email)
)
class EmailService:
"""Отправка email — единственная ответственность"""
def __init__(self, smtp_server):
self._smtp = smtp_server
def send_welcome(self, user: User):
message = f"Welcome, {user.name}!"
self._smtp.send(user.email, message)
class EmailValidator:
"""Валидация — единственная ответственность"""
@staticmethod
def is_valid(email: str) -> bool:
return "@" in email and "." in emailВыгоды:
Принцип открытости/закрытости: сущности должны быть открыты для расширения, но закрыты для модификации.
class PaymentProcessor:
def process(self, payment_type: str, amount: float):
if payment_type == "credit_card":
self._process_credit_card(amount)
elif payment_type == "paypal":
self._process_paypal(amount)
elif payment_type == "crypto":
self._process_crypto(amount)
# При добавлении нового типа придётся менять этот метод
else:
raise ValueError(f"Unknown payment type: {payment_type}")
def _process_credit_card(self, amount): ...
def _process_paypal(self, amount): ...
def _process_crypto(self, amount): ...Проблема: каждый новый тип оплаты требует изменения существующего кода → риск сломать работающее.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
"""Абстракция — открыта для расширения"""
@abstractmethod
def process(self, amount: float) -> bool:
pass
class CreditCardPayment(PaymentMethod):
def __init__(self, card_number, cvv):
self.card_number = card_number
self.cvv = cvv
def process(self, amount: float) -> bool:
# Логика обработки кредитной карты
return True
class PayPalPayment(PaymentMethod):
def __init__(self, email):
self.email = email
def process(self, amount: float) -> bool:
# Логика PayPal
return True
class CryptoPayment(PaymentMethod):
def __init__(self, wallet_address):
self.wallet_address = wallet_address
def process(self, amount: float) -> bool:
# Логика криптовалюты
return True
class PaymentProcessor:
"""Закрыт для модификации — работает с абстракцией"""
def process(self, method: PaymentMethod, amount: float) -> bool:
return method.process(amount)Выгоды:
Принцип подстановки Барбары Лисков: объекты дочерних классов должны быть заменяемы объектами родительского класса без нарушения корректности программы.
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
def area(self):
return self._width * self._height
class Square(Rectangle):
"""Нарушает LSP: меняет поведение setter'ов"""
@Rectangle.width.setter
def width(self, value):
self._width = value
self._height = value # !
@Rectangle.height.setter
def height(self, value):
self._width = value # !
self._height = value
# Контракт: функция ожидает, что width и height независимы
def resize(rectangle: Rectangle):
original_area = rectangle.area()
rectangle.width *= 2
rectangle.height *= 2
assert rectangle.area() == original_area * 4
# Для Rectangle: area увеличится в 4 раза ✓
# Для Square: width=2s → height=2*(2s)=4s → area=16s² ≠ 4s² ✗class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width, height):
self._width = width
self._height = height
def area(self) -> float:
return self._width * self._height
class Square(Shape):
def __init__(self, side):
self._side = side
def area(self) -> float:
return self._side ** 2
# Теперь client code работает корректно с любыми Shape
def print_area(shape: Shape):
print(f"Area: {shape.area()}")Выгоды:
Принцип разделения интерфейса: клиенты не должны зависеть от методов, которые они не используют.
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def sleep(self):
pass
class HumanWorker(Worker):
def work(self): ...
def eat(self): ...
def sleep(self): ...
class RobotWorker(Worker):
def work(self): ...
def eat(self):
raise NotImplementedError("Robots don't eat!") # !
def sleep(self):
raise NotImplementedError("Robots don't sleep!") # !Проблема: RobotWorker вынужден реализовывать ненужные методы.
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Eatable(ABC):
@abstractmethod
def eat(self):
pass
class Sleepable(ABC):
@abstractmethod
def sleep(self):
pass
class HumanWorker(Workable, Eatable, Sleepable):
def work(self): ...
def eat(self): ...
def sleep(self): ...
class RobotWorker(Workable):
def work(self): ...
# Только нужные интерфейсыВыгоды:
В Python есть три способа определить контракт. Выбор зависит от ситуации:
ABC — когда нужен runtime-контракт (наследование обязательно) или общая реализация:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def area(self) -> float:
return 3.14 * self._r ** 2Protocol — структурная типизация (наследование не нужно, достаточно совпадения методов):
from typing import Protocol
class Repository(Protocol):
def get(self, id: int) -> dict | None: ...
# Любой класс с методом get(id) -> dict подходит — без явного наследования
class PostgresRepo:
def get(self, id: int) -> dict | None:
...Duck typing — контракт не объявляется, проверяется только в рантайме:
def total_area(shapes):
return sum(s.area() for s in shapes) # Любой объект с методом area()
total_area([Circle(5), Rectangle(3, 4)]) # ✓
total_area([NotAShape()]) # AttributeError — ошибка только в рантаймеКогда что использовать:
Принцип инверсии зависимостей: модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
class MySQLDatabase:
def connect(self): ...
def execute(self, query, params=None): ...
class UserService:
def __init__(self):
self._db = MySQLDatabase() # Прямая зависимость от конкретики
def get_user(self, user_id):
# 1. Зависимость от конкретной БД — нельзя заменить на PostgreSQL
# 2. Нет возможности подменить для тестов
return self._db.execute(
"SELECT * FROM users WHERE id = %s", (user_id,)
)Проблема:
from typing import Protocol
class UserRepository(Protocol):
"""Интерфейс — порт, через который UserService работает с данными"""
def get_by_id(self, user_id: int) -> dict | None: ...
class UserService:
def __init__(self, users: UserRepository): # Зависимость от абстракции
self._users = users
def get_user(self, user_id: int) -> dict | None:
return self._users.get_by_id(user_id)
# Реализации — вставляются снаружи
class MySQLUserRepository:
def __init__(self, connection):
self._conn = connection
def get_by_id(self, user_id: int) -> dict | None:
cursor = self._conn.execute(
"SELECT * FROM users WHERE id = %s", (user_id,)
)
return cursor.fetchone()
class InMemoryUserRepository:
"""Для тестов"""
def __init__(self, data: dict[int, dict] | None = None):
self._data = data or {}
def get_by_id(self, user_id: int) -> dict | None:
return self._data.get(user_id)Dependency Injection (внедрение зависимости):
# В production — конкретная реализация
repo = MySQLUserRepository(connection)
service = UserService(repo)
# В тестах — фейковая реализация
fake_repo = InMemoryUserRepository({1: {"id": 1, "name": "Alice"}})
service = UserService(fake_repo)
assert service.get_user(1) == {"id": 1, "name": "Alice"}Выгоды:
DIP vs DI: Dependency Inversion — это принцип (зависеть от абстракций), а Dependency Injection — техника (передавать зависимости снаружи). DI — один из способов применить DIP, но не единственный (фабрики, service locator, модули).
Когда не применять: если зависимость только от одной конкретной реализации и она не будет меняться (например, утилита-хелпер), premature abstraction добавит косвенность без выгоды.
Каждое знание должно иметь единственное, непротиворечивое представление в системе. Это не запрет на любые одинаковые строки — иногда два похожих куска кода относятся к разным бизнес-правилам и не должны объединяться. DRY про источник знания, а не про copy-paste.
# Плохо: критерий фильтрации дублируется — при изменении правила нужно менять два места
def get_active_users():
return User.objects.filter(status='active', is_deleted=False)
def count_active_users():
return User.objects.filter(status='active', is_deleted=False).count()
# Хорошо
ACTIVE_USER_FILTER = {'status': 'active', 'is_deleted': False}
def get_active_users():
return User.objects.filter(**ACTIVE_USER_FILTER)
def count_active_users():
return User.objects.filter(**ACTIVE_USER_FILTER).count()Избегайте излишней сложности.
# Слишком умно
result = functools.reduce(lambda acc, x: acc + x['value'], filter(lambda x: x['active'], items), 0)
# Просто и понятно
total = sum(item['value'] for item in items if item['active'])Не добавляйте функциональность, пока она не понадобится.
# Не надо заранее
class UserService:
def get_user(self, user_id): ...
def get_user_cached(self, user_id): ... # Пока не нужно
def get_user_from_replica(self, user_id): ... # Пока не нужноДан класс ReportGenerator:
class ReportGenerator:
def generate_pdf_report(self, data, output_path):
pass
def generate_csv_report(self, data, output_path):
pass
def send_email(self, to, report_path):
pass
def save_to_database(self, data):
pass
def validate_data(self, data):
passЗадание: разделите на классы с единственной ответственностью. Каждый класс — одна причина для изменения.
Критерии: ReportGenerator не отправляет email, не сохраняет в БД и не валидирует. Валидация, генерация, хранение и отправка — отдельные компоненты.
Расширьте решение:
Критерии: ReportService не знает о конкретных форматах. Тест проходит без внешних сервисов.
Добавьте требования:
Определите:
Критерии: новый формат добавляется без изменения существующих классов. Оркестратор не знает ни о форматах, ни о способах доставки.
class DataValidator:
def validate(self, data: dict) -> bool:
return bool(data)
class ReportFormatter:
def generate(self, data: dict, fmt: str, output_path: str) -> str:
if fmt == "pdf":
return self._generate_pdf(data, output_path)
elif fmt == "csv":
return self._generate_csv(data, output_path)
raise ValueError(f"Unknown format: {fmt}")
class ReportRepository:
def save(self, report_path: str, data: dict) -> None: ...
class NotificationService:
def send(self, to: str, report_path: str) -> None: ...
class ReportService:
def __init__(self, validator, formatter, repo, notifier):
self._validator = validator
self._formatter = formatter
self._repo = repo
self._notifier = notifier
def create_report(self, data, fmt, output_path, notify_to):
if not self._validator.validate(data):
raise ValueError("Invalid data")
path = self._formatter.generate(data, fmt, output_path)
self._repo.save(path, data)
if notify_to:
self._notifier.send(notify_to, path)Это один из вариантов. Альтернатива: вынести валидацию в middleware, использовать стратегии вместо if/elif. Разбор компромиссов — упражнение для читателя.
SOLID описывает принципы на уровне классов. Но архитектура системы — это про границы модулей и потоки данных. Следующий шаг:
Об этом — в разделе «Архитектурные паттерны».
Далее: Архитектурные паттерны