How to use Singleton without losing testability

Introduction

Singleton is a generative design pattern that ensures that an object exists in only one instance and provides a global point of access to it (modern critics consider this an implementation pattern, not a design pattern).

So let's imagine we have some data Info, which can be obtained from the database. This data is used in different parts of the program and does not change during its execution. This looks like an ideal candidate for caching with Singleton.

Implementation

We're recruiting Singleton Myers.

Do not use the implementation given in GoF's “Design Patterns” in modern C++, it has many problems, in particular data race in multi-threaded programs.

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_;
};

And we use it.

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

Everything seems to be fine, and it even works, but… here we decide to cover processInfo unit tests.

Oops, function processInfo implicitly depends on GlobalInfowhich cannot be initialized in the test environment.

There are different ways to get around this problem, such as taking info as an argument. But let's not change the signature processInfobut just add one more abstraction.

Relationships

Relationships

Let's say that in GlobalInfo there is not just global data, but global default data. And access to current global data we will provide through functions getGlobalInfo And setGlobalInfo. Now processInfo knows nothing about GlobalInfo and depends only on the interface getGlobalInfowhich reduces connectivity.

Next, I will use global variables and free functions, which does not change the essence, I just like it better 🙂

Let's rewrite the code.

// 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);
}

And we use it.

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

Now the function processInfo can be covered by unit tests. To do this, just install a Mock object in the test environment using setGlobalInfo.

Okay, but what if someone decides to set setGlobalInfo to a pointer to an object that doesn't live to see the result of getGlobalInfo used?

There are two solutions.

You can simply write a contract for this function in the documentation. For example, the object pointed to new_info when setting the value via setGlobalInfomust survive all the challenges getGlobalInfootherwise behavior undefined.

But let's think about what is actually required from new_info? We want this to be data from a singleton. A singleton is most likely a function or an object with a static function that returns a reference to Info. Let's write it down like that.

Let's create a type InfoHandlerwhich is a pointer to a function that returns a reference to Info. We will store a function pointer as a global state, not data.

// 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);
}

Performance

What about performance?

I would like to note right away that in this article there is no attempt to make the fastest singleton, otherwise everything would be in the headers.
The goal is to make a singleton that is optimal in both performance and usability.

I just took Google Benchmark and called info/getGlobalInfo in several threads and this is what happened.

Performance

Performance

Simple singleton – Myers' Singleton.
Value singleton – singleton with getGlobalInfowhich stores a pointer to data.
Function singleton – singleton with getGlobalInfowhich stores a pointer to a function.

Some options for extending the life of an object installed using setGlobalInfosuch as std::atomic, std::shared_ptr + std::mutexwere discarded because they slowed down by 30-500 times.

Considering that the call operation time is ~1 ns and the error is large, it cannot be said that a singleton with getGlobalInfo, which stores a pointer to data, is faster than a classic singleton. But the graph shows that they have approximately the same performance.

It is also possible to make some estimates based on the code. Let's consider getting data already initialized by default.

  • When receiving data from a classic singleton, we have: 1 check to see if the static variable is initialized; 1 reading at address.

  • When receiving data from a singleton with an additional function storing a pointer to the data, we have: 1 check to see if the atomic variable is initialized; 1 reading at address.

  • When receiving data from a singleton with an additional function storing a function pointer, we have: 1 checks to see if the atomic variable is initialized; 1 check whether the static variable is initialized; 1 additional function call by pointers; 1 reading at address.

Thus, it was possible to maintain testability of the code without losing performance due to abstraction.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *