본문으로 건너뛰기
Previous
Next
C++20 Modules — Complete Guide

C++20 Modules — Complete Guide

C++20 Modules — Complete Guide

이 글의 핵심

Including one large header pulls in dozens more, and the same text gets reparsed across many .cpp files. Modules are units that “parse once and reuse the result,” which helps compile times. This article walks through the ideas and examples step by step for learning and production use.

Introduction: “Even a single include makes the build too slow”

The cost of headers

One large header often includes dozens of others, and the same content is reparsed across many .cpp files. Modules are units that parse once and reuse the result, which helps compile times.

Goals:

  • Declare a module and define a public interface with export
  • Pull in other modules with import
  • Build settings (compiler flags) when using modules

With export, you fix what this module exposes; unlike a wholesale #include, implementation details do not leak, and dependencies are visible from import alone. MSVC, Clang, and GCC support C++20 modules, so starting new libraries as modules is one practical way to adopt them incrementally.

After reading this article you will be able to:

  • Author module files (.cppm / .ixx)
  • State dependencies with import and reduce compile cost
  • Mix modules with existing headers

Requirements: a compiler with C++20 (GCC 11+, Clang 13+, MSVC 2019 16.10+). CMake 3.28+ has stable module build support. Works on Linux, macOS, and Windows.


Production note: this article draws on real issues and fixes from large C++ codebases. It includes practical pitfalls and debugging tips that textbooks often skip.

Table of contents

  1. What is a module?
  2. Problem scenario: include hell
  3. Module declaration and export
  4. Complete module examples
  5. Using import
  6. Module partitions
  7. Builds and tooling
  8. Common errors
  9. Best practices
  10. Performance comparison
  11. Production patterns

1. What is a module?

A module is a C++20 unit that parses once and then reuses the result. Roughly: if #include pastes and reparses text every time, a module reads once, caches, and reuses—helping compile times.

Headers vs modules at a glance:

