When we build our code, it is not immediately compiled. Instead, it is sent to the preprocessor.
The preprocessor is a behind-the-scenes tool. It modifies our code based on special instructions we insert into our source files, known as preprocessor directives.
The preprocessor does not modify our original code. It generates temporary, intermediate copies of our files, and modifies those. Therefore, we normally don't see the modifications the preprocessor does - it happens behind the scenes.
Once the preprocessor has finished generating these temporary copies, with all the changes based on the directives we supply, they are sent off for compilation.
In this lesson, we'll explore how to use these directives to make our projects more flexible and efficient.
The modifications to our source code before it is compiled are 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.
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 preprocessor directives are very powerful. They are a programming language in their own right. They have variables, conditional statements, function-like syntax, and more.
These preprocessor directives give us a range of new abilities, which we’ll cover in this chapter. They include:
What is the preprocessor?
A common use of the preprocessor is to determine what code is 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. To do this, we create different versions, or releases, of our software.
The development version might show a lot more information to help us debug issues. In the releases we use for demos, or ultimately send to customers, all of that additional code is stripped out.
We can do that by wrapping the code we want to conditionally include between #ifdef
and #endif
directives:
#include <iostream>
using namespace std;
int main(){
cout << "Hello There";
#ifdef DEVELOPMENT_BUILD
cout << "\nThis is a developer build";
#endif
}
In this example, DEVELOPMENT_BUILD
is a preprocessor definition that we can choose to set or not. We show how to set this in the next section.
If we set DEVELOPMENT_BUILD
, our output will be this:
Hello There
This is a developer build
If we don’t, our program will output:
Hello There
The opposite of #ifdef
is #ifndef
. This will include code if a flag is not defined:
#include <iostream>
using namespace std;
int main(){
cout << "Hello There";
#ifndef DEVELOPMENT_BUILD
cout << "\nThis is a public build";
#endif
}
We can also use #elif
(which means "else if") and #else
:
#include <iostream>
using namespace std;
int main(){
cout << "Hello There";
#ifdef DEVELOPER_BUILD
cout << "\nThis is a developer build";
#elif DEMO_BUILD
cout << "\nThis is a demo build";
#else
out << "\nThis is a public build";
#endif
}
How we set a preprocessor definition, such as our DEVELOPMENT_BUILD
example, depends on our IDE.
In Visual Studio, we can set it under Project -> Properties -> C/C++ -> Preprocessor -> Preprocessor Definitions
Preprocessor definitions are part of Visual Studio’s release configuration system. Within Visual Studio, we can define multiple configurations, and two are usually provided by default - Debug and Release.
We can create more configurations as needed. Each configuration can have its own set of preprocessor definitions (and other settings), and we can then switch between configurations quickly through the user interface.
Other IDEs commonly have similar options.
#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 analyzed at build time, whilst if
statements are analyzed 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 want 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
DirectiveAs we saw in the previous section, if we want to provide a preprocessor definition across our whole project, we typically do it through our build tools.
But we don’t have to - we can just define things within our source files. For example, we can define a flag like this:
#define DEVELOPMENT_BUILD
In files where this directive exists, the effect is equivalent to having set it through our tooling:
#define DEVELOPMENT_BUILD
#include <iostream>
using namespace std;
int main(){
cout << "Hello There";
#ifdef DEVELOPMENT_BUILD
cout << "\nThis is a developer build";
#endif
}
Hello There
This is a developer build
Some things to note about #define
are:
DEVELOPMENT_BUILD
is a macro_
as a separatorHow can we define a macro called IS_DEMO
that we could use with an #ifdef
directive?
The utility of macros goes beyond simple defined or not-defined logic.
Text substitution macros allow us to #define
blocks of code, and then easily insert those blocks of code elsewhere in our project as needed.
The following sections can make people a little nervous but don’t worry. It’s fairly uncommon that we need to create text replacement macros. As a beginner, we’re much more likely to be using these macros than defining them.
So if the following examples are a bit uncomfortable, just treat them as demonstrations of the types of things that are possible with macros.
Below, we create a DEFINE_INT
macro. This will replace any instances of DEFINE_INT
in our file with the code to create an integer variable with a value of 4
.
#define DEFINE_INT int MyInt{4};
#include <iostream>
using namespace std;
struct MyType {
DEFINE_INT
};
int main(){
DEFINE_INT
cout << "MyInt: " << MyInt << '\n';
MyType MyObject;
cout << "MyObject.MyInt: " << MyObject.MyInt;
}
MyInt: 4
MyObject.MyInt: 4
Below, we define a macro for creating functions. Preprocessor definitions can span multiple lines, by using the \
character as a line break.
In the following examples, we added additional white space to distinguish our C++ code from preprocessor code, but it’s not required:
#define DEFINE_GREET \
void Greet(){ \
cout << "Hello There\n"; \
};
#include <iostream>
using namespace std;
struct MyType {
DEFINE_GREET
};
DEFINE_GREET
int main(){
Greet();
MyType MyObject;
MyObject.Greet();
}
Hello There
Hello There
Secondly, macros can accept arguments to use within their replacement text. This can make them behave somewhat like functions.
The following version of the macro accepts two arguments to be used in the replacement:
#define DEFINE_GREET(Greeting, Name) \
void Greet() { \
cout << Greeting << ", I am " << Name \
<< "\nNice to meet you!\n\n"; \
};
#include <iostream>
using namespace std;
struct MyType {
DEFINE_GREET("Howdy", "the MyType class")
};
DEFINE_GREET("Hi there", "a free function")
int main(){
Greet();
MyType MyObject;
MyObject.Greet();
}
Hi there, I am a free function
Nice to meet you!
Howdy, I am the MyType class
Nice to meet you!
Again, don’t worry if this all looks a little weird. We’re unlikely to need to write code like this as a beginner. It’s more important at this stage to understand what macros are and how to use them.
A common way to identify when you're using a macro is that they tend to have a 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 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("Hello!"))
In this lesson, we've explored the essentials of the preprocessor. We covered:
#ifdef
, #ifndef
, and related directives for compiling code conditionally.#include
directiveAside from conditional inclusion, the other main preprocessor directive we use is #include
.
This allows us to import code from other places into our files, like we’ve been doing with #include <iostream>
.
In the next lesson, we’ll cover how the #include
directive works, and how we can make full use of it.
Explore the essential concepts of C++ preprocessing, from understanding directives to implementing macros
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way