Skip to content

5.5 Separate Compilation

C++ supports separate compilation, where different parts of a program can be compiled independently. This follows the two-stage approach of compilation and linking. With separate compilation, changes to one class do not necessarily require recompilation of all the other classes.

The compiler generates intermediate object files (.o on Linux/macOS, .obj on Windows), which are then combined by the linker into the final executable. With g++, the linking step is handled automatically as part of the compilation command.

This approach allows:

  • Classes to be compiled and tested independently.
  • Source code to be organised into reusable libraries.
  • Large projects to be maintained more efficiently.

It is therefore good practice to place each class in its own source and header files to take advantage of separate compilation.

The source code for each class is normally split into two files:

  • Header file (.h) – contains the class declaration (including method signatures).
  • Source file (.cpp) – contains the method definitions (implementations).

A separate application file will typically contain the main() function. For example, the Account class might be structured as:

  1. Account.h – class declaration.
  2. Account.cpp – class method implementations.
  3. Application.cpp – main application file.
Quiz
Select 0/1

In the context of C++ separate compilation, what is the primary role of a header (.h) file?

To provide the class declaration and method signatures so that other parts of the program can use the class.
To contain the full implementation code for every member method to ensure the compiler can inline them easily.
To act as a temporary storage space for intermediate object code generated during the compilation phase.
To store the main() function and serve as the primary entry point for the entire compiled application.
Code Cloze
C++

Standard Header File Structure

Example: Account.h:

#include <string>
using std::string; // only import what is needed
class Account {
protected:
int accountNumber;
float balance;
string owner;
public:
Account(string owner, float aBalance, int anAccountNumber);
Account(float aBalance, int anAccountNumber);
Account(int anAccountNumber);
Account(const Account &sourceAccount);
// Other member methods...
};

Example: Account.cpp:

#include "Account.h"
using namespace std;
Account::Account(string anOwner, float aBalance, int anAccNumber) :
accountNumber(anAccNumber), balance(aBalance), owner(anOwner) {}
Account::Account(float aBalance, int anAccNumber) :
accountNumber(anAccNumber), balance(aBalance), owner("Not Defined") {}
Account::Account(int anAccNumber) :
accountNumber(anAccNumber), balance(0.0f), owner("Not Defined") {}
Account::Account(const Account &sourceAccount) :
accountNumber(sourceAccount.accountNumber + 1),
balance(0.0f),
owner(sourceAccount.owner) {}
// Other method definitions...

Example: Application.cpp:

#include "Account.h"
int main() {
Account a = Account("Derek Molloy", 35.00, 34234324);
// Application logic here...
return 0;
}

Compiling Separate Compilation Projects with g++

Section titled “Compiling Separate Compilation Projects with g++”

When using g++ at the command prompt, you must compile all source files that are part of the program. For example:

Terminal window
g++ Application.cpp Account.cpp -o Application

Where:

  • Application.cpp contains main().
  • Account.cpp contains the implementation of the Account class.
  • The -o Application flag specifies the output executable name.

You can also compile in two stages:

  1. Compile each .cpp file into an object file:
Terminal window
g++ -c Account.cpp # produces Account.o
g++ -c Application.cpp # produces Application.o
  1. Link the object files into an executable:
Terminal window
g++ Account.o Application.o -o Application

This second approach is particularly useful for large projects, as only modified files need to be recompiled. With this structure, your program is modular, maintainable, and can easily scale to larger projects.

Concept Match

Match Compilation Stages

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Compiler
drag a definition here…
Linker
drag a definition here…
Object File
drag a definition here…
g++ -c
drag a definition here…

Definition Pool

A command-line flag used to compile a source file without performing the linking step.
An intermediate file containing machine code that has not yet been linked.
Combines multiple object files and libraries into a single final executable.
Translates individual source files into intermediate object files (.o or .obj).
Quiz
Select 0/1

What is a major advantage of using separate compilation for large software projects?

🎓Tutor: From source files to an executable

Section titled “🎓Tutor: From source files to an executable”

Predict what g++ does with the Student/UndergradStudent project in each tab, then click any box to see the translation unit, object file or linker table behind the result.

Interactive Tutor

Separate Compilation: Build Pipeline Lab

Setting up VS Code for separate compilation

Section titled “Setting up VS Code for separate compilation”

