coroutines и modulescoroutines and modules

modules

import вместо #include: модуль компилируется один раз в бинарное представление, переиспользуемое при каждом импорте — без повторного парсинга текста заголовка и без утечки макросов в код, который его импортирует.import instead of #include: a module compiles once into a binary representation that's reused on every import — no re-parsing of header text, and no macro leakage into the code that imports it.

меняет модель сборки, не только синтаксисchanges the build model, not just syntax поддержка тулчейнами долго была нестабильнойtoolchain support was unstable for a long time частый вопрос: решают ли modules проблему ABIcommon question: do modules solve the ABI problem

Что этоWhat it is

Модуль объявляется через export module name;, экспортируемые сущности (функции, классы, переменные) помечаются ключевым словом export. Использующий код пишет import name; вместо #include "name.h". Ключевое отличие от заголовков не в синтаксисе, а в модели компиляции: модуль компилируется один раз в бинарное представление (обычно файл с расширением .pcm у Clang или .ifc у MSVC — конкретное расширение и формат зависят от компилятора), которое затем переиспользуется при каждом импорте — компилятору не нужно заново парсить текст модуля в каждой единице трансляции, которая его импортирует.A module is declared with export module name;, and exported entities (functions, classes, variables) are marked with the export keyword. Code that uses it writes import name; instead of #include "name.h". The key difference from headers isn't the syntax — it's the compilation model: a module compiles once into a binary representation (typically a file with a .pcm extension for Clang, or .ifc for MSVC — the exact extension and format is compiler-specific), which is then reused on every import — the compiler doesn't need to re-parse the module's text in every translation unit that imports it.

до C++20 — #includebefore C++20 — #include
// math_utils.h// math_utils.h
#define PI 3.14159   // просачивается всюду, где есть #include// leaks everywhere this gets #included
int square(int x);

// main.cpp — заголовок парсится заново// main.cpp — the header is re-parsed
#include "math_utils.h"   // в каждой единице трансляции,// in every translation unit
                          // которая его включает// that includes it
C++20 — модульC++20 — a module
// math_utils.cppm// math_utils.cppm
export module math_utils;
export int square(int x) { return x * x; }
                          // внутренний #define PI не виден снаружи// an internal #define PI isn't visible outside

// main.cpp — компилятор переиспользует// main.cpp — the compiler reuses
import math_utils;   // уже скомпилированное бинарное представление// the already-compiled binary representation

Зачем ввелиWhy it was introduced

Модель #include, унаследованная от C, накопила за десятилетия три постоянные проблемы:The #include model, inherited from C, accumulated three persistent problems over the decades:

01

Повторный парсинг одного и того же текста в каждой единице трансляцииRe-parsing the same text in every translation unit

Если 200 .cpp-файлов включают один и тот же тяжёлый заголовок, компилятор парсит его текст 200 раз — заново, с нуля, в каждом файле. Precompiled headers частично компенсируют эту стоимость, но это отдельный, менее переносимый между компиляторами и более хрупкий в настройке механизм, а не решение проблемы в её корне. На больших кодовых базах повторный парсинг заголовков составляет заметную долю времени полной пересборки.If 200 .cpp files include the same heavy header, the compiler parses its text 200 times — from scratch, in every file. Precompiled headers partially offset this cost, but they're a separate, less portable across compilers, and more fragile-to-configure mechanism — not a fix at the root of the problem. On large codebases, re-parsing headers accounts for a noticeable share of full rebuild time.

02

Утечка макросов между единицами трансляцииMacro leakage across translation units

Любой #define, сделанный внутри заголовка, становится видим везде, где этот заголовок включён после — и во всех заголовках, включённых позже в том же файле. Конфликт имён макросов между независимо написанными библиотеками — источник трудноуловимых, зависящих от порядка include, ошибок сборки, которые сложно локализовать.Any #define made inside a header becomes visible everywhere that header gets included afterward — and in every header included later in the same file. Macro name collisions between independently written libraries are a source of hard-to-track, include-order-dependent build errors that are difficult to localize.

03

Результат компиляции зависит от порядка #includeCompilation results depend on #include order

Поскольку #include — это буквально текстовая вставка, смысл заголовка может зависеть от того, что уже было определено (макросы, объявления) до его включения. Один и тот же заголовок в одном файле компилируется иначе, чем в другом, если порядок include отличается — источник багов, которые проявляются только в определённой комбинации файлов.Because #include is literally a textual insertion, a header's meaning can depend on what was already defined (macros, declarations) before it was included. The same header compiles differently in one file than in another if the include order differs — a source of bugs that only manifest under a particular combination of files.

Как это работает под капотомHow it works under the hood

