Почему нам не нужны шаблоны проектирования в Python

Перевод с английского небольшого выступления на EuroPython 2017 программиста Себастьяна Бучински (Sebastian Buczyński), в котором приводится несколько реальных примеров кода, показывающих ненужность имплементации нескольких известных шаблонов проектирования (design patterns). В перевод добавлено несколько слайдов презентации и не включены ряд высказываний общего характера.

Источник (Youtube): Why you don't need design patterns in Python?

Содержание

  • Введение
  • Шаблоны проектирования
    • Одиночка (Singleton)
    • Фасад (Façade)
    • Команда (Command)
    • Посетитель (Visitor)
    • Декоратор (Decorator)
    • Заключение

Введение

слайды с выступления про ненужность шаблонов проектирования в Python

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

Он и правда хорошо справлялся, и это по-настоящему укрепило его уверенность в себе. И это, конечно, не удивительно, потому что хорошее понимание фреймворка не было его единственным активом. Он рьяно практиковался в написании тестов и использовал подход к разработке на основе тестирования (test driven development) каждый раз, когда это было возможно.

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

слайды с выступления про ненужность шаблонов проектирования в Python

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

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

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

слайды с выступления про ненужность шаблонов проектирования в Python

Чего не хватало нашему разработчику? Его мысль не выходила за пределы своего ограниченного пространства. К счастью, затем он заметил, что есть какая-то проблема и, допустим, обратился с вопросом к кому-нибудь более опытному или более заинтересованному в программной разработке. Попросил совета: что же мне делать в ситуации, когда мои инструменты не подходят для решения какой-то определенной проблемы.

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

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

Наверное, можно подумать, для чего они вообще могут пригодится, раз про них пишется в каких-то старых книгах какими-то людьми из C++. На самом деле, в сфере IT есть такой забавный факт. Для людей из нашей сферы характера своего рода амнезия. Каждые 20-30 лет мы забываем все про все концепции и идеи, а затем заново их открываем и говорим: "Ничего себе, это же так круто".

Для примера возьмем IO и подпрограммы (coroutines). Идея с ожиданием события (event loop) очень крутая, но совершенно не новая, потому что на данный момент ей уже почти 50 лет, и про нее знали в 70-х. Но мы в Python только сейчас добрались до нее.

Что еще нужно учитывать по поводу шаблонов проектирования. Можно ли ими пользоваться сегодня? Книга была написана 23 года назад. Тогда использовались другие инструменты, а не Python. С помощью Python многие вопросы становятся проще.

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

Шаблоны проектирования

Одиночка (Singleton)

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

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

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


# SINGLETON - __NEW__

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super().__new__(cls, *args, **kwargs)

        return cls._instance

Здесь используется метод с двойным подчеркиванием __new__() при создании экземпляра. Данное решение сводится к применению атрибута на уровне класса, в котором будет храниться наш единственный экземпляр. То есть, каждый раз, когда будет нужен этот класс, возвращается именно он.


one_instance = Singleton()
another_instance = Singleton()

one_instance is another_instance # True

Решение довольно простое. Мы просто обычным способом создаем объект. Конечно, мы можем использовать данный вариант реализации в своих программах, но тогда давайте больше не будем называть себя питонистами.

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

Есть другой вариант, немножко получше. Можно использовать метод для класса @classmethod.


# SINGLETON - @CLASSMETHOD

class Singleton:
    _instance = None

    @classmethod
    def get_instance(cls):
        if not cls._instance:
            cls._instance = cls()

        return cls._instance

one_instance = Singleton.get_instance()
another_instance = Singleton()

one_instance is another_instance # False

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

На самом деле, есть способ гораздо проще, ведь у нас Python, и на дворе 2017 год. Кстати, этот подход доступен в Python уже несколько лет, насколько я знаю, или даже дольше. Вы просто создаете объект экземпляра в модуле, а затем просто импортируете в код этот экземпляр, а не сам класс. Таким образом, вы получите тот же результат.


class Singleton:
    pass

singleton = Singleton()

# другие модули
from my_code import singleton

