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

  • Назначение этого паттерна: та проблема, для решения которой он предназначен

  • Реализация: точная структура класса или код для воплощения этого паттерна

Рассказывая о «паттернах проектирования в Python, о которых следует забыть», мы имеем в виду как раз реализации. В самом деле, эти паттерны решают реальные задачи. Но в Python решение этих задач ничуть не напоминает те варианты, которые предлагаются на C++ или Java.

Держа в уме эту идею, делаем простой вывод:

Мишка учится лазать по деревьям, чтобы добраться до мёда. Но орлы никуда не лазают, они летают

В части 1 мы разобрали паттерны Строитель и Синглтон, показав такие возможности Python (как, например, аргументы по умолчанию или модули), при использовании которых многие «классические» реализации становятся ненужными или даже контрпродуктивными.

В этой статье поговорим ещё о двух паттернах: это Приспособленец (Flyweight) и Прототип (Prototype). Оба они решают реальные проблемы. Но, как увидите, в Python для их решения есть более простые и естественные способы.

Паттерн Приспособленец: совместное использование памяти для её экономии

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

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

Но вот в чём проблема: разработчики часто прибегают к Синглтону в тех случаях, когда им нужен Приспособленец. А бывает и хуже: на самом деле, им не нужен ни тот, ни другой.

Вот быстрая проверка, подобная лакмусовой бумажке:

  • Параметров конструктора нет (или они всегда одни и те же)? Пожалуй, класс вам не нужен. Воспользуйтесь объектом уровня модуля.

  • Важны ли параметры конструктора? Пожалуй, Синглтон вам не подойдёт, а решение в стиле паттерна Приспособленец, возможно, будет более уместным.

Как паттерн Приспособленец описан в книге

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

"Применяет механизм совместного использования для эффективной поддержки большого числа мелких объектов."

Классический пример — текстовый редактор, в котором фигурируют тысячи символов. У каждого символа будет своя гарнитура, кегль и позиция. Символ – это крошечный объект, но, когда у вас десятки тысяч таких объектов, вскоре для их содержания потребуется много памяти. Если создавать по полноценному объекту на каждый символ, то память легко израсходовать, особенно в сравнительно старых окружениях, в которых действуют жёсткие ограничения. 

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

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

Если поискать в Интернете, как реализовать паттерн Приспособленец на Python, вам наверняка попадутся подобные примеры:

from typing import ClassVar
from dataclasses import dataclass


class User:
    _users: ClassVar[dict[tuple[str, int], "User"]] = {}

    def __new__(cls, name: str, age: int) -> "User":
        if not (u := cls._users.get((name, age))):
            cls._users[(name, age)] = u = super().__new__(cls)
        return u

    def __init__(self, name: str, age: int):
       self.name = name
       self.age = age

Это подход, при котором new используется в сочетании с переменной класса. Так контролируется создание экземпляров, подобно тому, как делается с паттерном Синглтон. Как правило, это переусложнение, из-за которого открывается целая бездна проблем.

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

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

u = User("user", 20)
assert type(u) is User

Допустим, кто-то наследует от вашего класса:

class Admin(User):
    ...


In [6]: Admin("user",20)
Out[6]: <__main__.Admin at 0x7c123e18b650>

In [7]: User("user",20)
Out[7]: <__main__.Admin at 0x7c123e18b650>

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

Как лучше поступить: фабричная функция с кэшем

from functools import lru_cache

@lru_cache
def create_user(name: str, age: int) -> User:
    return User(name, age)

Так мы избегаем подводных камней, связанных с new и хранением состояния на уровне класса. Это просто, явно и безопасно.

В отличие от ситуации, представленной в предыдущей версии, здесь lru_cache гарантирует, что create_user(...) всегда возвращает реальный User, а не его подклассы. При этом, поскольку кэш связан с функцией, а не с классом, экземпляры не могут случайно измениться или заменить разделяемое состояние. Можно рассуждать об этом коде точно как и о любой другой функции: те же данные на вход, на выход, всё всегда предсказуемо.

class UserFactory:
    @lru_cache
    def create_user(self, name: str, age: int) -> User:
        return User(name, age)

В данном случае у каждого экземпляра UserFactory будет собственный отдельный кэш. Дело в том, что self включается в число хешируемых аргументов. Поэтому, вызывая  factory1.create_user("Alice", 30) и factory2.create_user("Alice", 30), мы будем попадать в разные кэши, даже в остальном эти данные будут совершенно одинаковыми.

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

_cache = {}  # упорядочено!

_MAXCACHE = 512
def _compile(pattern, flags):
    # внутреннее: паттерн компиляции
    if isinstance(flags, RegexFlag):
        flags = flags.value
    try:
        return _cache[type(pattern), pattern, flags]
    except KeyError:
        pass
    if isinstance(pattern, Pattern):
        if flags:
            raise ValueError(...)
        return pattern
    if not _compiler.isstring(pattern):
        raise TypeError(...)
    if flags & T:
        import warnings
        warnings.warn(...)
    p = _compiler.compile(pattern, flags)
    if not (flags & DEBUG):
        if len(_cache) >= _MAXCACHE:
            # Отбрасываем самый старый элемент
            try:
                del _cache[next(iter(_cache))]
            except (StopIteration, RuntimeError, KeyError):
                pass
        _cache[type(pattern), pattern, flags] = p
    return p

