03 февраля 2022

🐍 3 классических паттерна проектирования в Python: синглтон, декоратор и итератор

Kaggle expert⚛️ Пишу материал о различных алгоритмах и техниках в сфере Machine Learning.
Паттерны в Python – это шаблоны для решения задач, которые часто встречаются в практике программиста. Они представляют из себя огромный набор инструментов. В этом материале вы познакомитесь с самыми главными из них.
🐍 3 классических паттерна проектирования в Python: синглтон, декоратор и итератор

Благодаря книге «Паттерны проектирования: Elements of Reusable Object-Oriented Softwar» (авторы Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес), шаблоны (паттерны) приобрели популярность в компьютерной науке. В отрасли ее называют Gangs of Four – «Банда четырех».

Эта книга учит мозг улавливать паттерны, которые в дальнейшем можно применить к существующему коду, чтобы повысить эффективность вашей разработки. Следует изучить как можно больше паттернов. Это позволит выбрать нужный, когда его использование будет необходимо.

Большая часть книги посвящена паттернам для языка программирования Java и C++. Однако, в этой статье мы сделаем упор на использование паттернов в языке Python. Рассмотрим несколько шаблонов проектирования из каждой категории, согласно изначально предложенной классификации внутри книги, которые показались мне наиболее интересными в контексте программирования на Python.

Что такое паттерн проектирования?

В разработке программного обеспечения паттерн – это общее, многократно используемое решение проблемы, которая часто встречается внутри конкретной ситуации. Это похоже на готовые чертежи, которые можно использовать для решения проблемы в вашем коде.

Стоит отметить, что нельзя применять паттерн проектирования так же, как используется функция из импортированной библиотеки. Вместо этого, вы должны следовать концепции паттерна и реализовать решение, которое соответствует требованиям вашей программы. Паттерн – это не фрагмент кода, а общая концепция, которая описывает, как решить конкретную повторяющуюся проблему.

Классификация паттернов проектирования

Изначально существовало две основные классификации паттернов проектирования:

  1. Какую проблему решает паттерн.
  2. Как относится паттерн к классам или объектам.

Принимая во внимание первую классификацию, паттерны можно разделить на три группы:

  1. Порождающие – предоставляют возможность создания контролируемым образом, инициализации и конфигурации объектов, классов и типов данных на основе требуемых критериев.
  2. Структурные – помогают организовать структуры связанных объектов и классов, предоставляя новые функциональные возможности.
  3. Поведенческие – направлены на выявление общих моделей взаимодействия между объектами.

Позже появились новые паттерны проектирования, из которых можно выделить еще одну категорию:

Concurrency (параллелизм) – это тот тип паттернов проектирования, который имеет дело с многопоточной парадигмой программирования.

Паттерн 1: Синглтон

Синглтон (одиночка) – это паттерн проектирования, цель которого ограничить возможность создания объектов данного класса одним экземпляром. Он обеспечивает глобальность до одного экземпляра и глобальный доступ к созданному объекту.

Примеры использования

  • Класс в вашей программе имеет только один экземпляр, доступный всем клиентам. Например, один объект базы данных, разделяемый различными частями программы.
  • В случае если вам необходим более строгий контроль над глобальными переменными.

Пример кода:

Первый наивный подход (naive approach):

        class Logger:
   @staticmethod
   def get_instance():
       if '_instance' not in Logger.__dict__:
           Logger._instance = Logger()
       return Logger._instance

   def write_log(self, path):
       pass


if __name__ == "__main__":
   s1 = Logger.get_instance()
   s2 = Logger.get_instance()
   assert s1 is s2
    

Что не так с этим кодом?

Он нарушает причины изменения, которые приняты в концепции SRP (single responsibility principle). Необходимо помнить, что доступ к экземплярам класса осуществляется только методом get_instance(). Проблема в дескрипторе, куда пишутся логи со стороны класса Logger.

        class Singleton:
  _instances = {}
  def __new__(cls, *args, **kwargs):
      if cls not in cls._instances:
          instance = super().__new__(cls)
          cls._instances[cls] = instance
      return cls._instances[cls]
class Logger(Singleton):
  def write_log(self, path):
      pass
if __name__ == "__main__":
  logger1 = Logger()
  logger2 = Logger()
  assert logger1 is logger2
    

Итак, проблемы из предыдущего примера решены. Но возможно ли найти более оптимальный способ (без наследования классов)?

