The #include Problem Modules Solve
For three decades, C++ programs shared code through a mechanism inherited from C: the #include preprocessor directive. It worked, but it brought a growing set of problems — slow builds, order-dependent compilation, and macros that leaked across translation unit boundaries. C++20 modules are the language-level fix: a self-contained, compile-once unit with explicit exports, no macro leakage, and order-independent imports.
Three problems with #include
Before modules existed, every C++ project relied on header files and the #include directive to share declarations across translation units. The preprocessor literally copies the header text into each source file that includes it — and that causes three compounding problems.
1. Compilation overhead
#include <iostream> alone adds tens of thousands of lines of code that the compiler must parse, every time, in every source file that includes it. When a project has dozens of files each including a set of standard library headers, the compiler repeats this work independently for every translation unit. The problem compounds when you need <iostream>, <vector>, <string>, <format>, and more — every TU expands to hundreds of thousands of preprocessed lines before the compiler sees a single line of your own code.
// file_a.cpp #include <iostream> // preprocessor pastes ~70,000 lines #include <vector> // + ~40,000 more lines // ... and the same expansion happens in file_b.cpp, file_c.cpp, etc.
2. Macro pollution
The preprocessor has no concept of scope. Any #define in a header bleeds into every subsequent header and source line in the same translation unit. Platform headers such as <windows.h> define min and max as macros, silently breaking any code that uses those names for functions or variables. The only safeguards — #undef and include guards — are manual and error-prone.
// evil_header.h #define STATUS_OK 0 // bleeds into every file that includes this header // my_code.cpp #include "evil_header.h" #include <algorithm> // STATUS_OK is now a macro here — may silently break unrelated code
3. Order dependencies and fragility
Because each header sees the preprocessor state left by every header before it, including headers in a different order can change compilation behavior or even break a build. Circular dependencies between headers require carefully placed forward declarations. Include guards prevent duplicate definitions within a single TU, but not across TUs — violating the One Definition Rule across files is a linker-time error that gives no hint about where the duplicate originated.
// order matters — swapping these may break compilation #include "A.h" // defines X, which B.h needs #include "B.h" // uses X — would fail if included first // duplicate definitions across TUs violate ODR with confusing linker errors // error: multiple definition of 'globalVar' (no file/line info from linker)
How modules solve every one of these
C++20 modules are not glorified precompiled headers — they are a fundamentally different compilation model. A module is compiled once to a binary format. Every subsequent import of that module reuses the binary result rather than re-parsing source text. The implications cascade across every pain point that #include introduced.
| Problem | With #include | With modules |
|---|---|---|
| Build throughput | Headers re-parsed in every TU that includes them | Module compiled once; all importers use the cached binary |
| Incremental builds | Touching a widely-included header recompiles the entire project | Changing a module implementation file only recompiles that module; users are unaffected |
| Macro isolation | Macros leak from headers into all downstream code | Macros inside a module never escape it; external macros cannot affect module compilation |
| Import order | Order of includes can affect compilation and behavior | Order of import declarations is irrelevant |
| Name isolation | Internal helpers in headers become globally visible | Only explicitly exported names are visible to importers; module-internal names have module linkage |
One important caveat on incremental compilation: functions and templates marked inline, and template definitions in general, still require recompilation of users when they change, because the compiler needs their complete implementation at the call site.
Writing your first module
A C++20 module consists of a module interface file (conventionally .cppm) that declares what the module exports, and optionally one or more module implementation files (plain .cpp) that hold the bodies. The interface file begins with export module name;— this is the named module declarationthat establishes the module's identity. Everything in the file from that point forward is inside the module's purview. You mark what consumers can see with the export keyword.
// person.cppm — module interface file
export module person; // named module declaration: this file defines the "person" module
import std; // import the standard library (C++23); or use import <string> etc.
export class Person // export keyword makes Person visible to importers
{
public:
Person(std::string firstName, std::string lastName)
: m_firstName { std::move(firstName) }
, m_lastName { std::move(lastName) } { }
const std::string& getFirstName() const { return m_firstName; }
const std::string& getLastName() const { return m_lastName; }
private:
std::string m_firstName;
std::string m_lastName;
};// main.cpp — consuming code
import person; // import declaration: brings Person into scope
import std; // need this explicitly to spell std::cout (see visibility below)
int main()
{
Person p { "Kole", "Webb" };
std::println("{}, {}", p.getLastName(), p.getFirstName());
}Notice: no #include, no include guards, no header-file boilerplate. The import order in main.cppdoesn't matter — both could be swapped and the program compiles identically.
Splitting interface from implementation
Just as header files traditionally contained declarations and .cpp files contained definitions, a module can be split into an interface file that holds class definitions and function prototypes, and an implementation file that holds the bodies. The implementation file begins with module person; — the same name but without the export keyword. This tells the compiler the file is part of the person module but contributes no new exports. Critically, the implementation file implicitly inherits all importdeclarations from the interface file — you don't need to repeat import std; even though the bodies use std::string and std::move.
person.cppm — interface
export module person;
import std;
export class Person {
public:
Person(std::string first,
std::string last);
const std::string& getFirstName() const;
const std::string& getLastName() const;
private:
std::string m_firstName;
std::string m_lastName;
};person.cpp — implementation
module person; // no "export" keyword
// no "import std;" needed — inherited from interface
using namespace std;
Person::Person(string first, string last)
: m_firstName { move(first) }
, m_lastName { move(last) } { }
const string& Person::getFirstName() const
{ return m_firstName; }
const string& Person::getLastName() const
{ return m_lastName; }Incremental compilation advantage
Because the module interface consists only of class definitions and function prototypes, changing a function body in person.cpp — or even inline in the .cppm — does not require recompilation of any code that imports person, as long as the function prototype (name, parameters, return type) is unchanged. The two exceptions are inline functions and templates, whose complete implementations the compiler needs at the call site.
Visibility vs. reachability
One subtlety trips almost every developer encountering modules for the first time. When you import person; in your consuming code, you gain access to the Person class — but you do not automatically get std::string in scope, even though person.cppm imports it. The standard distinguishes two concepts: an entity can be visible (you can spell its name) or merely reachable (you can use it indirectly). Importing person makes the standard library reachable — member functions work, auto deduction works — but it does not make std::string visible.
import person; // std is now reachable but NOT visible
int main()
{
// Works — Person is exported and thus visible:
Person p { "Kole", "Webb" };
// Works — auto deduction and member function call on reachable type:
const auto& lastName = p.getLastName();
auto length = lastName.length(); // std::string::length is reachable
// ERROR — std::string is reachable but its name is NOT visible:
// std::string str = p.getLastName(); // compile error
}// Fix: explicitly import std (or just <string>) in your consuming file
import person;
import std; // now std::string is visible
int main()
{
Person p { "Kole", "Webb" };
std::string str = p.getLastName(); // OK
}This behavior is intentional: it prevents transitive namespace pollution. A module's internal imports don't silently dump names into every downstream consumer the way a chain of #includes does. You get exactly the names you explicitly import.
Structuring with submodules
The C++ standard permits dots in module names, which creates a natural hierarchy. These are called submodules— the standard doesn't use the term officially, but the dot-separated naming convention is a real and widely-adopted pattern. Submodules are independent modules with related names; a client can import them selectively or together by naming their common prefix.
// datamodel.person.cppm
export module datamodel.person; // person submodule
export namespace DataModel { class Person { /* ... */ }; }
// datamodel.address.cppm
export module datamodel.address; // address submodule
export namespace DataModel { class Address { /* ... */ }; }
// datamodel.cppm — top-level module ties submodules together
export module datamodel;
export import datamodel.person; // re-export person submodule
export import datamodel.address; // re-export address submodule
import std;
export namespace DataModel { using Persons = std::vector<Person>; }// Consumers can import selectively: import datamodel.address; // only Address — faster builds if person is unneeded // Or import everything at once: import datamodel; // Person, Address, Persons all available
Submodule granularity is build-time valuable: changing datamodel.address only recompiles files that import that specific submodule, not everything that imports datamodel.
Internal structure with partitions
Where submodules are a public-facing decomposition, partitions are a private internal one. Partitions exist only to help the author organize a large module — they are not importable by external code. A partition is declared with a colon separator: export module datamodel:person;. The primary module interface file must ultimately export import every partition so that all exported names end up accessible through the top-level module name.
// datamodel.person.cppm — interface partition
export module datamodel:person; // colon = partition (NOT a submodule)
export namespace DataModel { class Person { /* ... */ }; }
// datamodel.address.cppm — interface partition
export module datamodel:address;
export namespace DataModel { class Address { /* ... */ }; }
// datamodel.cppm — primary module interface file
export module datamodel;
export import :person; // import and re-export the person partition
export import :address; // import and re-export the address partition
import std;
export namespace DataModel { using Persons = std::vector<Person>; }// External code can only import the whole module — partitions are invisible: import datamodel; // OK — Person, Address, Persons available // import datamodel:person; // ERROR — partitions are internal to the module
| Feature | Submodules (dots) | Partitions (colon) |
|---|---|---|
| Visible to users? | Yes — can import selectively | No — internal only |
| Import syntax | import datamodel.address; | import :address; (within module only) |
| Use case | Public decomposition of a library | Internal organization of a large module |
Importing the standard library
All C++ standard library headers are importable headers — you can use import instead of #include for any of them. C++23 additionally defines a named module called std that exposes the entire standard library at once, and std.compat that additionally makes C functions available in the global namespace for legacy interop.
// C++20: import individual standard library headers import <vector>; import <string>; import <algorithm>; // C++23: import the entire standard library at once (preferred) import std; // C++23: std + C functions in global namespace (for legacy code) import std.compat; // enables ::sqrt() in addition to std::sqrt()
For code that must still use legacy headers that cannot be modularized — for example third-party libraries or system headers that depend on preprocessor macros being defined — use the global module fragment. This fragment must appear before the named module declaration and can only contain preprocessor directives:
module; // start of the global module fragment
#include <cassert> // legacy header that defines the assert() macro
export module person; // named module declaration starts here
import std;
export class Person { /* ... */ };The rule of thumb from Gregoire's Professional C++: if you can import a header, do so. Only fall back to #includeinside a global module fragment when the header's content depends on preprocessor macros, making it un-importable.
In summary: what you gain
Faster full builds
Standard library headers compiled once to binary, reused by all importers instead of being re-parsed per TU.
Faster incremental builds
Changing a function body in a module implementation file does not trigger recompilation of anything that imports that module.
No macro leakage
Macros never cross module boundaries in either direction; module consumers can't accidentally activate macros the module defines.
Explicit, auditable interfaces
Only what is marked export is visible. Everything else has module linkage — internal helpers can't accidentally become part of the public API.
Order-independent imports
import declarations have no ordering constraint; any permutation produces the same result.