Lesson 3: Separate Compilation

Lesson 0003 — A Tour of C++, Chapter 3 (§3.1–§3.4.4)

Chapter 1 gave you the building blocks — types, functions, and the machine mapping. Chapter 2 gave you the first abstraction mechanism — user-defined types with encapsulation. Now you need a way to put those types (and the functions that use them) into separate, independently compilable pieces. That is the subject of this chapter: modules, header files, namespaces, and the full story on how arguments are passed and values returned.

Why separate compilation matters now: a real program consists of many source files. Compiling them independently — and linking them afterward — is the foundation of every C++ project. Without it, every change would require recompiling everything. With it, you get faster iteration, cleaner dependency graphs, and the ability to ship libraries whose implementation details are hidden from users.

3.1 Introduction — Interfaces vs. Implementations

A C++ program is made of separately developed parts: functions (§1.2.1), user-defined types (Chapter 2), class hierarchies (§5.5), and templates (Chapter 7). The key to managing all these parts is to clearly define the interactions among them. The first and most important step is distinguishing between the interface to a part and its implementation.

At the language level, C++ represents interfaces with declarations. A declaration says what a function or type looks like — its name, parameter types, return type — without saying how it works. Here is a declaration for sqrt and a class Vector:

double sqrt(double);
class Vector {           // what is needed to use a Vector
public:
    Vector(int s);
    double& operator[](int i);
    int size();
private:
    double* elem;        // elem points to an array of sz doubles
    int sz;
};
// the square root function takes a double and returns a double

3.2 Modularity

Mind your own business — that is the principle behind modularity. The body of a function (its definition) can be "elsewhere," and the representation of a class (its private members) can also be elsewhere. For Vector, we need to define all three member functions eventually — but the user of Vector never needs to see the definition to use it.

Vector::Vector(int s)    // definition of the constructor
    : elem{new double[s]}, sz{s}   // initialize members
{}
double& Vector::operator[](int i)  // definition of subscripting
{ return elem[i]; }
int Vector::size()             // definition of size()
{ return sz; }

An entity can have many declarations but only one definition. A translation unit is a .cpp file that is compiled by itself, including all the header files it #include. A program can consist of thousands of translation units.

3.2 Separate Compilation

C++ supports separate compilation in two ways: header files (§3.2.1) and modules (§3.2.2). Either organizes a program into semi-independent code fragments that can be compiled separately, minimized compilation times, and enforce clean interfaces.

3.2.1 Header Files

Traditionally we place declarations for a piece of code in a file whose name indicates its purpose, with a .h extension. For Vector: Vector.h. Users #include that file to get the interface.

Example: Vector.h and Vector.cpp

Vector.h — the interface:

// Vector.h: class Vector {
public:
    Vector(int s);
    double& operator[](int i);
    int size();
private:
    double* elem;    // elem points to an array of sz doubles
    int sz;
};

user.cpp — uses Vector through the header:

#include "Vector.h"   // get Vector's interface
#include <cmath>      // get the standard-library math interface (sqrt)

double sqrt_sum(const Vector& v) {
    double sum = 0;
    for (int i = 0; i != v.size(); ++i)
        sum += std::sqrt(v[i]);   // sum of square roots
    return sum;
}

Vector.cpp — the implementation, also includes the header it implements:

#include "Vector.h"   // get Vector's interface

Vector::Vector(int s)
    : elem{new double[s]}, sz{s}    // initialize members
{}

double& Vector::operator[](int i) { return elem[i]; }
int Vector::size() { return sz; }

The code in user.cpp and Vector.cpp shares the Vector interface from Vector.h, but the two files are otherwise independent and can be separately compiled:

// compile separately:
// g++ -c user.cpp -o user.o
// g++ -c Vector.cpp -o Vector.o
// g++ user.o Vector.o -o myprogram -lm

Why header files are problematic

The header-file technique for organizing code goes back to the earliest days of C and C++, and it has significant disadvantages:

  1. Compilation time — if you #include a header in 101 translation units, the compiler processes the header's text 101 times.
  2. Order dependencies — the declarations and macros in one header might affect the meaning of code in another header included in a different order.
  3. Inconsistencies — defining an entity (type, function) in two different files with slightly different declarations leads to crashes or subtle errors.
  4. Transitivity — all code needed to express a declaration in a header must be present in that header, which leads to massive code bloat as headers include other headers.

These problems have been a major source of cost and bugs since C's earliest days. Header files remain viable because updating large programs away from them is costly and time-consuming. But they are not the best tool for the job.

3.2.2 Modules

In C++20, we finally have a language-supported way of directly expressing modularity. Consider the Vector and sqrt_sum example expressed as modules:

export module Vector;         // defining the module called "Vector"

export class Vector {
public:
    Vector(int s);
    double& operator[](int i);
    int size();
private:
    double* elem;
    int sz;
};

Vector::Vector(int s) : elem{new double[s]}, sz{s} {}
double& Vector::operator[](int i) { return elem[i]; }
int Vector::size() { return sz; }

export bool operator==(const Vector& v1, const Vector& v2) {
    if (v1.size() != v2.size()) return false;
    for (int i = 0; i != v1.size(); ++i)
        if (v1[i] != v2[i]) return false;
    return true;
}

This defines a module called Vector that exports the class, all its member functions, and the non-member operator==.

Using a module: import

// file user.cpp:
import Vector;               // get Vector's interface (not pasted, compiled once)
#include <cmath>              // get the standard-library math interface (sqrt)

double sqrt_sum(Vector& v) {
    double sum = 0;
    for (int i = 0; i != v.size(); ++i)
        sum += std::sqrt(v[i]);    // sum of square roots
    return sum;
}

I could have imported the standard-library mathematical functions too, but I used the old-fashioned #include just to show that old and new styles can be mixed. Such mixing is essential for gradually upgrading older code from #include to import.

Why modules are fundamentally better: a module is compiled once only. Two modules can be imported in either order without changing their meaning. If you import something into a module, users of your module do not implicitly gain access — import is not transitive. The effects on maintainability and compile-time performance can be spectacular. I have measured the "Hello, World!" program using import std; compile 10 times faster than the version using #include <iostream>, despite std containing the whole standard library. From here on, I assume import std for all examples. Appendix A explains how to get a module std if a standard-library implementation doesn't yet supply it.

When defining a module, we don't have to separate declarations and definitions into different files — we can if it improves organization, but we don't have to. The compiler separates a module's interface (specified by export specifiers) from its implementation details. The exported interface is generated by the compiler and never explicitly named by the user. Using modules, we don't have to complicate our code to hide implementation details; a module only grants access to exported declarations.

export module vector_printer;
import std;

export template<typename T>
void print(std::vector<T>& v) {     // the only function seen by users
    cout << "{\n";
    for (const T& val : v)
        std::cout << " " << val << '\n';
    cout << '}';
}

By importing this trivial module, we don't suddenly gain access to all of the standard library. The template parameter is how we parameterize a function with a type (§7.2).

3.3 Namespaces

In addition to functions (§1.3), classes (§2.3), and enumerations (§2.4), C++ offers namespaces — a mechanism for expressing that some declarations belong together and that their names shouldn't clash with other names. For example:

namespace My_code {
    class complex { /* ... */ };
    complex sqrt(complex);
    int main();
}

int My_code::main() {
    complex z{1, 2};
    auto z2 = sqrt(z);
    std::cout << '{' << z2.real() << ',' << z2.imag() << "}\n";
}

int main() { return My_code::main(); }

The simplest way to access a name in another namespace is to qualify it with the namespace name (std::cout, My_code::main). If repeatedly qualifying a name becomes tedious or distracting, a using-declaration brings a single name into scope:

void my_code(vector<int>& x, vector<int>& y) {
    using std::swap;                  // make std::swap available locally
    swap(x, y);                       // calls std::swap (or another overload)
}

A using-declaration makes a name from a namespace usable as if it were declared in the scope in which it appears. After using std::swap;, it is exactly as if swap had been declared in my_code(), with argument-dependent lookup still choosing the best overload.

To gain access to all names in a namespace, use a using-directive: using namespace std;. This makes unqualified names from std accessible from the scope in which the directive appears:

export module vector_printer;
import std;
using namespace std;                  // implementation detail, local to this module

export template<typename T>
void print(vector<T>& v) {           // can use unqualified "vector", "cout"
    cout << "{\n";
    for (const T& val : v)
        cout << " " << val << '\n';
    cout << '}';
}

Importantly, this use of the namespace directive does not affect users of our module — it is an implementation detail, local to the module. By using a using-directive we lose the ability to selectively use names from that namespace, so this facility should be used carefully, usually for a library that is pervasive in an application (e.g., std) or during a transition for an application that didn't use namespaces.

Namespaces are primarily used to organize larger program components — libraries. They simplify the composition of a program out of separately developed parts.

3.4 Function Arguments and Return Values

The primary and recommended way of passing information between parts of a program is through a function call. Information needed to perform a task is passed as arguments; the results are passed back as return values. The default behavior for both argument passing and value return is to make a copy (§1.9), but many copies can be implicitly optimized to moves.

3.4.1 Argument Passing

By default we copy (pass-by-value). If we want to refer to an object in the caller's environment, we use a reference (pass-by-reference). If we want to refer without modifying, we use a const-reference — by far the most common case in ordinary good code: fast and not error-prone.

Copy vs. reference vs. const-reference:

void test(vector<int> v, vector<int>& rv)
// v is passed by value (copied); rv is passed by reference
{
    v[1] = 99;          // modifies only the local copy
    rv[2] = 66;         // modifies the caller's vector
}

int main() {
    vector<int> fib = {1, 2, 3, 5, 8, 13, 21};
    test(fib, fib);
    cout << fib[1] << ' ' << fib[2] << '\n';   // prints 2 66
}

When performance matters, we pass small values by-value and larger ones by-reference-to-const. "Small" means something cheap to copy — about the size of two or three pointers or less. If it might be significant to your performance, measure.

Default arguments: a value that is considered preferred or the most common case can be specified as a default:

void print(int value, int base = 10);    // print value in base "base"
print(x, 16);    // hexadecimal
print(x, 60);    // sexagesimal (Sumerian)
print(x);        // use the default: decimal

A default argument is a notationally simpler alternative to overloading — one definition handles both the default and the explicit case. Using default arguments means there is only one definition of the function — usually good for comprehension and code size. When different code is needed for different types, overloading is the right choice.

Argument passing summary for built-in types:

Scenario Pass as Why
Small built-in (int, double, pointer) by value Cheap to copy; no aliasing risk
Large built-in (struct wider than 2-3 pointers) by const-reference Avoids copy cost; no mutation
User-defined type you don't want to copy by const-reference Standard idiom for read-only access
User-defined type you want to modify by non-const reference Call-by-reference allows mutation
Optional modification (move semantics) by value (for movable types) Compiler may optimize copy to move

3.4.2 Value Return

Once a result is computed, it needs to get back to the caller. The default for value return is to copy; for small objects that's ideal. We return by reference only when we want to grant the caller access to something local to the function.

Good — returning a reference to a member:

class Vector {
public:
    double& operator[](int i) { return elem[i]; }  // return reference to element
private:
    double* elem;
};

The i-th element of a Vector exists independently of any call to operator[], so we can return a reference to it. The caller reads or writes the element through the reference.

Bad — returning a reference to a local variable:

int& bad() {
    int x;    // local variable, lives on the stack
    return x; // bad: x is destroyed when bad() returns; dangling reference
}

Fortunately, all major C++ compilers will catch the obvious error in bad() (with a warning at minimum, a hard error at best for returning a reference to a local non-static variable).

Returning a reference or a value of a small type is efficient. For large objects we face the question: how do we pass a large result out of a function without copying it? The answer: rely on move semantics and copy elision. A Matrix is expensive to copy but cheap to move:

Matrix operator+(const Matrix& x, const Matrix& y) {
    Matrix res;                              // default-construct result
    // ... compute res[i,j] = x[i,j] + y[i,j] ...
    return res;                              // move or copy-elide: no deep copy
}

Matrix m1, m2;
Matrix m3 = m1 + m2;    // no copy — move or elision

We should not regress to manual memory management: returning a pointer to a heap-allocated object is the "20th century style" — a "complicated and error-prone" pattern that is a major source of memory leaks and dangling pointers in older code. Don't write such code.

3.4.3 Return Type Deduction (auto)

The return type of a function can be deduced from its return value. The compiler looks at what the function returns and uses that as the declared return type:

auto mul(int i, double d) { return i * d; }
// "auto" here means "deduce the return type" → double

This can be convenient — especially for generic functions (§7.3.1) and lambdas (§7.3.3) — but should be used carefully because a deduced type does not offer a stable interface: a change to the implementation of the function can change its type silently, and callers won't know until compile time breaks.

3.4.4 Suffix Return Type

Why does the return type come before the function name and arguments? The reason is mostly historical — tradition from C, Simula, and Fortran. However, sometimes we need to look at the arguments to determine the type of the result. Return type deduction is one example; other examples include namespaces (§3.3), lambdas (§7.3.3), and concepts (§8.2). C++ allows adding the return type after the argument list — called a suffix return type — using the -> syntax:

