Паттерны проектирования Порождающие паттерны: Factory, Builder, Singleton, Prototype
0%

Порождающие паттерны: Factory, Builder, Singleton, Prototype

Порождающие паттерны: Factory, Builder, Singleton, Prototype

Порождающие (creational) паттерны отвечают на один вопрос: кто и как создаёт объекты. Звучит скучно — пока не столкнёшься с классом, у которого конструктор принимает одиннадцать аргументов, шесть из которых None; с сервисом, который невозможно протестировать, потому что внутри он делает new PostgresConnection(...); или с глобальным Config.instance(), из-за которого два теста не проходят, если запустить их в другом порядке. Всё это — сбои на этапе конструирования.

Зачем выделять создание объектов

Оператор new делает две вещи сразу: решает, какой конкретный класс использовать, и выполняет конструирование. Первое — решение, второе — механика. Когда они слиты в одном выражении в глубине бизнес-логики, получаем два следствия.

Жёсткая связь на этапе компиляции. new PdfReport() внутри OrderService.export() означает: сервис знает про PdfReport, его пакет, его библиотеки. Это нарушение принципа инверсии зависимостей из SOLID — высокоуровневый модуль зависит от низкоуровневой детали.

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

Смещение связности при введении фабрики

Все порождающие паттерны — вариации одной идеи: изолировать точку вариации создания. Различаются они тем, что именно варьируется.

Что варьируется Паттерн
Какой класс создать Factory Method
Целое семейство согласованных классов Abstract Factory
Как собрать сложный объект по шагам Builder
Сколько экземпляров существует Singleton
От чего копировать вместо конструирования Prototype
Откуда взять уже готовый экземпляр Object Pool

Канонический источник — Gamma, Helm, Johnson, Vlissides, Design Patterns (1994). Обзор каталога целиком — в обзорной статье трека.

Factory Method

Интуиция. Кофейня-франшиза: головной офис описал процесс (принять заказ, приготовить напиток, выдать чек), но какой именно напиток умеет делать точка — решает точка. В инструкции офиса написано «приготовить напиток», про матчу он не знает и знать не должен.

Factory Method — ровно это: базовый класс задаёт алгоритм, а решение о конкретном участнике отдаётся наследнику через переопределяемый метод-создатель.

Ключевая деталь, которую часто упускают: export() — это шаблонный метод, вызывающий create_report(). Смысл паттерна не в «функции, которая делает объекты», а в том, что общий алгоритм уже написан, а изменяемая часть вынесена в hook.

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Order:
    id: str
    total: float


class Report(ABC):
    """Продукт: то, что создаёт фабричный метод."""
    @abstractmethod
    def render(self, order: Order) -> bytes: ...


class PdfReport(Report):
    def render(self, order: Order) -> bytes:
        return f"%PDF order={order.id} total={order.total:.2f}".encode()


class CsvReport(Report):
    def render(self, order: Order) -> bytes:
        return f"id,total\n{order.id},{order.total:.2f}".encode()


class Exporter(ABC):
    """Создатель: владеет алгоритмом, но не знает конкретного продукта."""

    @abstractmethod
    def create_report(self) -> Report:
        """Фабричный метод — единственная точка вариации."""

    def export(self, order: Order) -> bytes:
        # Общий алгоритм одинаков для всех форматов и живёт здесь.
        report = self.create_report()
        payload = report.render(order)
        self._audit(order, len(payload))
        return payload

    def _audit(self, order: Order, size: int) -> None:
        print(f"[audit] заказ {order.id}, {size} байт")


class PdfExporter(Exporter):
    def create_report(self) -> Report:
        return PdfReport()


class CsvExporter(Exporter):
    def create_report(self) -> Report:
        return CsvReport()


# Выбор реализации — на границе приложения (composition root, разбор конфига).
EXPORTERS: dict[str, type[Exporter]] = {"pdf": PdfExporter, "csv": CsvExporter}

def build_exporter(fmt: str) -> Exporter:
    try:
        return EXPORTERS[fmt]()          # O(1): словарь вместо цепочки if/elif
    except KeyError:
        raise ValueError(f"неизвестный формат: {fmt}") from None

Simple Factory. Функция build_exporter — это Simple Factory («параметризованная фабрика»). В каталоге GoF её нет, и это нормально: 80 % практических задач решаются именно ей. Правило выбора:

  • выбрать класс по строке или enum → Simple Factory (словарь либо match);
  • переиспользовать уже написанный алгоритм с новым продуктом → Factory Method;
  • создавать несколько связанных объектов согласованноAbstract Factory.

