Aller au contenu

Les modules de C++20

Découvrez pourquoi les modules remplacent les en-têtes, comment déclarer et exporter des modules, les partitions de modules et la voie de transition depuis du code basé sur les en-têtes.

Pourquoi les modules remplacent les en-têtes

Le système #include est le fondement de la compilation C++ depuis des décennies, mais il pose de sérieux problèmes. Les en-têtes sont inclus textuellement — le préprocesseur copie le fichier entier dans chaque unité de traduction. Ainsi, #include peut coller plus de 50 000 lignes de code dans chaque fichier .cpp. Résultat : une compilation lente, un ordre d'inclusion fragile (les macros d'un en-tête affectent un autre) et des violations de la One Definition Rule (ODR) qui provoquent des comportements indéfinis silencieux.

Les modules de C++20 résolvent tous ces problèmes. Un module est compilé une seule fois en une interface binaire, puis importé par ses utilisateurs sans nouvelle analyse. Il n'y a ni inclusion textuelle, ni fuite de macros, ni dépendance à l'ordre d'inclusion. Les temps de build peuvent être améliorés d'un facteur 5 à 20 sur les gros projets.

Déclaration et export d'un module

Un module possède une unité d'interface (qui déclare ce qui est exporté) et éventuellement une ou plusieurs unités d'implémentation. Le mot-clé export rend les déclarations visibles pour ceux qui importent le module :

math_utils.cppm
// math_utils.cppm — module interface unit
export module math_utils;    // module declaration

import <cmath>;               // import a header unit

// Exported — visible to importers
export double circle_area(double radius) {
    return std::numbers::pi * radius * radius;
}

export struct Point {
    double x, y;

    double distance_to(const Point& other) const {
        double dx = x - other.x;
        double dy = y - other.y;
        return std::sqrt(dx * dx + dy * dy);
    }
};

// NOT exported — module-private (internal linkage)
double helper_function(double val) {
    return val * val;
}

// Export a namespace — all members become visible
export namespace geometry {
    double triangle_area(double base, double height) {
        return 0.5 * base * height;
    }

    double rectangle_area(double width, double height) {
        return width * height;
    }
}

Importer des modules

Les utilisateurs d'un module emploient import pour accéder à ses déclarations exportées. Contrairement à #include, import n'effectue pas d'inclusion textuelle — les macros et les détails d'implémentation ne franchissent pas les frontières du module :

main.cpp
// main.cpp — imports the module
import math_utils;

#include <iostream>  // can still use #include for non-modularized headers

int main() {
    double area = circle_area(5.0);
    std::cout << "Circle area: " << area << "\n";

    Point p1{0.0, 0.0};
    Point p2{3.0, 4.0};
    std::cout << "Distance: " << p1.distance_to(p2) << "\n";  // 5.0

    std::cout << "Triangle: " << geometry::triangle_area(10, 5) << "\n";

    // helper_function(42);  // ERROR: not exported, not visible

    return 0;
}

// Compile order matters for modules:
// 1. Compile the module interface first:
//    g++ -std=c++20 -fmodules-ts -c math_utils.cppm
// 2. Then compile the consumer:
//    g++ -std=c++20 -fmodules-ts main.cpp -o main

Partitions de modules

Les gros modules peuvent être découpés en partitions — des sous-modules qui forment ensemble l'interface complète du module. Les partitions sont internes au module et ne peuvent pas être importées directement par du code extérieur :

module_partitions.cppm
// --- math_utils-algebra.cppm (partition interface) ---
export module math_utils:algebra;

export double square(double x) { return x * x; }
export double cube(double x) { return x * x * x; }

// --- math_utils-trig.cppm (partition interface) ---
export module math_utils:trig;

import <cmath>;

export double deg_to_rad(double degrees) {
    return degrees * std::numbers::pi / 180.0;
}

export double sin_deg(double degrees) {
    return std::sin(deg_to_rad(degrees));
}

// --- math_utils.cppm (primary module interface) ---
export module math_utils;

// Re-export partitions — consumers see everything
export import :algebra;
export import :trig;

// Additional exports can go here
export double hypotenuse(double a, double b) {
    return std::sqrt(square(a) + square(b));
}

// --- Consumer ---
// import math_utils;  // gets algebra, trig, and hypotenuse

import std (C++23)

C++23 introduit import std;, qui importe toute la bibliothèque standard sous forme de module. Cela change la donne pour la vitesse de compilation :

import std;  // Tout : iostream, vector, string, algorithm, ...

int main() {
    std::println("Hello from C++23 modules!");
    std::vector<int> v{1, 2, 3};
    std::ranges::sort(v);
}

import std; remplace des dizaines de directives #include par un seul import qui se compile beaucoup plus vite, car le module de la bibliothèque standard est précompilé. Il existe aussi import std.compat;, qui fournit en plus les noms de la bibliothèque C dans l'espace de noms global (comme printf, strlen).

Piège

La prise en charge par les compilateurs mûrit encore : en 2024, GCC, Clang et MSVC prennent tous en charge les modules, mais avec des niveaux de complétude variables et une intégration différente dans les systèmes de build. MSVC offre la prise en charge la plus mature.

Intégration aux systèmes de build : CMake 3.28+ prend en charge les modules avec la fonctionnalité CXX_MODULE_SETS, mais l'écosystème (scanners de dépendances, gestionnaires de paquets) est encore en train de rattraper son retard. L'ordre de build compte désormais — les interfaces de modules doivent être compilées avant leurs utilisateurs.

Pas d'export de macros : les modules n'exportent délibérément pas les macros #define. Le code qui dépend de macros issues d'en-têtes (comme WIN32_LEAN_AND_MEAN ou NOMINMAX) doit les gérer autrement.

Stratégie de migration : commencez par import std; (C++23) pour remplacer les inclusions de la bibliothèque standard, puis modularisez progressivement votre propre code en commençant par les bibliothèques « feuilles » qui ont peu de dépendances.

Bonne pratique

Lorsque vous adoptez les modules :

1. Gardez des interfaces de modules légères — n'exportez que ce dont les utilisateurs ont besoin. Les entités non exportées ont une liaison de module et ne peuvent pas provoquer de violations de l'ODR.
2. Utilisez des partitions pour les gros modules — découpez-les en sous-modules logiques (par ex. :types, :algorithms, :io).
3. Préférez export namespace — exportez des espaces de noms entiers plutôt que des déclarations individuelles pour garder une interface organisée.
4. Ne mélangez pas #include et import pour une même bibliothèque — choisissez l'un des deux et restez cohérent au sein d'une unité de traduction.
5. Utilisez les extensions .cppm ou .ixx pour les fichiers d'interface de module afin que les systèmes de build puissent les identifier automatiquement.

Points clés
  • Les modules remplacent #include par import — pas d'inclusion textuelle, pas de fuite de macros, pas de bugs liés à l'ordre d'inclusion
  • Utilisez export pour rendre les déclarations visibles aux importateurs ; les entités non exportées sont privées au module
  • Les partitions de modules découpent les gros modules en sous-composants tout en présentant une interface unifiée
  • import std; (C++23) remplace toutes les inclusions de la bibliothèque standard par un seul import rapide
  • Les systèmes de build doivent compiler les interfaces de modules avant leurs utilisateurs — l'ordre de compilation compte

Quiz — Testez vos connaissances

(15 XP)

1. Quel est le principal avantage des modules par rapport à #include ?

2. Qu'advient-il d'une fonction définie dans un module mais qui n'est PAS marquée `export` ?

3. Que fait `import std;` en C++23 ?