5.2.32 · D2C++ Programming

Visual walkthrough — Modules (C++20) — concept and syntax

3,035 words14 min readBack to topic

We build everything from zero. No word is used before it has a picture.


Step 1 — The old world: #include is a photocopier

WHAT. We have two source files, a.cpp and b.cpp, both writing #include "math.h".

WHY show this first? To feel the pain the module system was invented to cure. If you do not see the waste, import looks like pointless new syntax.

PICTURE. Look at the figure. One header file math.h (the mint page) gets copied whole into both files (the two lavender pages). The same text is parsed twice — once per .cpp. Multiply "twice" by hundreds of files including hundreds of headers and you get the slow builds the parent note complained about.

Figure — Modules (C++20) — concept and syntax

Step 2 — What "parsing" costs, and why repetition hurts

WHAT. We count how many times the same header text is parsed.

WHY this tool — counting? Because "modules are faster" is a quantitative claim. To justify it we must literally count re-parses. This is the Build Systems and Compilation Speed argument in one number.

PICTURE. The bars show parse-work. With #include (coral bars) the header's parse cost appears once per including file — three files, three full parses. With import (mint bar) the header is parsed once, compiled to a stored form, and every importer just reads that stored form.

Figure — Modules (C++20) — concept and syntax

Step 3 — Build the module: export module math;

WHAT. We write the primary interface unit.

// math.ixx  — primary interface unit
export module math;   // <- names this box "math", marks it the front cover

Term by term:

  • export — "outsiders are allowed to know about this box."
  • module — the keyword that says box, not ordinary file.
  • math — the box's name; importers will later type import math;.

WHY this step / why this keyword? We need a single unambiguous name so the compiler can store one compiled interface and later hand it to any importer. Ordinary files have no such handle; that is why old headers needed textual copying. A name replaces the copy.

PICTURE. The lavender box has a labelled front cover (export module math;). Nothing has crossed out of it yet — the box is sealed. See how this differs from Namespaces in C++: a namespace only groups names; a module seals a compilation boundary.

Figure — Modules (C++20) — concept and syntax

Step 4 — Two kinds of names inside the box

WHAT. We add two functions.

export module math;
 
export int add(int a, int b) { return a + b; }  // gate OPEN: crosses out
int secret_helper()          { return 42;      } // gate CLOSED: stays inside

Term by term on the exported line:

  • export — the gate token; put it in front of a declaration and that name may leave the box.
  • int add(int a, int b) — the name being exported: a function returning int.
  • (no export) on secret_helper — no token, no gate, no exit.

