Ce qu'on nomme compilation, par souci de simplicité, est souvent un abus de langage, du fait que nous tendons à y regrouper la combinaison de plusieurs actions connexes incluant la compilation à proprement dit.
Résumé: un programmeur analyste développe un ou plusieurs algorithmes qu'il traduit en programme dans un langage donné. L'objectif du code ainsi produit est d'en faire un exécutable, soit un automatisme permettant de résoudre un problème ou une classe de problèmes.
![]() Dans nos exemples plus bas, nous présumerons un projet nommé "UnProjet" qui inclura les fichiers source "octets.cpp", "bits.cpp" et "UnProjet.cpp", et aura pour but de générer l'exécutable "UnProjet.exe". On pourrait donc dire, à la limite, que le projet du projet "UnProjet" est la génération de l'exécutable "UnProjet.exe". |
Commençons par quelques définitions: Puisque la programmation C++ a pour but de générer des exécutables, il est de mise d'y construire des projets. Au minimum, tous les fichiers source utilisés devront y figurer. Les fichiers essentiels à la génération d'un exécutable sont les suivants: |
Question piège: |
combien d'exécutables la compilation d'un projet générera-t-il? |
Il y a au moins deux manières, grossièrement, de prendre du code source et d'en faire un exécutable: le compiler, et l'interpréter. Ici, nous allons nous intéresser à la mécanique de la compilation, spécialement pour le langage C++.
Le code machine produit par la compilation n'est pas un exécutable, mais y ressemble beaucoup. On dit de ce code que c'est du code objet (".obj").
On compile donc du code source (".cpp" pour le langage C++) pour générer du code objet (".obj" sur plate-forme Win32), mais la compilation en soi n'est pas suffisante pour générer un exécutable.
Dans le schéma plus haut, on remarque qu'il y a une phase précédant la compilation en langage C++ (comme en langage C). C'est la phase de précompilation, et nous allons commencer par nous attarder à cette étape.
Question piège: |
peut-on compiler un fichier d'en-tête (".h")? |
Le préprocesseur est un outil qui modifie le code source avant que le compilateur n'entre en oeuvre. Ses directives débutent toutes par un dièse (#) se trouvant dans la première colonne d'une ligne de code, et ne se terminent pas par un point virgule (;).
Dans cette section, nous porterons notre regard sur les possibilités et les limites de cet outil, en nous concentrant sur ses principales commandes (ou directives).
La directive #define crée un symbole pour le préprocesseur. Ce symbole peut, de façon optionnelle, avoir une valeur. Le cas où aucun texte de remplacement n'est offert sera couvert plus bas (directives d'inclusion conditionnelle), là où il prendra son sens.
Prenons l'exemple suivant:
#define XYZ 3 |
Ceci associe au symbole "XYZ" l'équivalent lexical "3". Ainsi, à chaque endroit que le préprocesseur rencontrera dans le texte du code source ayant défini cette directive le mot "XYZ", il le remplacera par "3".
Avant le préprocesseur |
Après le préprocesseur |
---|---|
#define XYZ 3 |
|
int a = XYZ |
int a = 3 |
z = f (-XYZ); |
z = f (-3); |
#define FRED 33 int somme (int a, int b) { return (a + b); } int main () { int a= -FRED* 4+ somme (FRED, -4); } // main () |
int somme (int a, int b) { return (a + b); } int main () { int a= -33* 4+ somme (33, -4); } // main () |
Question piège: |
quelle sera la valeur de z dans ce qui suit? #define A 4 #define B ((A < b)? A: b) int main () { int b= 3, z= B; } |
La directive #include demande au préprocesseur d'inclure le texte d'un fichier à la place de la directive d'inclusion elle-même.
// cette ligne sera remplacée par le texte du fichier math.h #include <math.h> // cette ligne sera remplacée par le texte du fichier bits.h #include "bits.h" |
La différence entre un fichier inclus entre <> et un autre inclus entre "" est la suivante:
Question piège: |
si "a.cpp" contient la ligne #include "a.h" et "b.h" contient la ligne #include "a.h" et "c.h" contient les lignes #include "b.h" #include "a.h" et si "c.cpp" contient les lignes #include "c.h" combien de fois le texte du fichier "a.h" se retrouvera-t-elle dans "c.cpp"? |
En effet, si on prend l'exemple d'une déclaration de type, avoir une seule déclaration qui puisse être inclue par différents fichiers source, inclure ce fichier assure que tous les fichiers source perçoivent exactement le même type.
Exemple: si le fichier "octets.h" contient (entre autres) la déclaration suivante:
typedef enum { partieHaute, partieBasse } partieDOctet; |
alors tout fichier ayant la directive suivante:
#include "octets.h" |
s'assurera d'inclure de facto la déclaration du type "partieDOctet" s'y trouvant. Ceci évite que quelqu'un décide de définir localement le même type, et rédige par exemple (erronément) ce type comme suit:
typedef enum { partieBasse, partieHaute } partieDOctet; |
Les valeurs associées aux constantes énumérées "partieBasse" et "partieHaute" dans les deux cas ne sont pas les mêmes, ce qui pourrait conduire à des problèmes de logique lors de l'exécution du programme.
![]() |
Dans le schéma à gauche, les flèches indiquent le sens de l'inclusion des fichiers. |
Remarquez que "bits.h" est inclus par "bits.cpp" et par "UnProjet.cpp". Ceci signifie que le texte de "bits.h" fera partie de "bits.cpp" et fera partie de "UnProjet.cpp".
La même règle s'applique ici au contenu de "octets.h", qui se retrouve intégralement dans "octets.cpp" et dans "UnProjet.cpp".
Cela signifie que le type "partieDOctet" (mentionné en exemple plus haut), dans "octets.h", fait partie des fichiers "octets.cpp" et "UnProjet.cpp", mais ne fait pas partie de "bits.cpp".
On ne compile, en C++, que des fichiers source. Les fichiers d'en-tête servent de support à la compilation, mais ne sont pas compilés.
Ainsi, pour le projet "UnProjet" en exemple ici, les fichiers "bits.cpp", "octets.cpp" et "UnProjet.cpp" seront compilés, mais pas les fichiers "bits.h" et "octets.h". Toutefois, ces derniers seront inclus dans des fichiers source pour que ceux-ci aient tout le nécessaire pour être correctement compilés.
Dans notre exemple, on présumera avoir écrit des fonctions capables de manipuler des bits et des octets (respectivement dans "bits.cpp" et dans "octets.cpp"). Le module "UnProjet.cpp" comptera sur leur existence pour sa propre exécution.
Exemple: le fichier "bits.h" contient le prototype de fonction suivant:
bool Lire_Bit (unsigned short Mot, unsigned char Position); |
Tout fichier ayant la directive suivante:
#include "bits.h" |
s'assurera d'inclure de facto le prototype en question.
Ceci signifie que tous les fichiers source connaissant ce prototype savent comment appeler une fonction appelée "Lire_Bit()", retournant un "bool", et prenant deux paramètres (un "unsigned short" et un "unsigned char").
Note: |
inclure les prototypes des fonctions n'est pas la même chose qu'inclure le corps (la définition) de ces fonctions. |
Le lien entre la fonction appelée et le code associé, lors de l'appel de code se trouvant dans un autre fichier objet, se fera lors de l'édition des liens.
Un prototype de fonction déclare son nom, la description (type et nom) de ses paramètres, et (de façon optionnelle) le type de sa valeur de retour.
Question piège: |
quelles sont les fonctions différentes dans ce qui suit? int f (int); int g (int); int g (int, int); void g (int, int); |
Le langage C++ nécessite qu'on déclare chaque objet avant de s'en servir, peu importe que cet objet soit un type, une constante, une variable, une fonction, et ainsi de suite.
Parfois, une fonction ne peut être définie avant son appel. Parfois aussi on aimerait utiliser une fonction qui est définie dans un autre module. Dans ces cas, le compilateur C++ acceptera que la définition de la fonction en question vienne après son utilisation, dans la mesure où il en connaît déjà le prototype.
Déclarer un prototype permet au compilateur de reconnaître l'existence de la convention d'appel d'une fonction avant que celle-ci ne soit définie. Ainsi, sans savoir exactement ce que la fonction en question exécutera comme instructions, le compilateur peut en générer le schéma d'appel[1].
Examinez le code suivant:
// Additionner_Doubles (a,b)--retourne la somme de "a" et "b" double Additionner_Doubles (double a, double b) { return (a + b); } |
On parle ici de définition d'une fonction, du fait qu'on en voit le prototype, mais aussi le corps, soit le code qui sera exécuté lors d'un appel de ladite fonction.
Le prototype lui-même de cette fonction serait:
double Additionner_Doubles (double a, double b); |
ou encore (au choix):
// on peut se limiter au nombre et aux types des paramètres... double Additionner_Doubles (double, double); |
Le prototype d'une fonction offre donc le minimum nécessaire au compilateur pour que celui-ci puisse produire des appels valides à cette fonction sans avoir à connaître son contenu précis.
Puisque le rôle du prototype d'une fonction est de suppléer à l'absence de sa définition par l'essentiel de sa convention d'appel. Ainsi, un prototype doit absolument se trouver avant le premier appel à la fonction qu'il représente dans le fichier source.
Le prototype devra posséder (cela va de soi) la même structure que l'en-tête de la définition de la fonction, à la différence que la déclaration ne possède pas de corps et puisqu'il s'agit d'une instruction elle doit se terminer par un point virgule (;).
[type] nomDeFonction ([type1 [nom1], type2 [nom2], ... typeN [nomN]]); |
Question piège: |
quels sont les prototypes de fonctions valides dans ce qui suit? int f (int a); int g (int); int g (int, int) void g (int &a, int b) { a = b; }; |
La norme la plus récente du langage C++ oblige à l'utilisation de prototypes de fonctions lorsque celles-ci ne sont pas définies au moment de leur appel. Les compilateurs permettent toutefois, par souci de compatibilité avec le code existant, de contourner cette obligation à l'aide d'options de compilation.
Toutefois, les prototypes sont d'une utilité indéniable. En plus de clarifier le code en tant que tel, ils permettent d'éviter des erreurs fortuites dans l'exécution du code généré.
Note: |
l'annexe 1 de ce document explique comment éviter les problèmes d'inclusion multiple d'un fichier d'en-tête à l'aide de symboles et du mécanisme d'inclusion conditionnelle. Nous vous invitons fortement à vous familiariser avec cette mécanique... |
Question piège: |
laquelle des versions de "fonctionBizarre()" sera celle appelée dans la procédure "main()"? #define ROGER #define BIQUETTE 4 #ifndef ROGER int fonctionBizarre (int z) { return (z * 34 - (z*z)/15); } #elif defined BIQUETTE && (BIQUETTE >= 3) int fonctionBizarre (int z) { return ((float)z / 15 - (1.0/z)); } #else int fonctionBizarre (int z) { return (-1); } #endif int main () { int a= fonctionBizarre (4); } // main() |
Bien que le langage C++ permette d'écrire des programmes qui soient "portables", un anglicisme au sens d'être utilisable sur plusieurs plates-formes, il est possible d'utiliser des directives indigènes (en anglais, "native") à chaque plate-forme.
Cette tactique devrait être tenue au minimum, puisqu'elle limite les possibilités d'un programme aux plates-formes où une directive indigène donnée existe.
Nous ne couvrirons pas l'étendue des directives indigènes dans ce document--ce ne serait pas pertinent. Si vous avez envie d'en savoir plus, consultez la documentation de votre outil de travail.
Une fois que le préprocesseur aura terminé son travail, le compilateur cherchera à traduire le code source en code objet.
Pour ce faire, il accomplira trois types de validation:
Dans les trois cas, il produira des erreurs s'il n'est pas en mesure de générer du code objet étant donné le code source qu'on lui demande de traduire, et produira des avertissements s'il note des instructions à première vue douteuses mais ayant un certain risque de fonctionner.
Note: |
une fois rendu à la compilation à proprement dit, il n'y a plus qu'un fichier source à traduire en code objet. Les fichiers d'en-tête inclus en chemin se sont fondus dans le code source en traitement. |
Le code objet contient le fruit de la compilation, mais n'est pas tout à fait au point. Il lui manque plusieurs petites choses pour pouvoir être transformé en exécutable. La première chose qui lui manque, d'ailleurs, est le code requis pour exécuter correctement les fonctions définies dans d'autres modules.
Par exemple, si "UnProjet.cpp" contient un appel à la fonction "Lire_Bit()" définie dans "bits.cpp", la compilation de "UnProjet.cpp" en "UnProjet.obj" aura été rendue possible du fait que le prototype de la fonction se trouve dans "bits.h", que "UnProjet.cpp" incluait (sagement).
Par contre, du fait que le prototype d'une fonction se résume à sa convention d'appel, pas à son corps, il faut à un moment donné que le code (compilé dans "bits.obj", puisque la définition se trouvait dans "bits.cpp") de cette fonction soit liée au code objet se trouvant dans "UnProjet.obj".
![]() |
Un programme spécial se charge généralement des "résolutions externes" requises pour passer du code objet à du code exécutable. L'éditeur de liens cherchera à résoudre les liens externes à chaque module objet (".obj"), dans le but d'en arriver à un exécutable cohérent. Ainsi, il pourra rencontrer certaines situations l'empêchant de réaliser sa tâche, la plupart du temps parce qu'il sera incapable de faire un choix entre plusieurs entités équivalentes. Quelques exemples classiques: |
Note: |
ci-après se trouve un schéma global du processus de compilation, tel que nous l'avons vu jusqu'ici. |
[1] Nous reviendrons sous peu sur le schéma du fonctionnement d'appels de sous programmes.