Перейти к содержимому
Фёдор Башинский

Одиночка

5 min read
Обложка статьи «Одиночка»

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

Проблема

В статье про Прототип двигатели измерялись через DynoService, который обращается к внешнему стенду. У такого стенда есть физическое ограничение: он один, и держать к нему больше одного открытого соединения нельзя — параллельные подключения будут мешать друг другу и портить показания.

Наивный вариант — создавать соединение там, где оно нужно:

class DynoService
{
    public static function measure(string $model): int
    {
        $connection = new DynoStandConnection(); // новое соединение при каждом вызове
        return $connection->measure($model);
    }
}

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

Решение

Одиночка запрещает создавать объект через new напрямую и вместо этого предоставляет единственную точку доступа — статический метод, который либо создаёт экземпляр при первом обращении, либо возвращает уже существующий:

final class DynoStandConnection
{
    private static ?self $instance = null;
 
    private function __construct()
    {
        // Дорогая операция: установка соединения с физическим стендом.
    }
 
    public static function getInstance(): self
    {
        return self::$instance ??= new self();
    }
 
    public function measure(string $model): int
    {
        // ...отправка запроса на стенд и чтение показаний.
    }
}

Ключевые детали:

  • Приватный конструктор — снаружи класса вызвать new DynoStandConnection() невозможно, единственный способ получить объект — getInstance().
  • final — если класс можно унаследовать, наследник может создать свой собственный экземпляр в обход приватного конструктора родителя.
  • self::$instance ??= new self() — экземпляр создаётся лениво, при первом реальном обращении, а не при загрузке класса.

Теперь DynoService работает с единственным на всё приложение соединением:

class DynoService
{
    public static function measure(string $model): int
    {
        return DynoStandConnection::getInstance()->measure($model);
    }
}

Защита от обхода через клонирование и сериализацию

В статье про Прототип clone создавал независимую копию объекта — для Одиночки это ровно та лазейка, которую нужно закрыть, иначе clone DynoStandConnection::getInstance() даст второй экземпляр в обход всей защиты:

final class DynoStandConnection
{
    private static ?self $instance = null;
 
    private function __construct() {}
 
    public static function getInstance(): self
    {
        return self::$instance ??= new self();
    }
 
    private function __clone(): void
    {
        // Приватный __clone запрещает клонирование снаружи класса.
    }
 
    public function __wakeup(): void
    {
        throw new \LogicException('Cannot unserialize a singleton.');
    }
 
    public function measure(string $model): int
    {
        // ...
    }
}

__wakeup() закрывает второй обходной путь — десериализацию через unserialize(), которая тоже создаёт объект в обход конструктора.

Из чего состоит паттерн

  • Одиночка (DynoStandConnection) — единственный класс паттерна: сам хранит свой единственный экземпляр в статическом поле, сам управляет его созданием через статический метод доступа и сам защищает себя от обхода этой защиты.

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

Одиночка — самый спорный паттерн

В отличие от Фабричного метода, Абстрактной фабрики, Строителя и Прототипа, Одиночку часто называют антипаттерном, и на то есть причины:

Скрытая зависимость. Сигнатура DynoService::measure() не показывает, что метод зависит от соединения к стенду — это видно только заглянув внутрь реализации. Обычная зависимость, переданная через конструктор, видна сразу.

Глобальное изменяемое состояние. Одиночка — это, по сути, глобальная переменная с процедурой доступа. Любой код в приложении может незаметно повлиять на его состояние.

Проблемы с тестированием. Тест, использующий DynoService, не может подменить DynoStandConnection на заглушку — метод жёстко обращается к конкретному классу через getInstance(). Состояние одиночки к тому же переживает между тестами, если его не сбрасывать вручную.

Более тестируемая альтернатива — обычный объект, время жизни которого контролирует не сам класс, а внешняя точка сборки приложения (композиционный корень) или DI-контейнер:

class CarDiagnostics
{
    public function __construct(
        private readonly DynoStandConnection $connection,
    ) {}
 
    public function measure(string $model): int
    {
        return $this->connection->measure($model);
    }
}
 
// Единственный экземпляр создаётся один раз в точке сборки приложения...
$connection = new DynoStandConnection();
 
// ...и передаётся явно всем, кому он нужен.
$diagnostics = new CarDiagnostics($connection);

Единственность экземпляра здесь обеспечена не классом DynoStandConnection (который теперь ничем не отличается от обычного класса с публичным конструктором), а дисциплиной сборки: соединение создаётся один раз и передаётся всем зависимым объектам явно. Зависимость видна в конструкторе, а в тестах CarDiagnostics можно передать любую подставную реализацию.

Когда применять

  • Ограничение на единственность продиктовано реальным миром, а не удобством — как физическое соединение к единственному диагностическому стенду, а не «просто чтобы было удобно достучаться откуда угодно».
  • Небольшой скрипт или CLI-утилита, где нет инфраструктуры для явной передачи зависимостей и накладные расходы DI-контейнера не оправданы.

Плюсы и минусы

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

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

Итог

На этом можно закрыть основные порождающие паттерны GoF: Фабричный метод, Абстрактная фабрика, Строитель, Прототип и Одиночка. Первые четыре решают вопрос «как создать объект, не привязываясь к конкретному классу или процессу создания». Одиночка — единственный из пяти, который вместо этого решает вопрос «как гарантировать, что объект будет ровно один», и именно поэтому его стоит применять только тогда, когда единственность — часть предметной области, а не способ спрятать глобальную переменную.