The g++ commands above work from any terminal, but most of the time you will build and debug from inside VS Code. VS Code does this through its task system: when you press Run or Debug, it reads a file called tasks.json to find out which compiler to run and which arguments to pass to it. The file that the C/C++ extension generates compiles only the file that is open in the editor (the active file). That is fine for the single-file programs in Chapter 3, but with separate compilation the program is spread across several .cpp files. If only one of them is compiled, the linker cannot find the code in the others and the build fails.

The fix is a one-line change to tasks.json. The steps below start from an empty folder and build a small program with two classes, Student and UndergradStudent, so that you can see the failure first and then the fix.

The video below works through Steps 1 to 7. Steps 8 and 9 are described in the text only.

Play

You can also view the video directly on YouTube: https://youtu.be/Ye19rteAFcc

Step 1: Check that the compiler is installed

Section titled “Step 1: Check that the compiler is installed”

You need VS Code with the C/C++ Extension Pack from Microsoft (installed in Tutorial 1 in Chapter 3) and the g++ compiler. On Windows, the recommended compiler is GCC from MinGW-w64, installed through MSYS2. If you have not installed it yet, follow Microsoft’s guide Using GCC with MinGW.

Open a terminal in VS Code (Terminal > New Terminal) and check that g++ is on your path:

PS C:\Users\student> g++ --version

The first line of the output ends with the GCC version number. The video uses GCC 15.2.0, but any recent version works for this section.

If PowerShell reports that the term g++ is not recognised, the compiler’s bin folder (e.g., C:\msys64\ucrt64\bin for MSYS2, or C:\mingw64\bin as in the video) is not on your PATH. Add it, then close and reopen VS Code so that it picks up the new path.

Create a new folder for the project, e.g., EEN1097\StudentApp. In VS Code, choose File > Open Folder… and select it. If VS Code asks whether you trust the authors of the files in this folder, choose Yes, I trust the authors. In Restricted Mode, VS Code does not run build tasks.

Use this folder for this one program only. The change you make in Step 6 compiles every .cpp file in the folder, so the folder must contain only one main() function.

In the Explorer pane, click the New File icon and create Student.h. This is the class declaration:

Student.h
#ifndef STUDENT_H
#define STUDENT_H
#include <string>
using std::string;
class Student {
protected:
string name;
int id;
public:
Student(string name, int id);
void display() const;
};
#endif // STUDENT_H

The #ifndef, #define and #endif lines are include guards. They are explained in The C++ Preprocessor later in this section. For now, type them in as shown; Step 8 shows what goes wrong without them.

Now create Student.cpp, which contains the method definitions:

Student.cpp
#include <iostream>
#include "Student.h"
using namespace std;
Student::Student(string name, int id) : name(name), id(id) {}
void Student::display() const {
cout << "Student " << id << ": " << name << "\n";
}

UndergradStudent is a child class of Student that adds the student’s year of study. Create UndergradStudent.h:

UndergradStudent.h
#ifndef UNDERGRADSTUDENT_H
#define UNDERGRADSTUDENT_H
#include "Student.h"
class UndergradStudent : public Student {
protected:
int year;
public:
UndergradStudent(string name, int id, int year);
void display() const;
};
#endif // UNDERGRADSTUDENT_H

The header includes Student.h because the compiler needs the full declaration of the parent class before it can process class UndergradStudent : public Student.

Then create UndergradStudent.cpp:

UndergradStudent.cpp
#include <iostream>
#include "UndergradStudent.h"
using namespace std;
UndergradStudent::UndergradStudent(string name, int id, int year) :
Student(name, id), year(year) {}
void UndergradStudent::display() const {
Student::display();
cout << " Undergraduate, year " << year << "\n";
}

Step 5: Create the application and try to run it

Section titled “Step 5: Create the application and try to run it”

Create Application.cpp, which contains main():

Application.cpp
#include "Student.h"
#include "UndergradStudent.h"
int main() {
Student s("Derek Molloy", 12345678);
UndergradStudent u("Aoife Byrne", 23456789, 2);
s.display();
u.display();
return 0;
}

Your Explorer pane should now list five files: Application.cpp, Student.cpp, Student.h, UndergradStudent.cpp and UndergradStudent.h.

With Application.cpp as the active tab, click the drop-down arrow beside the Run (▷) button at the top right of the editor and choose Debug C/C++ File. The first time you do this in a folder, VS Code asks you to select a configuration. Choose C/C++: g++.exe build and debug active file. VS Code creates a .vscode folder containing tasks.json and then runs the build.

