Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Одиночка и почему его не любят

This lesson is not translated yet – the Russian original is below.

Одиночка – паттерн, гарантирующий, что у класса ровно один экземпляр, и дающий к нему глобальную точку доступа. В C++ он интересен вдвойне: реализация упирается ровно в те механизмы, которые разобраны в этом модуле, а его недостатки – хороший повод поговорить о глобальном состоянии.

Наивная реализация и её проблемы

class Logger {
public:
    static Logger* Instance() {
        if (instance_ == nullptr) {
            instance_ = new Logger;      // кто это удалит?
        }
        return instance_;
    }

private:
    Logger() = default;
    static Logger* instance_;
};

Logger* Logger::instance_ = nullptr;

Что здесь не так:

  • Гонка. Два потока одновременно проходят проверку и создают два объекта.
  • Утечка. delete не вызывается никогда.
  • Копирование не запрещено. Имея указатель, можно сделать Logger copy = *Logger::Instance();.

Реализация Мейерса

Всё это решается локальной статической переменной:

class Logger {
public:
    static Logger& Instance() {
        static Logger instance;    // создаётся при первом вызове, разрушается при выходе
        return instance;
    }

    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

    void Log(const std::string& message);

private:
    Logger() = default;
    ~Logger() = default;
};

Работают три механизма, разобранные ранее:

  1. static внутри функции – переменная создаётся один раз и живёт до конца программы. С C++11 инициализация гарантированно потокобезопасна: параллельные потоки дождутся её завершения.
  2. Приватный конструктор – создать второй объект снаружи нельзя.
  3. = delete – копирование и присваивание запрещены; без этого одна строка размножила бы «единственный» экземпляр.

Возврат по ссылке, а не по указателю, важен: ссылка не бывает пустой, проверять её незачем, и delete по ней никто не вызовет.

Ленивое создание решает и «фиаско порядка статической инициализации»: глобальный объект инициализировался бы до main в непредсказуемом относительно других глобальных объектов порядке, а здесь – в момент первого обращения, то есть заведомо после всего, что для него нужно.

Порядок разрушения

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

Logger::~Logger() {
    Config::Instance().Save();    // а Config уже мог быть разрушен
}

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

Почему его не любят

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

Скрытая зависимость. Функция дёргает Logger::Instance() внутри – по сигнатуре этого не видно. Понять, от чего зависит модуль, можно только вычитав все его тела.

void Process(const Request& r);       // выглядит независимой
// ...
void Process(const Request& r) {
    Logger::Instance().Log("...");    // а на самом деле нет
    Config::Instance().Get("timeout");
}

Тестируемость. Подменить одиночку заглушкой нельзя – метод Instance жёстко зашит в место вызова. Состояние между тестами не сбрасывается, и тесты начинают зависеть от порядка запуска.

Единственность как решение задачи. «У нас один логгер» – утверждение о текущей конфигурации, а не о природе вещей. В тестах хочется свой, в многопользовательском сервисе – по одному на арендатора. Одиночка превращает конфигурационное решение в свойство типа.

Что вместо

Обычно достаточно передать зависимость явно:

class Service {
public:
    explicit Service(Logger& logger) : logger_(logger) {}

    void Process(const Request& r) {
        logger_.Log("processing");
    }

private:
    Logger& logger_;
};

Logger logger;
Service service(logger);       // в тесте подставим другой

Зависимость видна в конструкторе, подменяется в тесте, а объект по-прежнему может быть один – просто это решает вызывающий код, а не сам класс.

Ссылка-поле, как здесь, делает класс некопируемым и требует, чтобы логгер пережил сервис. Если это неудобно, поле делают указателем или shared_ptr. Ссылка при этом честнее сообщает: зависимость обязательна и не меняется.

Когда он всё-таки уместен

Список короткий, но не пустой:

  • Ресурс действительно один по природе – пул потоков процесса, реестр типов, обёртка над единственным устройством.
  • Точка доступа нужна отовсюду и подмена не требуется – глобальный аллокатор, счётчики метрик.
  • Легаси, где протаскивать зависимость через двадцать уровней дороже, чем жить с одиночкой.

Даже тогда полезно отделить одиночку от класса: пусть Logger будет обычным классом, а глобальный доступ – отдельной функцией Logger& GlobalLogger(). Тогда в тестах используется обычный Logger, а глобальный экземпляр остаётся деталью продакшн-сборки.

Главный признак злоупотребления – одиночка с изменяемым состоянием, к которому обращаются из разных потоков. Тогда к скрытым зависимостям добавляются гонки, а точку синхронизации приходится размазывать по всем методам. Если состояние изменяемое и общее – это уже не «паттерн», а разделяемый ресурс со всеми вытекающими.