Résumé
Jusqu’ici, chaque programme du cours tenait dans un seul fichier. Un programme de mille lignes ne se lit plus d’un seul trait, et ses fonctions méritent de servir à d’autres programmes. Cette leçon enseigne à le diviser : un en-tête qui déclare ce qu’un module offre, un fichier d’implémentation qui le définit et un main.cpp qui l’utilise. Elle présente les gardes d’inclusion ; la règle de la définition unique, qui explique les deux erreurs classiques de l’éditeur de liens et le sens moderne de inline ; la compilation séparée, qui ne recompile que ce qui a changé ; et elle se termine par une bibliothèque personnelle, faite de fonctions de leçons précédentes.
Objectifs d’apprentissage
- Diviser un programme en un en-tête contenant des déclarations, un fichier d’implémentation et un
main.cpp, et le compiler. - Écrire des gardes d’inclusion et expliquer quel problème elles résolvent ; reconnaître dans
#pragma onceune extension que tous les compilateurs acceptent, mais que la norme ne retient pas. - Expliquer la règle de la définition unique et diagnostiquer ses violations à partir du message de l’éditeur de liens (« multiple definition », « undefined reference »).
- Compiler séparément avec
-cet lier les objets, en expliquant ce qui est recompilé quand chaque fichier change.
Interface et implémentation
La modularité
Une fonction munie d’un contrat, comme celles de la leçon Concevoir avec des fonctions, sépare déjà ce qu’elle promet, écrit dans sa déclaration et son contrat, de la manière dont elle tient sa promesse, écrite dans son corps ; qui l’appelle n’a besoin que de la première partie. S’il en est ainsi, rien n’empêche de ranger les deux parties dans des fichiers distincts. Un module est un groupe de fonctions (et des types qu’elles utilisent) ayant un but commun ; son interface est ce qu’il offre à qui l’utilise, et son implémentation, la manière dont il le fait. En C++, l’interface s’écrit dans un fichier d’en-tête (extension .hpp, ou .h, héritée du C) et l’implémentation dans un fichier .cpp du même nom.
Il s’ensuit trois avantages. On comprend par parties : pour utiliser le module, il suffit de lire son en-tête. On compile par parties : un changement dans l’implémentation n’oblige pas à recompiler ceux qui n’utilisent que l’interface. Et on teste par parties, contre chaque contrat, comme l’enseignera la leçon Tester. Le cours LaTeX pour la rédaction académique divise de la même façon un document long, dans sa leçon « Documents longs ».
Trois fichiers
Le module de géométrie plane de cet exemple offre trois fonctions ; deux d’entre elles viennent de la leçon 5.3. L’en-tête ne fait que les déclarer, chacune avec son contrat. Les trois lignes qui commencent par # seront expliquées dans la section suivante ; pour l’instant, il suffit de savoir que tout en-tête les porte.
// geometry.hpp : interface du module de géométrie plane.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP// Aire du cercle de rayon radius.// Précondition : radius >= 0.// Postcondition : renvoie l'aire du cercle de rayon radius.double circleArea(double radius);// Si trois longueurs sont les côtés d'un triangle.// Précondition : aucune.// Postcondition : renvoie true si et seulement s'il existe un triangle de côtés a, b et c.bool isTriangle(double a, double b, double c);// Aire du triangle de côtés a, b et c.// Précondition : isTriangle(a, b, c).// Postcondition : renvoie l'aire du triangle de côtés a, b et c (formule de Héron).double triangleArea(double a, double b, double c);#endifLe fichier d’implémentation inclut son propre en-tête et définit les fonctions. Les en-têtes de la bibliothèque standard dont il a besoin (<cmath> pour la racine, <numbers> pour ) sont inclus ici et non dans l’en-tête, parce que qui utilise le module n’en a pas besoin.
// geometry.cpp : implémentation du module de géométrie plane.#include "geometry.hpp"#include <cmath>#include <numbers>double circleArea(double radius){ return std::numbers::pi * radius * radius;}bool isTriangle(double a, double b, double c){ return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}double triangleArea(double a, double b, double c){ const double s{(a + b + c) / 2}; return std::sqrt(s * (s - a) * (s - b) * (s - c));}Enfin, main.cpp inclut l’en-tête et utilise le module, sans voir une seule ligne de son implémentation :
#include <print>#include "geometry.hpp"int main(){ std::println("Cercle de rayon 2 : {:.4f}", circleArea(2.0)); std::println("Y a-t-il un triangle de côtés 3, 4, 5 ? {}", isTriangle(3.0, 4.0, 5.0)); std::println("Son aire : {}", triangleArea(3.0, 4.0, 5.0)); std::println("Et de côtés 1, 2, 3 ? {}", isTriangle(1.0, 2.0, 3.0));}Cercle de rayon 2 : 12.5664Y a-t-il un triangle de côtés 3, 4, 5 ? trueSon aire : 6Et de côtés 1, 2, 3 ? falseLes guillemets de #include "geometry.hpp" cherchent le fichier d’abord dans le dossier de celui qui l’inclut, et les chevrons de #include <print>, dans ceux du système : les guillemets sont pour les fichiers que l’on écrit soi-même. Le fichier geometry.cpp inclut son propre en-tête en premier, de sorte que, s’il manquait à l’en-tête une inclusion dont il a lui-même besoin, l’erreur apparaîtrait à la compilation du module et non dans le fichier de quelqu’un d’autre. Et le triangle de côtés 1, 2 et 3 n’existe pas (), de sorte que la précondition de triangleArea oblige à poser la question avant.
Pour compiler, la commande reçoit les deux fichiers .cpp ; l’en-tête n’est pas nommé, parce qu’il entre dans chacun d’eux par #include. Les options du cours sont gardées dans une variable du terminal, FLAGS, pour ne pas les répéter à chaque commande ; $FLAGS les récupère. C’est ainsi qu’on l’écrit sous Linux et sous macOS ; dans PowerShell, les variables s’écrivent autrement, et il vaut mieux écrire les options en entier ou compiler depuis l’éditeur (section « Compilation séparée »).
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./mainCercle de rayon 2 : 12.5664Y a-t-il un triangle de côtés 3, 4, 5 ? trueSon aire : 6Et de côtés 1, 2, 3 ? falseLa figure reprend celle de la leçon Programmes, compilateurs et C++ pour ce projet.
Démonstration
Le programmeur écrit trois fichiers : un en-tête et deux fichiers
.cpp. Seuls les.cppsont remis au compilateur.Le préprocesseur copie
geometry.hppdans chaque fichier qui l’inclut. Il y a donc deux copies des déclarations, une dans chaque unité de traduction, et c’est correct : une fonction peut être déclarée autant de fois qu’on le veut.Chaque unité est compilée séparément en son fichier objet. En compilant
main.cpp, le compilateur ne regarde pasgeometry.cpp: la déclaration lui suffit pour vérifier les types de chaque appel.L’éditeur de liens réunit les deux objets et la bibliothèque standard, et résout chaque appel de
main.oàcircleAreapar la définition qui se trouve dansgeometry.o.
Une erreur fréquente consiste à inclure le fichier d’implémentation, #include "geometry.cpp". Si de plus on le nomme dans la commande, ses fonctions se trouvent définies deux fois et l’édition de liens échoue, en vertu de la règle qu’expliquera la section « La règle de la définition unique » ; si on ne le nomme pas, main.cpp redevient un seul texte, recompilé en entier à chaque changement. Les fichiers .cpp se compilent ; les en-têtes s’incluent.
Chargement du contenu…