Давайте попробуем.

        class Singleton(type):
  _instances = {}
  def __call__(cls, *args, **kwargs):
      if cls not in cls._instances:
          instance = super().__call__(*args, **kwargs)
          cls._instances[cls] = instance
      return cls._instances[cls]
class Logger(metaclass=Singleton):
  def write_log(self, path):
      pass
if __name__ == "__main__":
  logger1 = Logger()
  logger2 = Logger()
  assert logger1 is logger2
    

Все работает. Однако, надо сделать еще одну настройку – подготовить программу к работе в многопоточной среде.

        from threading import Lock, Thread
class Singleton(type):
  _instances = {}
  _lock: Lock = Lock()
  def __call__(cls, *args, **kwargs):
      with cls._lock:
          if cls not in cls._instances:
              instance = super().__call__(*args, **kwargs)
              cls._instances[cls] = instance
      return cls._instances[cls]
class Logger(metaclass=Singleton):
  def __init__(self, name):
      self.name = name
  def write_log(self, path):
      pass
def test_logger(name):
  logger = Logger(name)
  print(logger.name)
if __name__ == "__main__":
  process1 = Thread(target=test_logger, args=("FOO",))
  process2 = Thread(target=test_logger, args=("BAR",))
  process1.start()
  process2.start()
    

Вывод:

        FOO
FOO
    

Подведем итоги. Особенности использования Синглтона:

  • Класс имеет только один экземпляр;
  • Вы получаете глобальную точку доступа к этому экземпляру;
  • Синглтон инициализируется только при первом запросе;
  • Маскирует плохой дизайн до определенного момента. Это одна из причин, почему многие считают синглтон антипаттерном.

Паттерн 2: Декоратор

Декоратор – это структурный паттерн. Цель которого – предоставление новых функциональных возможностей классам и объектам во время выполнения кода.

Чаще всего декоратор представляет собой абстрактный класс, принимающий в конструкторе объект, функциональность которого мы хотим расширить. Но в Python есть и встроенный механизм декораторов, который можно использовать.

Случаи использования

  • Необходимость назначить дополнительные обязанности объектам во время выполнения, не ломая код, который использует эти объекты;
  • По каким-то причинам невозможно расширить «цепочку обязанностей» объекта через наследование.

Пример кода

Используя декораторы, вы можете обернуть объекты несколько раз, поскольку и цель, и декораторы реализуют один и тот же интерфейс.

Получаемый объект будет обладать объединенной и сложенной функциональностью всех декораторов.

        from abc import ABC, abstractmethod
class Component(ABC):
  @abstractmethod
  def operation(self):
      pass
class ConcreteComponent(Component):
  def operation(self):
      return "ConcreteComponent"
class Decorator(Component):
  def __init__(self, component):
      self.component = component
  @abstractmethod
  def operation(self):
      pass
class ConcreteDecoratorA(Decorator):
  def operation(self):
      return f"ConcreteDecoratorA({self.component.operation()})"
class ConcreteDecoratorB(Decorator):
  def operation(self):
      return f"ConcreteDecoratorB({self.component.operation()})"
if __name__ == "__main__":
  concreteComponent = ConcreteComponent()
  print(concreteComponent.operation())
  decoratorA = ConcreteDecoratorA(concreteComponent)
  decoratorB = ConcreteDecoratorB(decoratorA)
  print(decoratorB.operation())


    

Вывод:

        ConcreteComponent
ConcreteDecoratorB(ConcreteDecoratorA(ConcreteComponent))
    

Практичный пример с использованием встроенного механизма декораторов:

        import sys
def memoize(f):
  cache = dict()
  def wrapper(x):
      if x not in cache:
          cache[x] = f(x)
      return cache[x]
  return wrapper
@memoize
def fib(n):
  if n <= 1:
      return n
  else:
      return fib(n - 1) + fib(n - 2)
if __name__ == "__main__":
  sys.setrecursionlimit(2000)
  print(fib(750))
    

Вывод:

        2461757021582324272166248155313036893697139996697461509576233211000055607912198979704988704446425834042795269603588522245550271050495783935904220352228801000
    

Без использования декоратора кэша для функции, которая рекурсивно вычисляет n-й член ряда Фибоначчи, трудно вычислить результат для значения 100 за все время работы.

