Comment structurer son projet C++ ?
Un exemple d’un début de projet :
project/
└── main.cpp#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <map>
class Student {
private:
std::string mName;
int mId;
public:
Student(std::string name, int id) {
mName = name;
mId = id;
}
std::string getName() const {
return mName;
}
int getId() const {
return mId;
}
};
class Group {
private:
std::string mName;
std::vector<Student> mStudents;
public:
Group(std::string name) {
mName = name;
}
std::string getName() const {
return mName;
}
const std::vector<Student>& getStudents() const {
return mStudents;
}
void addStudent(const Student& student) {
//...
}
int getStudentCount() const {
//...
}
};
class Room {
private:
std::string mName;
int mCapacity;
public:
Room(std::string name, int capacity) {
mName = name;
mCapacity = capacity;
}
std::string getName() const {
return mName;
}
int getCapacity() const {
return mCapacity;
}
};
void displayStudent(const Student& student) {
//...
}
void displayGroup(const Group& group) {
//...
}
void displayRoom(const Room& room) {
//...
}
void assignStudentToGroup(Group& group, const Student& student) {
//...
}
void assignGroupToRoom(Group& group, Room& room) {
//...
}
int main() {
//...
return 0;
}Découpage du code¶
Pourquoi ne pas regrouper tout le code dans un seul fichier ?
Lorsque le projet devient plus complexe, la séparation du code apporte les avantages suivants : modularité, réutilisabilité, maintenabilité, extensibilité, encapsulation, lisibilité et meilleure gestion des erreurs.
Chaque fichier a un rôle précis, ce qui facilite la compréhension et l’évolution du programme.
Où est using namespace std ?
using namespace std ?using namespace std évite de réécrire std:: devant tous les types, fonctions ou objets de la bibliothèque standard de C++.
Cependant, lorsque les projets deviennent plus complexes, des conflits de noms peuvent apparaître si une fonction (ou un type/objet) porte le même nom qu’un élément de la bibliothèque standard.
Pour éviter ces problèmes et rendre le code plus explicite, nous n’utiliserons plus using namespace std à partir de maintenant et nous écrirons explicitement std::.
Une première séparation (non fonctionnelle)¶
Nous pouvons d’abord séparer le code en plusieurs fichiers comme ceci.
project/
├── student.cpp
├── group.cpp
├── room.cpp
├── display.cpp
├── assignment.cpp
└── main.cppclass Student {
private:
std::string mName;
int mId;
public:
Student(std::string name, int id) {
mName = name;
mId = id;
}
std::string getName() const {
return mName;
}
int getId() const {
return mId;
}
};class Group {
private:
std::string mName;
std::vector<Student> mStudents;
public:
Group(std::string name) {
mName = name;
}
std::string getName() const {
return mName;
}
const std::vector<Student>& getStudents() const {
return mStudents;
}
void addStudent(const Student& student) {
//...
}
int getStudentCount() const {
//...
}
};class Room {
private:
std::string mName;
int mCapacity;
public:
Room(std::string name, int capacity) {
mName = name;
mCapacity = capacity;
}
std::string getName() const {
return mName;
}
int getCapacity() const {
return mCapacity;
}
};void displayStudent(const Student& student) {
//...
}
void displayGroup(const Group& group) {
//...
}
void displayRoom(const Room& room) {
//...
}void assignStudentToGroup(Group& group, const Student& student) {
//...
}
void assignGroupToRoom(Group& group, Room& room) {
//...
}int main() {
//...
return 0;
}#include manquant
#include manquantLa directive #include copie simplement le contenu du fichier inclus dans le fichier courant avant la compilation.
#include <iostream>va inclure le contenu de la bibliothèqueiostreamdans le code.#include "<nom-du-fichier>"va inclure le contenu du fichier<nom-du-fichier>dans le code.
Par exemple :
#include "student.cpp"
class Group {
private:
// ...
public:
//...
};Si student.cpp contient :
class Student {
private:
// ...
public:
//...
};Alors le préprocesseur transforme le fichier group.cpp en quelque chose comme :
class Student {
private:
// ...
public:
//...
};
class Group {
private:
// ...
public:
//...
};C++ ne compile donc pas directement plusieurs fichiers.
Chaque fichier .cpp devient en réalité un grand fichier texte appelé translation unit.
Le code précédent ne fonctionne pas car il manque #include pour inclure les fichiers contenant le code nécessaire.
Par exemple, Group a besoin de Student ainsi que main pour fonctionner.
Nous pouvons être tenté de faire la chose suivante :
#include "student.cpp"
// Contenu de group.cpp#include "student.cpp"
#include "group.cpp"
// Contenu de main.cppLors de la compilation, le contenu des fichiers .cpp deviennent :
// #include "student.cpp" devient :
// Contenu de student.cpp
// Contenu de group.cpp// #include "student.cpp" devient :
// Contenu de student.cpp
// #include "group.cpp" devient :
// Contenu de student.cpp
// Contenu de group.cpp
// Contenu de main.cpp#include "filename.cpp" et One Definition Rule
#include "filename.cpp" et One Definition RuleLes problèmes potentiels :
Encapsulation violée :
Groupa besoin de la déclaration deStudentmais n’a pas besoin de connaître son implémentation.One Definition Rule (ODR) : une seule définition par objet. Une classe définie plusieurs fois est autorisée seulement si toutes les définitions sont identiques. Une fonction en dehors d’une classe ayant plusieurs définitions va causer une erreur de compilation (sauf si elle est
inlinemais cela n’est pas recommandé).Compilation modulaire impossible : nous voulons éviter de violer la ODR en n’incluant chaque fichier une seule fois dans le projet mais cela empêche de compiler les fichiers séparément dans les gros projets.
Comment les bibliothèques de C++ évitent ce problème ?
Header et source¶
La réponse est de séparer la déclaration et l’implémentation des objets :
project/
├── include/
│ ├── student.h
│ ├── group.h
│ ├── room.h
│ ├── display.h
│ └── assignment.h
└── source/
├── student.cpp
├── group.cpp
├── room.cpp
├── display.cpp
├── assignment.cpp
└── main.cppLes headers :
#ifndef STUDENT_H
#define STUDENT_H
#include <string>
class Student {
private:
std::string mName;
int mId;
public:
Student(std::string name, int id);
std::string getName() const;
int getId() const;
};
#endif#ifndef GROUP_H
#define GROUP_H
#include <string>
#include <vector>
#include "student.h"
class Group {
private:
std::string mName;
std::vector<Student> mStudents;
public:
Group(std::string name);
std::string getName() const;
const std::vector<Student>& getStudents() const;
void addStudent(const Student& student);
int getStudentCount() const;
};
#endif#ifndef ROOM_H
#define ROOM_H
#include <string>
class Room {
private:
std::string mName;
int mCapacity;
public:
Room(std::string name, int capacity);
std::string getName() const;
int getCapacity() const;
};
#endif#ifndef DISPLAY_H
#define DISPLAY_H
#include "student.h"
#include "group.h"
#include "room.h"
void displayStudent(const Student& student);
void displayGroup(const Group& group);
void displayRoom(const Room& room);
#endif#ifndef ASSIGNMENT_H
#define ASSIGNMENT_H
#include "student.h"
#include "group.h"
#include "room.h"
void assignStudentToGroup(Group& group, const Student& student);
void assignGroupToRoom(Group& group, Room& room);
#endifLes codes sources :
#include "student.h"
Student::Student(std::string name, int id) {
mName = name;
mId = id;
}
std::string Student::getName() const {
return mName;
}
int Student::getId() const {
return mId;
}#include "group.h"
Group::Group(std::string name) {
mName = name;
}
std::string Group::getName() const {
return mName;
}
void Group::addStudent(const Student& student) {
//...
}
const std::vector<Student>& Group::getStudents() const {
return mStudents;
}
int Group::getStudentCount() const {
//...
}#include "room.h"
Room::Room(std::string name, int capacity) {
mName = name;
mCapacity = capacity;
}
std::string Room::getName() const {
return mName;
}
int Room::getCapacity() const {
return mCapacity;
}#include <iostream>
#include "display.h"
void displayStudent(const Student& student) {
//...
}
void displayGroup(const Group& group) {
//...
}
void displayRoom(const Room& room) {
//...
}#include <algorithm>
#include <map>
#include "assignment.h"
void assignStudentToGroup(Group& group, const Student& student) {
//...
}
bool canGroupFitInRoom(const Group& group, const Room& room) {
//...
}#include <iostream>
#include "student.h"
#include "group.h"
#include "room.h"
#include "display.h"
#include "assignment.h"
int main() {
//...
return 0;
}Fichiers header .h en C++
.h en C++En C++, les fichiers header (.h) contiennent les déclarations de fonctions, de classes et de variables, mais pas leur implémentation.
Les fichiers sources (.cpp) contiennent les définitions de ces éléments.
Les headers peuvent être inclus dans plusieurs fichiers car ils contiennent des déclarations, pas les définitions.
Cette séparation permet de modulariser le code et de gérer les dépendances entre les fichiers.
Par exemple, si un fichier source .cpp a besoin d’utiliser la classe Student, il suffit d’inclure :
#include "student.h"Le header student.h annonce à C++ que la classe Student existe et décrit ses attributs et ses méthodes.
Le compilateur sait alors que leur implémentation sera fournie ailleurs (dans student.cpp).
Certains utilisent l’extension .hpp pour indiquer explicitement qu’il s’agit d’un header C++, mais .h et .hpp sont simplement des conventions. L’important est surtout d’être cohérent dans tout le projet.
Les directives du préprocesseur en C++
Cette partie est appelée les directives du préprocesseur :
#ifndef STUDENT_H
#define STUDENT_H
#include <string>
//...
#endifCes directives sont exécutées avant la compilation par un programme appelé préprocesseur.
Elles permettent notamment :
d’inclure d’autres fichiers (
#include)de définir des macros (
#define)de contrôler conditionnellement le code (
#ifdef,#ifndef, etc.).
Include what you use
Une bonne pratique en C++ est Include what you use.
Chaque fichier doit inclure les bibliothèques dont il dépend directement, sans supposer qu’elles sont incluses ailleurs.
Cela rend le code plus robuste et plus facile à maintenir.
Un include supplémentaire ne pose aucun problème grâce aux include guards.
Student::
Student::Dans student.cpp, il est nécessaire d’écrire :
Student::methodName()Le préfixe Student:: indique que cette méthode appartient à la classe Student.
Cela relie l’implémentation située dans student.cpp à la déclaration présente dans student.h.
Compilation en C++
Pour bien comprendre l’organisation du code en C++, il est utile de comprendre les différentes étapes de compilation.
Ce que l’on appelle généralement “compilation” correspond en réalité à quatre étapes.
Preprocessing : les directives du préprocesseur sont exécutées. Les
#includesont remplacés par le contenu des fichiers inclus.Translation Unit : le fichier prétraité devient un grand fichier texte appelé translation unit.
Compilation : le translation unit est compilé en code machine dans un fichier objet (
.o).Linking : les fichiers objets sont assemblés par le linker pour former l’exécutable final.
Par exemple :
Chaque fichier
.cppdevient un fichier.o(student.cppdevientstudent.o).Les
.osont compilés puis assemblés par le linker pour former l’exécutable final.
Découpage du code
Une bonne organisation consiste à séparer :
les classes
les fonctions externes liées
le programme principal (
main)
Chaque classe possède généralement :
un header (
.hou.hpp)un fichier source (
.cpp)
Pour aller plus loin¶
Un exemple de structure MVC :
project/
├── include/
│ ├── model/
│ │ ├── student.h
│ │ ├── group.h
│ │ └── room.h
│ ├── view/
│ │ ├── student-view.h
│ │ ├── group-view.h
│ │ └── room-view.h
│ └── controller/
│ ├── group-controller.h
│ └── room-controller.h
└── source/
├── model/
│ ├── student.cpp
│ ├── group.cpp
│ └── room.cpp
├── view/
│ ├── student-view.cpp
│ ├── group-view.cpp
│ └── room-view.cpp
├── controller/
│ ├── group-controller.cpp
│ └── room-controller.cpp
└── main.cppInclude et include guards
Dans cette organisation, #include "student.h" est remplacé par #include "model/student.h" et #ifndef STUDENT_H par #ifndef MODEL_STUDENT_H.
Compilation automatique¶
project/
└── main.cppD’habitude, nous compilons notre seul fichier de code directement en exécutable avec
g++ main.cpp -o executableBuild systems
Au lieu de compiler et d’exécuter chaque projet manuellement, les projets devenant de plus en plus complexes nécessitent un build system reposant sur des build scripts.
Un build script est un fichier qui automatise le processus de compilation et de gestion des dépendances d’un projet. Il définit les étapes nécessaires à la construction du projet, comme la compilation du code source, l’assemblage des fichiers, la génération de la documentation ou encore l’exécution des tests. Son objectif est de simplifier, standardiser et accélérer le processus de construction du projet.
Les build systems standards en C++ sont CMake et Makefile. D’autres langages utilisent leurs propres outils, comme MSBuild pour C# ou Gradle et Maven pour Java. Certains IDE intègrent également leur propre build system. Par exemple, un projet Java sous Eclipse utilise le système de build natif d’Eclipse.
Le Makefile, un build system bas niveau pour C/C++ conçu pour les systèmes Unix (Linux et macOS). CMake est plus haut niveau et cross-platform (compatible avec d’autres systèmes comme Windows), mais nous nous concentrerons sur Makefile, qui offre un meilleur contrôle à bas niveau, pour mieux comprendre la compilation automatique.
Nous pouvons aussi utiliser un makefile pour simplifier les commandes de compilation, d’exécution, et de nettoyage à :
make
make run
make cleanproject/
├── main.cpp
└── makefileall: executable
executable:
g++ main.cpp -o executable
run:
./executable
clean:
rm -f executable
.PHONY: all run cleanSyntaxe d’un makefile
Un makefile utilise une syntaxe basée sur des cibles. Dans le terminal, on peut exécuter une cible avec la commande :
make <cible>Voici quelques exemples courants :
makeexécute la cible par défautall, qui compile le programme.make runexécute le programme compilé (l’exécutable).make cleansupprime les fichiers générés par la compilation pour nettoyer le projet.
Par défaut, make cherche un fichier nommé makefile ou Makefile pour y trouver les cibles à exécuter. Si votre build script porte un autre nom, comme my-build-script, vous devrez spécifier son nom avec l’option -f :
make -f my-build-script <cible>-f signifie file, pour indiquer un fichier makefile spécifique.
La syntaxe de base d’une cible :
<cible>: <premier prérequis> <deuxième prérequis> <...>
<première commande>
<deuxième commande>
<...>Nous allons séparer la compilation en translation unit (.o) et le linking.
all: executable
executable: main.o
g++ main.o -o executable
main.o: main.cpp
g++ -c main.cpp -o main.o
run: executable
./executable
clean:
rm -f main.o executable
.PHONY: all run cleanComportement de make <cible>
make <cible>make <cible> cherche à créer un fichier portant le nom de la cible en exécutant les commandes associées. Pour simplifier, nous parlerons uniquement de “fichier” plutôt que de “fichier/répertoire”.
Avant de construire une cible, make cherche à construire ses prérequis.
Si tout est à jour, c’est-à-dire que pour chaque paire de (cible, prérequis) qui intervient dans la construction de la cible, le fichier correspondant à la cible a été créé/modifié après le fichier correspondant au prérequis, alors make ne fait rien. Si le fichier n’existe pas, make considère qu’il n’est pas à jour.
Quand une cible n’est pas à jour, make exécute les commandes associées à la cible.
Par exemple :
cible : prérequis
commande cible
prérequis :
commande prérequisDans l’exemple précédent, si les fichiers cible et prérequis existent, la dernière modification de cible est à 14h00 et de prérequis à 13h59, alors make vérifier que prérequis est à jour (ce qui est le cas vu qu’il n’a pas prérequis lui-même), puis vérifie que cible est à jour (ce qui est le cas vu que cible la dernière modification de cible est plus récente que celle de prérequis) et ne fait rien.
Ce comportement optimise la compilation, évitant de recompiler l’intégralité du projet, ce qui est crucial pour les grands projets pouvant prendre plusieurs heures à compiler.
Explication du makefile de l’exemple :
all: C’est la cible par défaut exécutée lorsque l’on tapemake. Elle ne contient pas de commande et vérifie simplement siexecutableest à jour.executable: Cette cible dépend demain.o, qui est un fichier objet générés à partir de fichier.cpp. Une fois ces prérequis à jour,makeexécuteg++ main.o -o executablequi crée l’exécutableexecutableà partir de(s) fichier(s) objet(s) (Linking, dernière étape de la compilation en C++).main.o:main.odépend demain.cpp. Même simain.cppn’est pas une cible explicite,makevérifie simplement si le fichier existe et s’il est à jour.Pour générer
main.o,makeexécuteg++ -c main.cpp -o main.o. Cela exécute le préprocesseur, génère la translation unit et compile le code en fichier objet.o.
run: Suppose que le programme est déjà compilé et exécute simplement./executable.clean:N’a aucun prérequis.
Supprime les fichiers générés avec
rm -f executable main.o hello.o.L’option
-f(force) permet de supprimer sans confirmation et sans erreur si les fichiers n’existent pas. Cette option est couramment utilisée pourcleandans les makefiles.
.PHONY
.PHONYVous avez peut-être remarqué que certaines cibles (all, run, clean) ne correspondent pas à des fichiers, et les commandes associées ne génèrent pas de fichiers ou de répertoires portant ces noms.
Comme ces fichiers n’existent pas, make considère toujours que ces cibles ne sont pas à jour et les exécute systématiquement, ce qui est bien le comportement voulu pour all, run et clean.
Problème potentiel :
Si le projet contient des fichiers nommés all, run ou clean, make pourrait les interpréter comme des fichiers à jour et ne pas exécuter les commandes associées.
Solution : les cibles .PHONY
Pour éviter cette confusion, on déclare ces cibles comme fictives (phony) en ajoutant la directive .PHONY: all run clean.
Cela indique que all, run et clean ne sont pas des fichiers, mais des commandes à exécuter systématiquement, même si des fichiers portant ces noms existent.
Quand make rencontre un prérequis .PHONY, il ne cherche plus à comparer les dates de modification, mais considère que la cible correspondante doit toujours être mis à jour (les commandes associés seront exécutées).
Tout ce qui en dépend après sera mis à jour.
Les makefiles pour les projets plus complexes seront vus en TP !