Як працювати з патернами | robot_dreams
Для відстеження статусу замовлення - авторизуйтесь
Введіть код, який був надісланий на пошту Введіть код із SMS, який був надісланий на номер
 
Код дійсний протягом 2 хвилин Код з SMS дійсний протягом 2 хвилин
Ви впевнені, що хочете вийти?
Сеанс завершено
На головну
Як працювати з патернами: вчимося обходити «граблі»

Як працювати з патернами: вчимося обходити «граблі»

Топ помилок, яких припускаються новачки, намагаючись писати чистий код

Хоча про чистий код говорять часто, у розробці ПЗ патерни проєктування не є жорстким стандартом. Крім того, хоча патерни розв’язують багато проблем та завдань — від дублювання коду до його нечитабельності, — рекомендації працюють не завжди. Якщо сліпо їм довіряти, можна отримати результат, далекий від ідеального.

У цій статті розбираємо, яких помилок під час написання чистого коду та використання патернів новачки припускаються найчастіше.

Агресивне використання патернів

Шаблони проєктування не завжди доречні, тому використовувати їх варто тільки тоді, коли вони дійсно необхідні. Примусове (агресивне) використання патернів — це одна з найчастіших помилок у програмуванні. Надмірне залучення патернів призводить до помітного ускладнення коду — він стає надлишковим.

Приклад надмірності коду

У коді нижче використано патерн проєктування «Фабричний метод», який дає змогу створювати об'єкти без зазначення конкретного класу.

У цьому прикладі клас 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.
  • Перед тим, як впроваджувати патерн у проєкт, переконайтеся, що він проходить тестування і не робить помилок.
  • Не кожен патерн можна легко застосувати в усіх мовах програмування. Переконайтеся, що обраний патерн підходить для вашої мови. Якщо є змога, використовуйте стандартні бібліотеки та реалізації патернів для вашої мови, щоб зменшити складність коду та підвищити його стабільність.
Ще статті
Експертки про те, як оцінюють кандидатів на нетехнічних інтерв’ю
Частина 2. Робота із записами: вставка, читання, змінення й видалення