auto mul(int i, double d) -> double {
    return i * d;    // the return type is explicitly "double"
}

After ->, auto means "the return type will be mentioned later or be deduced (§3.4.3)." Use the suffix form when the type depends on the arguments (e.g., an operator that returns the common type of its operands) or when readability demands it. For simple cases where the type is independent of the arguments, the prefix form is idiomatic.

3.5 Advice

Stroustrup closes the chapter with a subset of the C++ Core Guidelines. All 9 items, with the section where each is introduced:

# Guideline §
1 Think of a program as a set of modules with well-defined dependencies 3.2
2 Use header files for declarations and separate compilation to organize code 3.2.1
3 Prefer modules over header files in new code 3.2.2
4 Use namespaces to prevent name clashes 3.3
5 Avoid using-directives in header files 3.3
6 Pass arguments by const-reference when you need read-only access to large objects 3.4.1
7 Return by value; rely on move semantics and copy elision for large objects 3.4.2
8 Never return a reference or pointer to a local variable 3.4.2
9 Use auto for simple return types; consider suffix return type when the type depends on argument types 3.4.3, 3.4.4

Retrieval Quiz

Header vs. module: compilation

How many times is a header file's text processed by the compiler if it is included in 100 translation units?

Export and module visibility

In a C++20 module, what makes a declaration visible to importers?

using-declaration vs. using-directive

What is the key difference between using-std::swap; and using namespace std;

Dangling reference return

Why is return x; inside int& bad() { int x; ... } a bug?

Suffix return type syntax

What does auto in the suffix position (auto ... -> Type) mean?

Function argument passing strategy

You have a function that reads a large vector but never modifies it. How should you pass it?


Notes

Modularity :-

program many separately developed parts ; key clearly defined interactions

declaration interface ; all that's needed to use a function/type

definition implementation ; all that's needed to run

Headers v/s modules :-

a) header textually #included ; declarations shared

disadvantages compile-time cost, recompilation, ordering, no structure enforcement

b) module compiled once ; export module + import

non-transitive import imported names not re-exported ; export marks visible names

Namespaces :-

namespace prevents name clashes ; expresses logical structure

using-declaration brings one name (eg using std::cout)

using-directive brings every name (eg using namespace std)

ADL argument-dependent lookup ; finds function via argument's namespace

Argument passing :-

a) by value copy ; small types (eg int, double)

b) by reference no copy ; allows modification

c) by const reference no copy + read-only ; default for large objects

default arguments omitted trailing args filled (eg f(x, y=2))

Value return :-

return by value move semantics + copy elision cheap

member reference return ok (eg operator[] returns double&)

local reference return dangling ; never ×

auto return type deduced ; suffix -> Type when type depends on args


Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed., Chapter 3: "Separate Compilation, Modules, and Namespaces." Addison-Wesley.
Reference: Chapter 1–3 Quick Reference & Glossary — keep it beside you while you study.
Recommended supplement: cppreference on namespaces, C++20 modules, and function arguments and return.

Questions? Ask your agent — your teacher — about anything unclear: a quiz answer, the distinction between using-declaration and using-directive, or how separate compilation maps to a real multi-file project structure. Follow-ups are expected, not optional.

Chapters

  1. Lesson 1: The Basics (Ch. 1)
  2. Lesson 2: User-Defined Types (Ch. 2)
  3. Lesson 3: Separate Compilation (Ch. 3 — this lesson)
  4. Lesson 4: Error Handling (Ch. 4)
  5. Lesson 5: Classes (Ch. 5)
  6. Lesson 6: Essential Operations (Ch. 6)
  7. Lesson 7: Templates (Ch. 7)
  8. Lesson 8: Concepts and Generic Programming (Ch. 8)
  9. Lesson 9: Library Overview (Ch. 9)
  10. Lesson 10: Strings and Regular Expressions (Ch. 10)
  11. Lesson 11: Input and Output (Ch. 11)
  12. Lesson 12: Containers (Ch. 12)
  13. Lesson 13: Algorithms (Ch. 13)
  14. Lesson 14: Ranges (Ch. 14)
  15. Lesson 15: Pointers and Containers (Ch. 15)
  16. Lesson 16: Utilities (Ch. 16)
  17. Lesson 17: Numerics (Ch. 17)
  18. Lesson 18: Concurrency (Ch. 18)
  19. Lesson 19: History and Compatibility (Ch. 19)
← Course Dashboard ← Ch. 2: User-Defined Types Ch. 4: Error Handling → Quick Reference →