Les bases de CMake, comment écrire un bon projet CMake ?

Les bases de CMake, comment écrire un bon projet CMake ?

16/03/2020
cmake,tutoriel,C++,buildsystem

Pourquoi CMake ?

À siliceum, nous avons décidé d'utiliser CMake pour nos projets basés sur le langage C++, bien que plus ou moins récemment de nouveaux (meta) buildsystems fassent leur apparation, tels que build2 ou meson.

Nous avons fait ce choix car la majorité de l'écosystème C++ utilise CMake. Aujourd'hui, les IDEs le supportent assez bien, tout comme la plupart des bibliothèques et les principaux gestionnaires de dépendances. CMake ne demande pas de dépendances additionelles tel que Python, ce qui le rend un poil plus simple à installer pour l'intégration continue sur des plateformes comme Windows.

Bien que CMake soit loin d'être parfait, pour le moment, il fait son job.

Mais comment écrire un bon projet CMake ?

Nous espérons répondre à cette question afin que la communauté C++ puisse prospérer, et rendre plus simple pour tous l'intégration de bibliothèques dans un projet.

Ceci est le premier article d'une mini-série sur les buildsystems et l'intégration continue.

Le fichier CMakeLists.txt

Tous les buildsystems nécessitent un point d'entrée qui contient la définition du projet. Dans le cas de CMake, celui-ci s'appelle CMakeLists.txt, et est écrit dans son propre langage de script.

Spécification de la version CMake

La première chose dont vous aurez besoin, c'est de spécifier la version minimum de CMake que vous voulez utiliser. C'est important car CMake peut avoir des comportements différents en selon la version choisie, c'est ce que l'on appelle des politiques (policies).

En fonction des fonctionnalités que vous utiliserez, il sera peut être nécessaire de choisir une version plus récente de CMake. Dans notre cas nous utiliserons la version 3.14. Elle n'est pas trop vieille et supporte donc la majorité des fonctionnalités les plus récentes.

S'il vous plaît, n'utilisez pas une version plus vieille que la 3.1, qui date de 2014 !

cmake_minimum_required(VERSION 3.14)

Description du projet

Ensuite, vous devrez spécifier le nom, les langages ainsi que la version de votre project (nécessaire pour créer des paquets).

Cela se fait facilement avec la commande project:

project(YOUR_PROJECT_NAME VERSION 0.1.0 LANGUAGES C CXX)

Les commentaires

Les commentaires commencent avec le caractère # et peuvent être multi-lignes (bracket comments) en utilisant la syntaxe bracket_open.

# Ceci est un commentaire sur une ligne

Les cibles (targets)

Une cible est, en général, un executable ou une bibliothèque. Vous pouvez cependant créer une cible personnalisée si votre projet utilise des outils particuliers.
Celles-ci peuvent être créées respectivement via les commandes add_executable, add_library et add_custom_target.

Les dépendances entre cibles sont utilisées pour définir l'ordre de compilation et les commandes de linkage.

Un projet exemple

Imaginons que nous souhaitions créer un projet qui contient une bibliothèque et une application avec interface en ligne de commandes l'utilisant. Vous pouvez commencer par renseigner à CMake quels fichiers sont utilisés pour compiler la bibliothèque. Notez que la hiérarchie de fichiers que l'on utilisera suivra la convention pitchfork.

add_library(myawesomelib
  source/myawesomelib.cpp
  source/implementation-details.h
  include/myawesomelib.h
)

Puisque nous mettons nos headers (en-têtes) publics dans un dossier différent (include) du reste de nos fichiers sources, nous avons besoin d'indiquer au compilateur où ils se situent. On utilise pour cela la commande target_include_directories.

target_include_directories(myawesomelib PUBLIC include)

Propriétés de cibles et transitivités des pré-requis

Vous vous demandez probablement ce que signifie le paramètre PUBLIC. Dans CMake, les cibles ont une liste de propriétés utilisées lors de la compilation, et il est possible de les modifier avec diverses commandes. Certaines de ces propriétés sont dites transitives et peuvent se propager d'une cible à une autre lorsqu'une dépendance est déclarée entre elles.

Il existe 3 mots-clés controllant cette propagation:

Donc, lorsque l'on va linker notre myawesomelib, on pourra inclure myawesomelib.h depuis notre cible, mais on pourra également l'inclure depuis myawesomelib.cpp.

Dans le cas d'une bibliothèque header-only, nous devons préciser à CMake qu'il n'est pas nécessaire de compiler la bibliothèque, en créant une bibliothèque d'interface (interface library). Celle-ci ne pourra avoir que des propriétés INTERFACE. Pour cela, nous appelons add_library sans fichiers sources et avec le mot-clé INTERFACE, par example add_library(myheaderonlylib INTERFACE).

Il existe d'autres types de bibliothèques qui ne seront pas couverts par cet article, référez-vous à la documentation pour une liste exhaustive.

De même, pour plus d'informations sur les cibles, les propriétés transitives et le buildsystem CMake en général, nous vous invitons à lire cette page de la documentation.

Linker des bibliothèques

Maintenant que nous avons notre bibliothèque, nous allons vouloir l'utiliser dans notre application. Pour cela nous utiliserons la commande add_executable afin de créer une cible exécutable, et target_link_libraries indiquer les bibliothèques dont notre exécutable dépend :

add_executable(myawesomecli main.cpp)
target_link_libraries(myawesomecli PRIVATE myawesomelib)

Et c'est tout !

Compiler avec CMake

Il est grandement recommandé de créer un répertoire de compilation dédié avant de compiler un projet CMake, afin de ne pas polluer le reste du dossier du projet. Cette approche permet également d'avoir un dossier de travail par compilateur et par plateforme par exemple.

Ensuite, naviguez dans ce dossier (généralement nommé build) et lancez CMake depuis ce dernier. Vous pouvez pour cela utiliser l'interface graphique CMake-GUI (très utile pour la configuration) ou utiliser l'interpréteur de commandes cmake directement.

CMake est un meta-buildsystem, cela signifie qu'il ne compile pas votre code directement mais génère des fichiers utilisables par d'autres buildsystems. L'avantage principal de cette technique est qu'il peut générer des projets compatibles avec votre plateforme et/ou IDE préféré. Cela se fait en choisissant un générateur (ou en utilisant celui par défaut).

Si l'on utilise l'interpréteur de commandes, cela ressemble à l'enchaînement de commandes suivant:

mkdir build
cd build
cmake -G "Votre generateur" ..

Et vous avez ainsi généré les fichiers pour compiler votre projet. Il est possible d'omettre le -G "Votre generateur" et CMake choisira celui par défaut sur votre plateforme. Sur Windows ce sera une solution Visual Studio et sur Linux un Makefile.

Dans les deux cas, vous pouvez compiler votre projet directement avec la commande CMake plutôt que d'utiliser le buildsystem sous-jacent, ce qui permet d'abstraire les commandes à utiliser:

cmake --build . [--target votrecible]

Si vous préférez travailler depuis le dossier racine de votre projet et ne pas changer le répertoire de travail, vous pouvez à la place utiliser les lignes de commandes suivante:

cmake -B build -G "Your generator" -S .
cmake --build build

Plus de contrôle

Gardez vos dépendances sous contrôle

Comme vous l'aurez peut-être deviné, les propriétés transitives sont très pratiques car elles se propagent à travers plusieurs niveaux d'indirection lorsque vous linkez des bibliothèques. C'est pourquoi il est recommandé de toujours spécifier le type de propagation d'une propriété transitive.
Si une de vos bibliothèques nécessite de linker une autre mais ne veut pas l'exposer, par example si vous faites une abstraction de plusieurs bibliothèques, utilisez la propagation PRIVATE.

Vous pouvez bien entendu mélanger PRIVATE, INTERFACE et PUBLIC pour une même cible:

add_library(monAbstraction [...])
target_link_libraries(monAbstraction
  PRIVATE
    autreLib1
    autreLib2
  PUBLIC
    libNecessairePourLesUtilisateurs
)

Il est à noter que si une bibliothèque statique A link une bibliothèque B de façon privée, un binaire C utilisant A sera linké avec A et B, car cela est nécessaire. Mais, seulement les propriétés de A lui seront propagées.

Définitions et fonctionalités de compilation

Vous aurez souvent besoin de passer des définitions de macro au compilateur, cela est possible avec la commande target_compile_definitions.

Par exemple:

target_compile_definitions(myawesomelib
  PRIVATE
    USE_SIMD=1
    INTERNAL_MACRO
  INTERFACE
    ONLY_CONSUMERS_CAN_SEE_THIS_DEFINE=42
)

Si vous utilisez des fonctionalités récentes du C++, vous voudrez spécifier lesquelles utiliser lors de la compilation. CMake peut automatiquement détecter quels sont les options de compilation à donner au compilateur, et dans le cas où ces fonctionalités ne sont pas supportées, donner une erreur. Par exemple si l'on souhaite utiliser le standard c++14:

target_compile_features(myawesomelib PRIVATE cxx_std_14)

Bien que CMake donne accès à un contrôle plus fin des fonctionnalités des compilateurs, il n'expose désormais plus que les versions du standard. La liste complète des fonctionalités exposées pour le C++ est disponible ici.

Il est aussi possible d'utiliser target_compile_options pour donner des paramètres de compilation directement au compilateur. Cependant, si vous décidez de l'utiliser, il vaut mieux rendre optionnel les paramètres non nécessaires à la compilation. Plus de détails à ce sujet dans le prochain article !

Découper le CMakeLists.txt

Lorsqu'un projet grossit, il devient rapidement nécessaire de l'organiser en séparant le fichier CMakeLists.txt en plusieurs morceaux.

Exemple de structure

Nous allons utiliser la hiérarchie de fichiers suivante, avec 3 sous-projets libA, libB et programA. Nous aurons libB qui dépent de libA et programA qui dépent de libB, mais libA ne devra pas laisser ses propriétés fuiter dans programA.

myawesomeproject
└── libs
    ├── libA
    │   └── include
    │       └── libA.h
    ├── libB
    │   ├── include
    │   │   └── libB.h
    │   └── src
    │       └── libB.cpp
    └── programA
        └── src
            └── main.cpp

L'approche suggérée est de placer des fichiers CMakeLists.txt de la manière suivante :

myawesomeproject
├── CMakeLists.txt
└── libs
    ├── CMakeLists.txt
    ├── libA
    │   ├── CMakeLists.txt
    │   └── include
    │       └── libA.h
    ├── libB
    │   ├── CMakeLists.txt
    │   ├── include
    │   │   └── libB.h
    │   └── src
    │       └── libB.cpp
    └── programA
        ├── CMakeLists.txt
        └── src
            └── main.cpp

Contenu des fichiers CMakeLists.txt

Maintenant que nous avons notre structure de dossiers, que mettons-nous dans nos fichiers ?

Les sous-doussiers

CMake possède principalement deux façons de traiter un projet avec plusieurs dossiers, les commandes add_subdirectory et include.

Si vous utilisez add_subdirectory, vous changez la portée des variables, alors qu'avec include les variables seront déclarées comme étant dans la portée courante.
Les deux sont pertinentes selon le contexte. Nous conseillons d'utiliser add_subdirectory par défaut. À noter que include peut également être utilisée dans lors de la création de modules CMake, que nous aborderons dans un article futur.

En appelant add_subdirectory(dir), CMake va ajouter dir à la compilation en ouvrant le fichier dir/CMakeLists.txt.

Dans certains cas, vous voudrez rajouter des dossiers qui ne seront pas compilés par défaut, sauf si demandé, que ce soit par l'utilisateur ou par dépendance d'une cible. Dans ce cas vous pouvez rajouter le paramètre EXCLUDE_FROM_ALL. Celui-ci indique à CMake que les cibles de ce sous-répertoire ne seront pas rajoutées à la liste des dépendances de la cible all (pensez à make all) ni dans les IDEs. eg add_subdirectory( dossierAvecCiblesOptionelles EXCLUDE_FROM_ALL ).

Exemples

Il est également nécessaire d'appeler add_subdirectory dans un ordre correct si vous voulez avoir des dépendances entre différents sous-dossiers.

Un exemple de contenu minimaliste de ces fichiers:

./CMakeLists.txt

cmake_minimum_required(VERSION 3.14)
project(myawesomeproject VERSION 0.10 LANGUAGES C CXX)

add_subdirectory(libs)

libs/CMakeLists.txt

add_subdirectory(libA)
add_subdirectory(libB) # After libA so that we can link it
add_subdirectory(programA) # After libB so that we can link it

libs/libA/CMakeLists.txt

add_library(libA INTERFACE)
target_include_directories(libA INTERFACE include/)

libs/libB/CMakeLists.txt

add_library(libB src/libB.cpp include/libB.h )
target_include_directories(libB PUBLIC include/)

# PRIVATE so that libA doesn't leak into programA
target_link_libraries(libB PRIVATE libA)

libs/programA/CMakeLists.txt

add_executable(programA src/main.cpp)
target_link_libraries(programA PRIVATE libB)

Et ensuite ?

Cet article couvre les bases du CMake moderne, et devrait suffire pour un bon départ.

Certains sujets ont été délibérément ignorés tels que les configurations de compilation, les variables, les fichiers de toolchain, les modules ou encore le packaging. Ces sujets sont moins simples qu'ils ne le laissent paraître.

Dans le prochain article nous irons un peu plus en profondeur. Nous traiterons des variables, des configurations de compilation et d'expressions de générateurs.

Pour ceux en quête de savoir et dans l'attente du prochain article, nous vous recommandons de jeter un coup d'œil à ce projet: C++ boilerplate. Il s'agit d'un exemple concret C++ intégrant un ensemble de bonnes pratiques du CMake moderne et du développement en général.

Et bien sûr, nous n'oublions pas la documentation officielle de CMake.


Clément Grégoire