La compilation

Le C++ est un langage compilé : le code écrit va être transformé en langage machine (assembleur) pour être exécuté par la machine. Pour obtenir une librairie ou un exécutable, notre code source va passer par 3 étapes :

  1. Le pré-processeur va exécuter des opérations basiques, comme pour supprimer des parties de code ou copier-coller le contenu de fichiers.

  2. La compilation va changer le code de chaque fichier source en code binaire.

  3. Le linker va lier les différents fichiers binaires obtenus entre eux pour former l'exécutable ou la bibliothèque voulue.

Le pré-processeur

Les instructions du pré-processeur commencent par un #et font des opérations basiques :

  • #include "xxx" va être remplacé par le contenu du fichier 'xxx', recherché par rapport à l'emplacement du fichier courant.

  • #include <xxx> va rechercher 'xxx' en partant des 'include directories', pouvant être par exemple spécifiés avec -Imy_folder pour GCC. Le répertoire contenant la librairie standard en fait toujours partie.

  • #define name value définit une macro nommée 'name'. Ensuite, toutes les occurrences de 'name' sont remplacées par 'value'.

  • #define add(v1, v2) v1 + v2 définit une macro prenant deux arguments. Les fonctions macros provoquent vite des erreurs mais peuvent être très utiles (voir dans 'aller plus loin').

  • #undef name supprime la macro nommée 'name', si elle existe.

  • #if condition ... #endif va évaluer 'condition'. Si elle est fausse, tout le code contenu entre #if et #endif sera supprimé.

  • #ifdef symbol ou #if defined(symbol) va être vrai si la macro 'symbol' est définie.

  • #error message va arrêter le build et afficher 'message'.

  • #pragma command passe une commande propre au compilateur.

Une fois ces instructions effectuées, les fichiers sources obtenus sont appelés Translation Units (TU).

La compilation

Les TU vont être compilées de façon indépendante en Object Files : des fichiers contenant du code machine (assembleur) et qui indiquent les symboles externes référencés dans l'object file.

les symboles rencontrés peuvent être des déclarations ou des définitions de variables, de fonctions ou de classes. Ces symboles peuvent avoir un 'internal linkage' (accessibles seulement depuis la TU courante) ou un 'external linkage' (accessibles depuis toutes les TU).

On parle de déclaration lorsqu'on indique l'existence d'une variable, d'une fonction ou d'une classe, et de définition lorsqu'on donne le contenu de la variable, fonction ou classe :


// Déclaration d'une variable externe.
extern int value;

// Déclaration d'une fonction externe. Les symboles peuvent être déclarés plusieurs
// fois.
float make_float();
float make_float();

// Déclaration d'une fonction interne.
static void internal_func();

// Définition d'une variable interne.
static int internal_value;

// Les symboles ne peuvent être définis qu'une seule fois.
// static int internal_value;

// Définition de la fonction externe précédente.
float make_float() {
    return 42.f;
}

// Déclaration d'une classe externe.
class IntArray;

// Définition d'une classe externe.
class Empty {
    // Déclaration de 'boolean', une variable externe (sans mot-clé 'extern' ici)
    // 'static' sert ici à dire que la variable n'est pas contenue dans les objets
    // Empty.
    static bool boolean;
    
    // Déclaration d'une fonction externe.
    static void use_int(int val);
};

// Définition de la variable et de la fonction de Empty.
bool Empty::boolean = true;
void Empty::use_int(int val) {}

// L'utilisation d'un 'namespace anonyme' est préférée au mot-clé 'static' pour
// marquer des symboles internes.
namespace {
    // Déclaration d'une variable interne.
    extern int counter;
    
    // Déclaration d'une fonction interne.
    void use_float(float val);
    
    // Définition d'une classe interne.
    class Empty2 {
        // Déclaration d'une autre variable interne.
        static bool boolean;
        // Définition d'une fonction interne.
        static void use_int(int val) {}
    };
}

Le mot-clé staticpermet donc de définir une variable ou une fonction interne. Le namespace anonyme permet en plus de définir une classe interne et est préféré en C++. A l'intérieur d'une classe, static signifie que la variable ou la fonction n'est pas liée aux objets de la classe.

Dans une fonction, staticprend un 3e sens : il désigne une variable qui ne sera instanciée qu'une seule fois, lors du premier appel de la fonction (de façon thread-safe) :

int counter() {
    static int current = 0;
    return current++;
}

Le compilateur a besoin des définitions des fonctions pour tenter de les inline : inline une fonction va la recopier là où elle est appelée, pour éviter de jump là où elle est définie, éviter de lui passer des paramètres et permettre d'autres optimisations en fonction du contexte où la fonction a été recopiée. Cependant, les inline peuvent augmenter la taille de l'object file (puisque du code est recopié).

Cette restriction n'est plus tout à fait vraie depuis la 'Link-Time Optimization', qui permet d'inline des fonctions durant la troisième étape (du linker). cependant, moins d'optimisations pourront être effectuées à ce niveau.

De même, le compilateur a besoin des définitions du code template (classes, fonctions, variables) pour pouvoir les instancier.

Le linker

Une fois les object file obtenus, le linker va les relier ensemble pour créer la librairie ou l'exécutable souhaité.

les librairies statiques ne sont qu'un amalgame d'object file : des outils manipulant des archives peuvent permettre de retrouver les object file d'origine sur les systèmes Unix.