The build fails. VS Code shows a dialog saying that the preLaunchTask terminated with exit code -1. Click Abort, and look at the Terminal panel. There is one undefined reference error for each Student and UndergradStudent method that main() calls. The output ends like this (the video’s project folder is C:\temp\separate):

...
C:/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/15.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: C:/temp/separate/Application.cpp:9:(.text+0xe2): undefined reference to `UndergradStudent::display() const'
collect2.exe: error: ld returned 1 exit status

Each error line starts with ld.exe, and the last line says that ld returned an error, so these errors come from ld, the linker, not from the compiler. Each error also gives the file and line number of the call that could not be linked, here the call to u.display() in Application.cpp. Application.cpp compiled without errors because the two headers told the compiler that these constructors and methods exist. However, only Application.cpp was compiled, so there is no object code for those methods for the linker to join to main(). The build was the equivalent of typing g++ Application.cpp -o Application at the command line.

Step 6: Edit tasks.json to compile every .cpp file

Section titled “Step 6: Edit tasks.json to compile every .cpp file”

In the Explorer pane, open .vscode/tasks.json. The generated file looks like this (your command path may differ, depending on where the compiler is installed):

{
"tasks": [
{
"type": "cppbuild",
"label": "C/C++: g++.exe build active file",
"command": "C:\\mingw64\\bin\\g++.exe",
"args": [
"-fdiagnostics-color=always",
"-g",
"${file}",
"-o",
"${fileDirname}\\${fileBasenameNoExtension}.exe"
],
"options": {
"cwd": "${fileDirname}"
},
"problemMatcher": [
"$gcc"
],
"group": {
"kind": "build",
"isDefault": true
},
"detail": "Task generated by Debugger."
}
],
"version": "2.0.0"
}

The important fields are:

  • command: the program that VS Code runs, which is the g++ compiler.
  • args: the arguments passed to g++, one per string. -g adds debugging information, and -o names the output file.
  • ${file}: a VS Code variable that holds the full path of the active file. This is why only Application.cpp was compiled.
  • ${fileDirname} and ${fileBasenameNoExtension}: the folder that contains the active file, and the active file’s name without its extension. Together they name the output Application.exe, in the project folder.
  • group: marks this as the default build task, so Ctrl+Shift+B also runs it.

Replace the "${file}" argument with "${fileDirname}\\*.cpp":

"args": [
"-fdiagnostics-color=always",
"-g",
"${file}",
"${fileDirname}\\*.cpp",
"-o",
"${fileDirname}\\${fileBasenameNoExtension}.exe"
],

The \\ is how JSON writes a single backslash, so g++ receives the path C:\...\StudentApp\*.cpp, which matches every .cpp file in the project folder. You never need to edit this line again when you add a class: a new .cpp file in the folder is compiled automatically. Save tasks.json (Ctrl+S).

Click the Application.cpp tab to make it the active file again, and choose Debug C/C++ File from the Run drop-down. This time the build succeeds and the Terminal panel shows:

Student 12345678: Derek Molloy
Student 23456789: Aoife Byrne
Undergraduate, year 2

This build was the equivalent of:

PS C:\Users\student\EEN1097\StudentApp> g++ -g Application.cpp Student.cpp UndergradStudent.cpp -o Application.exe

The name of the executable still comes from the active file. If Student.cpp is the active tab when you press Run, all three files are still compiled, but the program is called Student.exe. Run the program from Application.cpp to avoid this.

Step 8: See why the include guards are needed

Section titled “Step 8: See why the include guards are needed”

Application.cpp includes Student.h twice: once directly, and once more through UndergradStudent.h. To see the effect, comment out the three guard lines in Student.h:

// #ifndef STUDENT_H
// #define STUDENT_H
...
// #endif // STUDENT_H

Build again. This time the compiler fails, before the linker runs, with an error similar to:

Student.h:8:7: error: redefinition of 'class Student'

After the preprocessor has inserted both copies of Student.h, Application.cpp declares class Student twice, which breaks the One Definition Rule. With the guards in place, the second copy is skipped. Remove the // from the three lines, save, and build again to check that the program runs.

Because the build uses -g, the debugger can follow the program from one source file into another. Open UndergradStudent.cpp and click in the margin to the left of the line Student::display(); to set a breakpoint (a red dot). Switch back to Application.cpp and choose Debug C/C++ File. The program stops at the breakpoint. Press Step Into (F11) and the debugger opens Student.cpp at Student::display(). Press Continue (F5) to finish the run.

Before continuing, it is worth briefly discussing the C++ preprocessor. Preprocessor directives are instructions that are handled by the preprocessor before compilation begins. They are not part of the program itself, and they:

  • Must appear on a single line.
  • Do not end with a semicolon (;).

Common Preprocessor Directives: #include – inserts the contents of a header file.

#include <iostream>
#include "Account.h"

#define – defines a macro (often used for constants before C++11).

#define PI 3.14159

#undef – removes a macro definition.

Conditional compilation (#if, #ifdef, #ifndef, #else, #elif, #endif) – allows parts of the program to be included or excluded depending on conditions.

#ifndef MAX_WIDTH
#define MAX_WIDTH 1000
#endif

#line – changes the line number and filename shown in compiler error messages. #error – generates a compiler error and aborts compilation.

#ifndef __cplusplus
#error A C++ compiler is required for this code!
#endif

#pragma – provides compiler-specific options. Commonly used but not standardised across compilers.

Include Guards
When multiple classes depend on the same header file, the preprocessor must ensure headers are not included more than once (otherwise redefinitions would occur). This is typically done with include guards:

#ifndef CURRENTACCOUNT_H
#define CURRENTACCOUNT_H
#include "Account.h"
class CurrentAccount : public Account {
protected:
float overdraftLimit;
// ...
public:
CurrentAccount(int theNumber, string theOwner, float theOverdraftLimit);
// ...
};
#endif // CURRENTACCOUNT_H

Here, the macro CURRENTACCOUNT_H is defined the first time the header is included, and subsequent attempts to include it in the same translation unit are ignored. This enforces the One Definition Rule (ODR): declarations may appear many times, but a definition must appear only once.

Quiz
Select 0/1

What occurs if a header file is included multiple times in the same translation unit without include guards?

The compiler will issue errors because the class and its members would be defined more than once (ODR violation).
The compiler will silently ignore the duplicate inclusions and only process the first instance it encounters.
The preprocessor will compress the file into a single line to save memory space during the compilation phase.
The linker will automatically resolve any name collisions by merging identical class definitions together.
Code Cloze
C++

Implementing Include Guards

Alternative: #pragma once \n An alternative to include guards is the non-standard but widely supported:

#pragma once
#include <iostream>
class Student {
// ...
};

This instructs the compiler to include the file only once. Although simpler, it is not part of the official C++ standard and may be unreliable in unusual filesystem setups (e.g. symbolic links). Unfortunately, Include guards remain the most portable solution.

The key benefits are their simplicity and reduced chance of error as there’s no need to invent a unique macro name, and thus no risk of name collisions or typos. While technically a compiler extension, #pragma once has been adopted by virtually every major compiler (including GCC, Clang, and MSVC) for decades, making it a safe and pragmatic choice for modern C++ projects. It effectively streamlines the process of preventing multiple-inclusion errors, a common hurdle in separate compilation.

Weaknesses of Macros for Constants
The preprocessor is essentially a text substitution tool. It does not understand C++. Using #define for constants can lead to subtle and hard-to-find errors. For example:

#define PI 3.14159;

The preprocessor simply replaces PI with 3.14159;, including the semicolon. This would turn:

area = PI * radius * radius;

into:

area = 3.14159; * radius * radius; // invalid!

leading to obscure compiler errors (as the substitution is not visible in the editor window!).

Modern Alternatives to Macros
Since C++11, you should prefer constexpr or const for constants, as they are type-safe and understood by the compiler:

#include <iostream>
using namespace std;
constexpr double PI = 3.14159;
constexpr const char* EEN1097 = "EEN1097 Edge Programming";
int main() {
cout << "Welcome to " << EEN1097 << "\n";
cout << "The value of pi is " << PI << "\n";
}

This gives the output:

Welcome to EEN1097 Edge Programming
The value of pi is 3.14159

constexpr ensures that the value is a compile-time constant, and unlike #define, it participates in type checking. It can also be used in contexts such as switch statements, where true compile-time constants are required.

Quiz
Select 0/1

Why is using #define for constants generally discouraged in modern C++ projects?

Macros are restricted to only storing integer values and cannot be used for floating-point or string constants.
The use of macros forces the operating system to allocate significant amounts of extra RAM during execution.
The preprocessor cannot handle macros that are defined across multiple files in a separate compilation setup.
Macros do not participate in the compiler's type-checking system, which can lead to subtle and dangerous bugs.
Quiz
Select 0/1

Which C++11 keyword is preferred for defining constants that can be evaluated at compile time?

Important Points about the C++ Preprocessor:

  • Preprocessor directives are processed before compilation.
  • Use include guards (or #pragma once) to prevent multiple inclusions.
  • Avoid #define for constants, instead use constexpr or const.
  • The preprocessor is powerful, but its role is limited: it does not understand C++, only text substitution and conditional inclusion.

In C and early C++, programmers often used macros (#define) to implement short functions. These were expanded by the preprocessor directly into the code, effectively “inlining” the function body wherever it was called. For example:

#define square(x) x*x
int main() {
int y = square(5); // expands to y = 5*5;
}

This produces the expected result of 25.

However, macros come with serious pitfalls. For instance:

int y = square(3+4); // expands to 3+4*3+4

This evaluates as 3 + (4*3) + 4 = 19, not the expected 49, due to operator precedence rules. Macros are simply text substitutions and have no understanding of C++ types or evaluation order. They also provide no type checking, can generate confusing compiler errors, and can cause code bloat (the macro body is duplicated everywhere it is used).

The inline Keyword
Modern C++ replaces function-like macros with inline functions. An inline function behaves like an ordinary function but suggests to the compiler that the function body should be substituted directly at the call site, avoiding the overhead of a function call.

inline int square(int x) {
return x * x;
}
int main() {
int y = square(3+4); // correctly evaluates to 49
}

Unlike macros, inline functions:

  • Respect operator precedence.
  • Are type-safe (the compiler checks argument types).
  • Integrate with namespaces and scoping.
  • Produce clearer compiler error messages.

Compiler Control of Inlining (Advanced)
It is important to understand that the compiler ultimately decides whether or not to inline a function. The inline keyword is a hint, not a command. The compiler may choose not to inline a function if:

  • Optimisation settings do not allow it.
  • The function is too large or complex.
  • The function is recursive or called indirectly.

Typically, very small functions such as accessors and mutators are inlined automatically because the cost of a function call (stack setup, jumps, returns) outweighs the actual computation.

Forcing Inlining
Most compilers (including g++) support attributes that can force inlining, although this is rarely recommended.

#include <iostream>
using namespace std;
int square_forced(int) __attribute__((always_inline));
int square_forced(int x) { // always inlined
return x * x;
}
inline int square_suggested(int x) { // suggested, not forced
return x * x;
}
int main() {
int y = square_forced(5);
int z = square_suggested(5);
cout << "y = " << y << " and z = " << z << "\n";
}

Output:

y = 25 and z = 25

On some compilers, forcing inlining may still produce warnings, especially if optimisation is disabled. For example, g++ may warn: warning: 'always_inline' function might not be inlinable unless also declared 'inline' [-Wattributes]

According to the GCC documentation:

  • Functions are not normally inlined unless optimisation is enabled.
  • inline is a hint.
  • always_inline forces inlining where possible, but if the compiler cannot inline, it issues an error or warning.

Best Practice for Inlining

  • Prefer inline (or constexpr inline) functions over macros.
  • Let the compiler decide inlining based on optimisation settings.
  • Use always_inline only in very performance-critical code after profiling.
  • Avoid over-inlining: excessive inlining increases code size (“bloat”), which can hurt performance due to instruction cache misses.

Rule of thumb: Trust the compiler unless you have hard evidence (from profiling) that manual inlining is needed.

Concept Match

Match Inlining Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

inline Keyword
drag a definition here…
Code Bloat
drag a definition here…
Macro Pitfall
drag a definition here…
Type Safety
drag a definition here…

Definition Pool

A benefit of inline functions over macros where the compiler verifies argument types.
A hint to the compiler to substitute the function body directly at the call site.
An increase in binary size caused by duplicating function code in many different locations.
A text-substitution issue where operator precedence can lead to incorrect evaluation results.
Quiz
Select 0/1

How does the C++ compiler handle the 'inline' keyword in modern applications?