Сложность и цена. Создание — O(1) плюс один виртуальный вызов; в горячих циклах на миллионы итераций это заметно в языках без девиртуализации, в остальном — шум. Настоящая плата — рост числа классов и косвенность: вопрос «кто на самом деле создал этот объект» становится многошаговым. Это главная претензия к паттернам, разбираемая в статье о паттернах в реальном коде.

Abstract Factory

Factory Method создаёт один продукт. Но продуктов бывает несколько, и они обязаны быть из одного семейства. Учебный пример — UI-тулкит, где Button и Checkbox должны быть в одном стиле. Взрослый пример — слой данных: UserRepository, OrderRepository и UnitOfWork обязаны работать с одним соединением и одной транзакцией. Postgres-репозиторий с in-memory unit of work — баг, который проявится только в проде.

from typing import Protocol


class Storage(Protocol):
    """Abstract Factory: интерфейс, создающий согласованное семейство продуктов."""
    def users(self) -> UserRepo: ...
    def orders(self) -> OrderRepo: ...
    def transaction(self) -> Transaction: ...


class PostgresStorage:
    def __init__(self, pool):
        self._pool = pool                       # всё семейство делит одно соединение

    def users(self) -> UserRepo:
        return PgUserRepo(self._pool)

    def orders(self) -> OrderRepo:
        return PgOrderRepo(self._pool)

    def transaction(self) -> Transaction:
        return self._pool.begin()


class InMemoryStorage:
    def __init__(self):
        self._data: dict[str, list] = {"users": [], "orders": []}

    def users(self) -> UserRepo:
        return MemUserRepo(self._data)

    def orders(self) -> OrderRepo:
        return MemOrderRepo(self._data)         # те же данные — семейство согласовано

    def transaction(self) -> Transaction:
        return NullTransaction()

Практическая выгода: весь набор тестов бизнес-логики гоняется на InMemoryStorage без единого мока.

Ограничение, о котором молчат туториалы. Abstract Factory закрыт для добавления новых видов продуктов: чтобы добавить payments(), придётся поменять интерфейс и все реализации. Паттерн открыт для новых семейств и закрыт для новых видов продуктов. Если у вас часто добавляются виды, а семейств всего два — он будет мешать.

Builder

# Как это обычно вырождается
client = HttpClient("https://api.example.com", 30, 3, True, None, None, "gzip", False)

