-
Notifications
You must be signed in to change notification settings - Fork 0
Different approach to globals
There is a lot of contention in programming circles on the idea of global variables. Some opponents claim that global state is downright unacceptable while others don't see the harm. What ever your opinion is, one thing is certain: globals are unavoidable.
There's systems you'll have that need to be global. Logging frameworks, allocators, a console system, etc. Often you'll see these things represented by singletons.
While designing Rex I wanted to build a better way of doing globals in C++. There's lots of things that plague globals in C++, some of these include: initialization order fiasco, static globals can be constructed / initialized from many threads concurrently (yes threaded static global initialization is permitted in C++), the lack of runtime reflection tooling and debugging support around them, etc.
This blog post is going to go through a unique and novel approach that Rex uses to make for a very powerful globals system that has none of the problems of traditional globals with a lot of conveniences.
It does it completely with static storage too, no dynamic memory management, no virtual function calls and absolutely no runtime support required. It doesn't even require the C++ standard library.
The most important component is the ability to defer initialization so we can do it on our own when ever, which means we need storage suitably aligned that we can placement new construct an object. This is actually quite easy to express in C++.
template<typename T>
struct deferred_object {
T* data() {
return reinterpret_cast<T*>(storage);
}
void init() {
new (storage) T;
}
void fini() {
data()->~T();
}
alignof(T) char storage[sizeof(T)];
};
The init
and fini
functions allow us to construct and destruct the object. This is very limiting however because we cannot specify constructor arguments for T
. The init
function should probably be:
template<typename... Ts>
void init(Ts&&... arguments) {
new (storage) T(arguments...);
}
The second important component is the ability to build a global registry of deferred objects. There's a neat, completely static way to build a linked-list of objects during initialization by threading linked-list nodes. We can express that quite easily.
struct node {
node() {
if (!head) {
head = this;
}
if (tail) {
prev = tail;
prev->next = this;
}
tail = this;
}
node* next;
node* prev;
static node* head;
static node* tail;
};
Now when we make static instances of this node
type we will get a doubly-linked list that can be iterated forward or reverse starting from head or tail, like so.
// forward
for (node* n = node::head; n; n = node->next) {
// ...
}
// reverse
for (node* n = node::tail; n; n = node->prev) {
// ...
}
- Note: A singly-linked list is not sufficient because we'll want to finalize globals in reverse order they were initialized.
There is a small problem with this though. C++ does allow threaded initialization of statics which means our linked list requires some minimal synchronization to prevent possible corruption during construction. That's easy enough to prevent with a simple spinlock, which I presume you have an implementation of.
struct node {
node() {
lock.lock();
// ...
lock.unlock();
}
// ...
static spin_lock lock;
};
We have a deferred_object
type which is templated over the type, however we have multiple types of globals we may want to create, we need a mechanism to type-erase them so we can represent a linked-list of them. This is easy to do. We just need to store function pointers to specialized functions that do the initialization and finalization for us.
template<typename T>
static void init(char* data) {
new reinterpret_cast<T*>(data) T;
}
template<typename T>
static void fini(char* data) {
reinterpret_cast<T*>(data)->~T();
}
struct deferred_node {
template<typename T>
deferred_object(deferred_object<T>& object)
: m_data(object.storage), m_init(init<T>), m_fini(fini<T>)
{}
void init() { m_init(m_data); }
void fini() { m_fini(m_data); }
char* m_data;
void (*m_init)(char*);
void (*m_fini)(char*);
};
Now we embed the linked list node
inside deferred_node
instead giving us an intrusive linked-list of deferred_node
.
Doing this type-erasure we lose the ability to pass values to the constructor. We need a place to store constructor arguments and unpack them.
This is where things get a little complicated but not too bad. Lets define a recursive template structure that just stores a list of values of any type which we will use to encode arguments. This is basically an implementation of tuple
except much smaller and inlined here.
template<typename F, typename... Rs> // F = first, Rs = rest
struct arguments : arguments<Rs...> {
arguments(F&& first, Rs&&... rest) : arguments(forward<Rs>(rest)...), first(forward<F>(first)){}
F first;
};
template<typename F>
struct arguments<F> {
arguments(F&& first) : first(forward<F>(first)) {}
F first;
};
- The
forward
just enables perfect forwarding in C++, it's not needed but encouraged.
Now we can do arguments<type1, type2, etc>
and initialize it in that order. We'll use this to store our constructor arguments. But before we can do that, we need a way to read an argument from this recursive type by index.
template<int I, typename F, typename... Rs>
struct read {
static auto value(const arguments<F, Rs...>* args) {
return read<I-1, Rs...>::value(args);
};
};
template<typename F, typename... Rs>
struct read {
static F value(const arguments<F, Rs...>* args) {
return args->first;
}
};
template<int I, typename F, typename... Rs>
static auto get(const arguments<F, Rs..>& args) {
return read<I, F, Rs...>::value(&args);
}
Now we can use get<n>(some_argument_object)
to fetch value at index n
. This is just a recursive walk of n
in the inheritance hierarchy of arguments
to read that first
value.
It's not useful to just read a value by index, we need to unpack the whole thing into a constructor call. To do that we need a way to express a sequence of numbers and call get<>
for each number in the sequence and expand that into a constructor call.
template<int...>
struct unpack_sequence {};
template<int N, int... Ns>
struct unpack_arguments : unpack_sequence<N-1, N-1, Ns...> {};
template<int... Ns>
struct unpack_arguments<0, Ns...> {
using type = unpack_sequence<Ns...>;
};
Now doing something like, typename unpack_arguments<sizeof...(Ts)>::type()
will give us a "sequence" object that can be applied recursively.
Now we can create some functions for initializing and finalizing our arguments. The data
field here is supposed to be some type-erased storage for the arguments<Ts...>
.
template<typename... Ts>
static void init_args(char* data, Ts&&... args) {
new (data) arguments<Ts...>(forward<Ts>(args)...);
}
template<typename... Ts>
static void fini_args(char* data) {
using type = arguments<Ts...>;
reinterpret_cast<type*>(data)->~type();
}
Now we can tie it all together. The global can now call the constructor with arguments constructed by init_args
by calling init
below, which will unpack them (calling get<n>
for each) applying it over the constructor. The init_helper
is what enables this.
template<typename T, typename... Ts, int... Ns>
static void init_helper(unpack_sequence<Ns...>, char* args_data, char* object_data) {
new (object_data) T(get<Ns>(*reinterpret_cast<arguments<Ts...>*>(args_data))...);
}
template<typename T, typename... Ts>
static void init(char* args_data, char* object_data) {
init_helper<T, Ts...>(typename unpack_arguments<sizeof...(Ts)>::type(), args_data, object_data);
}
- Note: It's important to call
fini_args
when the arguments are no more because an argument may have a destructor too.
There's not much more to it other than that. I've left out some of the minor headaches of making this into a usable interface. You can see the implementation in Rex for that.
- Explicit control of init and fini of globals.
- No order fiasco.
- Easily debuggable (all your globals are in one data structure that can be printed).
- It's fast.
- It permits organization (Rex has grouping, see below for more info.)
- No need to track externs all over the place.
The actual implementation supports way more features than listed here. In particular:
- Grouping (grouping globals, e.g all loggers, console variables, LUTs have their own groups.)
- Static names for globals and groups.
- Search for globals or groups by name.
- Prevention from multiple init or fini.
- Overloaded operators to enable using the global as if it were a plain object.
- Error checking.
- Constructor arguments can be overridden by explicit
init()
.
The full implementation of this in Rex can be found here: