Organisation multi-répertoires avec CMake

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.

  1. 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.

  1. 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++.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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).

  1. 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 ?

  1. Les en-têtes sont regroupés dans include/, prêts à être fournis si le projet devient une bibliothèque.
  2. Les sources sont dans src/, séparant l'interface de l'implémentation.
  3. Le sous-dossier calc/ dans include/ 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

  1. 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 :

  1. Créer src/mul/ avec ses fichiers et son CMakeLists.txt.
  2. Ajouter add_subdirectory(mul) dans src/CMakeLists.txt.
  3. Ajouter mul_lib dans target_link_libraries.

Aucune modification du fichier racine ou des autres modules n'est nécessaire.

  1. 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é.

  1. 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.

  1. 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.

Étiquettes: CMake add_subdirectory target_include_directories target_link_libraries private

Publié le 5 juillet à 18h07