Preprocessor Directives and the Build Process

Learn the fundamentals of the C++ build process, including the roles of the preprocessor, compiler, and linker.

Ryan McCombe
Updated

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:

  • those who have completed our introductory course, but want a quick review
  • those who are already familiar with programming in another language, but are new to C++
  • those who have used C++ in the past, but would benefit from a refresher

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.

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

The Build Process

In C++, building code is broken into three steps:

  • The preprocessor executes all our preprocessor directives, such as #include, to generate translation units
  • The compiler translates each of these translation units into collections of machine code called object files
  • The linker connects the object files together, into a single coherent package (eg, an executable file, or a library)

When 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.

Header 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.

Preprocessor Directives

The way we communicate with the preprocessor is through preprocessor directives. We already covered the #include directive, but there are a few more

The #define directive

The #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 covered in more detail in the introductory course:

Preprocessor Definitions

Explore the essential concepts of C++ preprocessing, from understanding directives to implementing macros

Conditional Inclusion - #if, #ifdef, #endif and friends

The 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

We also have some additional preprocessor directives that can help us build more advanced conditional logic:

  • #else - The preprocessor 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)

Header Guards and the #pragma once Directive

When 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:

  • if File A includes File B and File C
  • and 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 is simply the name given to using regular preprocessor directives to solve this specific problem. It looks like this:

#ifndef MyClass
#define MyClass

class MyClass {
  // ...
};

#endif

The first time the above file gets included into a source file, MyClass is not defined within that source file, 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 {
  // ...
};

Summary

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:

  • The preprocessor executes directives and generates translation units
  • The compiler translates translation units into object files
  • The linker connects object files into an executable or library
  • Header files (.h) typically contain class and function declarations
  • Source files (.cpp) contain the corresponding definitions
  • Key preprocessor directives: #include, #define, conditional inclusion (#if, #ifdef, etc.), and #pragma once
Next Lesson
Lesson 8 of 129

Understanding Reference and Pointer Types

Learn the fundamentals of references, pointers, and the const keyword in C++ programming.

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Defining Macros with Arguments
How can I define a macro that takes arguments in C++?
#ifdef vs #if defined()
What is the difference between #ifdef and #if defined() in C++?
Preventing Multiple Header Inclusion
What are the best practices to prevent multiple inclusion of header files?
Using #ifdef for Platform-Specific Code
How can I use #ifdef to write platform-specific code in C++?
Defining Constants with #define
Should I use #define to define constants in C++?
Using #pragma once in Header Files
What are the advantages of using #pragma once in header files?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant