Одиночка (Singleton)
Гарантирует один экземпляр на процесс и даёт к нему глобальную точку доступа. Первое полезно, второе почти всегда вредно.
Проблема
Настройки приложения читаются один раз и нужны везде: в приветствии, в ценах, в логах. Передавать объект настроек через десять конструкторов кажется утомительным, а создавать его заново в каждом месте — дорого и опасно: копии разойдутся.
Решение
Класс сам следит, чтобы экземпляр был один: конструктор закрыт, статический метод instance() создаёт объект при первом обращении и дальше возвращает его же. Клиент Greeter достаёт настройки через эту точку доступа, не получая их снаружи.
Так приём описан в книге. На практике важно разделить две его половины. «Один экземпляр на процесс» — нормальное требование, и его обеспечивают модуль, пакет или точка сборки приложения. «Глобальная точка доступа» — скрытая зависимость: по сигнатуре Greeter не видно, что он зависит от настроек, а тесты делят состояние между собой. Поэтому тесты ниже вынуждены выставлять значение явно перед каждой проверкой.
Структура
Одиночка держит и правило единственности, и состояние, и точку доступа в одном классе. Это удобно писать и неудобно заменять: подменить AppConfig на тестовый нельзя, не трогая сам класс.
Как это собирается
Одиночку наполняет загрузка приложения: один раз на процесс она читает окружение и кладёт lang в AppConfig::instance(). Дальше Greeter и всё остальное читают настройки через ту же точку доступа, ничего не получая в конструкторе. В примере обработчик запроса, как это часто делает middleware, переключает язык для пользователя, который просит русский, и записывает его прямо в общий объект.
Вывод показывает цену: Ann получила Привет, но и следующий запрос Bob без предпочтений тоже получил Привет, потому что состояние одно на процесс. Вызывающий код вынужден сам возвращать значение после запроса (finally, в Go defer), и только тогда Bob снова видит Hello. Честное решение - не писать в одиночку на запрос, а передавать язык параметром или держать его в объекте запроса.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Клиент во всех языках одинаково короток и одинаково непрозрачен: зависимость от настроек видна только внутри метода. В Go и Python сам язык уже даёт «один экземпляр на процесс» — пакет и модуль, поэтому классический класс там не пишут.
| Язык | Особенность |
|---|---|
| PHP | Классика: приватный конструктор, статическое поле, ??=; __clone закрыт |
| Go | Переменная пакета плюс sync.Once: создание один раз и потокобезопасно; пакет и есть точка доступа |
| Python | Модуль загружается один раз; functools.cache на фабрике делает ленивый одиночка одной строкой |
| TypeScript | Классическая статическая форма ради паттерна; в проекте ES-модуль с export const уже одиночка |
Запуск: make test T=patterns/creational/singleton
Плюсы и минусы
Тесты показывают приём и его цену одновременно: два вызова дают один объект, состояние видно отовсюду, клиент реагирует на настройки. Но каждый тест обязан выставить значение сам, потому что предыдущий мог его оставить, а в Go отдельный тест проверяет, что гонка первых вызовов не создаёт два экземпляра.
- ✅ Ровно один экземпляр, создаётся лениво.
- ✅ Доступ из любого места без проводки зависимостей.
- ❌ Скрытая зависимость: по сигнатуре клиента не видно, от чего он зависит.
- ❌ Общее состояние между тестами и потоками; подменить экземпляр нельзя.
- ❌ Нарушает единственную ответственность: класс и делает дело, и управляет своим временем жизни.
Когда не применять
- Почти всегда в прикладном коде: «один экземпляр» обеспечивает точка сборки приложения или DI-контейнер, а зависимость передаётся явно. См. практику.
- Когда объект нужен в тестах в разных конфигурациях.
- Когда «один на процесс» на самом деле «один на запрос» или «один на пользователя».
Отличия между языками
- PHP — классическая форма работает, но в PHP-FPM «процесс» живёт один запрос, и одиночка часто ничего не экономит. Фреймворки держат один экземпляр в контейнере, а классы его получают через конструктор.
- Go —
sync.Onceрешает потокобезопасность, которой в учебном коде обычно нет. Но пакетная переменная — та же глобальная точка доступа со всеми минусами; в Go её принято заменять явной передачей. - Python — модуль импортируется один раз, объект на уровне модуля и есть одиночка.
functools.cacheдаёт ленивость; для тестов естьapp_config.cache_clear(), и это уже признак проблемы. - TypeScript — модули кэшируются,
export const config = new AppConfig()достаточно. Статическая форма нужна только для ленивого создания или когда класс должен запрещатьnew.
Практика
Учебный одиночка смешивает два требования: один экземпляр и глобальный доступ. В проекте нужно только первое, а второе приносит тесты, зависящие от порядка, и гонки при первом обращении. Практика: тарифы, загруженные один раз оставляет единственность, но убирает точку доступа: ресурс загружается провайдером лениво и потокобезопасно, клиенты получают его через конструктор, а решение «один на процесс» живёт в точке сборки приложения.
Связанные темы
- Практика: тарифы, загруженные один раз — единственность без глобальной точки доступа
- Абстрактная фабрика (Abstract Factory) — конкретные фабрики часто делают одиночками; лучше создать их один раз в точке сборки.