4 must-have паттерна проектирования в Python

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

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

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

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

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

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

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

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

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

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

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 с реализацией методов наследником созданного абстрактного класса. Методы фабрики вызываются с различными аргументами, что помогает создавать объекты разного типа и возвращать их без сообщения деталей вызывающей стороне.

Прототип

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

Проще, если бы ни один класс в меню не нуждался в аргументах в __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) вместо жёсткого кодирования имени собственного класса для случая вызова метода в подклассе.

Компоновщик

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

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

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

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

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

Так как это программирование на 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()?

  • Добавьте в контейнер метод __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 с парой порождающих паттернов проектирования – абстрактной фабрикой и прототипом. Без труда реализуете структурный шаблон – прототип. По плечу вам имплементация поведенческого паттерна проектирования – итератора. Наверняка вас заинтересуют примеры других шаблонов проектирования на Python.

Как думаете, какого паттерна проектирования не хватает в статье?

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

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
28 июня 2018

3 самых важных сферы применения Python: возможности языка

Существует множество областей применения Python, но в некоторых он особенно...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

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