Logo de SAASF

II - 1. Organiser un projet en C++

Si vous vous rappelez, le premier cours de cette série comprenait une partie similaire à ce qui va être exposé là. Cependant, on ne faisait que froller les manières de configurer un projet en C++. Dans ce cours, on va rentrer plus en détail, pour comprendre, et de nouvelles choses, et des choses qu'on a déjà vu, mais survolé dans les précédents cours.
Contenu

A. Organiser son projet

Le C++ offre plein de fonctionnalités permettant de configurer proprement votre projet C++.

a. Les fichiers headers / sources

Pour l'instant, notre code est écrit dans un fichier avec l'extension ".cpp". Ces fichiers sont des fichiers sources C++, ceux que le compiler va compiler en fichiers objets en ".o". Pour rappel, le code objet est constitué d'assembleur. Plus les fichiers ".cpp" sont remplies, plus la compilation est longue. On peut mettre autant de fichier ".cpp" dans notre projet que l'on veut. Cependant, dans ce cas, pour utiliser du code présent dans un autre fichier, il faut inclure le code du fichier à utiliser dans le fichier de base. En C++, ce processus ce fait via une prédirective : "#include" (voir plus bas).

Cependant, imaginons un projet avec 150 fichiers en ".cpp" (ce qui est possible, si votre projet est un minimum ambitieux). La modification d'un seul fichier demande de recompiler tout le gros bloc de 150 fichiers, vous laissant le temps de courir un marathon entre deux compilations. Heuresement, il existe un moyen d'utiliser un fichier ".cpp", sans avoir à le recompiler à chaque fois : les fichiers headers en ".h". Un fichier header permet de déclarer quelque chose dans un code, sans avoir à le redéfinir, donc à le re-compiler (si il est déjà défini / compiler quelque part). Dans le fichier exécutable final, produit par le linker, le linker lie les déclarations de chaque fichier objet à leur définition déjà compilé. Si la définition de quelque chose déclaré n'est pas trouvé, le linker renvoie une erreur "undefined reference to `truc_pas_defini'". Un header peut contenir toutes les déclarations possibles et imagineables : variables, fonctions... Dans ce cas, la déclaration est réalisé via ce que l'on appelle un "lien externe" ("extern linkage" en anglais). En général, il est très fortement conseillé (voir obligatoire dans certains cas) de rajouter le mot clé "extern" devant la déclaration. Chaque fichier ".cpp" doit inclure le header contenant ses déclarations.

Il est important de noter que les fichiers headers peuvent aussi contenir des définitions. Cependant, dans ce cas là, le même problème qu'avec nos 150 fichiers ".cpp" apparaît. De plus, le linker pourraît se perdre dans les multiples couples déclarations / définitions possibles, causant d'étranges erreurs de linker. Dans ce cas là, la déclaration est réalisé via ce que l'on appelle un "lien interne" ("internal linkage" en anglais). Un moyen d'éviter les erreurs est de définir la déclaration du header de manière statique, en rajoutant le mot clé "static" avant elle. Avec cela, on garde notre problème des 150 fichiers ".cpp", car chaque fichier objet à sa propre copie de la définition, évitant les erreurs.

Un fichier header doit toujours avoir cette forme :

#ifndef NOM_DU_FICHER
#define NOM_DU_FICHER

// Trucs dans le header

#endif NOM_DU_FICHER
Nous verrons pourquoi dans la partie sur les prédirectives.

b. Configurer le compiler / linker

Pour compiler correctement votre code, il vous faut comprendre comment configurer le compiler et le linker. En général, tous les IDEs offrent une GUI pour les configurer. Sinon, une simple recherche Google devrait vous guider. Sans IDE, le compiler et le linker doivent s'utiliser entièrement grâce aux lignes de commandes. L'IDE se charge de passer les bons arguments au compiler.

La première tâche est de choisir le bon duo cimpler / linker à utiliser. Dans le cas général (l'environnement MinGW), tout ce qui est nécessaire à la compilation se trouve dans un même dossier. De base, tout le nécessaire est présent sous Linux, de manière différente selon la distribution. Cependant, si vous ne l'avez pas encore d'installer, sachez que l'installer comme tel est assez déconseillé. Il est fortement conseillé de passer par un environnement Linux, ou un émulateur d'environnement Linux si vous êtes sur Windows, comme MSYS2 ou Cygwin. Microsoft proporese un petit tutoriel, juste ici, pour l'installer avec MSYS2 (seule la partie MSYS2 nous intéresse ici). Dés que vous avez repérer le dossier le plus haut de votre MinGW, vous avez juste à le passer à l'IDE, qui fera la suite lui même. Si son chemin d'accès de ce dossier est ajoutée à la variable d'environnement "Path", alors l'IDE pourra le détécter automatiquement.

Ce dossier contient plusieurs sous-dossiers, que nous allons survoler, pour comprendre un peu mieux le tout. Le sous dossier "bin" contient tous les logiciels nécessaires à la compilation, comme le compiler. Le compiler C++ représente le logiciel "g++.exe", mais un compiler C est aussi fourni, sous le nom de "gcc.exe". Un compiler C++, spécialisé pour le debug, est aussi fourni : "gdb.exe". Un autre sous dossier important "include", qui contient tous les morceaux de codes que l'on pourra utiliser via "#include" (voir plus bas), sans avoir à fournir le chemin d'accès complet. Vous pouvez aller fouiller dedans si vous voulez, pour voir certains codes, comme "string.h" pour <string>, ou bien "math.h" pour <math>. Le dernier sous dossier est "lib", contenant tous les fichiers ".cpp" compilés nécessaires, pour faire marcher votre programme C++. En effet, les "#include" sont exclusivement des fichiers headers, mais leur partie compilé est présente, sous la forme de librairie. Une librairie, d'extension ".a", ".so" ou ".lib" selon la plateforme, représente un ensemble de fichiers ".cpp" déjà compilés. Ils permettent donc de ne pas avoir à recompiler tous les "#include" utilisés.

Certains des arguments optionnels sont très importants, donc, voici une liste non-exhaustive de certains d'entre eux :

  • Le C++ se décline en plusieurs versions différents, et la version précise à spécifier peut l'être grâce à un argument vers le compiler C++ : "-std". Si "-std" est "c++20", on utilise la version 20 du C++ (normalisée par l'ISO en 2020). Si "-std" est "c++98", on utilise la version 98 du C++ (normalisée par l'ISO en 1998).
  • Le C se décline en plusieurs versions différents (si vous devez utiliser du C à un moment dans votre projet), et la version précise à spécifier peut l'être grâce à un argument vers le compiler C : "-std". Si "-std" est "c17", on utilise la version 20 du C (normalisée par l'ISO en 2017). Si "-std" est "c90", on utilise la version 98 du C (normalisée par l'ISO en 1990).
  • Vous pouvez aussi passer aux compiler des arguments, de catégorie "#define", utilisables via les directives (voir plus bas).
Deux ensembles de configurations possibles, réglables dans l'IDE, sont les configurations "Debug" et "Release". La configuration "Debug" permet d'utiliser un outil, nommé le débugueur, pour traquer les potentielles bugs du code. Cependant, elle est moins performante et plus longue à compiler. La configuration "Release" permet de compiler normalement le code, sans possibilité de traquer les potentielles bugs. Cette configuration est nécessaire pour partager un logiciel compiler à autrui. Cependant, pour pouvoir être utilisable hors de votre environnement, il faut rajouter dans le même dossier que l'exécuteur des morceaux de codes spéciaux, nommés les DLLs. Les DLLs primaires en C++ sont présents dans le dossier "bin" du compiler, et sont les DLLs "libstdc++", "libwinpthread-1" et "libgcc_s_seh-1".

c. Ajouter des dépendances externes

Le code inclut dans l'environnement, c'est bien. Les codes plus complexes, c'est mieux. Pour télécharger du code provenant d'un autre endroit que de l'environnement, il y a pas mal de choses à faire.

Si vous téléchargez le code source à rajouter directement sous forme de fichiers ".cpp" et ".h", il vous faudra le compiler vous même une fois, pour le transformer en librairie. Pour cela, il y a plusieurs outils différents utilisables. L'un d'eux est CMake, permettant de configurer un projet C++, pour pouvoir le compiler après normalement, via un IDE, ou directement à même le compiler. Les codes sources utilisant CMake contiennent des fichiers "CMakeList.txt". Une autre méthode plus répandu est la compilation via l'outil "Make". Pour cela, il faut utiliser dans le même terminal que celui avec lequel vous avez installer votre compiler (MSYS, Cygwin...) la commande "make" avec comme argument le dossier vers le code source. Avant cela, il est conseillé d'effectuer la commande "./configure" pour configurer "make".

Si vous trouvez le code directement pré-compilé dans une librairie, alors pas besoin de le compiler vous même. Cependant, pour pouvoir pleinement l'utiliser, il y a encore quelques petites choses à faire. Actuellement, vous devriez avoir les headers et le code pré-compilé. Pour pouvoir utiliser les headers, nous allons prendre une technique simple, et simplement les placer dans le sous-dossier "include" de votre compiler. Comme ça, un simple appel comme <iostream> suffit. Il est conseillé de les mettre dans un dossier dans le dossier include, pour les retrouver plus rapidement. Pour les codes pré-compilés, on va faire la même chose, et simplement les placer dans le sous-dossier "lib" de votre compiler. Pour pouvoir les inclure au projet, vous devez aller dans les paramètres du linker, et rajouter le chemin d'accès du code pré-compilé à utiliser dans la section "Librairies liées". Certaines librairies peuvent produire ou demander des DLLs, que vous devez juste inclure dans le dossier de l'éxecuteur.

Contenu

B. Organiser le code de son projet

Certaines modifications textuelles du code permettent de configurer le projet, de manière plus étendue qu'on ne le pense.

a. Utiliser les prédirectives

Bien que ce nom puisse faire un peu peur, les prédirectives sont assez simples d'utilisation. Il s'agit de morceau de texte dans le code, permettant au compiler de modifier le code avant la compilation. Elles permettent de rendre le code plus portatifs, par exemple en permettant au compiler de ne pas compiler une certaine partie de code si il ne peut pas. Elles fonctionnent de manière entièrement textuel, et ne sont pas considérées comme des instructions. Elles ne finissent pas par un ";", mais lors du saut de ligne. Toutes les prédirectives en C++ commence par le symbole "#".

Nous avons déjà utilisés à plusieurs reprises une prédirectives : "#include". La prédirective "#include" permet, comme nous l'avons déjà vu, d'inclure un morceau de code dans un autre morceau de code. Pour être plus précis, le morceau de code à inclure remplace la ligne de prédirective dans le fichier de base. Voici deux utilisation simples de la prédirective :

#include <iostream>
#include "scls.h"
En général, il est conseillé de n'inclure que des fichiers headers, pour ne pas surcharger le code. Cette prédirective permet d'inclure tous les fichiers que vous voulez. Si le chemin d'accés du fichier est entouré de chevrons (comme iostream), le fichier se trouve dans des fichiers inclus au compiler. Si le chemin d'accés est entouré de guillemets, il se trouve autre part (n'importe où dans le système), relativement au fichier de base.

Si un morceau de code est inclut plusieurs fois, par exemple, dans deux autres morceaux de code différents, des conflits peuvent apparaître. Pour éviter cela, il faut utiliser trois autres prédirectives, dont "#define". "#define" permet de définir un morceau de texte, nommé une macro. Une macro est un morceau de texte, utilisable par un nom, qui, si écrit (le nom) quelque part dans le code, est remplacé par le morceau de texte de la macro. Voici un exemple de définition et d'usage d'une macro :

#include <iostream>
#define VERSION "1.5"

using namespace std;

int main()
{
        // VERSION est remplacé par "1.5" avant la compilation
        cout << "Version du logiciel : " << VERSION << endl; // Affiche "Version du logiciel : 1.5"
        return 0;
}
Cette prédirective est très utile pour utiliser des valeurs constantes, sans avoir à s'encombrer de variables constantes. En général, elles sont nommées avec des majuscules. Il existe quelques macros pré-définies en C++, comme "__LINE__" qui est remplacée par le numéro de la ligne actuelle (les autres sont documentées ici).

Si un morceau de code est inclut plusieurs fois, par exemple, dans deux autres morceaux de code différents, des conflits peuvent apparaître. Pour éviter cela, il faut utiliser trois autres prédirectives, dont "#define". "#define" permet de définir un morceau de texte, nommé une macro. Une macro est un morceau de texte, utilisable par un nom, qui, si écrit (le nom) quelque part dans le code, est remplacé par le morceau de texte de la macro. Voici un exemple de définition et d'usage d'une macro :

#include <iostream>
#define VERSION "1.5"

using namespace std;

int main()
{
        // VERSION est remplacé par "1.5" avant la compilation
        cout << "Version du logiciel : " << VERSION << endl; // Affiche "Version du logiciel : 1.5"
        return 0;
}
Cette prédirective est très utile pour utiliser des valeurs constantes, sans avoir à s'encombrer de variables constantes. Vous pouvez même les supprimer, avec la prédirective "#undef NOM_DE_LA_MACRO", pour libérer le nom de la macro pour plus tard si besoin. En général, elles sont nommées avec des majuscules. Il existe quelques macros pré-définies en C++, comme "__LINE__" qui est remplacée par le numéro de la ligne actuelle (les autres sont documentées ici). Vous pouvez aussi utiliser des parenthèses pour customiser la macro selon vos besoins, avec des valeurs spécifiques, comme par exemple :
#include <iostream>
#define VERSION_FINALE(version, sous_version) (string(version) + "." + sous_version)
// L'utilisation de la fonction "string" pour "version" est nécessaire, car on ne peut pas additionner
// deux chaînes de caractères sans en convertir au moins une en "string"

using namespace std;

int main()
{
        // VERSION_FINALE("1", "5") est remplacé par "1.5" avant la compilation
        cout << "Version du logiciel : " << VERSION_FINALE("1", "5") << endl; // Affiche "Version du logiciel : 1.5"
        return 0;
}

Pour l'instant, toutes les prédirectives que nous avons vu sont que statique, et ne peuvent modifient pas le code selon une condition. Cependant, il en existe d'autres pouvant le faire, de la même manière que "if", "else if" et "else". Ces prédirectives sont nommées prédirectives de conditions et sont sous la forme "#ifdef", "ifndef", "#if", "#else", "#elif" et "#endif". "#if" permet de ne pouvoir compiler un morceau de code que si une certaine condition est définie (implicant des macros), en sachant que sa syntaxe d'utilisation est la même que pour "if(condition)". Elle peut être couplée avec "#elif" (else if(condition)) ou "#else" pour être rendue plus complète. À la fin du morceau de code dans le "#if", il faut spécifier que l'on ferme la condition, avec "#endif". "#ifdef" est une version avancée de "#if". En effet, elle est considérée comme "vraie" si une macro, spécifiée juste après, est définie avec "#define" ou via les "#defines" du compiler ou non. À l'inverse, "ifndef" permet de savoir si une macro n'est pas définie. Un exemple d'utilisation de tout ça est le contenu obligatoire des fichiers headers. Un exercice que vous pouvez faire, et d'essayer de comprendre pourquoi leur structure est définie comme ça :

#ifndef NOM_DU_FICHER
#define NOM_DU_FICHER

// Trucs dans le header

#endif NOM_DU_FICHER
En tout cas, voici la réponse. Pour rappel, les headers sont inclus via "#include", copiant juste leur contenu dans le fichier dans lequel ils sont inclus. Cependant, si plusieurs fichiers appellent un même header, sont contenu est copié plusieurs fois, ce qui est inutile. Pour cela, à chaque copie, on regarde si la macro "NOM_DU_FICHIER" a été définie. À la première copie, la macro n'est pas encore définie, on peut la définir. Lors de tous les prochains "#include", la macro ayant déjà été définie, la copie ne se fera pas inutilement, optimisant le code à compiler. Cette technique est surtout utilisée pour permettre de ne pas compiler un morceau de code incompatible sur un certain environnement, sans avoir à le supprimer entièrement du code. Par exemple, si vous utilisez une fonction Windows qui n'est pas utilisable sur Linux, sa compilation sur Linux va retourner une erreur. En utilisant une prédirective conditionnelle, comme "#ifdef _WIN32", vous pouvez savoir si le code est compilé sur un environnement Windows ("_WIN32" est une macro pré-définie pour dire si l'on se trouve dans un environnement Windows, minimum 32-bits). La macro pré-définie pour Linux est "__linux__".

Pour en finir avec les prédirectives, nous allons voir les prédirectives un peu moins importantes que celles déjà vues. La prédirective "#error Texte a afficher au compiler" permet de créer une erreur de compilation. Elle est faite pour être utilisée avec les "#if" et ses homologues, par exemple si un code ne doit pas être compilé sur un certain système. La prédirective "#pragma parametre valeur" permet de spécifier un paramètre et sa valeur au compiler. Les paramètres dépendent du compiler utilisé. Finalement, la prédirective "#line numero_de_ligne texte" permet de spécifier le fonctionnement d'une ligne de code, en vu d'une possible erreur.

b. Utiliser les espaces de noms

Pour l'instant, parmis tous les codes que nous avons fait jusque ici, une seule ligne nous reste inexpliquée : "using namespace std;". Pourtant, c'est celle qui nous permet d'utiliser tous les codes que nous avons "#include" facilement. En effet, tous ces codes primaires aux C++ (string, iostream, math...) sont contenus dans ce que l'on appelle un espace de nom, nommé "std". Pour accéder à une chose contenu dans un espace de nom, il faut utiliser l'opérateur d'accès à un certain espace, s'écrivant "::", sur l'espace de nom en question. Dans le cas de string et iostream, il faudrai faire comme ça :

#include <iostream>
#include <string>

int main()
{
        std::string nom = "Thomas";
        std::cout << "Nom de l'utilsiateur : " << nom << std::endl;
        return 0;
}

Cependant, pourquoi ne pas l'avoir utilisé plus tôt ? Pour une raison simple : l'utilisation du mot clé "using namespace" permet d'utiliser un espace de nom sans avoir à utiliser l'opérateur "::". L'avantage est de ne pas avoir à noter "std::" à chaque fois. L'inconvénient est qu'il nous sera impossible d'écrire quelque avec le même nom que les fonctions déjà dans l'espace de nom "std", comme "string" ou "cout".

La création d'un espace de nom en C++ est très simple. Pour créer un espace de nom, il faut simplement utiliser le mot clé "namespace" (sans "using" ici), et en mettant le contenu entre accolades. À noter qu'il est possible de déclarer des choses dans le headers dans un espace de nom, et de les définir dans un fichier source dans le même espace de nom, en réutilisant "namespace". Voici un exemple, pour un fichier header et source de gestion d'utilisateur :

// gestion_utilisateur.h

#ifndef GESTION_UTILISATEUR
#define GESTION_UTILISATEUR

#include <iostream>
#include <string>
#include <vector>

namespace gestion_utilisateur {
        extern std::vector<std::string> utilisateurs;
        unsigned int nombre_utilisateurs();
}

#endif // GESTION_UTILISATEUR
// gestion_utilisateur.cpp

#include "gestion_utilisateur.h"
// Pas besoin d'inclure le reste, ils le sont déjà dans le header

namespace gestion_utilisateur {
        std::vector<std::string> utilisateurs = std::vector<std::string>();

        unsigned int nombre_utilisateurs() {
                return utilisateurs.size();
        }
}
// main.cpp

#include "gestion_utilisateur.h"

int main() {
        std::cout << gestion_utilisateur::nombre_utilisateurs(); // Affiche 0
        return 0;
}
Avec cela, organiser correctement votre code devient plus facile que jamais.

c. Déboguer son code

Pour finir avec cette partie, nous allons voir quelques manières de facilement déboguer son code, si besoin. Si votre code crash sans savoir pourquoi, une bonne chose à faire est d'utiliser un outil nommé le débogeur. Pour cela, il faut compiler votre code avec la configuration "Debug" (comme vu plus haut), et lancer le code via le débogeur. Le débogueur est capable de vous dire précisément quel ligne à fait planter votre code, quelles fonctions ont appelées la fonction de cette ligne, les threads du crash... En contre partie, le code est un peu plus long à compiler, et moins efficace lors de son éxécution.

Un autre moyen de faire est de traquer les données qui peuvent potentiellement provoquer un crash, ou un comportement inattendu, via une succession de "std::cout". Pour cela, l'idée est d'afficher les données concernées, d'inspecter leur valeur, et de les analyser pour voir si elles ont une part de responsabilité dans le crash. Vous pouvez aussi utiliser cette technique via une itération de boucle / fonction, pour voir quel itération provoque le crash, et qu'elle est sa part de responsabilité. Mettez en où vous pouvez, dans une structure de condition si nécessaire, pour rendre le déboguage le plus efficace possible.

Contenu

Un petit truc en plus

Pour en finir avec cette partie du cours, je vais vous présenter en vrac des choses qu'il faut faire, mais que je ne savais pas où mettre précisément.

La première chose : écrivez votre code en anglais. En effet, c'est la langue internationalement acceptée pour coder. Cela vous permettra d'être potentiellement aidé, si vous avez besoin d'aide, par n'importe qui sur Terre. Beaucoup de forums de ce genre existent, comme Stack Overflow. Vous pourrez aussi plus facilement stocker et partager votre code, via par exemple le cloud de Github. Dans ce cas là, il vous faut aussi comprendre comment fonctionnent les licences, pour ne pas vous faire juridiquement avoir.

La deuxième chose : pensez à documenter votre code pendant la réalisation. Sinon, vous risquez de vous perdre, et de perdre un précieux temps de réalisation. Le minimum est de commenter le code, pour comprendre ce qu'il fait.

Après tout cela, nous pourrons entrer dans du C++, un peu plus complexe.