Le linker va donc résoudre les symboles utilisés, pour qu'une TU puisse utiliser un symbole déclaré et défini dans une autre TU. Le même symbole ne peut pas être défini plusieurs fois, car le linker ne saura pas quel symbole utiliser. Il s'agit de la One Definition Rule (ODR). Les classes font office d'exception : elles peuvent être définies dans plusieurs TU.

Hors, comme les fonctions ne pourront être inline par le compilateur que dans les TU contenant leur définition, cela signifie qu'elles ne seraient inline que dans une seule TU. Cela signifierait également qu'il faille recopier le code template dans des symboles internes pour être utilisés dans plusieurs TU.

Le mot-clé inline permet alors d'indiquer au compilateur que plusieurs définitions de la fonction peuvent exister : le compiler est libre d'en choisir une arbitrairement. Les fonctions template, et les fonctions et variables constexpr sont implicitement marquées inline.

Depuis C++17, les variables peuvent également être marquées inline, pour que le compilateur puisse choisir une des définitions dans les TU à compiler. Cela permet de ne pas définir les variables dans les fichiers sources (comme pour les fonctions inline). De plus, cela assure l'ordre d'initialisation des variables.

// functions.cpp

// Toutes ces fonctions pourront être inline dans cette TU.
int make_int() {
    return 42;
}
inline int make_float() {
    return 3.14f;
}
template <class T>
T make_value() {
    return T{};
}

// Cette variable est définie et initialisée, et peut être incluse dans plusieurs TU.
inline std::string hello_world = "hello world !";
// main.cpp

// Cette fonction ne peut pas être définie une seconde fois.
// Elle ne pourra donc pas être inline dans cette TU.
int make_int();

// Ces fonctions pourront être inline dans cette TU.
inline int make_float() {
    return 3.14f;
}
template <class T>
T make_value() {
    return T{};
}

Cependant, recopier des définitions va ralentir la compilation. De plus, pour les grosses fonctions, cela ne sera sûrement pas utile car elles ne seront pas recopiées.

Les variables externes et internes (appelées objets ou variables statiques) sont allouées à la compilation, et initialisées au lancement du programme. Seuls des types primitifs pourront être initialisés à la compilation (ints, floats, types triviaux, ...).

Au sein d'une même TU, l'ordre d'initialisation correspond à l'ordre de définition de ces objets. Cependant, l'ordre n'est pas garanti entre des objets statiques provenant de deux TU différentes : il s'agit du Static Initialization Order Fiasco. Il faut alors faire attention lorsqu'on initialise un objet statique dépendant d'un autre objet statique.

Organisation

Les projets en C++ s'organisent avec deux types de fichier : les fichiers header (avec l'extension .h ou .hpp) et les fichiers source (finissant généralement par .cpp).

Les fichiers header contiennent tout ce qui peut être défini plusieurs fois dans différentes TU : déclarations, définitions de classes, templates, fonctions et variables inline, ... Ils sont destinés à être recopiés via l'instruction #include dans les fichiers sources, pour que ceux-ci disposent des mêmes symboles externes et des mêmes définitions inline.

Les fichiers sources sont les fichiers qui vont être utilisés par le build (et qui vont être changés en TU, puis en object file). Ils contiennent les symboles ne pouvant être définis qu'une seule fois (variables et fonctions externes non inlines) et les symboles internes.

Ce sont des règles d'organisation qui ne sont pas rigides : par exemple, il peut être utile de définir des symboles internes dans les headers (ils vont alors être recopiés dans chaque fichier source incluant le header).

Les headers sont généralement 'gardés', pour s'assurer que chaque TU ne possède qu'un exemple de chaque header :

// my_header.hpp

// N'entre dans le #if que la première fois où on l'atteint :
// La seconde fois, 'MY_HEADER_GUARD' sera défini.
#ifndef MY_HEADER_GUARD
#define MY_HEADER_GUARD

// Contenu du header

#endif

la commande #pragma once est supportée maintenant par (presque) tous les compilateurs : elle va empêcher le compilateur de recopier deux fois le même fichier pour la même TU. Cet exemple est équivalent à l'include guard montrée ci-dessus :

// my_header.hpp

#pragma once

// Contenu du header

Pour aller plus loin

Des flags peuvent demander au compilateur de s'arrêter au pré-processeur, ou d'afficher l'assembleur généré de façon lisible.

Les template sont compilés avec la two-phase lookup, qui va d'abord vérifier la syntaxe du code, puis les symboles dépendants des arguments template (exemple : T t;vérifiera que 't' est constructible par défaut).

Les macros peuvent changer un argument en string, et concaténer des noms, et/ou prendre un nombre d'argument variable avec __VA_ARGS__ ... De manière générale, les macros peuvent être très puissantes. Attention cependant à leurs dangers.

Le pré-processeur met à disposition plusieurs macros standard. D'autres non-standard sont également souvent supportées (__COUNTER__, __PRETTY_FUNCTION__).

L'ordre d'initialisation des objets statiques peut être forcé. std::cout, std::cerr et std::cin sont garantis d'être initialisés lorsque iostream est inclus grâce au pattern Nifty Counter. Les variables inlines n'ont pas ce problème et facilitent donc les utilisations de variables statiques en C++17.

Last updated