Why I love X macros

If you've ever worked with enums in C/C++, you probably know how lacking they are in terms of features. C/C++ enums don't support enum-string conversion, they do not come with support for any error handling, hell C/C++ enums don't even have a way to count the number of elements inside them natively.

This is because enums are essentially just numbers under the hood. The compiler always reduces any enums to straight up numbers. So how do we get these features inside C/C++? This is where X Macros come into picture.

To understand how X macros work, we first need to understand how the C/C++ compiler works. The compiler goes through three phases while compiling code

  • Preprocessing
  • Compiling
  • Linking

Of these three phases, the preprocessing phase is of our primary interest when working with macros. Let's take a closer look at it.

The preprocessor

The C++ pre-processor is fairly simple, it has a few directives available to it which instruct it to do certain things (mostly copy pasting). Of these, the three that we'll need are #include, #define, and #undef.

#include

The include directive is dead simple. All it does is a bit of copy and paste. The pre-processor tries to find the file written after #include, and if it's successful, it just copies and pastes the entire file at the line number. It is a common beginner misconception that the only files that #include works on are header(.h) files, but the truth is, the pre-processor is blind to file extensions. All it cares about are the file names, if it finds it, it will copy-paste it.

This means that you can give some special files different extensions, and the preprocessor will happily #include them.

Remember this, as this will be useful later

#define

This directive is also another form of copy paste that the C++ pre-processor gives you. It allows you to take certain tokens (as a general rule, all space separated words are tokens though there are exceptions), and substitute them with something else. That something else can be literally anything. For example,

#define Token This Is Some Giberrish

Token
Token
aToken
Token followed by more giberrish

This piece of code will be expanded to:

This Is Some Giberrish
This Is Some Giberrish
aToken
This Is Some Giberrish followed by more giberrish

It is worthwhile to note that the code generated by the pre-processor may not necessarily compile. As I said, the preprocessor is pretty much just a dumb copy-paste machine. Code-validity is handled in the next stage of compilation.

This directive also has a neat feature, which is that it allows you to create compile time functions. What I mean by that is that you can create pseudo-functions that can be expanded into slightly different things based on their arguments. Take this code for example:

#define Func(a, b) a + b

Func(10, 11)
Func("String", "String2")
Func(token1, token2)

becomes

10 + 11
"String1" + "String2"
token1 + token2

Copy & Paste, that's all it is. Code validity is up to the compiling stage, which comes after pre-processing.

#undef

The #undef directive is a bit special, in that it asks the pre-processor to remove a macro definition defined by #define. The preprocessor maintains a list of all the macros it has seen in a single compilation unit. A compilation unit is essentially what gets sent to the next stage of the compiler. For most cases, a single .c/.cpp file forms a compilation unit.

This means that any macros in any of your #include-d files will carry over to other files where that file is included. It also means that in case you have multiple definitions of a macro, only the last seen one remains.

To get around this, we have #undef. This directive instructs the pre-processor to forget about a particular macro definition. This code for example:

#define MyCoolMacro 10
#undef MyCoolMacro

MyCoolMacro

will not compile. The compiler will complain about MyCoolMacro being undefined.

This allows us to use a single macro for multiple uses within a single file. For instance, a file where our enum is defined.

Putting it all together

Armed with all this knowledge, we can start supercharging our enums with some neat quality of life features. We'll run through a small example of creating an enum that contains a video game character that can have a few potions applied on it that enhance it's abilities. We'll call that enum Effects.

Defining our enum

Because we'll be using X-macros, the definition of our enum will be slightly different to the way you've normally defined them. We'll make two files, one for containing the elements of our enum, the other for containing the associated functions.

You can name your first file anything(remember that the preprocessor does not care about file names/extensions), I usually like to name mine <Enum>.defs, so for our case I'll call it Effects.defs. In Effects.def:

EFFECT(None,  0)

EFFECT(Fire,  1 << 0)
EFFECT(Ice,   1 << 1)
EFFECT(Water, 1 << 2)
EFFECT(Earth, 1 << 3)

In another file (which I'll call Effects.h), we use this file:

#pragma once

#define EFFECT(name, position) name = position ,
enum class Effects 
{
	#include "Effects.def"
};
#undef EFFECT

Let's break this down step-by-step, We first use #define to define a macro, which we call EFFECT, and so now everywhere the preprocessor sees the token EFFECT, it will try to substitute it for our definition.

Next, we #include our definitions file. We specially prepared this file to use the macro that we would eventually define in our file. We gave it two properties, namely, its actual name, and a bitwise position. We gave it a bitwise position so that we could do something like Effects fireBulbasaur = Effects::Fire | Effects::Earth in order to signify that some potions can have multiple effects.

Finally, we #undef-ed the macro because we will be using it again a few lines later.

Counting the number of elements

If the effects in our game were unchanging, it would be simple to just the hard code the number of elements in the enum to 5. But what if you want to add more effects, such as lightning, or cold. This method of hard coding values quickly becomes error prone and is best avoided for multi-person projects anyway. X macros provide a convenient way to count the number of elements with no runtime cost.

In our Effects.h file:

static constexpr int NumberOfEffects()
{
	return 0
	#define EFFECT(name, position) +1
	#include "Effects.def"
	#undef EFFECT
;
}

The magic again lies in our macro definition, what we've done here is that for each element in our definitions file, we substitute a +1. So the overall expression becomes 0 + 1 + 1 ... +1. The C++ compiler is smart enough to figure out that this definition has some constant value that can be evaluated at compile time, and along with constexpr is able to make compile-time substitutions where ever needed.

Converting elements to strings

To convert this enum to string, we'll create an std::array<char*, maxSize> to essentially create a lookup dictionary. You may use some other data structure that is more efficient in terms of its memory footprint, but I wanted something that could reside on the stack rather than the heap.

The first step to creating this map is to figure out the maxSize number that our array needs to be sized to. We can't simply use the total number of elements, as in our enum, the 'Water' effect is at position 4, and the 'Earth' effect is at position 8. So we need this array to have a size of atleast 9. And to figure out the maximum element in our enum, we are going to use, you guessed it, some more macro magic.

We can define a function in our Effects.h file:

constexpr size_t lastElem(std::initializer_list<size_t> inits)
{
	size_t elem = 0;
	for (auto i: inits)
		elem = i;
	return elem;
}

#define EFFECT(name, position) position ,
constexpr auto largestElement = lastElem({
	#include "Effects.def"
}
);
#undef EFFECT

extern std::array<char*, largestElement + 1> effectsToString;

This function is fairly self explanatory, all it does is that it takes the elements from our enum definitions, and outputs the position of the last element.

Note that this function relies on the last element of the enum to be the largest, you will need modifications to this code in case that is not valid for your use case.

We also mark our effectsToString map as extern since this array will be populated externally from a cpp file. This does mean that there is a run time cost associated with it, however it will need to be populated exactly once per run, and should not have any significant performance hit for any reasonable sized enum.

Now, to populate this array, in our Effects.cpp file, we define the array as:

template<typename T, unsigned n>
std::array<T, n> sparse_array(std::initializer_list<std::pair<size_t, T>> inits)
{
	std::array<T, n> result;
	for (auto&& p : inits)
	{
		result[p.first] = std::move(p.second);
	}
	return result;
}

#define EFFECT(name, number) {number, #name}

std::array<char*, largestElem + 1> KeyCodeStrings = sparse_array<char*, largestElem + 1>({
	#include "Effects.def"
});

#undef EFFECT

This code does a couple of things, first off, the macro we define converts each element into its string representation. The # is a special preprocessor directive that instructs the preprocessor to stringify the argument. What that means here, is that the name of every effect will be converted into its respective string representation by the preprocessor.

We then send off all the strings (along with their respective positions) to the sparse array function, that iterates over every element, and sets the respective position with the correct name.

Pros & Cons

  1. This usage of X macros to supercharge enums is great especially if you're adding or deleting elements to the enums frequently as all our macro magic will just take care of it whenever you recompile.
  2. While I've only used X macros in the context of enums here, they are powerful tools for other things as well. Pretty much anytime you need to repeat lots of code with small variations, macros and templates are your friends.
  3. X macros while may seem free of cost, do come at the cost of developer time. Lots of macros, and templates, and compile time hacks do cost you. They cost you compile time. If your project gets big enough, X macros can come to haunt you with their significant increase in compile times. However, if your macros don't change very often, then that hit to compile time may just be a one off thing.
  4. You may run into extensibility issues. In case you want to add another property to your X macros, you will need to change every define that uses that macro. You should only really use them when you are certain that changes to the structure of the macro will be minimal.

Debugging tips

Macros by themselves are hard to debug, and adding multiple defines, and undefines becomes even harder. To make matters worse, intellisense is usually useless in debugging X macros. In case you can't figure out what's going wrong, you can output the preprocessed file and load it up in an editor of your choice to take a closer look at what the preprocessor is doing to your code.

Both GCC and Visual Studio come with inbuilt options to output the preprocessed files. Loading this file up into your editor should fire up the intellisense and allow you to see what's happening under the hood.

I do suggest removing other #includes wherever possible since some header files (such as iostream) can be thousands of lines long, making it hard to see what part of the file is your code.


Full Code: Github