Компилятор, встретив export module name;, компилирует модуль в самостоятельный артефакт (Binary Module Interface, BMI) — не текстовый файл, а уже разобранное, семантически проверенное представление экспортируемого интерфейса модуля. При import name; компилятор подгружает этот готовый артефакт напрямую, минуя лексический анализ и парсинг исходного текста модуля заново. Отсюда и структурная защита от утечки макросов: препроцессорные директивы внутри модуля обрабатываются на этапе его собственной компиляции и не являются частью экспортируемого интерфейса — импортирующий код просто не видит текстовое содержимое модуля, только скомпилированный результат.When the compiler encounters export module name;, it compiles the module into a standalone artifact (a Binary Module Interface, BMI) — not a text file, but an already-parsed, semantically-checked representation of the module's exported interface. On import name;, the compiler loads that ready-made artifact directly, skipping lexing and re-parsing the module's source text. This is also where the structural protection against macro leakage comes from: preprocessor directives inside a module are processed during the module's own compilation and aren't part of the exported interface — importing code simply never sees the module's textual content, only the compiled result.

// пример module-единицы с частично приватной реализацией// an example module unit with a partly private implementation
export module geometry;

export struct Point { double x, y; };

export double distance(Point a, Point b);   // экспортируется// exported

namespace detail {
    double sqrt_impl(double x) { /* ... *//* ... */ }
}   // не помечено export — недоступно импортирующему коду вообще,// not marked export — completely inaccessible to importing code,
    // не просто "приватная секция", а невидимая сущность снаружи// not just a "private section" — an entity invisible from outside

Логический модуль не обязан жить в одном файле. Module implementation unit — отдельный файл, который принадлежит тому же модулю (директива module geometry; без export), но не является интерфейсом: в нём можно писать реализацию функций, объявленных в интерфейсном файле, без риска раздуть сам интерфейс телами функций.A logical module doesn't have to live in one file. A module implementation unit is a separate file belonging to the same module (a module geometry; directive, without export) that isn't part of the interface — it's where you write the implementation of functions declared in the interface file, without bloating the interface itself with function bodies.

// geometry.cppm — интерфейс модуля, только объявления// geometry.cppm — the module interface, declarations only
export module geometry;

export double distance(Point a, Point b);   // только объявление, без тела// declaration only, no body

// geometry_impl.cpp — implementation unit того же модуля// geometry_impl.cpp — an implementation unit of the same module
module geometry;   // без export — это НЕ интерфейс, а часть реализации// no export — this is NOT an interface, it's part of the implementation

double distance(Point a, Point b) {   // тело здесь, видно только внутри модуля geometry// the body lives here, visible only inside the geometry module
    return std::hypot(a.x - b.x, a.y - b.y);
}

Ещё один способ разбить крупный модуль на файлы — module partitions: export module geometry:shapes; объявляет часть ("партицию") модуля geometry, а сам главный интерфейсный файл собирает партиции воедино через export import :shapes;. Партиции видны друг другу внутри модуля, но снаружи модуль по-прежнему выглядит как единое целое — импортирующий код пишет один import geometry;, не подозревая о внутреннем разбиении на файлы.Another way to split a large module across files is module partitions: export module geometry:shapes; declares a "partition" of the geometry module, and the main interface file pulls the partitions together via export import :shapes;. Partitions are visible to each other inside the module, but from the outside the module still looks like a single unit — importing code writes one import geometry;, unaware of the internal split across files.

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Простой модуль с одной экспортируемой функциейA simple module with one exported function

Минимальный законченный сценарий: модуль с одной функцией и использующий его файл — база, на которой строятся все более сложные случаи ниже.The minimal complete scenario: a module with one function, and a file that uses it — the base on which every more elaborate case below is built.

// string_utils.cppm// string_utils.cppm
export module string_utils;
#include <string>

