Polyglot
Languages C++ modules
C++ § modules

Modules and translation units

C++ has two compilation models. The classic inclusion model, inherited from C, is the model that has been in production since the language’s beginning: source files include header files, the preprocessor textually substitutes the included content, and each resulting translation unit is compiled independently. The C++20 module system is the alternative: separately-compiled, named, importable units that bypass the textual-inclusion mechanism, with substantially better encapsulation and substantially faster builds. The classic model remains dominant; module support and tooling are still maturing across compilers, and code targeting older standards or mixed environments continues to use the inclusion model. This page covers both, with emphasis on the inclusion model (the conventional state of the world) and a survey of modules (the future trajectory).

The translation pipeline

A C++ program is built in four conventional stages:

  1. Preprocessing#include directives are expanded; macros are substituted; conditional compilation is resolved. Output: one translation unit per source file.
  2. Compilation — the translation unit is parsed, type-checked, optimised, and translated to assembly. Output: an assembly file (.s).
  3. Assembly — the assembly is translated to a relocatable object file. Output: an object file (.o/.obj).
  4. Linking — object files plus libraries are combined into an executable or shared library.

The four stages are essentially C’s plus C++-specific concerns at the compilation stage: name mangling, template instantiation, and the C++ ABI.

c++ -E   file.cpp -o file.ii    # preprocess only
c++ -S   file.cpp -o file.s      # preprocess + compile to assembly
c++ -c   file.cpp -o file.o      # preprocess + compile + assemble
c++      file.o   -o program     # link

The conventional c++ file.cpp -o program performs all four stages.

Translation units and the One Definition Rule

A translation unit is the result of preprocessing one source file. The compiler sees one translation unit at a time; templates are an exception, requiring the compiler to instantiate them on demand from text it has seen.

The One Definition Rule (ODR) requires that every entity used by a program have at most one definition across the whole program, with exceptions for:

  • Inline functions (including in-class member functions) and inline variables — multiple definitions are permitted across translation units, provided each definition is token-for-token identical after preprocessing.
  • Templates — must be visible at every point of instantiation.
  • Class types — must have identical definitions in every translation unit that uses them.

ODR violations are typically silent at compile time and produce undefined behaviour at runtime, often manifesting as link errors or as inexplicable corruption.

Headers and the inclusion model

A header file (conventionally .h or .hpp) contains declarations: function prototypes, class definitions, type aliases, template definitions, inline-variable definitions, macro definitions. A .cpp file that needs entities defined in another .cpp file #includes the corresponding header:

// counter.h
#pragma once

#include <iostream>

class Counter {
public:
    Counter() = default;

    void increment()        { ++count_; }       // inline
    int  value() const;                         // declaration only

private:
    int count_ = 0;
};

// counter.cpp
#include "counter.h"

int Counter::value() const {
    return count_;
}

// main.cpp
#include "counter.h"

int main() {
    Counter c;
    c.increment();
    std::cout << c.value() << '\n';
}

Both counter.cpp and main.cpp include counter.h. The class definition is visible in both translation units; the implicit inline of the in-class increment accommodates the multiple inclusions.

The inclusion model has the same practical consequences as in C, with the C++ additions:

  • Header guards are required. Without them, transitive inclusion produces redefinition errors. #pragma once is a non-standard but universally-supported alternative.
  • Templates must be defined in headers. The compiler instantiates them in each translation unit that uses them; the body must be visible.
  • Compilation cost scales with header size. A project with many translation units and large headers re-parses the same content many times. Precompiled headers, forward declarations, and (now) modules are the conventional mitigations.

Header guards and #pragma once

The header guard is a textbook construction:

// counter.h
#ifndef COUNTER_H
#define COUNTER_H

class Counter {
    /* ... */
};

#endif  // COUNTER_H

The first inclusion defines the guard macro; subsequent inclusions find it defined and skip to #endif. The macro must be unique across the project; the conventional spelling is the file name in upper case with _H appended.

#pragma once is the modern alternative:

// counter.h
#pragma once

class Counter {
    /* ... */
};

#pragma once is non-standard but is supported by every contemporary compiler. It is shorter, less error-prone (no risk of duplicate guard names across files), and slightly faster (the compiler may skip the entire file based on its identity rather than re-tokenising). The disadvantages are that the standard does not require it and that some unusual setups (multiple identical files in different paths) may not handle it perfectly.

In modern code, #pragma once is the conventional choice.

Static and shared libraries

A static library is an archive of object files. The linker pulls in members that define symbols referenced by the program; unreferenced members are dropped:

c++ -c -o counter.o counter.cpp
c++ -c -o util.o    util.cpp
ar  rcs libutility.a counter.o util.o
c++ main.cpp -o main -L. -lutility

A shared library (or dynamic library) is loaded at program start (or on demand) and shared by every running process that uses it:

c++ -fPIC -c counter.cpp util.cpp
c++ -shared counter.o util.o -o libutility.so

-fPIC produces position-independent code, required for shared libraries on most platforms; -shared instructs the linker to produce a shared object rather than an executable. The runtime resolution of shared libraries is handled by the system’s dynamic loader (ld.so on Linux, dyld on macOS, the Windows loader on Windows).

The trade-offs are essentially those in C:

  • Static libraries produce self-contained executables; deployment is a single file, but every program has its own copy.
  • Shared libraries reduce the per-process memory footprint and admit library updates without recompiling the consumers.

