Порождающие паттерны: 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 продукт при мутабельном процессе сборки).
ничего не валидирует C->>B: build() B->>B: проверить инварианты alt инварианты нарушены B-->>C: ValueError с точной причиной else всё согласовано B->>P: сконструировать целиком P-->>C: готовый неизменяемый Request end
Обратите внимание на распределение ответственности: шаги ничего не проверяют, проверяет только 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. Абстракция «класс» заменена на «настроенный экземпляр», вариации живут в данных, а не в коде.
Как выбрать: практическая схема
или сложная валидация?} B -->|Да| C[Builder /
Functional Options] B -->|Нет| D{Конкретный класс
зависит от условий?} D -->|Нет| E[Обычный конструктор.
Паттерн не нужен] D -->|Да| F{Сколько видов
продуктов сразу?} F -->|Один| G{Есть общий алгоритм
для переиспользования?} G -->|Нет| H[Simple Factory:
словарь или match] G -->|Да| I[Factory Method] F -->|Несколько связанных| J[Abstract Factory] A --> K{Создание дороже
копирования?} K -->|Состояние воспроизводимо| L[Prototype] K -->|Ресурс переиспользуем| M[Object Pool] A --> N{Экземпляр обязан
быть один?} N -->|Да| O{Нужен глобальный доступ?} O -->|Нет| P[DI: один экземпляр
в composition root] O -->|Да, честно| Q[Singleton с явным
контрактом жизненного цикла] style E fill:#4a9d7c,fill-opacity:0.2,stroke:#4a9d7c style P fill:#4a9d7c,fill-opacity:0.2,stroke:#4a9d7c style Q fill:#d1685f,fill-opacity:0.2,stroke:#d1685f
Обратите внимание на зелёные узлы: самый частый правильный ответ — «паттерн не нужен» либо «используйте 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) на большом графе. - Самый частый правильный ответ — обычный конструктор. Паттерн вводится, когда вариация уже доказана, а не «на будущее».
Источники
- Gamma E., Helm R., Johnson R., Vlissides J. Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1994.
- Bloch J. Effective Java, 3rd ed. — Items 1–3: статические фабричные методы, builder, enum-синглтон.
- Refactoring.Guru: порождающие паттерны.
- Fowler M. Inversion of Control Containers and the Dependency Injection pattern, FluentInterface.
- Pugh B. The “Double-Checked Locking is Broken” Declaration.
- Hevery M. Singletons are Pathological Liars, Google Testing Blog.
- Cheney D. Functional options for friendly APIs; Uber Go Style Guide.
- Python docs: модуль
copy,sync.Once, Dependency injection в .NET.
Что дальше
Мы научились создавать объекты. Следующий шаг — соединять их: как обернуть чужой интерфейс в свой, навесить поведение без наследования, спрятать подсистему за одним фасадом и построить дерево из однородных элементов.
Структурные паттерны: Adapter, Decorator, Facade, Proxy, Composite
Если хочется сразу увидеть, как порождающие паттерны сочетаются с поведенческими (например, фабрика, выбирающая стратегию), — загляните в поведенческие паттерны.