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;
};
Работают три механизма, разобранные ранее:
staticвнутри функции – переменная создаётся один раз и живёт до конца программы. С C++11 инициализация гарантированно потокобезопасна: параллельные потоки дождутся её завершения.- Приватный конструктор – создать второй объект снаружи нельзя.
= 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, а глобальный экземпляр остаётся деталью продакшн-сборки.
Главный признак злоупотребления – одиночка с изменяемым состоянием, к которому обращаются из разных потоков. Тогда к скрытым зависимостям добавляются гонки, а точку синхронизации приходится размазывать по всем методам. Если состояние изменяемое и общее – это уже не «паттерн», а разделяемый ресурс со всеми вытекающими.