Как работать с паттернами: учимся обходить «грабли»
Топ ошибок, которые допускают новички, пытаясь писать чистый код
Хотя о чистом коде говорят часто, в разработке ПО паттерны проектирования не являются жестким стандартом. Более того, хотя паттерны решают массу проблем и задач — от дублирования кода до его нечитабельности, — рекомендации работают не всегда. Если слепо им доверять, можно получить результат, далекий от идеального.
В этой статье разбираем, какие ошибки при написании чистого кода и использовании паттернов новички совершают чаще всего.
Агрессивное использование паттернов
Шаблоны проектирования не всегда уместны, потому применять их стоит только тогда, когда они действительно необходимы. Принудительное (агрессивное) использование паттернов — это одна из самых частых ошибок в программировании. Чрезмерное задействование паттернов приводит к заметному усложнению кода — он становится избыточным.
Пример избыточности кода
В коде ниже используется паттерн проектирования «Фабричный метод», который позволяет создавать объекты без указания конкретного класса.
В этом примере класс AnimalFactory представляет фабрику для создания животных. Метод create_animal() принимает в качестве аргумента тип животного и возвращает объект этого типа
Использование паттерна «Фабричный метод» неуместно, потому что классы Dog и Cat не имеют сложной логики создания. В этом случае проще и понятнее создавать объекты напрямую, без использования фабрики, что и показано во второй части кода:
# Неуместное задействование фабричного метода для создания объекта
class AnimalFactory:
def create_animal(self, animal_type):
if animal_type == "Dog":
return Dog()
elif animal_type == "Cat":
return Cat()
# Также другие ветки для разных животных
# Правильный код: создание объектов напрямую без использования фабричного метода
dog = Dog()
cat = Cat()
Вот другой пример кода, больше приближенный к реальности. Здесь программист слишком часто использует паттерн Singleton.
Обычно он применяется, чтобы гарантировать, что в системе будет только один экземпляр определенного класса, и предоставляет глобальную точку доступа к этому экземпляру. Его основная цель — убедиться, что для определенного класса существует только один объект в системе, и обеспечить контроль доступа к этому объекту из разных частей программы.
class Database:
"""
Класс для доступа к базе данных.
"""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls, *args, **kwargs)
return cls._instance
def connect(self):
"""
Подключение к базе данных.
"""
...
def disconnect(self):
"""
Отключение от базы данных.
"""
...
class UserRepository:
"""
Класс для работы с пользователями.
"""
def __init__(self):
self.database = Database()
def get_user(self, id):
"""
Получение пользователя по ID.
"""
...
def save_user(self, user):
"""
Сохранение пользователя.
"""
...
class ProductRepository:
"""
Класс для работы с продуктами.
"""
def __init__(self):
self.database = Database()
def get_product(self, id):
"""
Получение продукта по ID.
"""
...
def save_product(self, product):
"""
Сохранение продукта.
"""
...
class OrderRepository:
"""
Класс для работы с заказами.
"""
def __init__(self):
self.database = Database()
def get_order(self, id):
"""
Получение заказа по ID.
"""
...
def save_order(self, order):
"""
Сохранение заказа.
"""
...
В этом коде паттерн Singleton переиспользуется в четырех классах Database, UserRepository, ProductRepository и OrderRepository. Это приводит к избыточности, так как код подключения к базе данных повторяется в каждом классе.
Чтобы избежать избыточности кода, можно переместить код подключения к базе данных в отдельный класс, например, DatabaseFactory. Затем можно использовать этот класс в качестве фабричного метода для создания экземпляров Database:
class DatabaseFactory:
"""
Фабричный метод для создания экземпляров Database.
"""
def create_database(self):
"""
Создание экземпляра Database.
"""
return Database()
class UserRepository:
"""
Класс для работы с пользователями.
"""
def __init__(self):
self.database = DatabaseFactory.create_database()
def get_user(self, id):
"""
Получение пользователя по ID.
"""
...
def save_user(self, user):
"""
Сохранение пользователя.
"""
...
class ProductRepository:
"""
Класс для работы с продуктами.
"""
def __init__(self):
self.database = DatabaseFactory.create_database()
def get_product(self, id):
"""
Получение продукта по ID.
"""
...
def save_product(self, product):
"""
Сохранение продукта.
"""
...
class OrderRepository:
"""
Класс для работы с заказами.
"""
def __init__(self):
self.database = DatabaseFactory.create_database()
def get_order(self, id):
"""
Получение заказа по ID.
"""
...
def save_order(self, order):
"""
Сохранение заказа.
"""
...
В этом варианте кода подключение к базе данных находится в одном месте, что делает его более понятным и компактным.
Обратите внимание: второй вариант соответствует сразу двум принципам написания «чистого кода» — принцип инверсии зависимостей из SOLID и DRY (Don't Repeat Yourself).
Паттерны без учета контекста проекта
Прибегая к паттерну, нужна учитывать суть проекта и его требования. Помните, что паттерны не являются целью сами по себе — это всего лишь инструменты для улучшения кода. В противном случае применение паттернов будет лишено смысла.
Пример: паттерн «Стратегия»
Предположим, вы работаете над приложением для онлайн-магазина и решаете использовать паттерн «Стратегия» для реализации скидок на товары. Этот паттерн позволяет определить семейство алгоритмов, инкапсулировать каждый из них и делать их взаимозаменяемыми.
Исходно, у вас есть два типа скидок: фиксированная скидка в долларах и процентная. Вы создаете два класса, представляющих каждый из этих типов:
class FixedDiscount:
def apply_discount(self, price, discount_amount):
return price — discount_amount
class PercentageDiscount:
def apply_discount(self, price, discount_percentage):
return price * (1 — discount_percentage / 100)
Затем вы создаете класс, представляющий товар, и используете паттерн «Стратегия» для применения скидки:
class Product:
def __init__(self, name, price, discount_strategy):
self.name = name
self.price = price
self.discount_strategy = discount_strategy
def apply_discount(self):
return self.discount_strategy.apply_discount(self.price, self.discount_amount)
Теперь у вас есть гибкая система скидок и вы можете применять разные скидки к разным товарам:
fixed_discount = FixedDiscount()
percentage_discount = PercentageDiscount()
product1 = Product("Widget", 100, fixed_discount)
product2 = Product("Gadget", 200, percentage_discount)
discounted_price1 = product1.apply_discount() # Используется фиксированная скидка
discounted_price2 = product2.apply_discount() # Используется процентная скидка
Но применение паттерна «Стратегия» имеет смысл только в том случае, если у вас есть реальная потребность в динамическом выборе алгоритма в зависимости от контекста. Если ваш магазин всегда использует только один тип скидок и никогда не меняет его, применение паттерна «Стратегия» может быть избыточным.
Использование антипаттернов
Антипаттерн — это шаблон проектирования, программирования или архитектурного решения, которое, несмотря на свою видимую эффективность, приводит к нежелательным и негативным последствиям в разработке программного обеспечения. Антипаттерны не стоит использовать, так как они могут ухудшать качество кода, усложнять его поддержку и вносить путаницу в разработку проекта.
Примеры: «код-спагетти» и God Object
Ниже приведен один из антипаттернов — код-спагетти. В нем запутанная логика и нет четкой структуры. Как следствие — любое изменение в логике или добавление новой функциональности будет сложно ввести без риска сломать существующий код:
def calculate_total_price(cart):
total_price = 0
for item in cart:
if item['product']['discount']:
if item['product']['discount']['type'] == 'percentage':
total_price += item['quantity'] * item['product']['price'] * (1 — item['product']['discount']['value'] / 100)
elif item['product']['discount']['type'] == 'fixed':
total_price += (item['quantity'] * item['product']['price']) — item['product']['discount']['value']
else:
total_price += item['quantity'] * item['product']['price']
else:
total_price += item['quantity'] * item['product']['price']
return total_price
Другой антипаттерн, с которым можно часто столкнуться на практике, — God Object. Ключевая особенность этого антипаттерна заключается в том, что он нарушает принцип единственной ответственности (Single Responsibility Principle) в рекомендациях SOLID.
Поскольку все методы связаны в одном классе, это затрудняет изменение или добавление новой функциональности. Код получается менее поддерживаемым, трудно понимаемым и тяжело расширяемым:
class GodObject:
def __init__(self):
self.users = []
self.products = []
self.orders = []
def get_user_by_id(self, id):
for user in self.users:
if user.id == id:
return user
return None
def get_product_by_id(self, id):
for product in self.products:
if product.id == id:
return product
return None
def get_order_by_id(self, id):
for order in self.orders:
if order.id == id:
return order
return None
def add_user(self, user):
self.users.append(user)
def add_product(self, product):
self.products.append(product)
def add_order(self, order):
self.orders.append(order)
god_object = GodObject()
user = god_object.get_user_by_id(1)
product = god_object.get_product_by_id(2)
order = god_object.get_order_by_id(3)
god_object.add_user(user)
god_object.add_product(product)
god_object.add_order(order)
Вместо использования God Object рекомендуется разделить ответственности на более мелкие и модульные компоненты. В этом конкретном случае целесообразнее создать отдельные классы для пользователей, продуктов и заказов, каждый из которых будет иметь собственную функциональность и методы. Это поможет сделать код более чистым и управляемым.
Пример исправленного кода:
class User:
def __init__(self, id):
self.id = id
class Product:
def __init__(self, id):
self.id = id
class Order:
def __init__(self, id):
self.id = id
class GodObject:
def __init__(self):
self.users = []
self.products = []
self.orders = []
def add_user(self, user):
self.users.append(user)
def add_product(self, product):
self.products.append(product)
def add_order(self, order):
self.orders.append(order)
def get_user_by_id(self, id):
for user in self.users:
if user.id == id:
return user
return None
def get_product_by_id(self, id):
for product in self.products:
if product.id == id:
return product
return None
def get_order_by_id(self, id):
for order in self.orders:
if order.id == id:
return order
# Использование:
god_object = GodObject()
user = User(1)
product = Product(2)
order = Order(3)
god_object.add_user(user)
god_object.add_product(product)
god_object.add_order(order)
retrieved_user = god_object.get_user_by_id(1)
retrieved_product = god_object.get_product_by_id(2)
retrieved_order = god_object.get_order_by_id(3)
Изменение кодовой базы под паттерн
Внедрение паттерна может потребовать больших изменений и даже потенциально нарушить работу программного обеспечения. Поэтому используйте паттерны с самого начала проекта или во время рефакторинга, чтобы избежать радикальных изменений уже имеющегося кода. Внедрение паттерна требует внимательного анализа и тестирования, чтобы убедиться, что новая архитектура не нарушает работу существующего программного обеспечения.
Пример: имплементация паттерна «Мост»
Вот пример такой ситуации. Предположим, есть код, который управляет разными устройствами и их функциональностью. Устройства могут быть телевизорами и радио, и у каждого из них есть свои уникальные методы и свойства:
class TVRemote:
def power_on(self):
# Включить телевизор
pass
def power_off(self):
# Выключить телевизор
pass
def change_channel(self, channel):
# Сменить канал
pass
class RadioRemote:
def turn_on(self):
# Включить радио
pass
def turn_off(self):
# Выключить радио
pass
def set_frequency(self, frequency):
# Установить частоту
pass
Цель: добавить новую функциональность, которая позволит управлять устройствами через пульт дистанционного управления с возможностью программирования. Для этого принимается решение внедрить паттерн «Мост», который разделяет абстракцию (в данном случае — пульт дистанционного управления) от реализации (устройства).
Создаем абстракцию RemoteControl и классы-реализации TVRemoteControl и RadioRemoteControl:
class RemoteControl:
def __init__(self, device):
self.device = device
def power_on(self):
self.device.power_on()
def power_off(self):
self.device.power_off()
def set_channel(self, channel):
self.device.change_channel(channel)
И классы-реализации:
class TVRemoteControl(RemoteControl):
pass
class RadioRemoteControl(RemoteControl):
def set_frequency(self, frequency):
self.device.set_frequency(frequency)
Теперь вы можете использовать абстракцию RemoteControl, чтобы управлять устройствами через пульт дистанционного управления:
tv_remote = TVRemote()
radio_remote = RadioRemote()
tv_controller = TVRemoteControl(tv_remote)
radio_controller = RadioRemoteControl(radio_remote)
tv_controller.power_on()
tv_controller.set_channel(5)
radio_controller.power_on()
radio_controller.set_frequency(101.5)
Даже на таком небольшом участке кода процедура внедрения паттерна «Мост» потребовала больших изменений в существующем коде, так как нужно было разделить абстракцию и реализацию, а также создать новые классы-реализации. Все это могло бы привести к потенциальным нарушениям работы программы, если бы изменения не выполнялись аккуратно и не были учтены все зависимости от старого кода.
Неправильный выбор паттерна
Использование сложных конструкций может привести к плохому пониманию архитектуры приложения. Поэтому рекомендуется по возможности выбирать лаконичные и простые решения, которые понятны и легко реализуются. Если выбирать паттерн исходя из соображений «работает — вот и хорошо», можно получить на выходе продукт, который будет сложно масштабировать, а у команды разработчиков уйдет много времени на анализ его архитектуры.
Сложный и простой примеры реализации функциональности
Предположим, мы хотим реализовать систему управления заказами для интернет-магазина. Принято решение имплементировать поведенческий паттерн «Команда», который может разделить запросы от клиентов на конкретные команды для выполнения позднее.
from abc import ABC, abstractmethod
# Абстрактный класс команды
class Command(ABC):
@abstractmethod
def execute(self):
pass
# Конкретная команда — создание заказа
class CreateOrderCommand(Command):
def __init__(self, order):
self.order = order
def execute(self):
self.order.create()
# Конкретная команда — отмена заказа
class CancelOrderCommand(Command):
def __init__(self, order):
self.order = order
def execute(self):
self.order.cancel()
# Класс заказа
class Order:
def create(self):
print("Заказ создан")
def cancel(self):
print("Заказ отменен")
# Клиентский код
order = Order()
create_command = CreateOrderCommand(order)
cancel_command = CancelOrderCommand(order)
create_command.execute()
cancel_command.execute()
Очевидно, что такое решение усложняет код — появляются дополнительные абстракции, излишние для такой простой задачи, как управление заказами.
Вместо использования паттерна можно было написать более лаконичный вариант:
# Простой класс заказа
class Order:
def create(self):
print("Заказ создан")
def cancel(self):
print("Заказ отменен")
# Клиентский код
order = Order()
order.create()
order.cancel()
Отсутствие документации
Хотя при правильном выборе и использовании паттерна он упрощает код, это не означает, что тот становится автоматически понятным всем остальным. Поэтому всегда ведите документацию.
Более того, процесс документирования заставляет и самих разработчиков задумываться над логикой и структурой кода, что может приводить к продуктивным идеям по улучшению его качества и читаемости.
Документацию можно написать в виде текстового документа, аннотаций к коду или другого формата. Важно, чтобы описание было понятным и доступным.
Пример с паттерном «Наблюдатель»
Важность документации можно проследить на примере паттерна «Наблюдатель». Этот шаблон используется для создания зависимости между объектами, когда при изменении состояния одного объекта все зависящие от него автоматически уведомляются и обновляются.
Предположим, у нас есть проект, в котором используется паттерн «Наблюдатель» для реализации функциональности уведомлений о событиях. Важно объяснить, как этот паттерн работает, чтобы другие разработчики могли применять его правильно.
Пример без документации:
class EventSubject:
def __init__(self):
self._observers = []
def add_observer(self, observer):
self._observers.append(observer)
def remove_observer(self, observer):
self._observers.remove(observer)
def notify_observers(self, event):
for observer in self._observers:
observer.update(event)
class EventObserver:
def update(self, event):
print(f"Received event: {event}")
subject = EventSubject()
observer1 = EventObserver()
observer2 = EventObserver()
subject.add_observer(observer1)
subject.add_observer(observer2)
subject.notify_observers("Event 1")
subject.notify_observers("Event 2")
То, как работает этот код и как именно организована связь между EventSubject и EventObserver, не очевидно. Другие программисты могут не понимать, что EventSubject действительно является объектом, наблюдающим за событиями, и как именно происходит уведомление.
Заключение
Проверенные шаблоны проектирования экономят время. Они помогают создавать код, который будет понятным, поддерживаемым и масштабируемым. Но только при условии их правильного использования.
Подводя итог вышесказанного, можно вывести ряд советов по корректному применению паттернов:
- Тщательно анализируйте задачу, выясняя, какой паттерн наилучшим образом соответствует ее решению.
- Используйте паттерны только там, где они точно нужны.
- Добавляйте документацию и комментарии к коду, чтобы объяснить, почему и какой паттерн используется. Это поможет другим разработчикам понять вашу логику.
- Следите за тем, чтобы код соответствовал принципам SOLID.
- Перед тем, как внедрять паттерн в проект, убедитесь, что он проходит тестирование и не вносит ошибок.
- Не каждый паттерн легко применим во всех языках программирования. Убедитесь, что выбранный паттерн подходит для вашего языка. Если возможно, используйте стандартные библиотеки и реализации паттернов для вашего языка, чтобы уменьшить сложность кода и повысить его стабильность.