image

Пишете на Python и не знаете, с какого паттерна проектирования начать?
В статье разбор популярных шаблонов с примерами кода на Python.

Абстрактная фабрика


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

Модуль стандартной Python библиотеки json иллюстрирует пример, когда требуется создание экземпляров объектов от имени вызывающей стороны. Рассмотрите строку JSON:

text = '{"total": 9.61, "items": ["Americano", "Omelet"]}'

По умолчанию модуль json создаёт unicode объекты для строк типа «Americano»,
float – для 9.61, list – для последовательности элементов и dict – для ключей и значений объекта.

Но некоторым эти настройки по умолчанию не подходят. Например, бухгалтер против представления модулем json точной суммы «9 долларов 61 цент» в виде приближённого числа с плавающей запятой, и предпочёл бы вместо этого использовать экземпляр Decimal.

Это конкретный пример проблемы:

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

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

<source lang="python">class Factory(object):
    def build_sequence(self):
        return []
 
    def build_number(self, string):
        return Decimal(string)

А вот загрузчик, что использует эту фабрику:
class Loader(object):
    def load(string, factory):
        sequence = factory.build_sequence()
        for substring in string.split(','):
            item = factory.build_number(substring)
            sequence.append(item)
        return sequence
 
f = Factory()
result = Loader.load('1.23, 4.56', f)
print(result)

[Decimal('1.23'), Decimal('4.56')]

Во-вторых, отделите спецификацию от реализации путём создания абстрактного класса. Этот последний шаг оправдывает слово «абстрактный» в названии паттерна проектирования «Абстрактная фабрика». Ваш абстрактный класс гарантирует, что аргументом factory в load() будет класс, соответствующий требуемому интерфейсу:

from abc import ABCMeta, abstractmethod
 
class AbstractFactory(metaclass=ABCMeta):
 
    @abstractmethod
    def build_sequence(self):
        pass
 
    @abstractmethod
    def build_number(self, string):
        pass

Далее сделайте конкретную Factory с реализацией методов наследником созданного абстрактного класса. Методы фабрики вызываются с различными аргументами, что помогает создавать объекты разного типа и возвращать их без сообщения деталей вызывающей стороне.

Прототип


Паттерн проектирования «Прототип» предлагает механизм, с помощью которого вызывающая сторона предоставляет структуру с меню классов для создания экземпляра, когда пользователь или другой источник динамических запросов выбирает классы из меню выбора.

image
Проще, если бы ни один класс в меню не нуждался в аргументах в __init__():

class Sharp(object):
    "The symbol ?."
 
class Flat(object):
    "The symbol ?."

Вместо этого, в дело вступает паттерн «Прототип», когда требуется создание экземпляров классов с заранее заданными списками аргументов:

class Note(object):
    "Musical note 1 ? `fraction` measures long."
    def __init__(self, fraction):
        self.fraction = fraction

Питонические решения


Питоническим подходом будет спроектировать классы исключительно с позиционными аргументами, без именованных. Затем легко хранить аргументы в виде кортежа, который предоставляется отдельно от самого класса. Это знакомый подход класса стандартной библиотеки Thread, который запрашивает вызываемый target= отдельно от передаваемых args=(...). Вот наши пункты меню:

menu = {
    'whole note': (Note, (1,)),
    'half note': (Note, (2,)),
    'quarter note': (Note, (4,)),
    'sharp': (Sharp, ()),
    'flat': (Flat, ()),
}

В качестве альтернативы, каждый класс и аргументы располагайте в одном кортеже:

menu = {
    'whole note': (Note, 1),
    'half note': (Note, 2),
    'quarter note': (Note, 4),
    'sharp': (Sharp,),
    'flat': (Flat,),
}

Затем структура будет вызывать каждый объект с использованием некоторой вариации tup[0](*tup[1:]).

Однако, возможно, классу потребуются не только позиционные аргументы, но и именованные. В ответ на это предоставьте простые вызываемые объекты, используя лямбда-выражения для классов, которые требуют аргументов:

menu = {
    'whole note': lambda: Note(fraction=1),
    'half note': lambda: Note(fraction=2),
    'quarter note': lambda: Note(fraction=4),
    'sharp': Sharp,
    'flat': Flat,
}

Хотя лямбда-выражения не поддерживают быструю интроспекцию для проверки, они хорошо работают, если структура только вызывает их.

Сам паттерн


Теперь представьте, что нет кортежей и возможности применять их в качестве списков аргументов. Сначала вы подумаете, что понадобятся фабричные классы, каждый из которых будет запоминать конкретный список аргументов, а затем предоставлять эти аргументы при запросе нового объекта:

# Чего избегает паттерн «Прототип»:
# создания фабрик для каждого класса.
 
class NoteFactory(object):
    def __init__(self, fraction):
        self.fraction = fraction
 
    def build(self):
        return Note(self.fraction)
 
class SharpFactory(object):
    def build(self):
        return Sharp()
 
class FlatFactory(object):
    def build(self):
        return Flat()

К счастью, ситуация не такая мрачная. Если перечитаете фабричные классы выше, то заметите, что каждый из них удивительно похож на целевые классы, которые хотим создать. Так же, как и Note, NoteFactory сам хранит атрибут fraction. Стек фабрик выглядит, как минимум, списками атрибутов, как стек создаваемых целевых классов.

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

Результатом будет паттерн «Прототип», который напишем на Python с нуля. Все фабричные классы исчезают. Вместо этого у каждого объекта появляется метод clone(), на вызов которого он отвечает созданием нового экземпляра с полученными аргументами:

# Шаблон «Прототип»: научите каждый экземпляр 
# объекта создавать копии самого себя.
 
class Note(object):
    "Musical note 1 ? `fraction` measures long."
    def __init__(self, fraction):
        self.fraction = fraction
 
    def clone(self):
        return Note(self.fraction)
 
class Sharp(object):
    "The symbol ?."
    def clone(self):
        return Sharp()
 
class Flat(object):
    "The symbol ?."
    def clone(self):
        return Flat()

Хотя пример и так иллюстрирует паттерн проектирования, при желании усложните его. Например, добавьте в каждом методе clone() вызов type(self) вместо жёсткого кодирования имени собственного класса для случая вызова метода в подклассе.

Компоновщик


Паттерн «Компоновщик» предполагает, что при разработке «контейнерных» объектов, которые собирают и упорядочивают «объекты содержимого», вы упрощаете операции, если предоставляете контейнерам и объектам содержимого общий набор методов. И тем самым поддерживаете максимум возможных методов при том, что вызывающему неважно, переданы отдельный объект контента или целый контейнер.

image

Реализация: наследовать или нет?


Преимущества симметрии, которую создаёт этот паттерн между контейнерами и их содержимым, увеличиваются, только если симметрия делает объекты взаимозаменяемыми. Но здесь некоторые статически типизированные языки встречают препятствие.

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

В других статических языках ограничение мягче. Нет строгой необходимости в том, чтобы контейнер и его содержимое делились реализацией. Пока оба соответствуют «интерфейсу», который объявляет конкретные общие методы, объекты вызываются симметрично.

Так как это программирование на Python, оба ограничения испаряются! Пишите код в предпочтительном для себя диапазоне безопасности и краткости. Хотите, пойдите классическим путём и добавьте общий суперкласс:

class Widget(object):
    def children(self):
        return []
 
class Frame(Widget):
    def __init__(self, child_widgets):
        self.child_widgets = child_widgets
 
    def children(self):
        return self.child_widgets
 
class Label(Widget):
    def __init__(self, text):
        self.text = text

Или задайте объектам один и тот же интерфейс. И положитесь на тесты, которые помогут поддерживать симметрию между контейнерами и содержимым. (Где для простейших скриптов ваш «тест» может быть фактом выполнения кода.)