C++‘s name mangling makes ABI compatibility considerably harder than C’s: a shared library compiled with one compiler version may not be link-compatible with a program compiled with another. The conventional discipline is to expose extern "C" interfaces at shared-library boundaries when ABI stability matters.

C++20 modules

A module is a separately-compiled, named, importable unit:

// counter.cppm — module interface unit
export module counter;

import <iostream>;

export class Counter {
public:
    Counter() = default;

    void increment()   { ++count_; }
    int  value() const { return count_; }

private:
    int count_ = 0;
};

export void log_value(const Counter &c) {
    std::cout << "value: " << c.value() << '\n';
}
// main.cpp
import counter;

int main() {
    Counter c;
    c.increment();
    log_value(c);
}

The module system has substantial advantages:

  • No textual inclusion. The module is parsed once and compiled into a binary representation; consumers import the binary representation in constant time, regardless of project size.
  • Strong encapsulation. Only entities marked export are visible to importers. Internal details (helper functions, internal types, macros) are hidden.
  • No header-guard machinery. Modules are imported by name, not included by file path; multiple imports do not cause duplication.
  • Faster builds. Importing a precompiled module is cheap; including a header is not.
  • Macros do not leak. Macros defined in a module are not visible to the importer (they are not exported).

The mechanism’s principal components:

Module declaration

export module mymodule;

The first non-comment token of a module’s primary interface unit. The module is then identified by mymodule for import purposes.

Imports

import std;             // import the entire standard library (C++23)
import std.io;          // import a sub-component
import <iostream>;      // import a header as a header unit
import mymodule;        // import a user-defined module

C++20 supports importing standard headers as header units; C++23 admits import std; as a single statement importing the whole standard library.

Exports

export class Counter { /* ... */ };
export void log_value(const Counter &c);

export {
    int helper();
    int another_helper();
}

Only exported entities are visible to importers; non-exported entities are module-internal.

Module partitions

Large modules may be split into partitions:

// counter:state.cppm — partition interface
export module counter:state;

export class State { /* ... */ };
// counter.cppm — primary interface
export module counter;

export import :state;        // re-export the state partition

Partitions admit modular development of a single named module without forcing a single-file definition.

Modules versus the inclusion model

The conventional state of the world (2025):

  • Most C++ code uses the inclusion model.
  • Compilers (GCC, Clang, MSVC) all support C++20 modules but with varying maturity.
  • Build systems (CMake, Bazel, Meson) have growing module support but it is not universal.
  • The <…> standard headers are still used; import std; requires C++23 and a sufficiently mature toolchain.

The trajectory: modules will eventually replace the inclusion model for most use cases. The trajectory’s pace depends on toolchain maturation and the gradual migration of existing code. New projects targeting C++20 or later may reasonably adopt modules; existing projects typically continue to use the inclusion model and migrate piece by piece.

This page documents both because both will remain relevant for years; new code should prefer modules where the toolchain admits them and accept the inclusion model otherwise.

Build systems

The C++ standard does not specify a build system. Compiling a non-trivial C++ program is the responsibility of the programmer or the build tool of choice. The conventional contemporary choices:

Build systemNotes
CMakeThe dominant cross-platform build system. Generates Makefiles, Ninja files, Visual Studio projects, and many others. find_package for dependencies; target_link_libraries for linking; add_subdirectory for sub-projects. Supports C++20 modules with sufficient generator and compiler.
MakeThe traditional Unix tool. Adequate for small projects; cumbersome at scale.
BazelGoogle’s build system. Strong reproducibility guarantees; non-trivial setup; well-suited to monorepos.
MesonDesigned for C and C++; faster than CMake; less ubiquitous.
xmakeA newer system focused on simplicity; growing user base.

A minimal CMake configuration:

cmake_minimum_required(VERSION 3.20)
project(mything CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(mything
    src/main.cpp
    src/counter.cpp
)

target_include_directories(mything PRIVATE src)

find_package(fmt REQUIRED)
target_link_libraries(mything PRIVATE fmt::fmt)

The configuration declares the project, specifies the C++ standard, lists the source files, configures include paths, and links a third-party library. Build with:

cmake -S . -B build
cmake --build build

For non-trivial projects, the build system is a substantial topic; CMake’s documentation is the practical authority.

Name mangling and the C++ ABI

C++ admits function overloading, so the linker’s symbol table cannot use the function’s source-level name as the symbol. The compiler mangles the name: it encodes the function’s full signature (parameter types, namespace, class membership) into the symbol:

int  square(int);            // _Z6squarei (Itanium ABI)
int  square(double);         // _Z6squared
namespace lib {
    int  square(int);        // _ZN3lib6squareEi
}

The mangling scheme is part of the platform’s C++ ABI (Application Binary Interface). Different compilers and different platforms may use different schemes; the Itanium ABI is the de facto standard on Unix-like platforms; MSVC uses a different scheme.

The principal practical consequence is that C++ libraries are not as portable across compilers as C libraries. The conventional defences:

  • Expose C-compatible interfaces (extern "C") at library boundaries when ABI stability matters.
  • Distribute libraries as source code rather than binary when possible.
  • Distribute pre-built binaries per compiler-and-version combination when source distribution is not feasible.

C++23 introduced no new ABI-stability mechanisms; the conventions remain those of the C++98/C++03 era.