Le préprocesseur est un outil utilisé en amont de la chaîne de compilation. Son rôle est de réécrire le code-source en intégrant toutes les déclarations nécessaires afin qu'il soit compréhensible par le compilateur.
Les directives spécialement adressées au préprocesseur sont préfixées par un caractère # ; deux directives sont principalement utilisées :
- #define FOOBAR 1 pour définir une constante ou macro (ici une constante FOOBAR avec la valeur 1)
- #include "foobar.hpp" pour inclure un fichier externe (ici le fichier foobar.hpp)
Les fichiers sources
En langage C ou C++, on distingue deux types de fichiers sources :
- Le fichier den-têtes (headers) qui contiennent les déclaration des structures de données et des fonctions{{On parle aussi de méthodes pour les fonctions membres d'une classe dans les langages orientés objet»» accessibles par d'autres modules. Les en-têtes sont généralement dans des fichiers d'extension //.h// ou //.hpp// (pour le C++)
- L'implantation des fonctions et l'initialisation des variables globales. Les fichiers d'implantation sont typiquement d'extension .c ou .cpp voire .c++ (pour le langage C++)
Cette séparation est conseillée mais non obligatoire. On pourrait très bien regrouper également l'implantation dans le fichier d'en-tête.
Le fichier d'en-tête présente la façon dont on utilise les fonctions et les structures de données. Il est :
- à la fois à destination du développeur pour lui expliquer comment utiliser les structures et les fonctions. À ce titre documenter ce fichier est essentiel.
- mais également utile pour le compilateur lorsqu'il compile d'autres modules qui font usage du module décrit dans l'en-tête. Ainsi il peut vérifier si les structures et fonctions sont bien utilisées avec les bons types (ce que l'on appelle la vérification statique du typage).
Une bonne pratique consiste à séparer fichiers d'en-tête et d'implantation dans des répertoires distincts. Une bibliothèque peut ainsi être distribuée en deux parties : les fichiers binaires issus de la compilation et les fichiers d'en-tête qui permettent de comprendre comment l'utiliser (pour l'humain et le compilateur).
Le fichier d'en-têtes
Il contient les élements suivants indispensables à l'usage des structures et fonctions par des modules externes :
-
Des définitions de constantes ou macros à usage externe. Il s'agit de fonctions du préprocesseur permettant de réaliser des opérations de substitution.
- Exemple de constante : #define UMAX_INT (123)
- Exemple de macro : #define MIN(x, y) (((x) < (y))?(x):(y))
-
Des définitions de variables globales (sans forcément les affecter)
- ⚠ Il faut cependant éviter d'utiliser trop de variables globales en programmation orientée objet ; tout doit être encapsulé dans des classes avec si nécessaire des membres statiques
-
Des définitions de type (possiblement paramétrés avec template)
- Alias de type déclarés avec typedef type nomAlias
- Type structure : struct _structName { ... }
- Type énumération : enum WeekDays { monday, tuesday, wednesday, thursday, friday, saturday, sunday }
-
Type classe : class MyClass { ... } (seulement en C++)
- Avec la déclaration des champs
- Avec la déclaration (et éventuellement) l'implantation des méthodes
- Des déclarations de fonctions
En C++, on place principalement des déclarations de classes dans les en-têtes. On peut y écrire la classe complètement, i.e. avec l'implantation complète des méthodes ou alors la classe avec seulement la déclaration des méthodes (en reportant l'implantation dans le ficher d'implantation). Toutefois si on utilise un template, la déclaration complète de la classe est indispensable car le compilateur a besoin de spécialiser le code en fonction des types utilisés et a donc besoin de connaître l'implantation et pas uniquement la signature des méthodes.
Le fichier d'implantation
Il contient les éléments suivants (qui n'ont pas besoin d'être connus par les modules externes) :
- l'implantation des fonctions
- l'initialisation de variables globales et champs statiques
L'inclusion des en-têtes
Les en-têtes ou l'implantation reposent sur l'utilisation de types (ou fonctions) définis extérieurement : il est nécessaire d'inclure les en-têtes qui les définissent.
L'inclusion d'un fichier externe est réalisée avec la directive #include du préprocesseur : cela a pour effet d'insérer le fichier à l'endroit de la directive.
Par exemple, si nous avons besoin de manipuler des entrées/sorties en C++, nous pouvons utiliser le module iostream de la bibliothèque standard. Nous incluons donc l'en-tête de ce module avec la directive #include :
#include <iostream> int main() { std::cout << "Hello!" << std::endl; }
Le nom du fichier inclus peut être spécifié de deux manières différentes :
- #include <nomFichier> : à réserver pour l'inclusion d'un fichier d'en-tête de la bibliothèque standard
- #include "nomFichier" : à utiliser pour l'inclusion d'autres fichiers n'appartenant pas à la STL ; dans ce cas le fichier est cherché dans le répertoire courant puis dans des répertoires prédéfinis. Avec GCC, il est possible d'ajouter des répertoires de recherche pour les en-têtes avec l'option -Ichemin/vers/répertoire
Macros et constantes
Les constantes
Nous pouvons définir une constante avec la directive #define du préprocesseur, par exemple :
#define PI 3.1415
Une constante peut également être définie comme une constante globale C++ dans un fichier d'en-tête :
const double PI = 3.1415;
Tous les fichiers incluant l'en-tête avec la déclaration et définition de PI pourront l'utiliser. Il est aussi possible de déclarer l'existence de la constante dans le fichier d'en-tête et de lui affecter une valeur dans le fichier d'implantation :
// Dans mymodule.hpp (en-tête) extern const double PI; // Dans mymodule.cpp (implantation) const double PI = 3.1415
Cette approche consistant à séparer la déclaration de la définition est à déconseiller car elle empêche le compilateur lors de la compilation d'un module utilisant PI de connaître sa valeur (il faudra attendre l'étape de liaison pour la récupérer). Le compilateur ne peut donc pas optimiser le code en fonction de la valeur de la constante.
Prenons l'exemple de ce module :
#include "mymodule.hpp" double computeSquarePi() { return PI * PI; }
Si PI est déclaré et défini dans mymodule.hpp, le compilateur peut précalculer la valeur PI * PI (sinon il faudrait attendre l'exécution).
Lorsque c'est possible, il est préférable d'utiliser une constante avec déclaration et définition const (qui n'implique pas le préprocesseur) plutôt qu'un #define qui réalise une simple substitution dans le code et peut complexifier le débuggage.
Dans certaines circonstances où l'on ne souhaite pas saturer l'espace de nommage principal, il est judicieux de déclarer le constante dans un namespace :
namespace MathConstants { const double PI = 3.1415; }
On peut ensuite utiliser ainsi la constante :
double computeSquarePi() { return MathConstants::PI * MathConstants::PI; }
Les macros
Le préprocesseur permet de définir des macros qui sont des sortes de mini-fonctions. L'exemple typique est celui d'une macro calculant le minimum (ou maximum) de deux nombres :
#define MIN(x, y) (((x) < (y))?(x):(y))
On notera l'abondance du parenthésage ce qui est une précaution nécessaire car le microprocesseur ne réalisant qu'une simple substitution pour les paramètres x et y, des problématiques de priorités opératoires pourraient apparaître (mieux vaut trop de parenthèses que pas assez).
La macro ainsi définie évalue plusieurs fois l'expression x et l'expression y. Ainsi MIN(fonctionSuperLongue(10), fonctionSuperLongue(11)) appelle deux fois fonctionSuperLongue avec les paramètres 10 et 11 pour la comparaison et appelle une seconde fois la fonction avec soit 10, soit 11 pour le résultat final. Les expressions sont susceptibles aussi d'avoir des effets de bord (i.e. modifier des valeurs) ; ainsi l'expression MIN(i++, j++) incrémentera deux fois une des valeurs (et retournera le résultat après la 1ère incrémentation).
Ainsi l'usage de macros est jonché de pièges. C'est pour cela qu'il faut leur préférer des fonctions :
template<class T> inline T min(T a, T b) { return (a < b)?a:b; }
Le mot-clé inline permet d'indiquer notre souhait au compilateur d'inliner la fonction min dans la fonction appelée : cela signifie que le corps de la fonction est intégrée sans avoir à réaliser un coûteux appel de fonction (qui nécessite d'initialiser une stack frame dans la pile d'appel, de sauter vers l'instruction de début de fonction...).l'inlining est à réserver pour les petites fonctions afin de ne pas trop alourdir l'exécutable généré. Marquer une fonction inline est juste une indication pour le compilateur qui peut sous certaines circonstances (code trop volumineux) ne pas inliner la fonction. Les méthodes de classe (non-virtuelles) dont l'implantation est définie directement dans le corps de la classe sont considérées inline par défaut (getters, setters...).
Constantes et macros : conclusion
On privilégiera dans la plupart des cas les fonctionnalités du langage à celles du préprocesseur. Ainsi :
- On déclarera et définira les constantes dans les fichiers d'en-tête (dans un namespace de préférence) : const T myConstant = value;
- On définira des fonctions inline plutôt que des macros (le corps de la fonction doit bien être présent dans le fichier d'en-tête inclus)
La compilation conditionnelle
Le préprocesseur supporte des instructions conditionnelles afin de ne compiler que certaines parties du code en fonction du contexte. Cela permet généralement de créer plusieurs versions du code selon les fonctionnalités à intégrer ou selon l'architecture de compilation.
Voici un squelette de blocs de compilation conditionnels (les blocs elif et else sont optionnels) :
#if expr1 ... #elif expr2 ... #else ... #endif
On indique pour #if ou #elif une expression booléenne à tester pour inclure le bloc ou non dans le code produit par le préprocesseur. Il peut s'agit directement d'une constante de préprocesseur (auquel cas on teste si sa valeur est différente de 0), des comparaisons entre entiers (avec ==, !=, <, <=, >, >=). Il est possible aussi de faire appel à des macros dans l'expression. L'opérateur defined() permet de tester si une constante a été définie ou non (indépendamment de sa valeur).
Les utilisations typiques des instructions de compilation conditionelle sont :
- L'écriture de code personnalisé en fonction de l'architecture de compilation. Cette pratique est toutefois à limiter, l'idéal étant d'écrire du code portable ; cependant dans certaines circonstances, nous pouvons être amené à écrire du code spécialement optimisé pour certaines architectures.
- Proposer des fonctionnalités activables ou non à la compilation. Ceci est utile pour ne compiler que ce qui est nécessaire limitant ainsi la taille du code objet produit (utile pour des applications embarquées).
- Intégrer du code de débuggage désactivable qui peut consister notamment à écrire des informations dans des fichiers journaux.
Les instructions de compilation conditionnelle sont aussi utilisées quasi-systématiquement dans les fichiers d'en-tête pour éviter des inclusions multiples. Ecrivons un fichier d'en-tête module1.hpp :
// module1.hpp: header file for module1 #if !defined( _MODULE1_HPP ) #define _MODULE1_HPP class MyClass1 { ... }; class MyClass2 { ... }; #endif
On notera que le préprocesseur retourne le code du fichier uniquement si la constante _MODULE_HPP n'est initialement pas définie. Ensuite dès l'entrée dans le code, la constante est définie. Cela permet de n'inclure qu'une seule fois le fichier même en cas d'inclusion transitive.
Par exemple, imaginons un fichier module2.hpp incluant module1.hpp :
#if !defined( _MODULE2_HPP ) #define _MODULE2_HPP #include "module1.hpp" ... #endif
Nous utilisons module1.hpp et module2.hpp pour notre programme main.cpp :
// main.cpp #include "module1.hpp" #include "module2.hpp" int main() { MyClass1 c1; ... }
En l'absence de bloc de compilation conditionnelle, module1.hpp aura été inclus deux fois dans main.cpp (une fois directement par #include "module1.hpp" et l'autre fois indirectement par #include "module2.hpp") ce qui aurait provoqué une erreur. Avec #if !defined ( _MODULE1_HPP ) n'est inclus qu'une fois.
Constantes de compilation
Le préprocesseur propose certaines constantes que l'on peut utiliser notamment pour le débuggage :
- __LINE__ est substitué par le numéro de ligne
- __FILE__ est substitué par une chaîne avec le nom du fichier
- __DATE__ est remplacé une chaîne avec la date de compilation
- __TIME__ est remplacé par une chaîne avec l'heure de compilation
Voici un exemple simple :
#include <iostream> using namespace std; int main() { cout << "The current file " << __FILE__ << " was compiled on " << __DATE__ << " at " << __TIME__ << endl; }
Définition d'une constante en ligne de commande
Une constante gérée par le préprocesseur peut aussi être définie en ligne de commande. Voici par exemple un programme calculant l'aire d'un cercle. La valeur de PI peut être indiquée au compilateur, ou si elle n'est pas indiquée, une valeur par défaut est considérée.