export std::string to_upper(std::string s) {
    for (char& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
    return s;
}

// main.cpp// main.cpp
import string_utils;

int main() {
    std::cout << to_upper("hi");   // "HI"// "HI"
}
02

Модуль, реэкспортирующий другой модульA module re-exporting another module

Фасадный модуль, объединяющий несколько нижележащих модулей в одну точку импорта для потребителя — export import делает содержимое импортированного модуля частью собственного экспортируемого интерфейса.A facade module that bundles several underlying modules into a single import point for consumers — export import makes the imported module's contents part of the facade's own exported interface.

// app_core.cppm — фасад, собирающий подсистемы воедино// app_core.cppm — a facade bundling subsystems together
export module app_core;

export import string_utils;   // всё, что экспортирует string_utils, теперь видно и через app_core// everything string_utils exports is now visible through app_core too
export import geometry;

// consumer.cpp — достаточно одного импорта вместо трёх// consumer.cpp — one import is enough instead of three
import app_core;   // to_upper() и distance() уже доступны// to_upper() and distance() are already available
03

Миграция заголовочного файла в модульMigrating a header file to a module

Типичный маленький заголовок-утилита и его прямой эквивалент в виде модуля — обратите внимание, что include guard/pragma once исчезает целиком: модулю не нужна защита от повторного включения, компилятор обрабатывает его ровно один раз по своей природе.A typical small utility header and its direct module equivalent — notice that the include guard/pragma once disappears entirely: a module needs no protection against repeated inclusion, the compiler processes it exactly once by its very nature.

// до: constants.h// before: constants.h
#pragma once
constexpr double kGravity = 9.81;
inline double free_fall_distance(double t) { return 0.5 * kGravity * t * t; }

// после: constants.cppm// after: constants.cppm
export module constants;
export constexpr double kGravity = 9.81;
export double free_fall_distance(double t) { return 0.5 * kGravity * t * t; }
04

Экспорт шаблонной функции из модуляExporting a template function from a module

Нюанс: реализация шаблона должна быть видна компилятору в момент инстанциации в коде потребителя — значит, тело шаблонной функции должно находиться именно в export-части модуля (интерфейсе или партиции), а не быть спрятано в implementation unit, как в примере с distance() выше. Implementation unit подходит только для нешаблонных функций.The nuance: a template's implementation must be visible to the compiler at the point of instantiation in consumer code — which means a template function's body must live in the export part of the module (the interface, or a partition), not be tucked away in an implementation unit like distance() was above. An implementation unit only works for non-template functions.

// numeric_utils.cppm// numeric_utils.cppm
export module numeric_utils;

export template <class T>
T clamp_positive(T value) {   // тело обязано быть здесь, в export-части// the body must be right here, in the export part
    return value < T{} ? T{} : value;
}   // вынести тело в implementation unit — компилятор не сможет// moving the body into an implementation unit — the compiler couldn't
    // инстанциировать шаблон для типа T, использованного потребителем// instantiate the template for the T type used by the consumer

ГраблиGotchas

Поддержка компиляторами и системами сборки была неполной/нестабильной долгое времяCompiler and build-system support was incomplete and unstable for a long time

CMake, различия в деталях реализации между MSVC/GCC/Clang — поддержка modules после выхода C++20 была неполной и местами нестабильной довольно долго. Практическая заметка: перед тем как принимать решение мигрировать реальный проект на модули, стоит явно проверить состояние поддержки в конкретном тулчейне (версия компилятора, версия CMake, генератор сборки) — а не полагаться на то, что "раз в стандарте есть, значит работает везде одинаково".CMake, and implementation differences between MSVC/GCC/Clang — module support after C++20 shipped remained incomplete and, in places, unstable for quite a while. A practical note: before deciding to migrate a real project to modules, explicitly check the state of support in the specific toolchain (compiler version, CMake version, build generator) — don't rely on "it's in the standard, so it must work the same everywhere."

Смешение модулей и заголовков возможно, но не тривиальноMixing modules and headers is possible, but not trivial

Миграция по частям — не "всё или ничего": существуют header units (заголовок, импортируемый как модуль без переписывания на export module-синтаксис) как промежуточная ступень. Но смешение добавляет реальной сложности — нужно понимать правила видимости макросов и включаемых деклараций на границе между классическим заголовком и module-единицей, и это не всегда интуитивно с первого раза.Migrating incrementally isn't "all or nothing" — header units (a header imported as a module without rewriting it into export module syntax) exist as an intermediate step. But mixing the two adds real complexity — you need to understand the visibility rules for macros and included declarations at the boundary between a classic header and a module unit, and it isn't always intuitive on the first try.

Modules НЕ решают проблему ABI-совместимости между компиляторамиModules do NOT solve cross-compiler ABI compatibility

Формат BMI (.pcm/.ifc) специфичен для конкретного компилятора и обычно даже для конкретной версии этого компилятора — прекомпилированный модуль, собранный одним компилятором, как правило нельзя просто взять и использовать с другим (или даже с другой версией того же). Это усложняет распространение прекомпилированных модулей как готовых бинарных библиотек — задача, которую modules сами по себе не решают, несмотря на интуитивное ожидание "раз это бинарный формат, значит переносимый".The BMI format (.pcm/.ifc) is compiler-specific, and usually even specific to a particular version of that compiler — a precompiled module built by one compiler generally can't just be picked up and used with a different one (or even a different version of the same one). This complicates distributing precompiled modules as ready-made binary libraries — a problem modules don't solve on their own, despite the intuitive expectation that "it's a binary format, so it must be portable."