class Frame(object):
    def __init__(self, child_widgets):
        self.child_widgets = child_widgets
 
    def children(self):
        return self.child_widgets
 
class Label(object):
    def __init__(self, text):
        self.text = text
 
    def children(self):
        return []

  • Или выберите другой подход из спектра дизайна между этими двумя крайностями. Вот что поддерживает Python:
  • Следуйте классической архитектуре с общим суперклассом, показанной в первом примере выше.
  • Сделайте суперкласс абстрактным базовым классом с помощью инструментов модуля стандартной библиотеки abc.
  • Объявите для двух классов совместно используемый интерфейс, наподобие поддерживаемых старым пакетом zope.interface.
  • Применяйте аннотации для получения жёстких гарантий того, что и контейнер, и содержимое реализуют требуемое поведение. Для этого понадобится установка Python библиотеки проверки типов, к примеру, MyPy.
  • Вы в праве использовать утиную типизацию и не просить ни разрешения, ни прощения!

Поскольку Python предлагает такой спектр подходов, не стоит определять паттерн «Компоновщик» классически, то есть как один конкретный механизм (суперкласс) для создания или гарантирования симметрии. Вместо этого определите его как создание симметрии любыми средствами в иерархии объектов.

Итератор


Как реализовать паттерн проектирования «Итератор» и подключиться к встроенным итерационным механизмам языка Python for, iter() и next()?

image

Добавьте в контейнер метод __iter__(), который возвращает объект итератора. Поддержка этого метода делает контейнер итерируемым.

Каждому итератору установите метод __next__() (в старом коде Python 2 next() записывали без двойного подчёркивания), который возвращает следующий элемент из контейнера при каждом вызове. Бросайте исключение StopIterator, когда больше нет элементов.

Помните, что некоторые пользователи передают в цикл for итераторы вместо основного контейнера? Чтобы обезопаситься в этом случае, каждому итератору также нужен метод __iter__(), который возвращает сам себя.

Посмотрите, как эти требования работают вместе, на примере нашего собственного итератора!

Обратите внимание, что не требуется, чтобы элементы, полученные в результате __next__(), сохранялись как постоянные значения внутри контейнера или даже присутствовали до вызова __next__(). Значит написать пример паттерна проектирования «Итератор» можно даже без реализации хранилища в контейнере:

class OddNumbers(object):
    "An iterable object."
 
    def __init__(self, maximum):
        self.maximum = maximum
 
    def __iter__(self):
        return OddIterator(self)
 
class OddIterator(object):
    "An iterator."
 
    def __init__(self, container):
        self.container = container
        self.n = -1
 
    def __next__(self):
        self.n += 2
        if self.n > self.container.maximum:
            raise StopIteration
        return self.n
 
    def __iter__(self):
        return self

Благодаря этим трём методам – ??одному для объекта-контейнера и двум для его итератора – контейнер OddNumbers теперь полноправно участвует в богатой итерационной экосистеме языка программирования Python. Он будет работать без проблем с циклом for:
numbers = OddNumbers(7)
 
for n in numbers:
    print(n)

1
3
5
7

И также работает со встроенными методами iter() и next().
it = iter(OddNumbers(5))
print(next(it))
print(next(it))

1
3

Он дружит даже с генераторами списков и множеств!

print(list(numbers))
print(set(n for n in numbers if n > 4))

[1, 3, 5, 7]
{5, 7}

Три простеньких метода – и вы разблокировали доступ к поддержке итераций на уровне синтаксиса Python.

Резюме


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