Как я уже говорил, некоторые шаблоны проектирования уже внедрены в Python. Это касается и шаблона Одиночка. Этот факт по поводу Одиночки не бросается в глаза, но в Python и правда есть одна вещь, которой мы пользуемся постоянно и которая отвечает признакам Одиночки. Это модуль, ведь в sys.modules в конкретный момент времени существует только один экземпляр.

Рассмотрим данный вариант подробнее. Если импортировать модуль один раз, то в принимающем модуле будет ровно один экземпляр такого модуля. То есть, можно без всяких проблем и очень наглядным и понятным методом получить один экземпляр модуля с помощью оператора import.

Если нужно, мы можем повторно создать экземпляр через перезагрузку importlib.reload(module) (работает в Python 3).

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

Фасад (Façade)

Раз мы говорим про модули, можно перейти к другому шаблону, который можно очень сильно упростить благодаря модулям. Речь идет про шаблон проектирования Фасад (Façade).

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

Например, у нас есть группа, которая занимается пользователями (users). Она отвечает за некие функции, например аутентификацию пользователей и т.д. Другая группа отвечает за создание блог-постов (Blog posts), а именно за создание, выведение и тому подобное. Ну, еще, допустим, есть отображение какой-нибудь рекламы (advertisements) в зависимости от контента.

слайды с выступления про ненужность шаблонов проектирования в Python

Связи между этими классами могут выглядеть вот так.

слайды с выступления про ненужность шаблонов проектирования в Python

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

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

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



# FAÇADE - CLASS

class AdvertisementsFacade:

    @classmethod
    def get_advert_for_single_post(post):
        pass

    @classmethod
    def get_adverts_for_main_page(count):
        pass

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

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


# FAÇADE - MODULE

def get_advert_for_single_post(post):
    pass

def get_adverts_for_main_page(count):
    pass

# в другом модуле
import advertisements

adverts = advertisements.get_adverts_for_main_page(count=3)

слайды с выступления про ненужность шаблонов проектирования в Python

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

Команда (Command)

Этот шаблон проектирования представляет собой объектно-ориентированный обратный вызов (callback). Первоначально предполагалось, что его реализация будет полезна при создании графических интерфейсов для пользователей.

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

Именно для этого был разработан шаблон Команда (Command). Он должен был создавать конфигурацию после запуска программы. Его интерфейс предполагает, что у вас будет только один метод, у нас это execute. То есть, при необходимости мы просто запускаем execute.


class Client:
    def foo(self):
        some_obj = SomeClass()
        command = Command(some_obj)
        self.menu_item.set_command(command)

        # ниже в коде для пункта меню menu_item
        self.command.execute() # у menu_item нет никакой информации

Для Python это, конечно, чрезмерное усложнение. Ведь мы можем просто создать обычную функцию, чтобы достичь того же результата, потому что в Python функции являются объектами первого класса (first class). Под этим понимается наличие у нас возможности передавать саму функцию другой функции и вернуть результат. Не нужно создавать никаких классов.


# COMMAND - FUNCTION

def command(discount_rate):
    some_obj.notify_users_about_discount()

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

В пакете functools есть functools.partial() для предварительной подготовки объекта command. Его можно будет использовать в дальнейшем.


import functools