WHY. This is encapsulation at file level (parent note's phrase). The box decides its own public surface. #include could never do this — a header exposes everything it contains.

PICTURE. The green arrow (add) passes through the gate in the box wall; the coral name (secret_helper) bounces off the inside wall. One picture, the whole meaning of export.

Figure — Modules (C++20) — concept and syntax

Step 5 — Compile the box into a sealed interface

WHAT. math.ixx → compiler → math's compiled interface (holds only add). Read as "is compiled into", exactly as just defined.

WHY this matters. This artifact is the whole reason back in Step 2. Reading it is fast because the hard work (parsing, checking) already happened once. secret_helper is not in it — the seal was applied at the gate.

PICTURE. The lavender source box flows through a compiler gear into a small butter-coloured "sealed interface" tag. Notice secret_helper was left behind on the source side; only add made it onto the tag.

Figure — Modules (C++20) — concept and syntax

Step 6 — Import: read the seal, no photocopy

WHAT. Another file uses the module.

// main.cpp
import math;                 // read math's sealed interface
#include <iostream>
 
int main() {
    std::cout << add(2, 3);  // OK: add was exported  -> prints 5
    // secret_helper();      // ERROR: never crossed the gate
}

Term by term:

  • import math; — request the box named math; receive its exported names (add).
  • add(2, 3) — legal, add is on the seal; the result is .
  • secret_helper() — illegal, it is invisible here; the compiler has never heard of it.

WHY. Contrast Step 1: there the whole header text arrived. Here only the gate-approved surface arrives, already compiled. No macro leakage, no order dependence — because there is no text being pasted at all.

PICTURE. The sealed tag from Step 5 slides into main.cpp; only add becomes visible (green), secret_helper shown greyed-out and unreachable behind the box wall.

Figure — Modules (C++20) — concept and syntax

Step 7 — Edge case: #include after export module (the quarantine), and header units

WHAT. Where #include is allowed vs forbidden, and the modern alternative.

module;              // <- global module fragment BEGINS
#include <cmath>     // legal ONLY here (quarantine zone)
export module geometry;   // named-module region begins; no #include past this line
 
export double hypot2(double a, double b) { return std::sqrt(a*a + b*b); }

WHY a whole step for this? Because it is the single most common beginner error (parent note's first mistake). The rule is positional, so it needs a picture, not a sentence. Legacy #include machinery is the The C++ Preprocessor; it is quarantined so its macros cannot leak into the sealed part.

The header-unit alternative (C++20). Instead of pasting a header's text you can import the header as a module-like unit:

export module geometry;
import <cmath>;      // header UNIT: <cmath> compiled once, then reused
  • import <cmath>; — treat the standard header <cmath> as a header unit: parsed once, cached, then read cheaply (like a real module).
  • Unlike import math;, a header unit does carry the header's macros across — that is the deliberate escape hatch for macro-dependent legacy headers.
  • Because it is an import (not #include), it is legal inside the named-module region — it does not need the quarantine. Only textual #include is banned past export module.

PICTURE. A red vertical line marks export module geometry;. Left of it (mint zone) #include is allowed. Right of it (lavender zone) #include is forbidden — a red ✗ — but import <cmath>; (butter arrow) is welcome, because it is an import, not a paste.

Figure — Modules (C++20) — concept and syntax

Step 8 — The four regions of one module file (the private module fragment)

WHAT. The complete layout of a primary interface unit.

module;                       // (1) global module fragment  — #include lives here
#include <cmath>
 
export module geometry;       // (2) named-module region begins
 
export double area(double r); // (3) purpose region — exported + private declarations
double helper(double x);      //     (private: no export)
 
module : private;             // (4) PRIVATE module fragment begins
double helper(double x)       //     definitions here are invisible to importers
{ return x * x; }             //     AND changing them never forces importers to rebuild

Region by region:

  • (1) global module fragmentmodule; … up to export module. Only place textual #include may sit.
  • (2) named-module region — starts at export module geometry;. From here on, #include is banned (use header units).
  • (3) purpose region — your exported and private declarations; the substance of the module.
  • (4) module : private; opens the private module fragment: a tail zone whose contents are completely invisible outside the module. Its special power: editing code here does not invalidate importers, so their builds are not triggered.

WHY a whole step? Without it the decomposition model has a missing quadrant. module : private; is not the same as an unexported declaration in region (3): both are private to importers, but only the private fragment guarantees importers won't rebuild when it changes.

PICTURE. Four stacked bands in a single file, top to bottom, each labelled with its rule; a red ✗ #include tag sits on every band after the first.

Figure — Modules (C++20) — concept and syntax

Step 9 — Degenerate case: one cover, many pages, and partitions

WHAT. The legal shapes, and the illegal one.

Line at top of file Role How many allowed
export module math; primary interface (front cover) exactly one
module math; implementation unit (inside page) many
export module math:geom; interface partition (named sub-section) many
a second export module math; illegal

WHY show the illegal one? Covering all cases means naming what breaks. Two front covers = two definitions of the box's public face = ambiguity. The compiler rejects it.

PICTURE. One butter cover on top; several lavender inside pages beneath it; a mint "partition" tab clipped to the side (same box, sub-labelled :geom); a second cover drawn faded with a red ✗.

Figure — Modules (C++20) — concept and syntax

The one-picture summary

The whole journey on one canvas: a source box (export module math;) with a gate that lets add out and keeps secret_helper in → compiled once into a sealed interface → imported by any number of files, each reading the seal cheaply instead of re-parsing text. On the left, the quarantine room confines legacy textual #include; a header unit (import <cmath>;) bypasses it entirely. (Every here means "is compiled into", per Step 5.)

Figure — Modules (C++20) — concept and syntax
Recall Feynman retelling — say it in plain words

Imagine a factory that makes one perfect labelled crate. Inside the crate are parts; a gate in the crate wall decides which parts are shown on the shipping label (those are exported) and which stay bolted inside forever (those are private). The factory builds and inspects this crate exactly once, then prints a small sealed label listing only the shown parts.

Anyone who wants the parts doesn't get a photocopy of the whole factory (that was the old #include way — slow, and it dragged in every stray note pinned to the walls, i.e. macros). Instead they just read the tiny label: import math;. They can use add, because add is on the label; they cannot use secret_helper, because it never left the crate.

Old header machinery still exists but is locked in one room at the front — the global module fragment, before export module Name;. You may keep textual #include there and nowhere past the door; if you'd rather, import <cmath>; treats the header as its own little cached crate and is allowed past the door (and it, uniquely, still carries macros). At the very back of the file there is a private room, module : private;, whose contents nobody outside ever sees and whose edits never force importers to rebuild.

Every crate has exactly one label (one primary interface); extra inside pages (module math;) add bolts but no new label entries; extra labelled sub-sections are partitions (export module math:geom;). That is the entire idea, and every figure on this page is one frozen frame of that crate's life.

Recall Quick self-test
  • What replaces the photocopied header text? → a compiled (binary) module interface, read once.
  • Which names appear on the sealed interface? → only the exported ones.
  • Where alone may textual #include live in a module? → the global module fragment, before export module.
  • How can you use a legacy header past that border? → import it as a header unit, e.g. import <cmath>;.
  • What does module : private; add? → a private module fragment: invisible to importers, and editing it never rebuilds them.
  • How many primary interface units per module? → exactly one.

Prerequisites revisited: Translation Units and Linkage, The C++ Preprocessor, Namespaces in C++, Build Systems and Compilation Speed, Module Partitions. Parent: the Hinglish companion.