Encapsulation logic, deferred and dynamic initialization, and ordered dynamic initialization
Singletons serve a wide variety of purposes in almost any programming language. In C++, singletons allow encapsulating logical that exists globally within a program. Instead of passing around a heap assigned object across function calls, a singleton’s unique instance can be accessed anywhere. However, it’s important to be careful of initialization order problems when singletons are used. Singletons may also be constructed at different points at runtime, depending on the desired behavior. This article will detail each of these tips and how to best use singletons.
First, let’s define what exactly a singleton is. A singleton is an object with only a single instance that exists within a program. Typically, it cannot be destructed, and lives until the end of the program once constructed.
Singletons are not directly supported in the C++ language but must be implemented using primitive guarantees of initialization behavior.
Let’s take a simple struct,
foobar , and make it into a singleton. Then, the example will demonstrate:
foobar is a singleton struct with the member
value . The
instance() method of
foobar returns the singular instance of the struct. The
static foobar base; inside the
instance() method uses deferred initialization.
As of C++11, the standard guarantees that
static objects within functions only get initialized the first time the function is called, not before
main() gets called, like most other static storage objects do.
Not only that, but there’s also a guarantee that the initialization only happens once. But to double check on that claim, let’s test it. Take 2 threads, make them retrieve the singleton instance a bunch of times, and confirm the constructor only runs once for a single thread.
This test, when run, should print something like:
Main Thread: 0x105bf3dc0Constructed by: 0x700007865000
Where the main thread id is different than the thread that constructs the singleton. This shows that the singleton construction is in fact deferred to when the child threads first call
Singletons allow grouping and encapsulation of global access patterns that would be very difficult to do without them.
One example of a global access pattern is a shared queue, where multiple parts of a program may enqueue or dequeue an object, such as a job. Ideally, such a queue should be thread-safe, and use a mutex. Using the singleton pattern explained before, here’s what the job queue looks like
First of all, this singleton uses r-value references, as opposed to l-value references. Jobs are moved onto the queue as opposed to being copied onto the queue.
The behavior of movement not only helps avoid unnecessary copying, it shapes the idea that a job should only be on the queue or not on the queue, never in both states at the same time.
Anytime, anywhere that
JobQueue::instance()->enqueue(Job&&) is called, the global size of the job queue increases. Any subsequent call to
JobQueue::instance()->dequeue() always reflects the last state from the enqueue call. The initialization of the queue and its thread safety is totally encapsulated within the singleton.
In terms of
static data variables and members, there are two main types of initialization. Deferred initialization is what we described earlier, that a given variable will be initialized the first time it is accessed.
Dynamic initialization is vastly different as it is unordered. This means that the point in time in which the variable is initialized is undetermined. All that one can know is that it will be initialized before
main() is called.
One way to think of that is a dynamically initialized boolean would be indeterminate, it’s unknown if it’s been initialized to
false , or yet to be initialized. Here’s what the C++ reference mentions:
Unordered dynamic initialization, which applies only to (static/thread-local) class template static data members and variable templates (since C++14) that aren’t explicitly specialized. Initialization of such static variables is indeterminately sequenced with respect to all other dynamic initialization except if the program starts a thread before a variable is initialized, in which case its initialization is unsequenced (since C++17). Initialization of such thread-local variables is unsequenced with respect to all other dynamic initialization
The biggest problem with dynamic initialization is the lack of order presents the risk of using an uninitialized variable. This happens when one dynamically initialized
static variable depends on some other dynamically initialized
Since both will be constructed at some point before
main() , there’s no guarantee that the order the programmer may intend for them to be constructed in would in fact be the order used. Here’s an example of a design pattern that’s at risk for that:
In the above, each
Member instance depends on the availability of
regr_manager being constructed and initialized. Since there’s no guarantee of such order, this design could encounter a static initialization ordering bug.
Thus, the solution here would be to convert the use of
Registrar to a singleton that’s deferred initialized. This would ensure that for any
Member there’s always the
Registrar instance that’s available.
There’s one important exception toward the definition of dynamic initialization. You may have noticed that compiling and running the program with
Member objects doesn’t run into problems.
That exception takes place when the relationship between
static Data members are contained within a single translation unit during the compilation process. If that condition is true, then the dynamic initialization does take place, but only in the order in which those variables appear syntactically. Specifically:
Ordered dynamic initialization, which applies to all other non-local variables: within a single translation unit, initialization of these variables is always sequenced in exact order their definitions appear in the source code. Initialization of static variables in different translation units is indeterminately sequenced. Initialization of thread-local variables in different translation units is unsequenced.
Although the above is true, it’s a very unreliable and error prone design choice. That’s because the build steps of a C++ program are external to the language itself. Looking at preprocessor statements like
#include does not indicate whether or not a variable is in a separate translation unit or not.
Some build tools like unity builds paste many
.cpp files into a single file before compilation. Regardless, the point is one should not create a dependency on a particular build arrangement of files in a project that isn’t visible from the language itself.