command = functools.partial(
    some_obj.notify_users_about_discount, discount_rate=0.5

command()
# и это то же самое, что:
some_obj.notify_users_about_discount(discount_rate=0.5)

Посетитель (Visitor)

Шаблон Команда очень мало используется в Python на данный момент. Но зато шаблон Посетитель (Visitor), к которому мы сейчас перейдем, сейчас довольно популярен в Python.

Он используется для решения ряда проблем. Допустим, у нас есть сложная многоуровневая структура для анализа. На каждом узле нужно выполнить свою логику. Посмотрим на абстрактный пример дерева синтаксиса.

слайды с выступления про ненужность шаблонов проектирования в Python

Стартовым элементом всегда будет module. Далее идет оператор import statement. Для разных стратегий анализа предусмотрены свои проверки и инструкции. Проще всего разделить элементы и запускать их индивидуально. Вот так мы пришли к шаблону проектирования Посетитель (Visitor).

Как он реализован в Java. У нас есть узел, посетитель, и его нужно как-то выделить среди остальных, чтобы применить к нему определенный вариант логики. Это можно сделать с помощью метода visit, аргументом будет вариант реализации.

public class ASTVisitor {
    public void visit(Import import) {}

    public void visit(FunctionDef functionDef) {}

    public void visit(Assign assign) {}
}

Вот так у нас получилось четкое разделение.

Что может предложить Python? Вышеприведенный вариант в нем недоступен. Можно начать с наивной реализации, соорудить большую структуру из операторов if-else. Мне не нужно говорить, насколько это всегда выглядит ужасно и медленно.



# PYTHON NAIVE IMPLEMENTATION

class ASTVisitor:
    def visit(node):
        if type(node) == Import:
            self.visit_import()
        elif type(node) == FunctionDef:
            self.visit_functiondef()
        elif type(node) == Assign:
            self.visit_assign()
        else:
            raise AttributeError

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



# PYTHON BETTER IMPLEMENTATION

class ASTVisitor:
    def visit(node):
        normalized_type_name = type(node).__name__.lower()
        # 'assign'
        method_name = '_visit_' + normalized_type_name
        # '_visit_assign'

        method = getattr(self, method_name)
        method()

(этот пример — из книги "Python Cookbook ", 3 издание)

Но, с выходом Python 3.4 у нас появился еще один вариант, речь про @singledispatch из functools. С его помощью мы подберемся максимально близко к Java.

Сначала мы создаем функцию visit в декораторе @singledispatch и стартовый вариант реализации.



from functools import singledispatch

@singledispatch
def visit(node):
    type_name = type(node).__name__
    raise AttributeError(f'No handler found for {type_name}')

После этого мы применяем данную реализацию к разным посетителям. Она превращается в регистрирующий декоратор, которому передается только один аргумент, а именно тип посетителя.



from ast_nodes import Assign, FunctionDef

@visit.register(Assign)
def visit(node):
    pass

@visit.register(FunctionDef)
def visit(node):
    pass

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

Декоратор (Decorator)

Это последний шаблон на сегодня. На самом деле, он не похож на то, что все сразу могли бы подумать, услышав это слово. Да, он никак не связан с функцией @decorator в Python. Разберемся на примере.

Какие возможности предлагает этот шаблон проектирования:
- он может разнообразить поведение объекта
- его можно использовать после запуска программы
- он допускает неоднократное применение с разными декораторами и в разном порядке

Все декораторы будут работать как и предполагалось или, как минимум, иметь нужное значение.

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

Посмотрим на примере. Допустим, есть класс с двумя методами для получения текста get_text и цифры get_number. Мы хотим завернуть один из них get_text в декоратор и добавить теги HTML. Для второго тоже придется реализовать вариант с декоратором.



class OriginalClass:
    def get_text(self):
        pass

    def get_number(self):
        pass

class Decorator:
    def __init__(self, decorated_obj):
        self.decorated_obj = decorated_obj

    def get_text(self):
        return f'{self.decorated_obj.get_text()}'

    def get_number(self):
        return self.decorated_obj.get_number()

Конечно, здесь появляется некоторая избыточность. Но что случится, если мы вызовем атрибут класса через точечную запись some_object.some_method. Ведь методы — это просто атрибуты класса.

Сначала Python вызовет специальный метод с двойным подчеркиванием __getattribute__, который ищет свойства объекта. Если никаких атрибутов не обнаружится, то вызывается другой специальный метод __getattr__, который по умолчанию показывает исключение (exception) с сообщением о том, что атрибут не найден.

Что касается свойств, они ищутся с помощью словаря __dict__ на уровне класса и для каждого объекта. Если свойство присутствует в классе и объекте, то объект становится приоритетным.

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

Однако, если нам нужна полноценная совместимость, то придется прописывать и другие методы. Например, __setattr__, __delattr__ и т.д. Но это уже другая история.

Заключение

Python — очень гибкий инструмент, который сильно отличается от того, что было в наличии 25 лет назад. Многие из тех вариантов сейчас могут оказаться избыточными сами по себе или слишком сложными. Python предлагает много возможностей для создания индивидуальных решений почти для всех аспектов процесса создания объектов.

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

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

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

Возможно, вы пишете прекрасный код и знаете все трюки языка. Но если вы не понимаете, как решать проблемы компании, в которой вы работаете, то все это не имеет смысла и применения.