This lesson is a quick introductory tour of header files and the build process within C++. It is not intended for those who are entirely new to programming. Rather, the people who may find it useful include:
It summarises several lessons from our introductory course. Those looking for more thorough explanations or additional context should consider completing Chapter 7 of that course.
In C++, building code is broken into three steps:
#include
, to generate translation unitsWhen we declare a function without defining it we are, in effect, telling the compiler that we’re going to define the function elsewhere. We’re telling the compiler that the linker will take care of it.
So, as long as our function is defined somewhere in the translation units that are sent to the compiler, the linker will be able to complete the build process successfully.
We previously mentioned functions can be declared and defined in different places. This separation is most typically done to split our code into header files and source files.
This is most common when dealing with class member functions
It is conventional for large classes to be split into two files - a "header" file to store the declarations, and a "source" file to store the definitions. Header files typically have the .h
extension, whilst source files often use .cpp
:
// Character.h
class Character {
int Health{100};
void TakeDamage(int Damage);
};
// Character.cpp
#include "Character.h"
void Character::TakeDamage(int Damage) {
Health -= Damage;
}
This is a convention not widely adopted outside of C++, so it’s worth giving a slightly longer explanation.
One benefit of this approach is that it gives us a quick way to get an overview of a class. We only need to look at the header file to get a good sense of what it does.
However, more importantly, it allows us to adopt a convention where we only need to #include
header files rather than the full implementations. This keeps compilation times to a minimum.
The way we communicate with the preprocessor is through preprocessor directives. We already covered the #include
directive, but there are a few more
#define
directiveThe #define
directive lets us define a block of text, which we can then reuse across our file. The thing we define is typically called a macro. To distinguish macros from regular variables, we typically give them UPPERCASE names, but this is not required.
Note that preprocessor directives are not C++ statements, so we do not need to include semicolons.
In the following code, the preprocessor will replace INTEGER_TYPE
with int
#define INTEGER_TYPE int
INTEGER_TYPE MyVariable{5};
As such, what the compiler sees will simply be:
int MyVariable{5};
There is a more advanced form of this, which allows us to pass arguments to our macros, effectively turning them into functions. This is something we cover later in this course.
#if
, #ifdef
, #endif
and friendsThe preprocessor includes support for conditional logic. This allows us to control which parts of our code get included or excluded from the compilation process.
#include <iostream>
#define DAY 7
int main() {
#if DAY >= 6
std::cout << "It's the weekend";
#endif
}
Typically, our conditional inclusion is based on whether or not we have defined a macro at all. This is used to generate different versions of our software. For example, we’re likely to have a "developer" or "debug" version of our software.
This version will include extra code to help us visualize what is going on during the development process, but we want it to be removed from what we ship to our users.
We can set this up by wrapping any such code in a directive that checks if a specific macro is defined
#include <iostream>
int main() {
#if defined(DEBUG_MODE)
std::cout << "Logging extra diagnostics";
#endif
}
Our build tools will give us the option to inject a preprocessor definition across our entire project, thereby letting us create these alternative builds in a simple, automated way.
The #if defined(SOMETHING)
pattern is so common, there is a shortened form - #ifdef
:
#ifdef DEBUG_MODE
// ...
#endif
#if
, #ifdef
, etc) vs Runtime Conditionals (if
, else
, etc)Note that conditional inclusion is not equivalent to standard conditional logic, such as an if
statement. Conditional inclusion is done at compile time, whilst if
statements are at run time. This has two implications:
We also have some additional preprocessor directives that can help us build more advanced conditional logic:
#else
- The basic equivalent of an else statement#elif
- The preprocessor equivalent of an else-if statement#ifndef
- "if not defined" - for example, #ifndef SOMETHING
is equivalent to #if !defined(SOMETHING)
The following uses these directives in a more complex example, which includes nested conditionals:
#include <iostream>
#define DAY 7
int main(){
#ifndef DAY
std::cout << "I don't know what day it is";
#elif DAY >= 6
std::cout << "It's the weekend";
#if DAY == 6
std::cout << " (Saturday)";
#else
std::cout << " (Sunday)";
#endif
#else
std::cout << "It's a weekday";
#endif
}
It's the weekend (Sunday)
Two additional directives were added in the C++23 language specification, and may not yet be available in all compilers:
#elifdef
- "else if defined" - for example, #elifdef SOMETHING
is equivalent to #elif defined(SOMETHING)
#elifndef
- "else if not defined" - for example, #elifndef SOMETHING
is equivalent to #elif !defined(SOMETHING)
#pragma once
directiveWhen we use the #include
directive, code from one file effectively gets copied and pasted into another. This can cause problems when a file gets included multiple times. This usually happens indirectly - for example:
File A
includes File B
and File C
File B
includes File C
Then File A
ends up including File C
twice. Once directly, and once indirectly through File B
We prevent this using header guards, which are a form of conditional inclusion:
#ifndef MyClass
#define MyClass
class MyClass {
// ...
};
#endif
The first time the above file gets included, MyClass
is not defined within the preprocessor, so the entire contents of the file are copied across.
That includes a definition for MyClass
so, after the first inclusion, MyClass
is then defined. If the file is included a second time, the #ifndef
directive detects the definition, so everything until #endif
(effectively the whole file) gets excluded.
This pattern is so common that a dedicated directive is available to handle the use case - the #pragma once
directive:
#pragma once
class MyClass {
// ...
};
At this point, seems like the preprocessor is pretty much a programming language in its own right.
If the concept of a programming language within a programming language seems slightly off, you’re not alone in thinking that. It is a long-term goal of C++ to reduce the relevance of the preprocessor, by covering its use cases within the base C++ language.
For example, the C++17 spec introduced if constexpr
statements. These are a variant of the basic if
statement that eliminates branches of code at compile time and is, therefore, an alternative to the preprocessor’s conditional inclusion features
C++20 introduced modules, which allow us to organize our code into smaller parts without requiring any #include
directives to bring it back together.
We cover both of these later in this course, but C++ development tends to be slower and more cautious than what we might be accustomed to from other languages.
It can take years for compilers to support new language features, and decades for them to become widely adopted within real-world projects. So, we need to be comfortable with the preprocessor for quite some time yet.
The C++ build process involves three main steps: preprocessing, compiling, and linking. Preprocessor directives, header files, and source files play crucial roles in organizing and manipulating code before compilation. Key takeaways:
Learn the fundamentals of the C++ build process, including the roles of the preprocessor, compiler, and linker.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games