Le temps de compilation

Les temps de compilation peuvent devenir un problème en C++ dès que le projet commence à grandir et à avoir des dépendances. Il existe plusieurs méthodes pour les diminuer.

On peut différencier les temps de la compilation complète (combien de temps la compilation prend lorsqu'on part du code source) et incrémentale (lorsque qu'on compile des changements après avoir déjà compilé le projet).

La compilation incrémentale utilise notamment le fait que les Translation Unit (TU) dont le code source ne change pas n'ont pas à être recompilées : on garde donc leurs Object File correspondant.

Facteurs principaux de ralentissement

Le C++ dispose d'une syntaxe plus dure à analyser que d'autres langages pour les compilateurs. On ne peut pas faire grand chose pour agir sur ce facteur.

Les modifications d'un header vont amener à recompiler toutes les TU où il est inclut, directement ou via d'autres headers. Il devient donc très coûteux de toucher aux headers utilisés partout dans un projet, et minimiser leurs dépendances va accélérer la compilation.

Les fonctions inline doivent être instanciées dans toutes les TU où elles sont utilisées, ce qui augmente la taille des Object Files et ralentit la compilation. De plus, le code d'une fonction inline est dans un header, donc sa modification force la recompilation des TU dépendantes du header.

Le code template a les mêmes restrictions que les fonctions inline, sauf qu'il doit en plus être instancié pour chaque type, ce qui ralentit encore la compilation. Comme pour les fonctions inline, chaque Object File stocke ses instanciations de template : le linker va donc garder qu'une instantiation et supprimer les autres.

Le compilateur

Les optimisations demandées au compilateur vont lui demander plus de travail, elles vont donc augmenter le temps de compilation (surtout -On). Les optimisations les moins coûteuses en temps sont disponibles avec -O1 ou -Og (qui enlève quelques optimisations de -O1).

La plupart des compilateurs peuvent compiler de façon parallèle, ce qui est très efficace puisque chaque TU est compilée de manière indépendante. Des systèmes de build permettent également de compiler de manière distribuée (avec plusieurs ordinateurs).

Les forward declarations

Les forward declarations sont utilisées pour minimiser les dépendances entre les headers, ce qui est souvent la cause principale de ralentissement de la compilation. Si on nécessite une classe uniquement pour l'utiliser comme pointeur ou référence, peut peut la déclarer au lieu d'inclure sa définition :

// ----- family.hpp

#include <vector>

// On n'a pas besoin de la définition de IPerson dans 'person.hpp'.
// Pour en utiliser des pointeurs et des références, il suffit de la déclarer :
class IPerson;

class Family {
    std::vector<IPerson*> persons;
    // Le stockage/passage par valeur nécessite de connaître la taille de l'objet.
    // IPerson p;
public:
    void add(IPerson& person);
    void remove(IPerson& person);
};

Les forward declarations de classes contenues dans le namespace std sont interdites.

Ici, "family.hpp" ne dépend plus de "person.hpp", ce qui réduit la taille du header et évite de recompiler les TU qui l'incluent si "family.hpp" est modifié.

Cependant, les forward declarations rendent les headers moins indépendants (on devra souvent inclure "person.hpp" en plus de "family.hpp") et peuvent ajouter de la redondance (si des types doivent souvent être redéclarés, comme des alias de type). Il peut donc être intéressant de maintenir un header contenant les déclarations souvent utilisées.

Pointer to Implementation

Appelé "pimpl", ce pattern consiste à implémenter une classe donnée dans son header avec une classe privée, définie dans son fichier source : cela évite d'inclure les headers dont dépendent les membres de la classe.

// ----- window.hpp

// Ce header ne dépend que de <memory>, pour std::unique_ptr.
#include <memory>

class widget;

class window {
    class impl;
public:
    window(char const* name, int width, int height);
    void display();
    void clear();
    void add(widget const& w);
    void remove(widget const& w);
private:
    std::unique_ptr<impl> pimpl;
};

// ----- window.cpp

// gros headers
#include <graphics>
#include <string>
#include <vector>
#include <widget>

class window::impl {
    std::vector<widget*> widgets;
    std::string name;
    int width;
    int height;
    // ... 
public:
    impl(char const* name, int width, int height) { /* ... */ }
    void display() { /* ... */ }
    void clear() { /* ... */ }
    void add(widget const& w) { /* ... */ }
    void remove(widget const& w) { /* ... */ }
};

// window délègue ses appels à window::impl.

window::window(char const* name, int width, int height) :
    pimpl(std::make_unique<impl>(name, width, height)) {}

void window::display()               { pimpl->display(); }
void window::clear()                 { pimpl->clear(); }
void window::add(widget const& w)    { pimpl->add(w); }
void window::remove(widget const& w) { pimpl->remove(w); }

Cette méthode va par contre réduire les performances de la classe, à son instanciation (via l'allocation dynamique de la classe privée) et lors de son utilisation (les fonctions ne peuvent pas être inline sans Link-Time Optimization, et elles nécessitent une indirection via le pointeur vers l'implémentation).

Afin de limiter des coûts de l'indirection et de l'allocation dynamique du pointeur, on peut envisager d'utiliser un allocateur spécialisé ou de stocker la classe privée à l'intérieur de la classe publique, via type punning. Attention à bien gérer les opérations de move et de copie.

Bibliothèques et headers précompilés

Les bibliothèques n'ont plus à être compilées, et les bibliothèques dynamiques n'ont pas non plus besoin d'être link. Cela permet d'isoler des morceaux de code qui ne changeront pas souvent, et de diminuer le temps de compilation pour les intégrer au programme.

Plusieurs compilateurs support la génération et l'utilisation de headers précompilés. Il s'agit de fichiers qui vont représenter le code source des headers dans une forme intermédiaire, pour que le compilateur n'ait plus à les analyser. Ils forment en quelque sorte une bibliothèque représentant un ou plusieurs headers.

Pour aller plus loin

Des systèmes de build existent pour accélérer la compilation (comme Ninja, s'intégrant facilement avec CMake).

L'Unity Build consiste à compiler un unique fichier source, incluant tous les autres fichiers sources. Il permet de beaucoup réduire les temps de compilation complète (non incrémentale). Cependant, il ne peut pas être exécuté en parallèle et il n'est pas garanti de fonctionner sans modification du code source :

  • Les symboles internes des différents fichiers sources peuvent être en conflit

  • Les macros définies dans les fichiers sources peuvent affecter les fichiers sources suivants

  • Les directives using namespace/function/class affectent également les fichiers sources suivants

Des outils et systèmes de builds permettent d'utiliser certains mécanismes automatiquement (comme la génération d'Unity Build et de headers précompilés) comme cotire (basé sur CMake).

Les templates externes permettent de dire au compilateur de ne pas instancier une fonction ou une classe template : il suffit de l'instancier une fois dans une TU dédiée, et les autres TU pourront utiliser cette instanciation.

Last updated