Комментарии (18)


  1. anton19286
    15.08.2019 07:00

    Что если бухгалтер попросит числа в русском формате, с запятой вместо точки? Идти глубже, строить абстрактную фабрику лоадеров?


    1. batyrmastyr
      15.08.2019 09:09

      Это уже вопрос отображения, а не хранения данных. На одно и то же число могут смотреть бухгалтера из разных стран.


  1. dopusteam
    15.08.2019 09:09

    "В языках со строгой типизацией объекты двух классов взаимозаменяемые только при наследовании от одного родительского класса, который реализует общие методы, или при наследовании одного класса непосредственно от другого."


    А как же реализация одного интерфейса?


  1. BasicWolf
    15.08.2019 10:09
    +1

    Товарищ, вы уж извините за грубость, но это "Java головного мозга".


    Потому что на кой в Питоне нужна AbstractFactory, когда есть функции высшего порядка? А в вышеприведённом случае можно сократить до


    result = Load('1.23, 4.56', Decimal)

    Шаблон "прототип" и clone() — чудесно. Но для начала стоило бы узнать как копируются / клонируются объекты в Питоне. Почитайте доки к модулю copy и специальным методам __copy__() и __deepcopy__().


    Компоновщик с наследованием от object принесёт много проблем особенно с аннотациями: как при таком подходе аннотировать функцию принимающую любой виджет в качестве аргумента? Неужто делать Union[Frame, Label, ...]?


    Итераторы в Питоне — это не шаблон, а встроенная фича языка. Сравните с тем, как итераторы реализованы в классическом C++:


    for(std::vector<T>::iterator it = v.begin(); it != v.end(); ++it) {
    
    }

    — вот здесь итератор — шаблон проектирования, с впихиванием итераторов в цикл for, доставшийся из языка С, переопределением оператора инкремента и указателями на начало и виртуальный конец контейнера.


    Пожалуйста, не тащите Java в Питон.


    1. gorodnev
      15.08.2019 20:21
      +1

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


      1. Vilaine
        16.08.2019 05:30

        Презентация за 1996 год, тогда и немного позже были распространены другие, более оптимистичные взгляды на будущее индустриальной разработки, и роль типизации в частности) С тех пор возможности типизации развились в самых популярных динамических ЯП: Python, PHP, Javascript в каком-то смысле. Также с тех пор такой же путь прошли и протоколы обмена: строковый XML (получил XSD), слабо-типизированный JSON (получил OAS). На каком-то этапе вдруг индустрии контракты становятся дороже свободы)


        1. germn
          16.08.2019 11:33

          Если вы про аннотации типов, то это не «возможность типизации»: типы будут проверяться все так же во время исполнения. Я бы сказал что аннотации нужны в большинстве случаев только для документации. С php дела обстоят так же, правда там из-за слабой типизации роль аннотаций для помощи IDE при определении типов больше. Но основная задача все так же — документация. В js из-за слабой типизации изобрели typescript, и вот там, как я понимаю, уже типизация, да.


          1. Vilaine
            17.08.2019 02:15

            Я бы сказал что аннотации нужны в большинстве случаев только для документации.
            Ну я не знаю, но я часто вижу Гвидо ван Руссо в репозитории mypy, всё чаще вижу использование его в сложных проектах. Я тоже использую этот инструмент для улучшения личной производительности и качества ПО. В плане распространения еще сказывается то, что Python-сообщество довольно догматично и замкнуто вплоть до экзальтированного продвижения отдельными представителями крайне низкопробных практик как истинно pythonic (например, в комментах), наличие «идеологии» этому способствует. Остальные ЯП опираются больше на общий опыт индустрии, чем на идеологию языка. Но, по-моему, движение в этом направление будет продолжаться. Проблем индустрии программирования больше общих, чем зависящих от ЯП.
            С php дела обстоят так же, правда там из-за слабой типизации роль аннотаций для помощи IDE при определении типов больше
            В РНР типы проверяет интерпретатор в рантайме, при этом типизируются пока только вызовы, т.е. слабая типизация переменных не причем. Не статическая компиляция, конечно, но вы неправы. Впрочем, статические анализаторы там тоже распространены, выбор популярных и разрабатываемых велик. В последней версии завезли типизируемые свойства классов, кстати, это уже более необычно.


            1. Vilaine
              17.08.2019 03:01

              Поправка про слабую типизацию: перепутана с динамической, в современном коде всё чаще ограничивается директивой declare(strict_types = 1). В любом случае, типизированные сигнатуры обеспечивают типобезопасность программы, и к IDE или документируемости имеют отношение не больше, чем типы в других ЯП.

              про аннотации типов,… типы будут проверяться все так же во время исполнения
              Кстати, в общем случае не будут. И это тоже неплохо, лучше их при билде проверять, иначе лишний оверхед.


    1. Vilaine
      16.08.2019 05:09

      Потому что на кой в Питоне нужна AbstractFactory, когда есть функции высшего порядка?
      Стало немного интересно. На кой в Java нужна AbstractFactory, когда есть функции высшего порядка?


      1. BasicWolf
        16.08.2019 07:24
        +1

        Вы сравните, в Питоне функции высшего порядка были изначально — т.е. ещё в середине 90х. В Java они появились в версии 8, т.е. 2014м году! Невозможность простым образом передавать метод класса как аргумент породило кучу костылей паттернов.


        1. Vilaine
          17.08.2019 02:32

          Невозможность простым образом передавать метод класса
          1997 год, Java 1.1
              private Vector history = new Vector();
          
              public void watch(Observable o) {
          	o.addObserver(new Observer() {
          	    public void update(Observable o, Object arg) {
          		history.addElement(arg);
          	    }
          	});
              }
          

          Попробуйте в нескольких местах настроить анонимную функцию в зависимости от контекста/среды выполнения, инвертировать зависимости и всё такое, что приходится делать в борьбе со сложностью. Этот шаблон появился по другим причинам, нежели ограничения Java.


      1. Tishka17
        16.08.2019 14:24

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


        1. BasicWolf
          16.08.2019 16:21

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


          1. Tishka17
            16.08.2019 16:52
            +1

            Я имел ввиду, что абстрактной фабрикой вообще правильнее считать некий "протокол", а не именно обязательный родительский абстрактный класс. В простых случаях это может быть просто Callable. В сложных, когда от фабрики ожидается умение, например, делать несколько типов объектов — уже набор методов.


            Раз просите, вот синтетический пример.


            class CarFactory(Protocol):
               def get_car(self):
                  raise NotImpltementedError
            
               def get_big_car(self):
                  raise NotImpltementedError
            
            class RealColorFacory:
                def __init__(self, color):
                  sefl.color = color
            
               def get_car(self):
                  return Car(color)
            
               def get_big_car(self):
                  return Bus(color)
            
            class FakeFacory:
               def get_car(self):
                  return FakeCar()
            
               def get_big_car(self):
                  return FakeBus()
            
            factory: CarFactory
            if debug:
               factory = FakeFactory()
            else:
               factory = RealFactory(green_color)

            Я тут воспользовался Protocol, который завезли в 3.8, для явного описания протокола фабрики. Но вообще и без него все будет работать благодаря утиной типизации.


            1. BasicWolf
              16.08.2019 18:36

              Спасибо. В вашем примере концепция фабрики раскрыта намного лучше, чем в статье.


  1. Tihon_V
    15.08.2019 10:21

    Вместо lambda-выражений лучше использовать functools.partial. Это позволит использовать предопределять лишь часть аргументов c параметром по-умолчанию.

    from functools import partial
    
    menu = {
        'whole note': partial(Note, fraction=1),
        'half note': partial(Note, fraction=2),
        'quarter note': partial(Note, fraction=4),
        'sharp': Sharp,
        'flat': Flat,
    }
    
    menu['flat'](some_valuable_arg)
    menu['whole note'](some_valuable_arg)
    


    1. BasicWolf
      15.08.2019 10:39

      Добавлю, что можно использовать enum, чтобы избежать KeyError возникающих в результате опечаток.
      Но в таком случае я бы написал:


      class NoteFraction(enum):
          WHOLE = 1
          HALF = 2
          QUARTER = 4
      
      whole_note = Note(NoteFraction.WHOLE)
      half_note = Note(NoteFraction.HALF)