Модули
До C++20 организация кода держалась на #include — механизме, который был унаследован ещё из C. Каждый #include буквально копирует текст заголовка в единицу трансляции. Это работало десятилетиями, но к концу 2010-х стало очевидно: платформа не масштабируется. Модули C++20 — ответ на эту проблему.
Почему возникли модули
Чтобы понять модули, нужно сначала понять, что именно сломано в #include.
Проблемы модели #include
Представьте, что у вас в проекте 500 файлов, и каждый включает <vector>, <string>, <map>. Компилятор каждый раз заново парсит все эти заголовки — миллионы строк кода — при компиляции каждой единицы трансляции.
// main.cpp — компилятор разбирает все эти заголовки с нуля
#include <vector> // ~15 000 строк после раскрытия
#include <string>
#include <map>
#include "my_lib.h" // и ваш заголовок тоже
Но медленная компиляция — не единственная беда:
ODR-нарушения (One Definition Rule). Правило C++ требует, чтобы каждая сущность была определена ровно один раз. С заголовками это легко нарушить: два .cpp включают один заголовок с определением функции без inline — линковщик выдаст ошибку (или, что хуже, молча возьмёт одно из определений).
Утечка макросов. #define не знает scope. Если один заголовок определяет #define max(a, b) ..., это сломает любой код ниже, который использует max как имя функции или метода.
// где-то в windows.h
#define min(a, b) ((a) < (b) ? (a) : (b))
#include <windows.h>
#include <algorithm>
// std::min перестаёт работать — макрос "перехватывает" вызов
Порядок включения. Если заголовок A определяет макрос, который меняет поведение заголовка B, то #include "a.h" перед #include "b.h" даёт другой результат, чем наоборот. Это хрупко.
Нет настоящей инкапсуляции. Всё, что объявлено в заголовке, видно всем, кто его включил — включая вспомогательные детали, которые "не должны быть частью API".
Модули решают всё это на уровне стандарта: интерфейс компилируется один раз в Binary Module Interface (BMI), макросы не пересекают границы модулей, а экспортируется только то, что помечено явно.
Базовая структура модуля
Минимальный модуль состоит из двух частей: объявление модуля и его использование.
Интерфейс модуля
// math.cppm — интерфейс модуля (расширение .cppm или .ixx)
export module math; // объявляем именованный модуль "math"
export double square(double x) { return x * x; }
export double cube(double x) { return x * x * x; }
// Эта функция НЕ экспортируется — она внутренняя деталь реализации
double helper(double x) { return x + 1.0; }
Ключевые слова:
export module math;— объявление модуля (должно стоять первым)exportперед функцией или классом — делает их частью публичного API модуля- Без
export— сущность видна только внутри модуля
Использование модуля
// main.cpp
import math; // импортируем модуль — BMI уже скомпилирован
int main() {
double a = square(3.0); // 9.0 — доступно, т.к. export
double b = cube(2.0); // 8.0 — доступно, т.к. export
// helper(1.0); // ошибка компиляции — не экспортировано
}
Компиляция вручную (Clang):
# Шаг 1: скомпилировать интерфейс в BMI
clang++ -std=c++20 --precompile math.cppm -o math.pcm
# Шаг 2: скомпилировать объектный файл из интерфейса
clang++ -std=c++20 -fmodule-file=math=math.pcm -c math.cppm -o math.o
# Шаг 3: скомпилировать и слинковать потребителя
clang++ -std=c++20 -fmodule-file=math=math.pcm main.cpp math.o -o main
Что можно экспортировать
export работает для большинства объявлений верхнего уровня:
export module mylib;
// Функции
export void foo();
export int bar(int x, int y);
// Классы и структуры
export class Widget {
public:
void render();
private:
int id_; // private остаётся private — модуль не меняет правила доступа
};
// Шаблоны
export template<typename T>
T identity(T x) { return x; }
// Псевдонимы типов
export using Id = unsigned int;
// Блок export — несколько объявлений сразу
export {
void start();
void stop();
struct Config { int timeout; int retries; };
}
// Перечисления
export enum class Color { Red, Green, Blue };
Нельзя экспортировать из anonymous namespace, а также static-сущности (они имеют internal linkage по определению).
Разделение интерфейса и реализации
Интерфейс и реализацию можно разложить по разным файлам:
// math.cppm — интерфейс (module interface unit)
export module math;
export double square(double x);
export double cube(double x);
// math_impl.cpp — реализация (module implementation unit)
module math; // без export — это unit реализации, не интерфейс
double square(double x) { return x * x; }
double cube(double x) { return x * x * x; }
Потребителям достаточно знать только интерфейс — implementation unit они не видят и не компилируют напрямую.
Разделы модуля (Module Partitions)
Большой модуль можно разбить на разделы (partitions) — логические части, которые вместе образуют один модуль:
// math-basic.cppm — раздел :basic
export module math:basic;
export int add(int a, int b) { return a + b; }
export int sub(int a, int b) { return a - b; }
// math-advanced.cppm — раздел :advanced
export module math:advanced;
export double power(double base, int exp);
export double sqrt_approx(double x);
// math.cppm — основной интерфейс, собирающий разделы
export module math;
export import :basic; // реэкспортировать раздел потребителям
export import :advanced;
Потребитель просто пишет import math; и получает всё. Разделы — деталь организации внутри модуля.
Глобальный фрагмент модуля — совместимость с #include
Модули не могут использовать #include внутри своего тела напрямую — это нарушает изоляцию. Для включения legacy-заголовков есть специальный механизм: global module fragment.
// Глобальный фрагмент — до объявления модуля
module; // открывает глобальный фрагмент
#include <cstdlib> // legacy-заголовки идут сюда
#include <cassert>
#include "legacy_lib.h"
// Теперь объявляем модуль
export module mylib;
// Здесь можно использовать всё из включённых заголовков,
// но они НЕ становятся частью экспорта модуля
export void do_something();
Важно: сущности из #include внутри глобального фрагмента не экспортируются потребителям модуля. Они видны только внутри unit реализации.
export import — реэкспорт
Модуль может реэкспортировать другие модули своим потребителям:
export module geometry;
export import math; // потребители geometry автоматически получат math
export import shapes;
export struct Point { double x, y; };
export struct Rect { Point origin; double w, h; };
Это удобно для создания "фасадных" модулей, которые объединяют несколько подмодулей в единый интерфейс.
Header Units — мост к legacy
Если переписывать все зависимости под модули пока невозможно, import можно использовать и с обычными заголовками:
import <vector>; // header unit из стандартной библиотеки
import <iostream>;
import "my_header.h"; // header unit из вашего кода (поддержка варьируется)
Header unit — это компилированный заголовок в формате BMI. Он избавляет от повторного парсинга, но макросы из header unit всё равно доступны потребителю — это отличает их от настоящих модулей.
Binary Module Interface (BMI)
BMI — бинарный файл, который компилятор создаёт из интерфейса модуля. Именно его читают потребители вместо исходного .cppm.
| Компилятор | Расширение BMI | Флаги |
|---|---|---|
| GCC 14+ | .gcm | автоматически |
| Clang 16+ | .pcm | --precompile, -fmodule-file=name=file.pcm |
| MSVC 2019+ | .ifc | /interface, /reference name=file.ifc |
math.cppm ──компилятор──▶ math.pcm (BMI)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
unit_a.cpp unit_b.cpp unit_c.cpp
(читает BMI, (читает BMI, (читает BMI,
не исходник) не исходник) не исходник)
Ускорение происходит потому, что BMI — уже разобранное представление семантики модуля. Компилятор не разбирает токены заново, а читает готовую структуру.
Важно: BMI-файлы не переносимы между компиляторами и зачастую между версиями одного компилятора. Не храните BMI в репозитории — их генерирует система сборки.
Поддержка в компиляторах и системах сборки
Модули C++20 требуют явной поддержки не только в компиляторе, но и в системе сборки — потому что сборка стала зависимо-ориентированной: BMI нужно создать до компиляции потребителей.
| Инструмент | Статус |
|---|---|
| GCC 14+ | Полная поддержка именованных модулей |
| Clang 16+ | Полная поддержка |
| MSVC VS 2022 17.5+ | Полная поддержка |
| CMake 3.28+ | Нативная поддержка (target_sources(... FILE_SET CXX_MODULES)) |
| Build2 | Поддержка модулей с ранних версий |
| Meson | Поддержка добавлена в 1.3.0 |
| Make / Autotools | Практически нет — нужен ручной порядок компиляции |
Пример CMake 3.28:
cmake_minimum_required(VERSION 3.28)
project(myapp CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
add_executable(myapp main.cpp)
target_sources(myapp
PUBLIC FILE_SET CXX_MODULES FILES math.cppm
)
Практические ограничения и ловушки
BMI не переносим. Нельзя передать .ifc из MSVC в GCC. Каждый toolchain пересобирает BMI сам.
Нельзя экспортировать макросы. Это намеренно — одна из главных целей модулей. Если ваш API зависит от макросов, модуль не поможет без дополнительного слоя.
Осторожно с include внутри модуля без глобального фрагмента. Некоторые компиляторы допустят #include внутри тела модуля, но поведение нестандартно и нежелательно.
Циклические импорты запрещены. Если A импортирует B, B не может импортировать A. Это осознанное ограничение — граф зависимостей должен быть ациклическим.
Смешивание с заголовками требует аккуратности. Если один .cpp использует #include "foo.h", а другой import foo_module (который включает тот же код), можно получить ODR-нарушение.
Legacy-код не мигрирует автоматически. Перевод большой кодовой базы на модули — постепенный процесс. Начинайте с leaf-зависимостей (без исходящих зависимостей в вашем коде) и двигайтесь вверх по графу.
Значение для собеседований
На собеседовании модули C++20 чаще всего всплывают в контексте разговора о стандарте C++20 в целом или о проблемах больших проектов. interview-importance здесь низкий — большинство компаний ещё не перешли на модули в production, — но понимание зачем они появились сигнализирует об уровне кандидата.
Что проверяет интервьюер:
- Понимает ли кандидат проблемы модели
#include(медленная компиляция, ODR, макро-загрязнение) — а не просто знает синтаксис - Знает ли разницу между module interface unit и module implementation unit
- Понимает ли, что такое BMI и почему он ускоряет сборку
- Знает ли о реальных ограничениях — поддержка компиляторов, сборочных систем, непереносимость BMI
Популярные направления вопросов:
- "Какие проблемы решают модули — и какие не решают?" (ODR, макросы, скорость — да; замена всего
#includeсразу — нет) - "Что такое BMI и чем он отличается от precompiled headers?"
- "Что такое module partition и зачем нужен?"
- "Что такое глобальный фрагмент модуля?"
- "Почему нельзя экспортировать макросы через модуль?"
Типичная ошибка. Кандидат говорит: "Модули — это просто лучший #include." Это неверно. Ключевая разница — семантическая изоляция: import не передаёт макросы, экспортируется только явно помеченное, нет зависимости от порядка. Это принципиально другая модель, а не улучшение текстовой подстановки.
Полный пример: модуль math
// === math.cppm ===
export module math;
export int gcd(int a, int b) {
while (b) { a %= b; std::swap(a, b); }
return a;
}
export int lcm(int a, int b) {
return a / gcd(a, b) * b;
}
// Вспомогательная — не экспортируется
namespace detail {
bool is_coprime(int a, int b) { return gcd(a, b) == 1; }
}
// === main.cpp ===
import math;
// #include <iostream> — iostream ещё не модуль в большинстве реализаций
// используем глобальный фрагмент в отдельном файле или просто #include
#include <iostream>
int main() {
std::cout << gcd(12, 8) << "\n"; // 4
std::cout << lcm(4, 6) << "\n"; // 12
}
Компиляция с GCC 14:
g++ -std=c++20 -fmodules-ts -c math.cppm
g++ -std=c++20 -fmodules-ts main.cpp math.o -o main
Компиляция с Clang 16:
clang++ -std=c++20 --precompile math.cppm -o math.pcm
clang++ -std=c++20 -fmodule-file=math=math.pcm -c math.cppm -o math.o
clang++ -std=c++20 -fmodule-file=math=math.pcm main.cpp math.o -o main
Кратко
Модули C++20 меняют базовую модель организации кода: вместо текстовой подстановки через #include — семантически изолированные единицы с явным экспортом. Компилятор создаёт BMI один раз и отдаёт его всем потребителям; макросы не утекают; порядок импортов не имеет значения. Основной барьер сегодня — не компиляторы (GCC 14, Clang 16, MSVC 2022 уже готовы), а системы сборки и legacy-кодовые базы, которые мигрируют постепенно. Понимать модули как "лучший #include" — ошибка: это другая модель инкапсуляции, а не синтаксический сахар.