Modules as Units of Separate Compilation
A language-level module gives namespacing, explicit dependencies, encapsulation and separate compilation without textual inclusion — a dependent reads a compiled interface rather than re-parsing your source.
What does a language-level module system give me that headers and includes do not?
A module is a named, compiled unit with an explicit export list. Its representation is two artifacts rather than one: a compiled *interface* — the exported names with their types and, where the language allows it, their inlinable bodies — and an *implementation* object. A dependent is compiled against the first and linked against the second. The question this split exists to answer is: what does someone who uses me need to know, and can they get it without reading how I work?
A dependent may be compiled against an interface alone only if the interface fully determines every property the dependent needs: the type of every exported name, the layout of every exported type it stores by value, and the calling convention of every exported function. Anything the dependent needs that is not in the interface — a private field's size, an inlinable body — must be smuggled into the interface anyway, which is why "opaque type" and "inline function" pull in opposite directions in every module system ever designed.
Key points
- This id deliberately collides with Software Design's: there, a module is a unit of encapsulation; here, it is a unit of separate compilation. Both are true.
- A module system does four separable jobs — namespacing, explicit dependencies, encapsulation and separate compilation — and systems differ mostly in the last.
- The build-relevant property is that a dependent reads a compiled interface instead of re-parsing source, which makes the interface a cacheable artifact.
- Encapsulation and inlining pull in opposite directions: the more the interface hides, the less the optimizer can do across the boundary.
- Macros are the specific thing textual inclusion cannot contain, and containing them is most of what C++20 modules buy.
A cross-domain collision, stated up front
Software Design owns modules as units of *encapsulation*: a boundary that hides a decision so that changing it does not ripple. This lesson owns modules as units of *separate compilation*: a boundary the compiler can process independently, publish an interface for, and invalidate precisely. Both readings are correct, and neither is a subset of the other.
They pull apart in a specific place. A design-good module hides its representation behind an opaque interface; a build-good module publishes enough that dependents need not be recompiled *and* enough that hot calls can be inlined. Those are contradictory. Every real module system picks a point on that line, and that point — not the syntax — is what distinguishes them.
Four jobs, usually conflated
A module system that people call good is doing four separable jobs at once, and comparing systems is much easier once they are pulled apart. Some languages do all four; several do two well and fake the rest.
The fourth job — separate compilation — is the one this domain owns and the one most often invisible, because it shows up only as build time. It is also the one textual inclusion cannot do at all: #include is string substitution, so a dependent must parse the provider's source every time, and there is no artifact to cache.
| System | Namespacing | Explicit deps | Encapsulation | Separate compilation |
|---|---|---|---|---|
| C / C++ headers | By convention only (prefixes) | Include order is a dependency; nothing declares it | None the compiler enforces | Yes, but the interface is re-parsed by every dependent |
| C++20 modulesimplementation | Named modules, no macro leakage | Explicit import | export list is checked | Yes, via a compiled binary module interface |
| Rust | Paths and mod | use plus crate dependencies in the manifest | pub visibility enforced by the compiler | At crate granularity, via crate metadata |
| ES modules | Per-file scope, explicit exports | Static import with statically analysable specifiers | Non-exported bindings are unreachable | Per file, though bundlers re-link everything |
| Python packages | Package and module names | import at runtime, resolved dynamically | Convention (_name), not enforcement | Per module, cached as .pyc |
| OCaml / Haskell | Module paths | Explicit and checked | Signature files hide representation | Yes, via .cmi / .hi interface files |
The macro problem, and why C++ modules exist
Textual inclusion leaks. A macro defined in a header is live in every unit that includes it, and in everything included after it. Include order therefore changes meaning, #define max(a,b) breaks std::max, and a header can silently change the behavior of a header included later. None of this is expressible in the type system, so none of it is checkable.
C++20 modules close this by making import a semantic operation rather than a textual one: importing a module brings in its exported declarations and not its macros, and the exported set is fixed by the module rather than by the order of anything. The compiled interface is then a cacheable artifact — the point where a module system becomes a build-time mechanism rather than a naming convention.
1// widget.h — textual2#pragma once3#include <vector>4#define WIDGET_MAX 645struct Widget { std::vector<int> v; int size() const; };6 7// widget.cppm — a module interface unit8export module widget;9import <vector>;10#define WIDGET_MAX 64 // not exported: macros do not cross an import11export struct Widget { std::vector<int> v; int size() const; };The visible difference is one line. The build difference is that the second form is compiled once into a binary module interface and read as a binary by every dependent, and that WIDGET_MAX cannot escape into anyone else's unit.
How it works
The steps, in the order the compiler takes them.
- The module interface is compiled once, producing a serialized artifact holding the exported declarations, their types, and whatever else dependents are permitted to see.
- A dependent that imports the module reads that artifact instead of parsing source; the frontend loads declarations directly into its symbol table — see
[[symbol-table]]. - The build system records the interface artifact as an input of every dependent, giving a real dependency edge rather than an inferred one — see
[[build-dependency-graph]]. - Names not in the export list are absent from the artifact, so a dependent cannot reference them even by accident.
- The module implementation is compiled separately into an object file and linked normally.
- When only the implementation changes, the interface artifact is unchanged, so dependents need not be recompiled — the whole point of the split.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A build silently reverts to source parsing because the module artifact could not be found or was built by a different compiler version, and build times quietly return to their old numbers with no error.
- Two dependents are compiled against interface artifacts built with different flags, and a type has different layouts in each; the link succeeds and the program corrupts a field.
- A hot call that used to inline through a header stops inlining once moved behind a module boundary that does not export the body, and a benchmark regresses with no source change.
- A cyclic import between modules is rejected in a language that forbids cycles, forcing an unplanned refactor late in a migration.
- In a dynamically resolved system, an import succeeds against a different version of the module than the one tested against, and a method exists but takes different arguments.
When it helps
- Large C++ codebases where header parsing dominates build time and the header graph is stable enough to compile once.
- Any codebase where implementation churn is far higher than interface churn: the split means only the churning half is rebuilt.
- Enforcing an architectural boundary the compiler can check, rather than one that lives in a review comment.
When it hurts
- Toolchains and build systems without mature module support, where the migration cost and the fragility exceed the build-time win.
- Codebases whose performance depends on inlining across the boundary you are about to make opaque.
What it costs
Every one of these is paid by something.
- Compiled interfaces buy the elimination of repeated parsing, and pay in a new build artifact with its own dependency edges, its own cache invalidation rules and a binary format tied to a compiler version.
- Strong encapsulation buys the ability to change an implementation without recompiling dependents, and pays in lost cross-boundary optimization unless bodies are re-exposed — at which point the encapsulation was partly given back.
- Explicit dependency declarations buy an accurate build graph and pay in churn: every new use of a name is now a change to a manifest as well as to code.
- A migration from headers to modules buys the above and pays a large one-time cost in a codebase that must keep building throughout, usually via a period where both forms exist.
What else you could do
What a different compiler or language does instead, and when that is better.
- Precompiled headers get most of the parse-time win with none of the encapsulation and none of the macro hygiene, and are far cheaper to adopt.
- A package or crate as the unit — Rust, Go — provides the same benefits at a coarser granularity, trading precise invalidation for a much simpler model.
- Signature files, as in OCaml and Haskell, separate the interface from the implementation as a *source* artifact the programmer writes, making the encapsulation explicit and reviewable — see
[[interface-files]]. - Dynamic module systems, as in Python and CommonJS, resolve imports at run time, which buys enormous flexibility and gives up static checkability and any hope of tree shaking.
See it for yourself
The flag, dump or tool that shows you this directly.
clang++ -std=c++20 --precompile widget.cppm -o widget.pcmproduces a BMI;clang++ -module-file-info widget.pcmprints what is inside it.ocamlc -i impl.mlprints the inferred interface — the.mliyou would otherwise write by hand.ghc --show-iface Foo.hidumps a Haskell interface file, including the unfoldings that make cross-module inlining possible.cargo metadataandgo list -jsonprint the module and package graph a build actually used, rather than the one the manifest suggests.node --experimental-print-module-graphor a bundler's stats output shows an ES module graph and what was tree-shaken from it.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Modules are just namespaces with better syntax." Namespacing is one of four jobs, and the only one that costs nothing. The build-time job is the expensive and valuable one.
- "Modules make builds faster." They remove repeated parsing and add artifact management. On a codebase where parsing was not the bottleneck, they can make builds slower.
- "A module boundary is free at runtime." It is free only if the optimizer can still see through it. Making a boundary opaque removes cross-boundary inlining, and that is a runtime cost.
- "If the language has
import, it has separate compilation." Python hasimportand compiles per module at run time; the two properties are independent.
Misconceptions
The claim, and what is actually true.
inline exports and #[inline] annotations.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A module is a named piece of code that says explicitly what it exports and what it depends on. Instead of pasting text into whoever uses you, the compiler builds a description of your exports once, and everyone else reads that description. You get names that do not collide, dependencies that are written down, privacy the compiler enforces, and a build that does less repeated work.
practical
When evaluating a module system, ask the four questions separately. Does it prevent name collisions? Are dependencies declared or inferred? Is privacy enforced or conventional? And — the one people forget — does a dependent read a compiled artifact or re-parse my source? The last question predicts build behavior, and the first three predict how the codebase ages. A system can score well on three and badly on the fourth, which is exactly the C++ headers row.
advanced
The hard design problem is that the interface must simultaneously be small enough to be stable and large enough to be fast. Haskell resolves it by putting unfoldings — actual function bodies — into .hi files, which means changing a body can change the interface hash and invalidate dependents; the compiler exposes this trade directly through inlining pragmas. C++ resolves it by letting you export inline definitions, with the same consequence. OCaml resolves it by making the .mli a source file the programmer controls, so the trade is made deliberately and reviewed like code. There is no fourth answer: either the body crosses the boundary and dependents recompile when it changes, or it does not and the call cannot be inlined.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
.hi files are likewise version-locked. Treat every compiled interface as a compiler-private artifact that must be rebuilt, never shipped.macro_rules!, Lisp macros — exports them deliberately, with rules about hygiene rather than about visibility.If you were asked this in an interview
- What does a module system give a build that a header does not?
- Why does exporting an inline function weaken the incrementality that modules were supposed to provide?
- Software Design and this domain both have a lesson called
modules. What is each one about?
Connections
- Software Design — Modules as units of encapsulation and information hidingThat domain owns the question of what a boundary should hide so a design can change safely; we own the question of what a boundary must publish so a build can be incremental. The collision is deliberate and the two answers conflict in a specific, useful way — a design-optimal interface and a build-optimal interface are not the same interface.