Gestion de projets multi-répertoires
Dans l'article précédent, nous avons maîtrisé la syntaxe essentielle de CMakeLists.txt pour compiler des projets simples. Cependant, un problème subsistait : lorsque tous les fichiers sources sont dans un unique CMakeLists.txt, la maintenance devient pénible à mesure que le projet grandit.
Un projet réel ne se limite presque jamais à un seul dossier. Imaginez développer une calculatrice avec des modules d'addition, soustraction, multiplication et division, plus un programme principal. Si tout est entassé dans un seul CMakeLists.txt, celui-ci devient volumineux, difficile à modifier et source de conflits en équipe.
Cette fois, nous résolvons ce problème en donnant à chaque module son propre CMakeLists.txt, géré depuis un fichier racine.
- Exemple d'un projet monolithique
Revenons à la structure de projet de l'article précédent :
├── add
│ ├── add.cpp
│ └── add.h
├── de
│ ├── de.cpp
│ └── de.h
├── main.cpp
└── CMakeLists.txt
Son CMakeLists.txt :
cmake_minimum_required(VERSION 3.10)
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
set(SOURCES
main.cpp
add/add.cpp
de/de.cpp
)
include_directories(add de)
add_executable(${PROJECT_NAME} ${SOURCES})
Le problème : Ajouter un module (par exemple mul, div) oblige à modifier trois endroits dans le fichier racine : set(SOURCES ...) pour y inclure les nouveaux fichiers, include_directories(...) pour ajouter le chemin d'inclusion. Le fichier devient fragile et difficile à maintenir.
De plus, si deux développeurs modifient ce fichier simultanément, des conflits risquent de survenir.
Nous avons besoin que chaque module "gère ses propres affaires" et que la racine les assemble.
- add_subdirectory : ajouter un sous-répertoire
add_subdirectory est la commande clé pour ce besoin. Elle indique à CMake : "va dans ce sous-dossier, exécute le CMakeLists.txt qui s'y trouve".
Syntaxe :
add_subdirectory(nom_du_sous_dossier)
Quand CMake atteint cette ligne, il entre dans le sous-dossier, exécute son CMakeLists.txt, puis revient au dossier courant pour continuer. C'est similaire à un appel de fonction en C++.
- Restructuration du projet
3.1 Structure cible
Nous transformons le projet en :
├── CMakeLists.txt # Fichier racine (directeur)
├── main.cpp
├── add
│ ├── CMakeLists.txt # CMakeLists.txt du module add
│ ├── add.cpp
│ └── add.h
└── de
├── CMakeLists.txt # CMakeLists.txt du module de
├── de.cpp
└── de.h
Chaque module possède son propre CMakeLists.txt.
3.2 CMakeLists.txt des sous-répertoires
Pour le module add (add/CMakeLists.txt) :
# Compile le module add en bibliothèque statique
add_library(add_lib add.cpp)
add_library transforme add.cpp en une bibliothèque nommée add_lib (fichier .a) utilisable par d'autres cibles.
Pourquoi "add_lib" et non "add" ? Parce que "add" est un mot réservé CMake (pour les opérations mathématiques). Utiliser "add" comme nom de cible provoquerait un conflit.
Pour le module de (de/CMakeLists.txt) :
# Compile le module de en bibliothèque statique
add_library(de_lib de.cpp)
Chaque sous-répertoire ne se soucie que de sa propre compilation.
3.3 CMakeLists.txt racine
Le fichier racine doit ajouter les sous-répertoires, générer l'exécutable et lier les modules.
# Exigence de version minimale de CMake
cmake_minimum_required(VERSION 3.10)
# Définition du projet
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)
# Standard C++
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# Ajout des sous-répertoires
add_subdirectory(add)
add_subdirectory(de)
# Génération de l'exécutable (seulement main.cpp, les modules sont gérés par leurs bibliothèques)
add_executable(${PROJECT_NAME} main.cpp)
# Chemins d'inclusion des en-têtes
target_include_directories(${PROJECT_NAME} PRIVATE add de)
# Liaison des bibliothèques à l'exécutable
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)
Remarquez les nouvelles commandes target_include_directories et target_link_libraries.
- target_link_libraries : lier des bibliothèques
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)
Cette commande lie les bibliothèques add_lib et de_lib à l'exécutable HelloCMake, équivalent à la commande manuelle g++ main.o add/add.o de/de.o -o main.
Le mot-clé PRIVATE sera expliqué ci-dessous.
- Différence entre target_include_directories et include_directories
Dans l'article précédent, nous utilisions include_directories :
include_directories(add de)
Ici, nous utilisons target_include_directories :
target_include_directories(${PROJECT_NAME} PRIVATE add de)
5.1 include_directories : global
include_directories est une commande globale. Une fois exécutée, toutes les cibles définies ultérieurement dans le même CMakeLists.txt en sont affectées.
include_directories(add de)
add_executable(target_a a.cpp) # target_a voit les en-têtes de add/ et de/
add_executable(target_b b.cpp) # target_b aussi, même si inutile
Problème : Si target_b n'a pas besoin de ces modules, elle est "polluée" par des chemins de recherche inutiles. Dans un grand projet, cela rend difficile l'identification des dépendances réelles.
5.2 target_include_directories : précision
target_include_directories n'affecte que la cible spécifiée :
target_include_directories(target_a PRIVATE add de) # Seule target_a voit ces chemins
add_executable(target_b b.cpp) # target_b n'est pas affectée
Ainsi, les dépendances de chaque cible sont claires et indépendantes.
5.3 Signification de PRIVATE, PUBLIC, INTERFACE
Ces mots-clés contrôlent la propagation des dépendances.
Imaginez une recette de cuisine (cible A) qui utilise de la sauce soja (dépendance B) :
- PRIVATE : La sauce soja est utilisée en cuisine, mais les convives n'ont pas besoin de savoir qu'elle a été utilisée. La dépendance ne se propage pas aux utilisateurs de la cible.
- PUBLIC : La sauce soja est utilisée en cuisine ET les convives doivent en avoir pour manger le plat. La dépendance se propage.
- INTERFACE : Vous n'utilisez pas la sauce soja en cuisine, mais les convives doivent en avoir pour manger. La dépendance se propage sans être utilisée par la cible elle-même.
Traduit en langage CMake :
| Mot-clé | Utilisé lors de la compilation de la cible | Propagation aux cibles liées |
|---|---|---|
| PRIVATE | Oui | Non |
| PUBLIC | Oui | Oui |
| INTERFACE | Non | Oui |
Exemple concret :
# Les chemins d'en-tête de add_lib sont définis comme PUBLIC
target_include_directories(add_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
# Toute cible liée à add_lib obtient automatiquement ces chemins
target_link_libraries(main PRIVATE add_lib)
# main n'a pas besoin d'une commande target_include_directories séparée pour trouver add.h !
Pour l'instant, PRIVATE et PUBLIC peuvent sembler identiques, mais leur différence deviendra cruciale lors de la création de bibliothèques réutilisables. Par défaut, utilisez PRIVATE.
- Simplification du CMakeLists.txt racine avec PUBLIC
En rendant les chemins d'en-tête PUBLIC dans les sous-répertoires, nous pouvons déléguer leur gestion aux modules.
Nouveau add/CMakeLists.txt :
add_library(add_lib add.cpp)
target_include_directories(add_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
Nouveau de/CMakeLists.txt :
add_library(de_lib de.cpp)
target_include_directories(de_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
Nouveau CMakeLists.txt racine :
cmake_minimum_required(VERSION 3.10)
project(HelloCMake VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
add_subdirectory(add)
add_subdirectory(de)
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)
Plus besoin de target_include_directories dans le fichier racine. Les chemins sont propagés automatiquement via PUBLIC. Chaque module gère ses propres chemins.
- Portée des variables
Avec add_subdirectory, la portée des variables suit une règle : les variables du parent sont visibles par l'enfant, mais pas l'inverse.
# CMakeLists.txt racine
set(MY_VAR "Variable racine")
add_subdirectory(add)
message(STATUS "Racine lit la variable enfant : ${CHILD_VAR}") # Vide ! Non lisible
# add/CMakeLists.txt
message(STATUS "Enfant lit la variable parent : ${MY_VAR}") # Lisible
set(CHILD_VAR "Variable enfant")
Sortie :
-- Enfant lit la variable parent : Variable racine
-- Racine lit la variable enfant :
Pour transmettre une variable de l'enfant vers le parent, utilisez PARENT_SCOPE :
# add/CMakeLists.txt
set(CHILD_VAR "Variable enfant" PARENT_SCOPE)
# Attention : dans la portée courante, CHILD_VAR n'est pas définie !
# message(STATUS "${CHILD_VAR}") afficherait " "
Si l'enfant a aussi besoin de cette variable, définissez-la deux fois :
set(CHILD_VAR "Variable enfant") # Portée courante
set(CHILD_VAR "Variable enfant" PARENT_SCOPE) # Portée parente
Conseil : Évitez de transmettre des variables de l'enfant vers le parent, car cela augmente le couplage. Préférez propager l'information via les propriétés des cibles (comme les chemins d'en-tête PUBLIC).
- Structure de projet plus rigoureuse
Dans la pratique, on sépare généralement les fichiers d'en-tête (include) des fichiers source (src).
├── CMakeLists.txt
├── include
│ └── calc
│ ├── add.h
│ └── de.h
├── src
│ ├── CMakeLists.txt
│ ├── add.cpp
│ ├── de.cpp
│ └── main.cpp
└── README.md
Pourquoi cette organisation ?
- Les en-têtes sont regroupés dans
include/, prêts à être fournis si le projet devient une bibliothèque. - Les sources sont dans
src/, séparant l'interface de l'implémentation. - Le sous-dossier
calc/dansinclude/permet d'utiliser#include "calc/add.h", réduisant les risques de conflit avec d'autres bibliothèques.
Mise à jour des fichiers :
include/calc/add.h :
#ifndef CALC_ADD_H
#define CALC_ADD_H
int add(int a, int b);
#endif
include/calc/de.h :
#ifndef CALC_DE_H
#define CALC_DE_H
int de(int a, int b);
#endif
src/add.cpp :
#include "calc/add.h"
int add(int a, int b) { return a + b; }
src/de.cpp :
#include "calc/de.h"
int de(int a, int b) { return b - a; }
src/main.cpp :
#include <iostream>
#include "calc/add.h"
#include "calc/de.h"
int main()
{
std::cout << "10 + 2 = " << add(10, 2) << std::endl;
std::cout << "de(2, 10) = " << de(2, 10) << std::endl;
return 0;
}
Fichiers CMakeLists.txt :
src/CMakeLists.txt :
set(LIB_SOURCES
add.cpp
de.cpp
)
add_library(calc_lib ${LIB_SOURCES})
target_include_directories(calc_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE calc_lib)
CMakeLists.txt racine :
cmake_minimum_required(VERSION 3.10)
project(Calculator VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
add_subdirectory(src)
Le fichier racine est maintenant très épuré. Toute la logique de compilation est dans le sous-répertoire src.
Construction et exécution :
mkdir build && cd build
cmake ..
make
./Calculator
Sortie :
10 + 2 = 12
de(2, 10) = 8
- Extension avec plusieurs sous-modules indépendants
Pour des projets plus complexes, chaque module peut avoir son propre dossier et son propre CMakeLists.txt.
├── CMakeLists.txt
├── include
│ └── calc
│ ├── add.h
│ └── de.h
├── src
│ ├── CMakeLists.txt
│ ├── add
│ │ ├── CMakeLists.txt
│ │ └── add.cpp
│ ├── de
│ │ ├── CMakeLists.txt
│ │ └── de.cpp
│ └── main.cpp
└── README.md
src/add/CMakeLists.txt :
add_library(add_lib add.cpp)
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
src/de/CMakeLists.txt :
add_library(de_lib de.cpp)
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
src/CMakeLists.txt :
add_subdirectory(add)
add_subdirectory(de)
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)
Le CMakeLists.txt racine reste inchangé.
On obtient une structure à trois niveaux : racine pour la configuration, src pour l'assemblage, chaque module pour sa compilation. Ajouter un nouveau module (par exemple mul) nécessite seulement :
- Créer
src/mul/avec ses fichiers et son CMakeLists.txt. - Ajouter
add_subdirectory(mul)danssrc/CMakeLists.txt. - Ajouter
mul_libdanstarget_link_libraries.
Aucune modification du fichier racine ou des autres modules n'est nécessaire.
- Questions fréquentes et astuces
L'ordre de add_subdirectory est-il important ? Généralement non, car CMake résout automatiquement les dépendances. Exception : si vous utilisez PARENT_SCOPE, l'ordre détermine quand la variable est disponible. Privilégiez la propagation via les propriétés des cibles.
Peut-on ajouter un dossier externe avec add_subdirectory ? Oui, mais il faut spécifier un deuxième argument pour le dossier de sortie de la construction :
add_subdirectory(/chemin/vers/module/externe dossier_sortie_module)
Cependant, il est plus courant de gérer ces dépendances en interne.
Peut-on imbriquer des add_subdirectory ? Oui, comme dans l'exemple ci-dessus (racine → src → add/de). Cela forme une arborescence, mais limitez-vous à 2-3 niveaux pour éviter la complexité.
- Tableau de synnthèse des commandes
| Commande | Rôle | Exemple |
|---|---|---|
add_subdirectory |
Ajoute un sous-dossier et exécute son CMakeLists.txt | add_subdirectory(src) |
add_library |
Compile des sources en bibliothèque | add_library(mylib src.cpp) |
target_link_libraries |
Lie une bibliothèque à une cible | target_link_libraries(app PRIVATE mylib) |
target_include_directories |
Définit les chemins d'en-tête pour une cible | target_include_directories(mylib PUBLIC include/) |
include_directories |
Définit globalement les chemins d'en-tête (déconseillé) | include_directories(include/) |
Variables utiles :
| Variable | Signification |
|---|---|
CMAKE_CURRENT_SOURCE_DIR |
Dossier source du CMakeLists.txt en cours d'exécution |
CMAKE_CURRENT_BINARY_DIR |
Dossier de construction correspondant |
PROJECT_SOURCE_DIR |
Dossier source de la dernière commande project() |
CMAKE_SOURCE_DIR |
Dossier source du CMakeLists.txt racine |
Astuce : PROJECT_SOURCE_DIR et CMAKE_SOURCE_DIR sont souvent identiques, sauf si votre projet est inclus comme sous-projet d'un autre via add_subdirectory. Dans ce cas, CMAKE_SOURCE_DIR pointe vers le projet le plus externe, tandis que PROJECT_SOURCE_DIR pointe vers votre projet. Pour des modules réutilisables, préférez PROJECT_SOURCE_DIR.
- Résumé et aperçu du prochain article
Nous avons appris à organiser un projet multi-répertoires avec add_subdirectory, compris la différence entre target_include_directories et include_directories, exploré les mots-clés PRIVATE, PUBLIC, INTERFACE, et découvert les règles de portée des variables.
Nous avons utilisé add_library sans entrer dans les détails. Le prochain article, CMake : bibliothèques statiques et dynamiques, expliquera les différences entre ces deux types, quand les utiliser, et comment contrôler leur chemin de sortie et leur version.