Введение

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

Итак, представим, что у нас есть некоторые данные Info, которые можно получить из базы данных. Эти данные используются в разных частях программы и не изменяются во время её выполнения. Похоже, это идеальный кандидат для кэширования с помощью Singleton.

Реализация

Набираем Синглтон Майерса.

Не используйте в современном С++ реализацию, приведенную в "Design Patterns" GoF, она имеет много проблем, в частности, data race в многопоточных программах.

class GlobalInfo final
{
public:
   static const GlobalInfo& getInstance() 
   {
      static GlobalInfo instance;
      return instance;
   }

   [[nodiscard]]
   const Info& info() const noexcept
   {
      return info_;
   }
private:
   GlobalInfo() :
      info_(getInfo()) { }
  
   GlobalInfo(const GlobalInfo&) = delete;
   GlobalInfo(GlobalInfo&&) noexcept = delete;
   GlobalInfo& operator=(const GlobalInfo&) = delete;
   GlobalInfo& operator=(GlobalInfo&&) noexcept = delete;
   
   Info info_;
};

И используем.

int proccessInfo()
{
   const auto& info = GlobalInfo::getInstance().info();
   // ...
}

Вроде всё хорошо, и это даже работает, но... тут мы решаем покрыть proccessInfo юнит-тестами.

Упс, функция proccessInfo неявно зависит от GlobalInfo, который невозможно инициализировать в тестовой среде.

Существуют разные способы обойти данную проблему, например, принимать info в качестве аргумента. Но давайте не будем менять сигнатуру proccessInfo, а просто добавим еще одну абстракцию.

Relationships
Relationships

Скажем, что в GlobalInfo находятся не просто глобальные данные, а глобальные данные по умолчанию. А доступ к текущем глобальным данным будем предоставлять через функции getGlobalInfo и setGlobalInfo. Теперь proccessInfo ничего не знает о GlobalInfo и зависит только от интерфейса getGlobalInfo, что уменьшает связность.

Далее я буду использовать глобальные переменные и свободные функции, что не меняет сути, просто мне так больше нравится ?.

Перепишем код.

// global_info.h
// ...

[[nodiscard]]
const Info& getDefaultInfo();

[[nodiscard]]
const Info& getGlobalInfo();

void setGlobalInfo(const Info* new_info) noexcept;
// global_info.cpp
// ...
const Info& getDefaultInfo()
{
   static Info instance = getInfo();
   return instance;
}

constinit std::atomic<const Info*> global_info;

const Info& getGlobalInfo()
{	
   const auto* instance = global_info.load(std::memory_order_relaxed);

   if(!instance) [[unlikely]]
   {
      const auto* default_info = std::addressof(getDefaultInfo());
      global_info.compare_exchange_weak(instance, default_info, 
                                        std::memory_order_relaxed);
      return *default_info;
   }

   return *instance;
}

void setGlobalInfo(const Info* new_info) noexcept
{
   global_info.store(new_info, std::memory_order_relaxed);
}

И используем его.

int proccessInfo() 
{
   const auto& info = getGlobalInfo(); 
   // ... 
}

Теперь функция proccessInfo может быть покрыта юнит-тестами. Для этого достаточно в тестовой среде установить Mock-объект с помощью setGlobalInfo.

Хорошо, но что если кто-то решит установить в setGlobalInfo указатель на объект, который не доживет до конца использования результата getGlobalInfo?

Существует два решения.

Можно просто написать контракт для данной функции в документации. Например, объект, на который указывает new_info при установке значения через setGlobalInfo, должен пережить все вызовы getGlobalInfo, иначе поведение не определено.

Но давайте подумаем, а что на самом деле требуется от new_info? Мы хотим, чтобы это были данные из синглтона. А синглтон это скорее всего функция или объект со статической функцией, который возвращает ссылку на Info. Давайте так и запишем.

Создадим тип InfoHandler, который является указателем на функцию, возвращающую ссылку на Info. В качестве глобального состояния будем хранить указатель на функцию, а не данные.

// global_info.h
// ...

using InfoHandler = const Info& (*)();

[[nodiscard]]
const Info& getDefaultInfo();

[[nodiscard]]
const Info& getGlobalInfo();

void setGlobalInfo(InfoHandler info_handler) noexcept;
// global_info.cpp
// ...
const Info& getDefaultInfo()
{
   static Info instance = getInfo();
   return instance;
}

constinit std::atomic<const Info& (*)()> global_info;

const Info& getGlobalInfo()
{	
   auto instance = global_info.load(std::memory_order_relaxed);

   if(!instance) [[unlikely]]
   {
      global_info.compare_exchange_weak(instance, getDefaultInfo, 
                                        std::memory_order_relaxed);
      return getDefaultInfo();
   }

   return instance();
}

void setGlobalInfo(InfoHandler info_handler) noexcept
{
   global_info.store(info_handler, std::memory_order_relaxed);
}

Производительность

А что насчет производительности?

Сразу хочу отметить, что в данной статье нет попытки сделать самый быстрый синглтон, в противном случае все бы было в хедерах.
Цель — сделать синглтон, который является оптимальным и по производительности, и по практичности.

Я просто взял Google Benchmark и вызывал info/getGlobalInfo в нескольких потоках и вот что получилось.

Performance
Performance

Simple singleton — Синглтон Майерса.
Value singleton — синглтон с getGlobalInfo, хранящий указатель на данные.
Function singleton — синглтон с getGlobalInfo, хранящий указатель на функцию.

Некоторые варианты с продлением жизни объекта, установленного с помощью setGlobalInfo, такие как std::atomic<std::shared_ptr>, std::shared_ptr + std::mutex, были отброшены, так как давали замедление в 30-500 раз.

Учитывая, что время работы вызова ~1 ns и велика погрешность, нельзя утверждать, что синглтон с getGlobalInfo, хранящий указатель на данные, быстрее, чем классический синглтон. Но из графика видно, что они имеют примерно одинаковую производи��ельность.

Кроме того, можно сделать некоторые оценки на основе кода. Рассмотрим получение уже инициализированных по умолчанию данных.

  • При получении данных из классического синглтона имеем: 1 проверка на то, является ли статическая переменная инициализированной; 1 чтение по адресу.

  • При получении данных из синглтона с дополнительной функцией, хранящей указатель на данные, имеем: 1 проверка на то, является ли атомарная переменная инициализированной; 1 чтение по адресу.

  • При получении данных из синглтона с дополнительной функцией, хранящей указатель на функцию, имеем: 1 проверки на то, является ли атомарная переменная инициализированной; 1 проверка на то, является ли статическая переменная инициализированной; 1 дополнительный вызов функции по указатели; 1 чтение по адресу.

Таким образом, удалось сохранить тестируемость кода без потери производительности на абстракцию.