Что означает True на четвёртой позиции? Через полгода — никто не помнит. Именованные аргументы (Python, Kotlin, C#) лечат симптом, но не решают три оставшиеся проблемы: валидацию комбинаций («если включён retry, обязателен backoff» — конструктор превращается в свалку if), пошаговое накопление (часть параметров известна в одном слое, часть в другом) и неизменяемость результата (хочется frozen продукт при мутабельном процессе сборки).

Обратите внимание на распределение ответственности: шаги ничего не проверяют, проверяет только build(). Это принципиально — промежуточное состояние билдера имеет право быть невалидным, иначе порядок вызовов начнёт иметь значение.

from __future__ import annotations
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Request:
    """Результат: полностью валидный и неизменяемый."""
    url: str
    method: str
    timeout_s: float
    retries: int
    backoff_s: float
    headers: tuple[tuple[str, str], ...]


class RequestBuilder:
    def __init__(self, url: str):
        self._url = url
        self._method = "GET"
        self._timeout_s = 10.0
        self._retries = 0
        self._backoff_s = 0.0
        self._headers: list[tuple[str, str]] = []

    # Каждый шаг возвращает self — получается fluent interface.
    def method(self, m: str) -> RequestBuilder:
        self._method = m.upper()
        return self

    def timeout(self, seconds: float) -> RequestBuilder:
        self._timeout_s = seconds
        return self

    def retry(self, times: int, backoff_s: float = 0.5) -> RequestBuilder:
        self._retries, self._backoff_s = times, backoff_s
        return self

    def header(self, name: str, value: str) -> RequestBuilder:
        self._headers.append((name, value))
        return self

    def build(self) -> Request:
        # Вся валидация — здесь, одним местом, с внятными сообщениями.
        if not self._url.startswith(("http://", "https://")):
            raise ValueError(f"url должен быть http(s): {self._url!r}")
        if self._timeout_s <= 0:
            raise ValueError("timeout должен быть положительным")
        if self._retries < 0:
            raise ValueError("retries не может быть отрицательным")
        if self._retries and self._backoff_s <= 0:
            raise ValueError("при retries > 0 нужен положительный backoff")

        return Request(
            url=self._url, method=self._method, timeout_s=self._timeout_s,
            retries=self._retries, backoff_s=self._backoff_s,
            headers=tuple(self._headers),   # снимок: клиент не изменит его задним числом
        )


req = (RequestBuilder("https://api.example.com/orders")
       .method("post").timeout(5).retry(3, backoff_s=0.25)
       .header("X-Trace-Id", "abc-123")
       .build())

Тонкость, которую легко пропустить: tuple(self._headers)защитная копия. Без неё клиент, сохранивший ссылку на билдер, сможет мутировать «неизменяемый» Request после сборки. То же правило действует для любых коллекций и datetime внутри value-объектов.

В каноническом GoF есть ещё роль Director — объект, знающий порядок шагов и переиспользующий его для разных билдеров. На практике он оправдан, когда одна последовательность шагов даёт разные представления: парсер документа одним обходом строит либо HTML, либо plain text, либо оглавление. Если рецепт один — директор лишний.

Functional Options: builder для языков без перегрузок

В Go идиоматичный аналог — functional options, приём, популяризованный Робом Пайком и Дэйвом Чейни (Functional options for friendly APIs). Те же гарантии — значения по умолчанию, необязательные параметры, валидация в одной точке — без промежуточного мутабельного типа в публичном API:

type Client struct {
    baseURL string
    timeout time.Duration
    retries int
    backoff time.Duration
}

// Option — функция, изменяющая конфигурацию.
type Option func(*Client)

func WithTimeout(d time.Duration) Option {
    return func(c *Client) { c.timeout = d }
}

func WithRetry(times int, backoff time.Duration) Option {
    return func(c *Client) { c.retries, c.backoff = times, backoff }
}

// New — единственная точка сборки и валидации.
func New(baseURL string, opts ...Option) (*Client, error) {
    c := &Client{baseURL: baseURL, timeout: 10 * time.Second} // разумные умолчания
    for _, opt := range opts {
        opt(c) // применяем опции в порядке передачи
    }
    if c.retries > 0 && c.backoff <= 0 {
        return nil, errors.New("httpx: при retries > 0 нужен backoff")
    }
    return c, nil
}

Такой стиль рекомендует Uber Go Style Guide; подробнее об идиомах языка — в треке по Go.

Сложность. O(k) по числу шагов, каждый шаг O(1) — сборка Request занимает десятки наносекунд. Память: временный билдер, который сразу уходит в сборщик мусора; на очень горячих путях (парсеры, сериализаторы) его переиспользуют через reset() или пул.

Частые ошибки. Валидация в шагах вместо build() — появляется скрытая зависимость от порядка вызовов. Возврат self из build() — билдер и продукт становятся одним объектом, и «неизменяемый результат» превращается в фикцию. Builder ради двух полей: если параметров меньше четырёх и все обязательные, обычный конструктор понятнее — Джошуа Блох в Effective Java (Item 2) ставит порог примерно на четырёх параметрах. Наконец, переиспользование билдера после build() без явного контракта: либо документируйте одноразовость, либо делайте build() чистым.

Singleton

Singleton гарантирует ровно один экземпляр класса и даёт к нему глобальную точку доступа. В книге GoF это полноправный паттерн; за тридцать лет он стал самым спорным пунктом каталога и регулярно попадает в списки антипаттернов. Претензии по существу:

  • Глобальное состояние под другим именем: любой код может дотянуться до синглтона, и зависимости перестают быть видны в сигнатурах.
  • Смерть тестируемости: состояние переживает тест, порядок тестов влияет на результат. Классический разбор — Singletons are Pathological Liars в Google Testing Blog.
  • Смешение двух решений: «экземпляр должен быть один» (иногда правда) и «доступ к нему глобальный» (почти никогда).

Второе решается инъекцией зависимостей: создать объект один раз в composition root и передавать явно — см. Inversion of Control Containers and the Dependency Injection pattern Мартина Фаулера. Именно так работают DI-контейнеры: services.AddSingleton<T>() в .NET, scope singleton по умолчанию в Spring. Время жизни — единственное, область видимости — контролируемая.

Наивная ленивая инициализация ломается в многопоточной среде: два потока проходят проверку «ещё не создан» одновременно и создают по экземпляру. Если конструктор открывает файл или порт — второй упадёт или, хуже, тихо перетрёт первого.

Python: модуль — уже синглтон. Модули импортируются один раз и кэшируются в sys.modules, поэтому самый идиоматичный синглтон здесь — объект уровня модуля. Так устроены logging.getLogger() и настройки большинства фреймворков.

# config.py — создаётся один раз при первом импорте, это гарантия интерпретатора
settings = Config(dsn=os.environ["DSN"], debug=os.getenv("DEBUG") == "1")

Если нужен именно класс с ленивой потокобезопасной инициализацией:

import threading


class SingletonMeta(type):
    """Потокобезопасный синглтон через метакласс."""
    _instances: dict[type, object] = {}
    _lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        # Быстрый путь без блокировки; словари CPython атомарны под GIL,
        # но полагаться на это в переносимом коде нельзя — замок обязателен.
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:      # повторная проверка под замком
                    cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]


