Managing external dependencies with CMake
CMake is a de facto standard build tool for C++. There are those who love it, and there are those who hate it. The tool had a few problems in the past, but Modern CMake solved most of them and continued to evolve and add more and more features.
Watch Effective CMake talk by Daniel Pfeifer and Using Modern CMake Patterns to Enforce a Good Modular Design by Mathieu Ropert to get an idea and hints on modern CMake.
In this blog post, I describe the approach I use to manage external dependencies in C++ projects.
The dependencies have to have a valid CMake configuration, i.e. they have to export their targets as described in Daniel Pfeifer’s "Effective CMake" starting at 37:34, Henry Schreiner’s Modern CMake: Exporting and Installing, and It’s Time To Do CMake Right by Pablo Arias.
Typical project layout
Let’s review the layout of one of my C++ projects:
$ outpost-cpp git:(master) tree -L 2
.
├── build
├── cmake
│ ├── Catch.cmake
│ ├── CatchAddTests.cmake
│ └── ParseAndAddCatchTests.cmake
├── conker
│ ├── include
│ ├── src
│ └── CMakeLists.txt
├── extern
│ └── CMakeLists.txt
├── outpost
│ ├── assets
│ ├── include
│ ├── src
│ └── CMakeLists.txt
├── test
│ ├── src
│ └── CMakeLists.txt
├── CMakeLists.txt
└── Doxyfile
12 directories, 9 files
There are a few items in the root directory of the project:
Directory | Purpose |
---|---|
. |
Project root with the root |
build |
That’s where you do |
cmake |
Consists of custom CMake modules |
conker |
A subproject for a library |
extern |
External dependencies |
outpost |
A subproject for a main application |
test |
A subproject for unit-tests |
Describing dependencies
The extern/CMakeLists.txt
file is a central repository for the
dependencies. Make it available from the root CMakeLists.txt
:
add_subdirectory(extern)
Let’s take a closer look at the dependencies file:
cmake_minimum_required(VERSION 3.11 FATAL_ERROR)
include(FetchContent)
# ------------------------------------------------------------------------------
# A modern, C++-native, header-only, test framework for unit-tests,
# TDD and BDD - using C++11, C++14, C++17 and later
FetchContent_Declare(
extern_catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v2.2.3)
FetchContent_GetProperties(extern_catch2)
if(NOT extern_catch2_POPULATED)
FetchContent_Populate(extern_catch2)
add_subdirectory(
${extern_catch2_SOURCE_DIR}
${extern_catch2_BINARY_DIR}
EXCLUDE_FROM_ALL)
endif()
# ------------------------------------------------------------------------------
# A multi-platform library for OpenGL, OpenGL ES, Vulkan, window and
# input http://www.glfw.org/
FetchContent_Declare(
extern_glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git)
FetchContent_GetProperties(extern_glfw)
if(NOT extern_glfw_POPULATED)
FetchContent_Populate(extern_glfw)
add_subdirectory(
${extern_glfw_SOURCE_DIR}
${extern_glfw_BINARY_DIR}
EXCLUDE_FROM_ALL)
endif()
# ------------------------------------------------------------------------------
# https://devblogs.nvidia.com/linking-opengl-server-side-rendering/
find_package(
OpenGL REQUIRED
COMPONENTS OpenGL)
The script uses CMake’s FetchContent module which enables populating content at configure time. That’s how you describe a dependency:
FetchContent_Declare(
extern_catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v2.2.3)
FetchContent_GetProperties(extern_catch2)
if(NOT extern_catch2_POPULATED)
FetchContent_Populate(extern_catch2)
add_subdirectory(
${extern_catch2_SOURCE_DIR}
${extern_catch2_BINARY_DIR}
EXCLUDE_FROM_ALL)
endif()
In other words, you declare where and how to get the dependency first
and then check if it is already available. If it is not avialable yet,
then you fetch it and include into the build using the add_subdirectory
command.
Importing dependencies in subprojects
FetchContent
makes exported targets available to the build so that you can use
them in target_link_libraries
:
target_link_libraries(outpost
conker
glfw
)
Here, I added the glfw
as a public dependency to the outpost
target.
Another example:
target_link_libraries(conker_tests
PRIVATE Catch conker)
Hierarchical dependency management
There are few ways to organise dependency management.
Keep all dependencies in the same file
It’s good enough for simple projects that live in the same repository, and there are no plans to move subprojects outside of it. That’s what I described in the previous sections.
├── CMakeLists.txt
├── extern
│ └── CMakeLists.txt
├── subproject_a
│ └── CMakeLists.txt
├── subproject_b
│ └── CMakeLists.txt
└── subproject_c
└── CMakeLists.txt
Each subproject describes its dependencies
In this case, you make subprojects independent by adding their own
external/CMakeLists.txt
. Then you can extract any of them from the
source tree and build it without any further configuration if needed.
├── CMakeLists.txt
├── subproject_a
│ ├── extern
│ │ └── CMakeLists.txt
│ └── CMakeLists.txt
├── subproject_b
│ ├── extern
│ │ └── CMakeLists.txt
│ └── CMakeLists.txt
└── subproject_c
├── extern
│ └── CMakeLists.txt
└── CMakeLists.txt
Combination of the previous methods
You declare dependencies for each subproject in their own
external/CMakeLists.txt
. Then you declare (i.e. duplicate)
dependencies on one level higher than the subprojects. This is how you
override population of content specified anywhere lower in the project
hierarchy.
├── CMakeLists.txt
├── extern
│ └── CMakeLists.txt
├── subproject_a
│ ├── extern
│ │ └── CMakeLists.txt
│ └── CMakeLists.txt
├── subproject_b
│ ├── extern
│ │ └── CMakeLists.txt
│ └── CMakeLists.txt
└── subproject_c
├── extern
│ └── CMakeLists.txt
└── CMakeLists.txt
The end
I hope this helps.