Как стать автором
Обновить

Как использовать Singleton и не потерять тестируемость

Уровень сложностиСредний
Время на прочтение4 мин
Количество просмотров4.6K

Введение

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 чтение по адресу.

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

Теги:
Хабы:
+3
Комментарии7

Публикации

Истории

Работа

Программист C++
110 вакансий
Веб дизайнер
35 вакансий
QT разработчик
11 вакансий

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область