class MetricsRegistry(metaclass=SingletonMeta):
    def __init__(self) -> None:
        self.counters: dict[str, int] = {}

Java: double-checked locking — исторический источник ошибок. До Java 5 идиома была принципиально сломана: модель памяти допускала публикацию частично сконструированного объекта. Каноничный разбор — The “Double-Checked Locking is Broken” Declaration Билла Пью. С Java 5+ она работает, но только с volatile-полем; проще и безопаснее enum-синглтон (Effective Java, Item 3) или holder-идиома.

В C# правильный ответ — Lazy<T>, который берёт синхронизацию на себя; в Go — sync.Once, примитив, специально созданный под однократную инициализацию:

public sealed class MetricsRegistry
{
    // ExecutionAndPublication — гарантия ровно одного вызова фабрики.
    private static readonly Lazy<MetricsRegistry> _instance =
        new(() => new MetricsRegistry(), LazyThreadSafetyMode.ExecutionAndPublication);

    public static MetricsRegistry Instance => _instance.Value;

    private MetricsRegistry() { }   // приватный конструктор закрывает обход
}
var (
    once     sync.Once
    registry *MetricsRegistry
)

func Registry() *MetricsRegistry {
    once.Do(func() { registry = newMetricsRegistry() })
    return registry
}

Практическое правило — два вопроса. Почему экземпляр должен быть один? Если «потому что он дорогой» — это не синглтон, а кэш или пул (см. паттерны конкурентности); если «потому что он владеет уникальным ресурсом» (пул соединений, реестр метрик, лог-подсистема) — единственность оправдана. Почему доступ должен быть глобальным? Почти всегда правильный ответ: не должен. Синглтон-как-время-жизни — хорошо, синглтон-как-глобальная-переменная — плохо.

Prototype

Иногда сконструировать объект с нуля дороже или сложнее, чем скопировать существующий: конструирование требует дорогого I/O (ответ API, распарсенный конфиг, прогретая ML-модель); конфигураций много и они отличаются от эталона двумя полями; конкретный класс клиенту неизвестен — есть только ссылка на интерфейс, а нужен «ещё такой же». Prototype решает это методом clone(): объект сам производит свою копию. В языках с прототипным наследованием это буквально механика языка — Object.create(proto) в JavaScript.

Поверхностная и глубокая копия

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

import copy
from dataclasses import dataclass, field


@dataclass
class Pipeline:
    name: str
    steps: list[str] = field(default_factory=list)
    env: dict[str, str] = field(default_factory=dict)

    def clone(self, **overrides) -> "Pipeline":
        """Глубокая копия плюс точечные переопределения."""
        c = copy.deepcopy(self)
        for key, value in overrides.items():
            setattr(c, key, value)
        return c


base = Pipeline(name="ci", steps=["lint", "test"], env={"CI": "1"})

shallow = copy.copy(base)
shallow.steps.append("deploy")
assert base.steps == ["lint", "test", "deploy"]   # оригинал испорчен!

deep = base.clone(name="ci-nightly")
deep.steps.append("bench")
assert base.steps == ["lint", "test", "deploy"]   # оригинал не тронут

Сложность. Поверхностная копия — O(k) по числу полей. Глубокая — O(n) по размеру всего достижимого графа объектов, и столько же по памяти. CPython рекурсивно обходит граф и ведёт memo-словарь id(объект) → копия: это решает проблему циклических ссылок, но стоит дорого, поэтому в горячем цикле deepcopy легко становится узким местом.

Если нужен контроль над тем, что копируется, а что разделяется, реализуйте __deepcopy__ (документация модуля copy):