Паттерн Прототип: какую проблему он решает?

Итак, мы увидели, что паттерн Прспособленец в Python часто схлопывается в простую кэширующую функцию. Теперь давайте рассмотрим другой паттерн, который в современном коде часто переосмысливается (или недопонимается): Прототип.

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

Допустим, вы пишете редактор для композиторов, пользуясь при этом GUI-фреймворком. В этом фреймворке предоставляется класс GraphicTool, с которым пользователь взаимодействует, создавая графику.

class GraphicTool:
    def click(self) -> Graphics: ... 
    
# Щёлкнув GraphicTool, пользователь получает в ответ объект графа, который будет отображаться на экране.

Вы определяете ваши собственные классы, например, MusicalNote, наследующие от базового типа Graphics:

from gui import Graphics

class MusicalNote(Graphics):
	def __init__(self, note: str = "C4"):
	    self.note = note

Вот как эта проблема описана в книге «Банды четырёх»:

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

И далее:

Вопрос в том, как каркас мог бы воспользоваться ею для параметризации экземпляров GraphicTool классом того объекта Graphic, который предполагается создать "

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

Возникает напряжение. Нельзя ожидать от фреймворка, что в нём будет жёстко закодирована поддержка для любого подкласса Graphics, определяемого пользователем. Настолько же непрактично наследовать от GraphicTool для каждого нового графического типа, который может быть привнесён клиентом. Чтобы справиться с этим, паттерн Прототип предлагает такое решение: не будем учить фреймворк, как сконструировать любой возможный объект, пусть лучше сам клиент предоставляет заранее сконфигурированный экземпляр (прототип), который фреймворк сможет склонировать именно тогда, когда ему понадобится новый объект.

В соответствии с этим паттерном определяем метод clone() в вашем собственном графическом классе, например, MusicalNote. Этот метод возвращает новую копию объекта, тем самым позволяя GraphicTool ничего не знать о конкретном типе, который он клонирует. Он просто держит ссылку на прототип и вызывает метод proto.clone() всякий раз, когда требуется создать новый экземпляр. Таким образом, логика создания новых экземпляров остаётся целиком под контролем клиента, а фреймворк остаётся гибким и расширяемым.

class GraphicTool:
    def __init__(self, proto: Graphics):
        self.proto = proto
        
    def click(self) -> Graphic:
        return self.proto.clone()

Из клиентского кода можно сделать так:

g = GraphicTool(proto=MusicalNote())

Это сработает, поскольку вы реализуете метод clone() в вашем собственном классе, и инструмент просто вызывает его, чтобы получить новый объект.

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

class GraphicTool:
    def __init__(self, graph_factory: Callable[..., Graphics]):
        self.graph_factory = graph_factory
        
    def click(self) -> Graphic:
        return self.graph_factory()
        
# из клиентского кода
g = GrpahicTool(graph_factory=MusicalNote)

# Чтобы создать со значениями по умолчанию, передавайте фабрику, то есть, лямбду: MusicalNote(note="C5")
g = GraphicTool(graph_factory=lambda: MusicalNote(note="C5"))

Такой паттерн с передачей вызываемого объекта используется в Python повсюду. От set_task_factory() из asyncio  до параметра target в threading.Thread, Python-разработчики привыкли полагаться на фабрики, поскольку фабрики прямолинейные и гибкие.

Почему же в книге «Банды четырёх» не рекомендуется передавать фабрику?

В книге на это дан прямой ответ:

" Прототип особенно полезен в статически типизированных языках вроде C++, где классы не являются объектами, а во время выполнения информации о типе недостаточно или нет вовсе. Меньший интерес данный паттерн представляет для таких языков, как Smalltalk или Objective C, в которых и так уже есть нечто эквивалентное прототипу (а именно — объект-класс) для создания экземпляров каждого класса."

Иными словами, в таких языках, как C++ (особенно до версии C++11) не поддерживалась передача классов или лямбда-выражений как объектов первого сорта. Нельзя было трактовать типы как значения или произвольно передавать фабричные функции. Вот почему в таком контексте было целесообразно использовать паттерн Прототип, при котором клонируется полученный в качестве образца объект — а не создавать новый объект.

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

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

Заключение

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

Но те способы, которыми они были спроектированы в контексте C++ и Java, бывает не так просто с лёгкостью перевести на Python.

В Python прямо из коробки предоставляются мощные возможности: функции как сущности первого класса, гибкие конструкторы, лёгкая мемоизация при помощи functools.lru_cache и динамические типы. При эффективном использовании этих инструментов многие классические паттерны уходят в тень — не потому, что мы пренебрегаем качественным проектированием, а просто потому, что переросли те ограничения, в которых эти паттерны были необходимы.

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

Обычно есть.


Не пропустите нашу распродажу в этот уикенд!

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


  1. Dhwtj
    30.08.2025 09:25

    Хоронили GoF, порвали два баяна.

    Вторая серия


  1. Tishka17
    30.08.2025 09:25

    Нет под рукой книги, открыл Википедию. Приспособленец там сделан через фабрику. Зачем делать через глобальный Стейт - неясно, если только ради статьи.

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