Подведем итоги. Возможности декоратора:

  • Расширение поведения объекта без создания подкласса;
  • Добавление или удаление обязанности объекта во время выполнения;
  • Объединение нескольких моделей поведения, путем применения к объекту нескольких декораторов;
  • Разделение монолитного класса, который реализует множество вариантов поведения на более мелкие классы;

При применении этого паттерна возникают следующие сложности:

  • Применение одной конкретной обертки (wrapper) из центра стека (stack);
  • Реализация декоратора, при исключении его зависимости от порядка, в котором обертки уложены в стек.

Паттерн 3: Итератор

Итератор – это поведенческий паттерн. Его цель – позволить вам обходить элементы коллекции, не раскрывая ее базовое представление.

Чтобы реализовать итератор в Python, у нас есть два возможных варианта:

  1. Реализовать в классе специальные методы __iter__ и __next__.
  2. Использовать генераторы.

Примеры использования

  • Коллекция имеет сложную структуру. Необходимо скрыть ее от клиента из соображений удобства или безопасности;
  • Требуется сократить дублирование обходного кода по всему приложению;
  • Обход элементов различных структур данных;
  • Изначально неизвестны детали структуры данных.

Пример кода

Создание пользовательской коллекции с итератором алфавитного порядка:

        from collections.abc import Iterator, Iterable


class AlphabeticalOrderIterator(Iterator):
   _position: int = None
   _reverse: bool = False

   def __init__(self, collection, reverse=False):
       self._collection = sorted(collection)
       self._reverse = reverse
       self._position = -1 if reverse else 0

   def __next__(self):
       try:
           value = self._collection[self._position]
           self._position += -1 if self._reverse else 1
       except IndexError:
           raise StopIteration()
       return value


class WordsCollection(Iterable):
   def __init__(self, collection):
       self._collection = collection

   def __iter__(self):
       return AlphabeticalOrderIterator(self._collection)

   def get_reverse_iterator(self):
       return AlphabeticalOrderIterator(self._collection, True)


if __name__ == "__main__":
   wordsCollection = WordsCollection(["Third", "First", "Second"])
   print(list(wordsCollection))
   print(list(wordsCollection.get_reverse_iterator()))


    

Вывод:

        ['First', 'Second', 'Third']
['Third', 'Second', 'First']
    

Следующий пример относится к генератору, который представляет собой особый вид функции. Функция может быть приостановлена и возобновлена с того места, где была совершена пауза. На основе сохраненного состояния можно возвращать различные значения при последующих вызовах генератора.

        def prime_generator():
  yield 2
  primes = [2]
  to_check = 3
  while True:
      sqrt = to_check ** 0.5
      is_prime = True
      for prime in primes:
          if prime > sqrt:
              break
          if to_check % prime == 0:
              is_prime = False
              break
      if is_prime:
          primes.append(to_check)
          yield to_check
      to_check += 2
generator = prime_generator()
print([next(generator) for _ in range(20)])
    

Вывод:

        [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71]
    

Подведем итоги. Возможности итератора:

  • Очистить клиентский код и коллекции, вынеся код обхода в отдельные классы;
  • Реализовать новые типы коллекций и итераторов с передачей их в существующий код без нарушений;
  • Обходить одну и ту же коллекцию с помощью нескольких итераторов параллельно, учитывая, что каждый из них хранит информацию о состоянии итерации;
  • Возможность отложить итерацию и продолжить ее по мере необходимости;

Использование этого паттерна будет лишним, если ваше приложение работает только с простыми коллекциями. Более того, использование итератора может быть менее эффективным, чем прямой обход элементов какой-либо специализированной коллекции.

Заключение

Знание паттернов важно для современного разработчика. Оно помогает решить проблемы внутри вашего кода, используя принципы объектно-ориентированного программирования. Этот материал дает возможность познакомиться с основами работы с паттернами.

***

Хочу освоить больше паттернов, этому где-нибудь учат?

9 февраля стартует курс «Архитектуры и шаблоны проектирования», на котором вы научитесь:

  • строить архитектуры приложений, которые позволяют не снижать скорость разработки по мере развития проекта;
  • писать модульные тесты на Mock-объектах;
  • применять SOLID принципы не только в объектно-ориентированных языках;
  • использовать CI и IoC контейнеры.

Что нужно для старта?

Для старта достаточно знать любой объектно-ориентированный язык программирования: Python, Java, PHP, C++, JavaScript, C# и др.

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