class MLScorer:
    def __init__(self, model, threshold: float):
        self._model = model          # тяжёлый и неизменяемый — разделяем
        self.threshold = threshold   # лёгкое состояние — копируем

    def __deepcopy__(self, memo):
        new = self.__class__.__new__(self.__class__)
        memo[id(self)] = new              # регистрируем ДО обхода полей: защита от циклов
        new._model = self._model          # намеренно общий: копировать веса незачем
        new.threshold = copy.deepcopy(self.threshold, memo)
        return new

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

Registry прототипов. Естественное развитие — реестр заготовок: вместо иерархии классов на каждую вариацию храним набор преднастроенных экземпляров.

PROTOTYPES: dict[str, Pipeline] = {
    "python-lib": Pipeline("python-lib", ["lint", "test", "build"], {"PY": "3.12"}),
    "docker-svc": Pipeline("docker-svc", ["lint", "test", "image", "push"], {}),
}

def new_pipeline(kind: str, name: str) -> Pipeline:
    return PROTOTYPES[kind].clone(name=name)

Ровно так работают шаблоны в Kubernetes (PodTemplate внутри Deployment — прототип, из которого штампуются поды) и префабы в Unity. Абстракция «класс» заменена на «настроенный экземпляр», вариации живут в данных, а не в коде.

Как выбрать: практическая схема

Обратите внимание на зелёные узлы: самый частый правильный ответ — «паттерн не нужен» либо «используйте DI». Порождающие паттерны платят сложностью за гибкость, и покупать её стоит только под доказанную потребность в вариации.

Как это выглядит в проде

  • DI-контейнеры — это Abstract Factory плюс управление временем жизни, поднятые на уровень фреймворка: AddSingleton, AddScoped, AddTransient — буквально декларация порождающей стратегии.
  • IHttpClientFactory в .NET существует потому, что наивный new HttpClient() в цикле исчерпывает сокеты, а вечный синглтон не подхватывает изменения DNS (разбор в документации Microsoft). Хрестоматийный пример фабрики, инкапсулирующей нетривиальную политику жизненного цикла.
  • Builder в SDK: StringBuilder, билдеры protobuf-сообщений, Command в Rust, цепочки sqlalchemy.select(...).where(...).limit(...), Django QuerySet — везде приём «мутабельная сборка → иммутабельный результат».
  • Фабрики агрегатов в DDD: агрегат должен рождаться сразу валидным, и нетривиальное конструирование выносится в фабрику — см. трек по DDD.
  • Прототипы в конфигурации: Kubernetes-манифесты, Helm-чарты, Terraform-модули — реестры прототипов, из которых клонируются экземпляры инфраструктуры.

Сводка ошибок

Ошибка Чем оборачивается Что делать
Фабрика на два варианта, которые никогда не менялись Лишний слой косвенности Прямой конструктор
Валидация в шагах билдера Скрытая зависимость от порядка вызовов Всё в build()
Билдер отдаёт мутабельную коллекцию Продукт не иммутабелен Защитная копия
Singleton как способ «дотянуться откуда угодно» Невоспроизводимые тесты, скрытые зависимости DI: единственность без глобальности
Double-checked locking без volatile Гонки, видимые раз в месяц под нагрузкой Lazy<T>, sync.Once, enum-синглтон
copy.copy вместо deepcopy в прототипе Клон незаметно мутирует оригинал Осознанное решение по каждому полю
deepcopy большого графа в горячем цикле O(n) на каждой итерации Разделять неизменяемое, копировать изменяемое
Abstract Factory при частом добавлении видов продуктов Правки во всех реализациях сразу Пересмотреть границы или перейти к композиции

Мини-итог

  • Порождающие паттерны изолируют точку вариации создания — не больше и не меньше.
  • Factory Method варьирует класс продукта через наследование, Simple Factory — через данные, Abstract Factory — гарантирует согласованность семейства и закрыта для новых видов продуктов.
  • Builder разделяет мутабельный процесс сборки и иммутабельный результат; вся валидация — в build(). В Go его роль играют functional options.
  • Singleton полезен как время жизни и вреден как глобальный доступ; в современном коде его роль почти целиком забрал DI-контейнер.
  • Prototype меняет new на clone; главный риск — глубина копирования и цена O(n) на большом графе.
  • Самый частый правильный ответ — обычный конструктор. Паттерн вводится, когда вариация уже доказана, а не «на будущее».

Источники

Что дальше

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

Структурные паттерны: Adapter, Decorator, Facade, Proxy, Composite

Если хочется сразу увидеть, как порождающие паттерны сочетаются с поведенческими (например, фабрика, выбирающая стратегию), — загляните в поведенческие паттерны.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов