Having all of our code in a single file is starting to become problematic. As we add more and more code, we really need to keep things more organised.
To accomplish this, we first need to familiarise ourselves with the preprocessor.
When we build our code, it is not sent directly to the compiler. Instead, it is first sent to the preprocessor. The preprocessor modifies our source code before the compiler sees it.
The modifications that occur to our source code before it is compiled is sometimes called translation. The files that are generated by translation are sometimes called translation units. It is these translation units that are sent for compilation.
The preprocessor does not modify our original code. It generates temporary copies of our files, and modifies those. These copies are removed after the compiler has completed. Therefore, we normally don't see the modifications the preprocessor does - it happens behind the scenes.
We can give instructions to the preprocessor by including specific instructions in our source code files. These instructions are called directives and, when the preprocessor encounters one, it reacts accordingly.
These pre-processor directives are very powerful. They are, in a sense, a programming language in their own right. They have variables, conditional statements, function-like syntax and more.
However, as the C++ standard evolved and added new options, the need for many of these directives have been removed or reduced.
In this introductory course, we will only cover the two aspects of the preprocessor that remain generally useful: the include directive, and conditional inclusion.
We'll look at the include
directive in the next lesson.
In this lesson, we'll see how we can use a combination of directives to achieve conditional inclusion.
What is the preprocessor?
#ifdef
A common use of the preprocessor is to determine what code is actually included in the software we build.
For example, we often want our software to behave differently when run by a developer, compared to a real world user.
The development version might show a lot more information to help us debug issues, and it might contain a lot of shortcuts to help us test different parts of the game quickly.
Rather than having a totally different project for each of the different versions, it's much easier to have a single project containing all of our code. Then, with the help of the preprocessor, we can generate multiple different versions of our program from the exact same code.
For example, we could generate two different versions (sometimes also called builds) of our software based on whether an option is toggled on or off prior to compilation. A toggle that makes this happen is sometimes called a build flag, or just a flag.
We will cover how to set a flag later in this lesson. For now, lets see how we could modify our code based on whether or not a specific flag was set.
We do that by wrapping the code between #ifdef
and #endif
directives:
void TakeDamage(int Damage) {
Health -= Damage;
#ifdef IS_DEVELOPMENT_BUILD
UseDeveloperCheats();
#endif
}
Now, if we build our software with the IS_DEVELOPMENT_BUILD
flag defined, an additional line of code will be included in our build.
The opposite of #ifdef
is #ifndef
. This will include code if a flag is not defined:
void TakeDamage(int Damage) {
Health -= Damage;
#ifndef IS_PUBLIC_BUILD
UseDeveloperCheats();
#endif
}
We can also use #elif
(which means "else if") and #else
:
void CreateWindow() {
#ifdef IS_DEVELOPER_BUILD
// Internal build
#elif IS_DEMO_BUILD
// Demo build
#else
// Public build
#endif
}
Other common uses for conditional inclusion might be when we are building for multiple platforms. For example, we might need a function to work slightly differently on Mac when compared to Windows.
Another use might be to ship different price tiers of our software where more expensive versions get additional features.
#ifdef
Directives vs if
StatementsA common question at this point is why would we ever need this. After all, we can just do the same thing with if
statements.
The key difference is when the conditional check is done. Preprocessor directives are analysed at build time, whilst if
statements are analysed at run time.
Most things can only be checked at run time, so if
statements are generally going to be much more common and useful.
But, if something doesn't need to be checked at run time, we should consider using the preprocessor instead. Conditional inclusion has two big benefits over if
statements:
#ifdef
directive is evaluated one time - when we build our software. An if
statement is evaluated every time the function is called, by everyone who runs our software.if
statement won't keep it hidden. It is quite easy for someone to reverse engineer and see things we wanted to keep hidden. If we use #ifdef
instead, the code is entirely removed from what we release.What is the purpose of the #ifdef
, #ifndef
, #elif
, #else
and #endif
preprocessor directives?
#define
We've seen how the conditional inclusion directives can remove code from our build based on the presence (or absence) of flags. We set these flags using the #define
directive. For example:
#define IS_DEVELOPMENT_BUILD
Some things to note about #define
are;
IS_DEVELOPMENT_BUILD
is a macro_
as a separatorA macro is available to any other preprocessor directive that is used, from the line the macro was defined, until the end of the file.
Typically, when using these flags for the conditional inclusion use cases described above, we want it to be defined globally, for the entire project.
We do this through our tooling. For example, in Visual Studio, we can define macros globally under Project -> Properties -> C/C++ -> Preprocessor -> Preprocessor Definitions
This integrates with Visual Studios build configuration system, allowing us to define a different set of macros for each configuration.
Build configurations are Visual Studio's way of letting us achieve the goal we described earlier in this section - to easily create different versions of our software from the same code base.
By default, our project was likely created with two configurations - Debug and Release - but we can delete, modify and create more as needed.
How can we define a macro called IS_DEMO
that we could use with an #ifdef
directive?
Macros have some additional properties, too. They're not particularly useful, and are increasingly uncommon as more modern C++ features make them unnecessary. But, they're worth noting briefly, as you're still likely to encounter them.
Firstly, macros can perform text substition in our code. For example, the following directive will replace any occurance of NAME
with the code to create an integer variable with a value of 4.
As such, the second line of code will be int MyVar { 4 };
when it reaches the compiler, thereby creating a variable.
#define CREATE_INT int MyVar { 4 };
CREATE_INT
cout << MyVar;
Secondly, macros can also accept arguments, to generate their replacement text dynamically. This can make them behave somewhat like functions.
The following version of the macro accepts two arguments to be used in the replacement. As such, the second line will become int Hello { 5 };
when it reaches the compiler, and 5
will be logged out.
#define CREATE_INT(Name, Value) int Name { Value };
CREATE_INT(Hello, 5)
cout << Hello;
There are additional interesting properties here, but going deep into what's possible with macros is is out of scope for this course. In addition, macros in general are becoming less useful over time with more modern C++ features get wider support.
For now, I'd just recommend remembering that these function-like macros exist.
You're unlikely to find many use cases for them, especially as a beginner. However, you'll likely find yourself using ones that other people created.
To understand what's going on in these scenarios, it's helpful to recognise that it's a macro you're using, rather than a C++ function.
A common way to identify when you're using a macro is that they tend to have an UPPERCASE_NAME, for example:
PROBABLY_A_MACRO(Hello, World)
Unreal provides a lot of useful utilities in the form of macros, so you're likely to be using them quite heavily if you're writing C++ in that context.
For example, logging to the Unreal console is done using two function-like macros, UE_LOG
and TEXT
:
UE_LOG(LogTemp, Error, TEXT("Something went wrong!"))
Aside from conditional inclusion, the other main use for the preprocessor allows us to import code from other places into our files. We will introduce this in the next lesson!
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way