5.5 Separate Compilation

Separate Compilation
Section titled “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.
File Organisation
Section titled “File Organisation”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:
- Account.h – class declaration.
- Account.cpp – class method implementations.
- Application.cpp – main application file.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”In the context of C++ separate compilation, what is the primary role of a header (.h) file?
Standard Header File Structure
Example: Account.h:
#include <iostream>#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:
g++ Application.cpp Account.cpp -o ApplicationWhere:
Application.cppcontainsmain().Account.cppcontains the implementation of theAccountclass.- The
-o Applicationflag specifies the output executable name.
You can also compile in two stages:
- Compile each
.cppfile into an object file:
g++ -c Account.cpp # produces Account.og++ -c Application.cpp # produces Application.o- Link the object files into an executable:
g++ Account.o Application.o -o ApplicationThis 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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Match Compilation Stages
What is a major advantage of using separate compilation for large software projects?
VS Code automates this build process for us!
The C++ Preprocessor
Section titled “The C++ Preprocessor”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_HHere, 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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”What occurs if a header file is included multiple times in the same translation unit without include guards?
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 ProgrammingThe value of pi is 3.14159constexpr 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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Why is using #define for constants generally discouraged in modern C++ projects?
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
#definefor constants, instead useconstexprorconst. - The preprocessor is powerful, but its role is limited: it does not understand C++, only text substitution and conditional inclusion.
Inline Functions
Section titled “Inline Functions”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+4This 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.
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 = 25On some compilers, forcing inlining may still produce warnings, especially if optimisation is disabled. For example, g++ may warn:
“always inlinable function might not be inlinable”
According to the GCC documentation:
- Functions are not normally inlined unless optimisation is enabled.
inlineis a hint.always_inlineforces inlining where possible, but if the compiler cannot inline, it issues an error or warning.
Best Practice for Inlining
- Prefer
inline(orconstexpr inline) functions over macros. - Let the compiler decide inlining based on optimisation settings.
- Use
always_inlineonly 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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Match Inlining Concepts
How does the C++ compiler handle the 'inline' keyword in modern applications?
© 2026 Derek Molloy, Dublin City University. All rights reserved.