Exercises — Modules (C++20) — concept and syntax
Before we start, one shared mental picture that every exercise leans on. Think of a module as a sealed box. The lid of the box lists the names that outsiders may reach in and touch (the exported names). Everything else is sealed inside. import hands you the lid's list; it never lets you rummage inside the box.
Look at the figure below and hold it in mind for the whole page. On the left is module math drawn as a box: the yellow lid line (export int add(...)) is the only thing that leaves the box; the red items (secret_helper, the quarantined #include) are sealed inside. On the right, main.cpp (green) says import math; and receives only what is on the lid. The yellow arrow shows the exported name crossing the boundary; the dashed red arrow shows a private name being blocked. Every problem on this page is really a question about which arrow you are drawing.

Figure description (for readers who cannot see the image): two boxes side by side. Left box, outlined in blue, is labelled "module math". Inside it, in yellow, is the LID list containing the single exported line export int add(...); below that, in red, is a SEALED INSIDE list containing int secret_helper() and #include <cmath> (fragment). Right box, outlined in green, is labelled "main.cpp" and contains import math; and the working call add(2,3) OK. A solid yellow arrow runs from the yellow lid line across to main.cpp, marked "crosses". A dashed red arrow runs from the red private items toward main.cpp but is marked "blocked".
Level 1 — Recognition
Goal: spot the correct keyword line and name the file's role.
L1.1
You open a file whose first real line is:
export module math;What is this file called, and how many such files may a module have?
Recall Solution
This is the primary module interface unit of module math. A module has exactly one such unit. Its exported names are the module's public face — the list on the box lid (the yellow line in the figure above).
Why exactly one? The primary interface unit is the definition of the module's public surface; if two files both claimed to be it, the compiler would have two conflicting definitions of the same surface — so the language forbids it.
Answer key: role = primary interface unit, count = 1.
L1.2
Classify each of these first-lines as interface unit, implementation unit, partition interface unit, or global module fragment start:
(a) module;
(b) export module math;
(c) module math;
(d) export module math:geometry;Recall Solution
(a) module;— starts the global module fragment (the quarantine room for#include, defined in the box above).(b) export module math;— the primary interface unit.(c) module math;— an implementation unit (noexport, adds no new public names).(d) export module math:geometry;— a partition interface unit (see Module Partitions).
L1.3
Which line brings only the exported names of module math into your file, and which one textually pastes a file's content?
Recall Solution
import math;→ brings in only the exported surface (the lid's list, the yellow arrow in the figure). No pasting.#include "math.h"→ textual copy-paste handled by The C++ Preprocessor.
Level 2 — Application
Goal: write correct module syntax and predict what compiles.
L2.1
Write a primary interface unit for a module strutil that exports a function int length(const char* s) but keeps a helper bool is_null(const char* s) private to the module.
Recall Solution
export module strutil;
bool is_null(const char* s) { // NO export => private to the module
return s == nullptr;
}
export int length(const char* s) { // exported => visible to importers
if (is_null(s)) return 0;
int n = 0;
while (s[n] != '\0') ++n;
return n;
}Why this compiles and is correct: export module strutil; is the first real declaration, so this file is legally the primary interface unit. is_null has no export, so by the module rule it never crosses the box boundary — it is private to the module. length has export, so its name is placed on the lid. Crucially, length may still call is_null because both live inside the same module box; "private" only blocks outsiders, never fellow module members. So the code is both legal and does what the problem asked.
L2.2
This file fails to compile. Find the single rule it breaks and fix it.
export module geometry;
#include <cmath>
export double norm(double a, double b) {
return std::sqrt(a*a + b*b);
}Recall Solution
Rule broken: #include may not appear after the named-module region begins. #include must live in the global module fragment (the quarantine room), before export module ...;.
Fixed:
module; // global module fragment starts
#include <cmath> // legacy header allowed ONLY here
export module geometry;
export double norm(double a, double b) {
return std::sqrt(a*a + b*b);
}Why the fix is legal: module; opens the fragment; #include <cmath> now sits inside the only region that permits preprocessor includes; then export module geometry; begins the clean named region, after which exported declarations are allowed. Every line is now in the region the language requires for it.
L2.3
Given this module:
export module counter;
export int increment(int x) { return x + 1; }
int seed() { return 100; }and this consumer:
import counter;
int main() {
int a = increment(5); // line A
int b = seed(); // line B
return a + b;
}Which line fails, and what is the value the program would compute if only the legal call ran (i.e. the value of a)?
Recall Solution
Line B fails — seed was never exported, so it is invisible across the module boundary (the dashed red arrow in the figure).
Why line A is legal: increment carries export, so its name is on the lid and import counter; places it in main's view. Line A therefore compiles and a = increment(5) = 6.
Level 3 — Analysis
Goal: reason about interface vs implementation, and about build cost.
L3.1
You split module account into two files:
// account.ixx
export module account;
export class Account {
public:
Account(double b);
double balance() const;
private:
double bal_;
};// account.cpp
module account;
Account::Account(double b) : bal_(b) {}
double Account::balance() const { return bal_; }The implementation file account.cpp says module account; with no export. Explain precisely why it adds nothing to the public interface, yet is still allowed to define Account::Account.
Recall Solution
module account; marks account.cpp as an implementation unit of module account. Implementation units:
- share the module's internals (they can see everything the interface declared, so
Accountis a known type here), and - cannot export new names — no
exportkeyword is permitted at the module-declaration level here.
So the definitions of Account::Account and Account::balance are implementations of already-declared members, not new public names. The public face was fixed by the single interface unit. This keeps interface files small (fast to parse for importers) while heavy definitions recompile separately — the Build Systems and Compilation Speed win.
L3.2
A header <vector> is #included in 100 translation units. With modules you instead build <vector> once as a reusable interface (whether as a header unit or a module) and reuse it. Suppose parsing/compiling the vector interface text costs one unit of work per parse. Estimate the parse-work for the two strategies (count raw interface parses). The comparison is drawn in the figure for this section.
Recall Solution
#include(textual paste): the interface text is re-parsed in every translation unit, so the cost is 100 times 1, which is 100 units.- compiled interface (parsed once, reused): parsed/compiled once into a binary interface, then reused, so the cost is 1 unit.
Speedup factor here is 100 divided by 1, which is 100 times. The general rule: for N importers, #include costs proportional to N, import costs proportional to 1 for that interface.
Why can't the compiler just cache the header work without modules? Each translation unit is compiled as an independent program run, and #include is pure text substitution done before the compiler even sees a "unit" — see Translation Units and Linkage and The C++ Preprocessor. The pasted header text can mean different things in different files, because macros defined before the #include line can change how the header parses. So the compiler is not allowed to assume the parse of <vector> in file A equals the parse in file B — it must redo it. A module interface removes that ambiguity: it is compiled in a fixed, self-contained context to a binary artifact, which is provably the same for every importer and therefore safely reusable. That provable sameness — not raw speed — is what unlocks the caching.

Figure description (for readers who cannot see the image): a grouped bar chart. The horizontal axis lists four cases, N=10, N=50, N=100, N=200, meaning the number of translation units importing the interface. For each case two bars stand side by side. The red bars, labelled "#include (O(N) parses)", have heights equal to N, so they grow taller as N increases (10, 50, 100, 200). The green bars, labelled "import (O(1) parse)", stay flat at height 1 in every case. The flat green bar visually shows that import cost does not grow with N, while the rising red bars show #include cost scaling linearly with N.
The bar chart above makes the scaling literal: the red #include bar grows with the number of translation units N, while the green import bar stays flat at 1 regardless of N. That flat green bar is the whole reason modules speed up large builds — see Build Systems and Compilation Speed.
L3.3
Order these four lines into the only legal sequence for a module that needs a legacy header, then justify each position:
export module m;
module;
export void f();
#include <cstdio>
Recall Solution
Legal order:
module; // (1) opens the global module fragment
#include <cstdio> // (2) legacy include — allowed only in the fragment
export module m; // (3) begins the named-module region
export void f(); // (4) an exported declaration, after the region beginsJustification: #include is only legal in the global module fragment, which must come before export module m;; and exported declarations are only legal after export module m;.
Level 4 — Synthesis
Goal: combine partitions, implementation units, and quarantine correctly.
L4.1
Module math is getting large. You want a public sub-piece math:trig that lives in its own file but is still part of module math. Write the partition interface unit and show how the primary interface re-exports it so that import math; alone gives users the trig names.
Recall Solution
Partition file (math-trig.ixx):
export module math:trig;
export double sine(double x); // declared in this partitionPrimary interface (math.ixx):
export module math;
export import :trig; // re-export the partition so importers see its namesWhy this is legal and works: export module math:trig; names a partition of math, not a new module — the colon syntax ties it to the parent module, so it is not a second primary interface (which would be illegal). The primary interface then writes export import :trig;, which both imports the partition and re-exports its names, placing them on math's own lid. Consequently a consumer writing import math; receives sine too. See Module Partitions. There is still exactly one primary interface unit; partitions are internal sub-units.
L4.2
Design a module geometry that (a) internally needs <cmath>, (b) exports a class Circle with a public method area() and a private radius, and (c) puts the definition of area() in a separate implementation unit. Write all three files.
Recall Solution
Global-fragment + interface (geometry.ixx):
module;
#include <cmath> // quarantined legacy header
export module geometry;
export class Circle {
public:
explicit Circle(double r);
double area() const; // declared here, defined in impl unit
private:
double r_; // private: hidden from callers
};Implementation unit (geometry.cpp):
module geometry; // no 'export' — adds no new public names
Circle::Circle(double r) : r_(r) {}
double Circle::area() const {
return 3.141592653589793 * r_ * r_;
}Consumer (main.cpp):
import geometry;
#include <iostream>
int main() {
Circle c(2.0);
std::cout << c.area(); // legal: area() is public and Circle exported
// c.r_; // ERROR: r_ is private
}Why each piece is legal: in geometry.ixx, the #include <cmath> sits in the global module fragment (the only place it is allowed), before export module geometry;; the class is exported so its name reaches importers. In geometry.cpp, module geometry; (no export) makes it an implementation unit that may define the already-declared members without adding new public names. In main.cpp, c.area() compiles because Circle was exported (module boundary crossed) and area() is public (class boundary crossed); c.r_ would fail because r_ is private — the class-access filter blocks it even though the module filter passed.
Numeric answer: for Circle c(2.0), area() returns pi times 2 squared, which is 4 pi, approximately 12.566370614.
Level 5 — Mastery
Goal: predict behaviour across the macro boundary and the whole build.
Recall the header unit idea one more time before this level, because L5.1 turns on it: a header unit is a header pulled in with import "config.h"; instead of #include "config.h";. The compiler builds it once like a module (parse-once speed), but because it is still made from a header, it does preserve the header's macros — unlike a normal module import, which never exports macros. So a header unit is the tool you reach for when you need both the speed and a shared macro.
L5.1
// config.ixx
export module config;
#define MAX 256 // (assume this is inside the module region)
export int cap() { return 100; }// main.cpp
import config;
int main() {
return cap() + MAX; // does MAX resolve here?
}Does MAX resolve in main.cpp? Explain, and give the alternative that would share the macro.
Recall Solution
MAX does not resolve in main.cpp. Why: import deliberately does not carry macros across the module boundary — this is the exact leakage cure that modules were designed for (contrast the paste behaviour of The C++ Preprocessor). So MAX is unknown in main.cpp and that file fails to compile on the MAX token.
The exported function is fine: cap is on the lid, so cap() = 100 is visible.
Alternative that shares the macro: use a header unit (defined just above this level):
// put MAX in config.h, then in main.cpp:
import "config.h"; // header unit — DOES preserve macrosWhy the alternative works: a header unit is still built from a header, so its #define MAX 256 survives into the importer, while it still enjoys the parse-once speed of a module. It is the one legitimate path to a shared macro under the module system.
L5.2
A project has one interface unit for module bignum, imported by N = 250 translation units. Under #include-style headers each translation unit re-parsed the bignum interface (cost 1 each). Compute total interface-parse cost under #include and under import, and the speedup ratio for this interface.
Recall Solution
#include: 250 times 1, which is 250 parse-units.import: 1 parse-unit (compiled once, reused).- Speedup ratio is 250 divided by 1, which is 250 times.
Why it collapses to one: as argued in L3.2, the module interface compiles to a single binary artifact that is provably identical for every importer, so the 250 re-parses that #include was forced into become a single reuse. This is the Translation Units and Linkage insight made quantitative: cost scales with how many times the same interface text is parsed, and modules collapse that to one.
L5.3 (capstone)
Given the following legal module:
export module bank;
export int fee() { return 5; }
int audit() { return 3; } // private
export int total(int deposit) {
return deposit - fee() - audit();
}and consumer:
import bank;
int main() {
return total(20) + fee(); // compute the returned value
}Compute the program's return value, and state whether the program would still compile if you replaced total(20) with total(20) + audit().
Recall Solution
Inside the module, total(20) = 20 - fee() - audit() = 20 - 5 - 3 = 12. Why total may call audit: they live in the same module box, and private only blocks outsiders — so this internal call is legal.
In main: total(20) + fee() = 12 + 5 = 17.
If you wrote total(20) + audit() in main, it would fail to compile — audit was never exported, so it is invisible across the module boundary (the dashed red arrow again). (The number would have been 12 + 3 = 15 if it compiled, but it does not.)
Active recall
Recall Rapid self-test (answer before expanding)
- How many primary interface units per module? → Exactly one.
- Where may
#includelegally sit inside a module? → The global module fragment (beforeexport module). - Does
import(of a module) bring macros? → No — but a header unit does. - Interface-parse cost scaling:
#includevsimportfor N importers? → order N vs constant. - What makes a file an implementation unit? →
module Name;with noexport.
Value of total(20) inside module bank?
Return value of total(20) + fee() in main?
Speedup ratio for 250 importers, #include vs import?
Value of Circle(2.0).area() with pi times r squared?
What is a header unit, and why use it?
import "config.h";) — parsed once like a module but it preserves macros, so it is the way to share a macro.