flowchart LR
  subgraph header[#include headers]
    H1[.cpp 1] --> H2[parse every time]
    H3[.cpp 2] --> H2
    H2 --> H4[duplicate parsing]
  end
  subgraph module[import modules]
    M1[.cpp 1] --> M2[parse once]
    M3[.cpp 2] --> M2
    M2 --> M4[reuse cache]
  end

Headers vs modules

AspectHeader (#include)Module (import)
UnitTextual pasteReuse parsed result
DuplicationParsed every timeParsed once, then reused
CyclesControlled with forward declarations, etc.Dependencies are explicit at module granularity

Basic shape

After module;, the global module fragment may only use legacy headers (#include <vector>, etc.). export module mylib; declares that this file is the mylib module. Only add and Widget marked export are visible to code that imports the module; definitions without export (e.g. internalHelper) stay internal. So unlike #include, which pastes entire headers, only exported declarations are exposed, and the compiler can reuse the one-time parse—often shrinking compile times on large projects. Extensions are commonly .cppm (Clang/GCC) or .ixx (MSVC).

// mylib.cppm (or .ixx)
module;

// Global module fragment: #include only here
#include <vector>

export module mylib;

export int add(int a, int b) {
    return a + b;
}

export class Widget {
public:
    void draw();
};

2. Problem scenario: include hell

What it looks like

Scenario: a project with 50 .cpp files, each using #include <vector>, #include <string>, and #include "common_utils.h". Suppose common_utils.h includes 10 more headers, which pull in others in turn.

flowchart TD
    subgraph cpp[50 .cpp files]
        C1[main.cpp]
        C2[parser.cpp]
        C3[renderer.cpp]
        C50[...]
    end
    subgraph headers[shared headers]
        H1[common_utils.h]
        H2[vector]
        H3[string]
        H4[algorithm]
    end
    C1 --> H1
    C2 --> H1
    C3 --> H1
    C50 --> H1
    H1 --> H2
    H1 --> H3
    H1 --> H4

Result: the compiler parses the same header contents 50+ times. <vector> alone is thousands of lines; repeating that 50 times grows compile time quickly.

Concrete costs of include hell

ItemHeader modelImpact
Parsing <vector>50 .cpp × 1 = 50 timesFull parse each time
Parsing common_utils.h50 timesIts 10 dependencies also ×50
Circular depsNeed forward decls, include guardsBuild breaks if mishandled
Exposed implementationEven private members in headersABI fragility, recompiles

How modules help

With modules, import std.vector; (C++23) or import mylib; brings an already parsed result. Even if 50 .cpp files import the same module, the module is parsed once; the rest reuse the cache (.pcm, etc.).

More scenarios

Scenario 2: template header explosion

Including template-heavy headers such as <algorithm>, <memory>, <optional> from 100+ .cpp files causes per-TU template instantiation. Even std::vector<int> may be instantiated many times. Modules share instantiation at module scope and reduce duplication.

Scenario 3: one-line header change, full rebuild

Changing one line in common_utils.h forces every .cpp that includes it to recompile. If 200 files depend on it, all 200 rebuild. With modules, only TUs that import the changed module recompile, so incremental builds are more efficient.

Scenario 4: macro / preprocessor pollution

A macro like #define min(a,b) ((a)<(b)?(a):(b)) in a header redefines min everywhere that header is included, which can clash with std::min. Modules do not leak macros across module boundaries the same way, improving encapsulation.

Scenario 5: ABI instability

Adding a private member to a header can require recompiling every consumer. Modules plus a PIMPL-style split can hide implementation details and limit ABI churn.


3. Module declaration and export

export module / export

export module math; declares this file as the math module interface. Only add and pi with export are usable from code that does import math;. internalHelper without export is visible only inside the module. That makes the module’s API explicit and reduces “private implementation leaking through headers.”

Example implementation of internalHelper:

export module math;

export int add(int a, int b) {
    return a + b;
}

export double pi = 3.14159;

// Without export: internal to this module only
static int internalHelper() {
    return 0;
}

Note: Across module boundaries, the export set effectively becomes your API contract. Export the minimal surface you intend to support.

Export blocks

export { ... } exports several declarations at once. Both foo and Bar are available to code that import utils;. For many declarations, a block is easier than repeating export on every line.

Example foo:

export module utils;

export {
    void foo();
    class Bar {};
}

Exporting templates

You can export templates from modules. Unlike headers, template definitions can live inside the module while importers instantiate them.

// container.cppm
module;

#include <utility>

export module container;

export template<typename T>
class Box {
    T value;
public:
    explicit Box(T v) : value(std::move(v)) {}
    const T& get() const { return value; }
};

Note: Templates still need a visible instantiation context. When splitting .cpp implementation units, follow your compiler’s rules for interface vs implementation units.


4. Complete module examples

Example 1: math utility module (single file)

math.cppm — interface and implementation in one file:

square (internal detail):

// math.cppm
export module math;

export int add(int a, int b) {
    return a + b;
}

export int multiply(int a, int b) {
    return a * b;
}

export constexpr double PI = 3.141592653589793;

// Internal only: no export
namespace detail {
    int square(int x) { return x * x; }
}

main.cpp — using the module:

// main.cpp
import math;

#include <iostream>

int main() {
    std::cout << add(3, 5) << "\n";        // 8
    std::cout << multiply(4, 7) << "\n";  // 28
    std::cout << PI << "\n";               // 3.14159...
    return 0;
}

Example 2: Split interface and implementation

geometry.cppm — declarations only:

// geometry.cppm
export module geometry;

export struct Point {
    double x, y;
};

export double distance(const Point& a, const Point& b);
export Point midpoint(const Point& a, const Point& b);

geometry_impl.cpp — implementation:

// geometry_impl.cpp
// Module implementation unit
module geometry;

#include <cmath>

double distance(const Point& a, const Point& b) {
    double dx = a.x - b.x;
    double dy = a.y - b.y;
    return std::sqrt(dx * dx + dy * dy);
}

Point midpoint(const Point& a, const Point& b) {
    return Point{(a.x + b.x) / 2, (a.y + b.y) / 2};
}

main.cpp:

// main.cpp
import geometry;

int main() {
    Point p1{0, 0}, p2{3, 4};
    double d = distance(p1, p2);  // 5.0
    Point m = midpoint(p1, p2);   // {1.5, 2}
    return 0;
}

Example 3: Global module fragment with legacy headers

// string_utils.cppm
module;

#include <string>
#include <algorithm>
#include <cctype>

export module string_utils;

export std::string to_upper(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
        [](unsigned char c) { return static_cast<char>(std::toupper(c)); });
    return s;
}

export std::string trim(const std::string& s) {
    auto start = s.find_first_not_of(" \t\n\r");
    if (start == std::string::npos) return "";
    auto end = s.find_last_not_of(" \t\n\r");
    return s.substr(start, end - start + 1);
}

Example 4: Large module with partitions

Splitting a network module into tcp, udp, and http partitions.

network.cppm — main interface:

// network.cppm
export module network;

export import network:tcp;
export import network:udp;
export import network:http;

network_tcp.cppm — TCP partition:

connect example:

// network_tcp.cppm
module network:tcp;

export class TcpSocket {
public:
    void connect(const char* host, int port);
    void send(const void* data, size_t len);
    size_t receive(void* buf, size_t len);
};

network_udp.cppm — UDP partition:

bind example:

// network_udp.cppm
module network:udp;

export class UdpSocket {
public:
    void bind(int port);
    void sendTo(const void* data, size_t len, const char* addr, int port);
};

network_http.cppm — HTTP partition (uses tcp partition):

// network_http.cppm
module;

#include <string>

module network:http;

import network:tcp;

export class HttpClient {
public:
    std::string get(const char* url);
};

main.cpp:

// main.cpp
import network;

int main() {
    TcpSocket tcp;
    tcp.connect("localhost", 8080);

    UdpSocket udp;
    udp.bind(9000);

    HttpClient http;
    auto response = http.get("https://example.com");

    return 0;
}

5. Using import

Importing another module

import math; brings add, pi, and other exported names from math. Unlike #include, import loads already parsed module artifacts, so even a large math keeps main.cpp relatively cheap to compile. The compiler writes .pcm (or the toolchain’s equivalent) and reuses it.

main example:

// main.cpp
import math;

int main() {
    int x = add(3, 5);
    return 0;
}

Multiple modules

// main.cpp
import math;
import utils;
import geometry;
// C++23 standard library modules (when supported)
// import std;

Mixing import and #include

  • You may import modules and #include headers in the same file.
  • Prefer import first, then #include, when possible.
// main.cpp
import math;
import geometry;

#include <iostream>
#include <vector>

int main() {
    std::cout << add(1, 2) << "\n";
    return 0;
}

6. Module partitions

What is a partition?

Partitions let you split one logical module across files while still exposing one module. Users can import mylib; once and get exports from all partitions.

flowchart TB
    subgraph mylib[mylib module]
        M1[mylib.cppm - main]
        P1[part1.cppm - partition 1]
        P2[part2.cppm - partition 2]
        P3[part3.cppm - partition 3]
    end
    M1 --> P1
    M1 --> P2
    M1 --> P3
    User[main.cpp] -->|import mylib| M1

Basic partition layout

mylib.cppm — main interface, re-exporting partitions:

// mylib.cppm
export module mylib;

export import mylib:part1;
export import mylib:part2;

part1.cppm — partition 1:

doSomething example:

// part1.cppm
module mylib:part1;

import mylib:part2;  // May import other partitions

export class Part1 {
public:
    void doSomething();
};

part2.cppm — partition 2:

// part2.cppm
module mylib:part2;

export class Part2 {
public:
    int value = 0;
};

part1_impl.cpp — Part1 implementation:

// part1_impl.cpp
module mylib:part1;

void Part1::doSomething() {
    // implementation
}

Partition rules

RuleDescription
Partition namemodule modulename:partitionname;
Internal-only partitionNo export on the partition; import mylib:internal; from inside the module
CyclesPartitions may import each other in cycles (with care); cross-module cycles are more restricted
Exposing to usersexport import mylib:part1; re-exports

Internal-only partition (hide implementation details)

// mylib.cppm
export module mylib;

export import mylib:public_api;
// Do not export internal partition → not visible outside
// public_api.cppm
module mylib:public_api;

import mylib:internal;  // Internal use only

export void publicFunction() {
    internalHelper();  // From internal partition
}

internalHelper implementation:

// internal.cppm
module mylib:internal;

void internalHelper() {
    // implementation details
}

7. Builds and tooling

GCC 11+

# Single module
g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o
g++ -std=c++20 -fmodules-ts main.cpp math.o -o app

# Module with partitions (order matters)
g++ -std=c++20 -fmodules-ts -c mylib:part2 -o part2.o
g++ -std=c++20 -fmodules-ts -c mylib:part1 -o part1.o
g++ -std=c++20 -fmodules-ts -c mylib -o mylib.o
g++ -std=c++20 -fmodules-ts main.cpp part2.o part1.o mylib.o -o app

Clang 13+

# Clang: C++20 modules without needing -fmodules-ts for the same workflow
clang++ -std=c++20 -c math.cppm -o math.o
clang++ -std=c++20 main.cpp math.o -o app

MSVC

  • Compile .ixx files as module interfaces.
  • In Visual Studio, enable C++ modules in project settings.
  • Command line: cl /std:c++20 /interface math.ixx

CMake 3.28+

cmake_minimum_required(VERSION 3.28)
project(MyApp LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)

add_executable(app main.cpp)
target_sources(app PRIVATE FILE_SET all MODULES
    math.cppm
    geometry.cppm
    geometry_impl.cpp
)

With partitions:

add_executable(app main.cpp)
target_sources(app PRIVATE FILE_SET all MODULES
    mylib/part1.cppm
    mylib/part2.cppm
    mylib/mylib.cppm
    mylib/part1_impl.cpp
)

8. Common errors

Error 1: “module not found” / undefined reference

Cause: The module interface (.cppm) was not compiled, or link / compile order is wrong.

Fix:

# Bad order: compiling main first
g++ -std=c++20 main.cpp math.cppm -o app  # may fail

# Good order: module first, then main
g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o
g++ -std=c++20 -fmodules-ts main.cpp math.o -o app

Error 2: export in the global module fragment

Cause: You cannot use export inside the module; block.

// Wrong
module;
export int foo() { return 0; }  // error!
#include <vector>
export module mylib;
// Right
module;
#include <vector>
export module mylib;
export int foo() { return 0; }

Error 3: Using non-exported symbols

Cause: Calling a function or class defined without export from outside the module.

// math.cppm
export module math;
int internalAdd(int a, int b) { return a + b; }  // not exported
// main.cpp
import math;
int x = internalAdd(1, 2);  // error: internalAdd is not exported

Fix: export only what external code should use.

Error 4: Circular imports

Cause: Module A imports B while B imports A.

// mod_a.cppm
export module mod_a;
import mod_b;  // circular if mod_b imports mod_a

Fix: Split with partitions or extract a shared interface module.

Error 5: #include before import

Cause: On some compilers, including headers before import lets macros affect later imports.

// Not recommended
#include <iostream>
import math;

// Recommended: import first
import math;
#include <iostream>

Error 6: Wrong extension for interface units

Cause: Saving a module interface as .cpp may compile it as a normal source file.

Fix: Use .cppm (Clang/GCC) or .ixx (MSVC).

Error 7: Wrong partition compile order

Cause: If partition B imports A, compiling B before A fails.

# Wrong order
g++ -std=c++20 -fmodules-ts -c mylib.cppm -o mylib.o  # may need part1, part2 first
g++ -std=c++20 -fmodules-ts -c part1.cppm -o part1.o  # may need part2 first

# Right order: leaf partitions first
g++ -std=c++20 -fmodules-ts -c part2.cppm -o part2.o
g++ -std=c++20 -fmodules-ts -c part1.cppm -o part1.o
g++ -std=c++20 -fmodules-ts -c mylib.cppm -o mylib.o

Error 8: export in a private implementation unit

Cause: Using export in module mylib; (implementation unit) is invalid.

// Wrong — geometry_impl.cpp implementation unit
module geometry;
export double distance(...) { ... }  // error: no export in impl unit

// Right — geometry.cppm interface unit
export module geometry;
export double distance(const Point& a, const Point& b);

Error 9: Macros leaking into imports

Cause: If #include runs before import, header macros can affect imported modules.

// Bad: Windows.h min/max macros may affect math
#include <windows.h>
import math;
int x = min(1, 2);  // macro min, not std::min

// Better: import first
import math;
#include <windows.h>
#define NOMINMAX  // or disable min/max before windows.h

9. Best practices

Minimize what you export

Rule: Export only the API consumers need; keep helpers and implementation details unexported.

// Good: public API only
export module config;

export struct Config {
    int timeout;
    std::string host;
};
export Config loadConfig(const std::string& path);

// Internal — no export
namespace detail {
    std::string parseEnv(const std::string& key);
}

One responsibility per module

Rule: One module, one concern. Use clear names like math, geometry, string_utils.

// Good: clear roles
export module math;      // math ops
export module geometry;  // geometry types and functions
export module logging;   // logging

// Bad: kitchen-sink module
export module utils;  // math, strings, dates, JSON, ...

Consistent import order

Rule: Standard library → third party → project modules makes dependencies easy to scan.

// Suggested order
// import std;        // C++23 (when available)
import third_party;
import myproject.math;
import myproject.geometry;

#include <iostream>   // Headers without modules last

Split large modules with partitions

Rule: Modules beyond ~500 lines often benefit from partitions for maintenance.

// mylib.cppm — main file only re-exports
export module mylib;
export import mylib:core;
export import mylib:io;
export import mylib:algorithm;

Minimize the global module fragment

Rule: Put only necessary #includes in module;. Prefer import inside the module when possible.

// Only when needed
module;
#include <windows.h>      // macros and platform APIs
#include <legacy_header.h>  // unmigrated legacy

export module mylib;
import std;  // C++23 std module when available instead of wide includes

10. Performance comparison

Compile-time benchmark (illustrative)

ScenarioHeadersModulesImprovement
10 .cpp, one shared util~5 s~2 s~60%
50 .cpp, five large headers~45 s~12 s~73%
200 .cpp, heavy templates~5 min~1 m 20 s~73%

Actual numbers depend on project layout, header size, and compiler.

Why modules are faster

flowchart LR
  subgraph header[Headers]
    A1[file1] --> P[parse]
    A2[file2] --> P
    A3[file3] --> P
    P --> R1[50x repeat]
  end
  subgraph mod[Modules]
    B1[module] --> Q[parse once]
    Q --> C[cache]
    C --> D1[reuse]
    C --> D2[reuse]
    C --> D3[reuse]
  end
StageHeadersModules
ParsingFull parse per .cppParse once, store .pcm
DependenciesRecomputed oftenLoaded from cache
Template instantiationPer using .cppShared at module scope

Incremental builds

  • Header change: every .cpp that includes it recompiles.
  • Module change: only .cpp files that import it recompile; .pcm is regenerated.

11. Production patterns

Pattern 1: Incremental migration

Do not rewrite all headers at once; write new code as modules first.

process example:

// legacy_code.cpp
#include "old_header.h"
import new_module;  // new code as a module

void process() {
    oldFunction();   // header-based
    newFunction();   // module-based
}

Pattern 2: Modules + PIMPL

Use PIMPL inside the module for ABI stability.

draw example:

// widget.cppm
export module widget;

export class Widget {
public:
    Widget();
    ~Widget();
    void draw();
private:
    struct Impl;
    Impl* pimpl;
};
// widget_impl.cpp
module widget;

#include <memory>

struct Widget::Impl {
    int state = 0;
};

Widget::Widget() : pimpl(new Impl) {}
Widget::~Widget() { delete pimpl; }
void Widget::draw() { /* ... */ }

Pattern 3: Wrapping standard headers

Until import std; is available everywhere, wrap frequently used headers in a project module.

// std_vector.cppm (project-local)
module;

#include <vector>

export module std_vector;

export template<typename T>
using vector = std::vector<T>;

// Export only what you need from std::vector

Pattern 4: Module dependencies in build scripts

# When module graphs get complex
add_library(mylib MODULE
    mylib/part1.cppm
    mylib/part2.cppm
    mylib/mylib.cppm
)
target_compile_features(mylib PUBLIC cxx_std_20)

Pattern 5: Caching .pcm in CI

# GitHub Actions example
- name: Cache module artifacts
  uses: actions/cache@v4
  with:
    path: build/modules
    key: modules-${{ hashFiles('**/*.cppm') }}

Pattern 6: Header units for gradual transition

When you cannot modularize a header immediately, compile it as a header unit and import it (MSVC: import "header.h";).

use example:

// MSVC: build legacy_utils.h as a header unit, then
import "legacy_utils.h";

void use() {
    legacyFunction();  // still header-based, but imported
}

Pattern 7: Split interface and implementation (large libraries)

Keeping declarations in .cppm and definitions in .cpp limits recompilation when implementation changes.

start example:

// api.cppm — declarations change rarely
export module api;

export class Service {
public:
    void start();
    void stop();
};
// api_impl.cpp — implementation changes often; fewer importers rebuild
module api;

void Service::start() { /* ... */ }
void Service::stop() { /* ... */ }

Pattern 8: Test-oriented module layout

When tests need internals, add a test-only partition or export test fixtures only in test builds.

// mylib.cppm
export module mylib;

export import mylib:public_api;
#ifdef ENABLE_TEST_API
export import mylib:test_support;  // test builds only
#endif

Production checklist

  • C++20+, GCC 11+ / Clang 13+ / MSVC 16.10+
  • CMake 3.28+ or another build system with module support verified
  • New libraries as modules; legacy code migrated gradually
  • Minimal export surface
  • Partitions for large modules
  • Consider caching module artifacts (.pcm) in CI


C++20 modules, module import export, header alternative, compile speed, module basics, module partitions, include hell — searches like these should surface this article.

Summary

TopicContent
Declareexport module name;
Exposeexport functions / classes / variables
Useimport name;
Partitionsmodule name:partition; to split
EffectFaster compiles, clearer dependencies
ToolingGCC / Clang / MSVC each need module-aware flags

FAQ

Q. When do I use this in practice?

A. This post covers C++20 module syntax, module / import / export, benefits over headers, and build setup. Use it for new libraries, shortening builds on large projects, and untangling header graphs. Apply the examples and production patterns above in real codebases.

Q. What should I read first?

A. Follow the previous post links at the bottom of each article in order. The C++ series index shows the full path.

Q. Where can I go deeper?

A. See cppreference and each library’s official docs. The modules proposal P1103R3 is also helpful.

One-line summary: module, export, and import speed up builds and clarify dependencies. Next, read module migration (#24-2).

Next post: [C++ in practice #24-2] Migrating an existing project to modules: step-by-step

Previous post: [C++ in practice #23-3] Async work and coroutines: non-blocking code with co_await


  • Migrating an existing C++ project to modules | step-by-step [#24-2]
  • C++20 coroutines | co_await and co_yield
  • C++ generators | lazy sequences and pipelines with co_yield
  • C++ async tasks and coroutines | escaping callback hell with co_await [#23-3]
  • C++20 Ranges | algorithms without raw begin/end

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, C++20, Modules, import, export, compile time 등으로 검색하시면 이 글이 도움이 됩니다.