Reusing projects with CMake
- Disclaimer
- What does
find_package
do? - How to make
core_project
available todependent_project
- How to use the installed package
- Summary
We talked about CMake before, why we use it, what it is and how to configure it to build various libraries and binaries. However, until now we targeted the development of a single project. If we want to develop multiple interdependent projects or make our CMake project reusable by others what we know so far is not enough.
One way of doing this within the CMake world is to install
a project and find it using the find_package
command from another CMake project.
As an example, some dependent_project
might have an executable print_hello
, implemented in a print_hello.cpp
file that uses a library from another core_project
by including its header and calling a function core_project::PrintHello()
from it:
dependent_project/print_hello.cpp
#include "core_project/some_library.hpp"
int main() {
core_project::PrintHello();
return 0;
}
To make this work we can use an already-installed core_project
by calling find_package(core_project REQUIRED)
from the CMakeLists.txt
file of the dependent_project
:
dependent_project/CMakeLists.txt
cmake_minimum_required(VERSION 3.16..3.24)
project(dependent_project
VERSION 0.0.1
DESCRIPTION "Sample project that uses the core project"
LANGUAGES CXX)
# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE INTERNAL "Build type")
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")
find_package(core_project REQUIRED)
add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)
After the find_package
is called, the print_hello
executable can use the functionality from some_library
which is part of the core_project
.
Note how we do not explicitly create the some_library
before linking it to our new executable print_hello
as it gets exported from the previously-installed core_project
when we call the find_package
command. The dependent_project
builds when we call the commands very similar to those we already know, with a tiny addition of providing CMake with a CMAKE_PREFIX_PATH
variable that points to the /tmp/install
folder where, in this example, we chose to install the core_project
:
# Note that -B flag requires at least CMake version 3.13
cmake -DCMAKE_PREFIX_PATH="/tmp/install" -S . -B build
cmake --build build -j 12
And as a final proof, we can of course run our executable print_hello
to get a "Hello World!" message printed to the terminal:
./build/print_hello
Hello World!
So let's dive in and see how all of this works!
This is quite a nuanced topic and I'd like to start with a disclaimer that there are many ways to structure CMake projects and even more ways to make these projects talk to each other. My aim for today is to show the concept here and not to cover all the possible details.
As a result, I show just one way that I grew to like. I'm sure there are other ways equally good if not better to achieve very similar behavior and I am always super interested to know what works for you!
That being said, I believe that the concepts we're covering here are more of less universal and can be applied to any concrete CMake project design with minor adaptations.
With that out of the way, we can start our deep dive by understanding what the find_package
does under the hood. And, just as in all the other topics we've already covered, there is of course no black magic here.
It is logical that in order to use the core_project::some_library
target find_package
must somehow provide us with it. What this really means is that the find_package
command must somehow include
all the CMake code that creates the needed core_project::some_library
target as well as sets the required compiler and linker flags for it.
For now let us assume that core_project
has already been installed into /tmp/install
folder and try to understand all of the steps that find_package
takes to allow us to use the core_project
's targets. We'll get to how it was installed there in the second half of this tutorial.
If we read CMake docs, there are two modes in which find_package
operates: the module mode and config mode.
By default when we call find_package(core_project)
in our CMakeLists.txt
, CMake first tries to use the module mode and looks for a file Findcore_project.cmake
in the path stored under the CMAKE_MODULE_PATH
CMake variable as well as among the default modules provided with the CMake installation. Once found, CMake include
s that file, which, in CMake world, means that it executes all the code from that file.
If CMake is unable to find the required Find*.cmake
file, or if we explicitly provide CONFIG
or NO_MODULE
as part of the find_package
signature, it switches to the config mode instead.
find_package(core_project CONFIG REQUIRED)
🚨 We must note that while module mode is the default one, in modern CMake we want to use config mode most of the time.
So what makes config mode better? On the surface config mode works in a very similar manner to the module mode.
For a call find_package(core_project CONFIG)
or find_package(core_project NO_MODULE)
CMake looks for a file core_projectConfig.cmake
or for core_project-config.cmake
using quite a comprehensive search procedure starting with the path specified in the CMAKE_PREFIX_PATH
CMake variable. Note that the names of these files are case sensitive and in the second option core_project-config.cmake
the name of the project is translated into lowercase, which, in our case does not make any difference but does play a role should your project name have capital letters.
However, the similarities end on this surface level. The full command for config mode has many more settings than the module mode has, and, even more importantly, it is possible to generate the needed config files almost completely automatically. This was not the case in the module mode, where most of the Find*.cmake
files historically had to be written by hand. As you might imagine, this makes the config mode setup much more maintainable and versatile. To the degree of my knowledge that's the main reason why we prefer it to the module mode.
So in today's tutorial, I'm going to focus exclusively on the config mode as a more modern way, but please let me know if you'd like to hear about the module mode too as it might still be useful to understand when working with legacy code.
Now that we know which files find_package
looks for and where it looks for them, we can take a peek inside these files.
As we've mentioned before, because we call find_package(core_project)
, we expect to find a file core_projectConfig.cmake
in a subfolder of the folder stored in the CMAKE_PREFIX_PATH
CMake variable. In our case, as our core_project
was installed into /tmp/install
folder, we can inspect that folder to find the file that matches our description in one of its subfolders: /tmp/install/share/cmake/core_project/core_projectConfig.cmake
. This file is not very large, and its most important part is that it includes another some_library_export.cmake
CMake file:
include ( "${CMAKE_CURRENT_LIST_DIR}/some_library_export.cmake" )
Looking around the same folder, we can easily find this some_library_export.cmake
file, which has a lot more auto-generated code in it. Today, we don't care about most of this code, and focus on the relevant parts only, starting with the creation of an IMPORTED
library:
# Create imported target core_project::some_library
add_library(core_project::some_library STATIC IMPORTED)
The keyword IMPORTED
simply indicates that the actual binary file for this library will be provided at a later point.
A bit lower within the same some_library_export.cmake
file, there is another relevant part of auto-generated code:
# Load information for each installed configuration.
get_filename_component(_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH)
file(GLOB CONFIG_FILES "${_DIR}/some_library_export-*.cmake")
foreach(f ${CONFIG_FILES})
include(${f})
endforeach()
Here, CMake is instructed to look for additional CMake files to include and if we look around the folder that contains the some_library_export.cmake
file we will find more files that match this pattern, for example some_library_export-release.cmake
. This name depends on the configuration we used during build, having a release
suffix in our case as we use the Release
configuration by default.
Looking into this new file, we can find the code that sets all the relevant properties of our IMPORTED
library target, including its language, its binary file location, and the location of its headers:
# Import target "core_project::some_library" for configuration "Release"
set_property(TARGET core_project::some_library APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE)
set_target_properties(core_project::some_library PROPERTIES
IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/libsome_library.a"
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include/"
)
❓ Actually, I have a couple of questions to you here: ❓
- Which library kind is
core_project::some_library
?- How do you think this code would change if the library would be a header-only one?
The rest of the code in these files is not as relevant to us as it mostly performs some checks so that CMake is sure that all the necessary files are actually present on the system. But by all means, feel free to read into it when you have the time.
So, you see, we've found the commands that create the targets as well as those that set their relevant properties.
As a final piece of the puzzle, if we examine the lib
and include
folders in our CMAKE_PREFIX_PATH
, we'll eventually find the needed binary and header files /tmp/install/lib/libsome_library.a
and /tmp/install/include/core_project/some_library.hpp
.
We have now traced the full path that CMake takes in order for us to be able to use targets from another project. To recap, we start with a call to find_package
, which looks for a *Config.cmake
file, that includes the necessary export
files that point towards the headers and relevant binary files:
graph TD
find[<code>find_package#40;core_project#41;</code>] --> configs[Config file:<br><code>.../core_project/core_projectConfig.cmake</code>] --> export[Export files:<br><code>.../core_project/some_library_export.cmake</code><br><code>.../core_project/some_library_export#8722;release.cmake</code>] --> headers[Binary and header files:<br><code>.../lib/libsome_library.a</code><br><code>.../include/core_project/some_library.hpp</code>]
So far so good. We now understand how a library within a dependent_project
is able to use the library from the core_project
after a find_package
call. Now is a great time to figure out how these export files are actually created as well as how the header files and all the binary files land into the install folder in the first place.
We'll actually follow the diagram that we've just introduced, but in reverse, starting at the bottom and making it all the way up to its top.
Before we learn how to to install the project we will have to define it so that this example does not get too abstract. We've already done very similar things before, so feel free to refer to the CMake tutorial if we rush too fast over some details here.
Let's start with the C++ code. For the sake of our example, our some_library
has a single function PrintHello
that is declared in a header file some_library.hpp
:
core_project/core_project/some_library.hpp
:
#pragma once
namespace core_project {
void PrintHello();
} // namespace core_project
With the implementation of this function, that just prints "Hello, world!" to the terminal, living in the corresponding source file some_library.cpp
:
core_project/core_project/some_library.cpp
#include "core_project/some_library.hpp"
#include <iostream>
namespace core_project {
void PrintHello() {
std::cout << "Hello, world!" << std::endl;
}
} // namespace core_project
And just to test that our library actually works, let's also add an example executable to print_hello.cpp
file that includes some_library.hpp
and calls it:
core_project/examples/print_hello.cpp
:
#include "core_project/some_library.hpp"
int main() {
core_project::PrintHello();
return 0;
}
The CMake part of the core_project
is pretty standard too - it declares a new project, chooses build type to be Release
, if the user did not provide one, and delegates the target creation to other CMakeLists.txt
files within the core_project
and examples
folders:
core_project/CMakeLists.txt
:
cmake_minimum_required(VERSION 3.16..3.24)
project(core_project
VERSION 0.0.1
DESCRIPTION "Project to illustrate installation"
LANGUAGES CXX)
# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")
add_subdirectory(core_project)
add_subdirectory(examples)
These other CMakeLists.txt
files are, again, nothing special and we've seen their kind before.
In the core_project/CMakeLists.txt
we use a LIBRARY_NAME
CMake variable for convenience, create our library target, set its C++ standard to C++17, indicate to it and its descendants where to look for the includes, and, finally, create an alias for this library that is prefixed by the ${PROJECT_NAME}
. Strictly speaking, this alias is not required but it is a good practice to use the library in the same way from within the project as well as in other projects. We will see why a bit later.
core_project/core_project/CMakeLists.txt
:
set(LIBRARY_NAME some_library)
add_library(${LIBRARY_NAME} ${LIBRARY_NAME}.cpp)
target_compile_features(${LIBRARY_NAME} PUBLIC cxx_std_17)
target_include_directories(${LIBRARY_NAME} PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}>
$<INSTALL_INTERFACE:include/>
)
add_library(${PROJECT_NAME}::${LIBRARY_NAME} ALIAS ${LIBRARY_NAME})
As for the examples/CMakeLists.txt
, it creates an executable print_hello
and links it to the core_project::some_library
target:
core_project/examples/CMakeLists.txt
:
add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)
For now we have done nothing new, we've already done very similar things before in the previous lectures on CMake. So we already know that we can easily configure and build our project with CMake as follows:
# Note that -B flag was introduced in CMake version 3.13
cmake -S . -B build
cmake --build build -j 12
Once it builds, we can run the example binaries without any issues:
./build/examples/print_hello
Hello world!
But we are still unable to use this library from our dependent_project
aren't we? All of the special export
and Config
files are missing!
💡 We say that we install a CMake package when we make CMake create the config and export files as well as copy all the headers and binaries into the needed folders where they can be found by other projects.
And for now our core_project
does not know how to install itself!
This installation process usually involves a couple of steps. It must:
- Copy all the needed headers into some
include
folder - Copy all of the binary libraries into a
lib
folder and all executables into abin
folder (typically) - Create the appropriate
*_export.cmake
files that create and configure the imported libraries - Create the
*Config.cmake
file that includes all of the needed export files
And there is a special CMake command that we can call to kick-off all of these steps: cmake --install
, which also gets an optional --prefix
flag that sets the folder into which this package will be installed. We call this command after configuring and building the project:
# Note that --install flag was introduced in CMake version 3.15
cmake -S . -B build # Configure.
cmake --build build -j 12 # Build.
cmake --install build --prefix /tmp/install # Install.
Here, we aim to install our package into some temporary install folder, but a more standard location, like /usr/local
can be used if the --prefix
flag is omitted. Note that in such a case we have to call this command with sudo
rights.
💡 However, if we call the install command for our package now, nothing happens!
To actually perform all of the actions we've just talked about we need to write some more CMake code. But fear not, there is a number of different built-in CMake install
commands that help us in most of these steps.
Let's look at all of these steps in detail.
Our first step is to copy the headers over. To this end, we add the following code to the end of core_project/core_project/CMakeLists.txt
file:
install(
DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DESTINATION include # Must appear before FILES_MATCHING!
FILES_MATCHING PATTERN "*.hpp"
)
This install
command finds all the *.hpp
files in the folder specified under DIRECTORY
, in our case core_project/core_project
and copies that directory and all the matching files into the include
folder, as the DESTINATION
option specifies. If you also need to copy other header files, like *.h
for example, you can always adapt the FILES_MATCHING PATTERN
.
If we try installing our package after adding this CMake code to our project we'll be able to find the only header of our core_project
installed into the expected location: /tmp/install/include/core_project/some_library.hpp
:
cmake -S . -B build # Configure.
cmake --build build -j 12 # Build.
cmake --install build --prefix /tmp/install # Install.
/tmp/install
└── include
└── core_project
└── some_library.hpp
We only have one header for now but I hope it is easy to imagine that we can do this for any number of headers and in any number of folders. I prefer to add these install statements on a per-folder basis. This way we do not have to add too much code while keeping the folder structure intact. That being said, I've also seen people adding a single similar install
statement to the top-most CMakeLists.txt
file of the project instead with minor modifications.
Now that the headers are all installed, we need to do the same for all the binary files. We do this by adding another install
command to core_project/core_project/CMakeLists.txt
:
install(TARGETS ${LIBRARY_NAME})
As well as one more to core_project/examples/CMakeLists.txt
for installing our example binaries:
install(TARGETS print_hello)
This will automatically install all executables to a bin
folder and all of the binary libraries to a lib
folder.
After running the install command again, we'll see that we now also have all of the binaries installed:
λ › tree /tmp/install
/tmp/install
├── bin
│ └── print_hello
├── include
│ └── core_project
│ └── some_library.hpp
└── lib
└── libsome_library.a
With this, all of our binary and header files are where we want them to be. But we still lack the *_export.cmake
files to tell CMake where to find them!
One classical way to do create these export files is to change the install(TARGETS ...)
commands slightly for each of our library targets (one in our case) by adding an EXPORT
entry to them:
install(
TARGETS ${LIBRARY_NAME}
EXPORT ${LIBRARY_NAME}_export # <-- this one
)
Now this command not only installs the binary library file into the lib
folder but it also associates it with an *_export.cmake
file.
But this command alone is not enough for us as it does not actually copy the created _export
file to the install folder, we can see it by running install again and not finding the _export
file in the install folder. However, we do find it in the build folder!
So what remains is to copy this already generated file to the install folder, which we can do by using yet another install
command that copies our export file to the /tmp/install/share/cmake/core_project
folder:
install(EXPORT ${LIBRARY_NAME}_export
FILE ${LIBRARY_NAME}_export.cmake
NAMESPACE ${PROJECT_NAME}::
DESTINATION share/cmake/${PROJECT_NAME}
)
Running our install procedure again with all of this new code, we'll be able to see this file where we expect to find it within the install folder tree:
λ › tree /tmp/install
/tmp/install
├── bin
│ └── print_hello
├── include
│ └── core_project
│ └── some_library.hpp
├── share
│ └── cmake
│ └── core_project
│ ├── some_library_export-release.cmake
│ └── some_library_export.cmake
└── lib
└── libsome_library.a
Note also the NAMESPACE
option that adds the project prefix to all targets that we export within the export files. Super handy to disambiguate our targets from any other ones. Also, this matches our own ALIAS
that we created before, keeping everything nice and consistent.
Finally, with the export files in-place we only need to create the missing config files so that find_package
can find all the export files we've just created. This process is a bit convoluted and involves a couple of steps.
To create the core_projectConfig.cmake
file we typically call configure_package_config_file
from the CMakePackageConfigHelpers
that we include into our main CMakeLists.txt
file:
include(CMakePackageConfigHelpers)
# Create a config file that CMake looks for when we call FindPackage(core_project)
configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
INSTALL_DESTINATION "share/cmake/${PROJECT_NAME}"
)
For this to work we need to create a "template file" that, in its simplest form includes all the export files we created before:
${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in
@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/some_library_export.cmake")
check_required_components(core_project)
We can specify all of the export files by hand, like we just did, but we can also use a glob expression that matches any file that ends in *_export.cmake
:
${CMAKE_CURRENT_SOURCE_DIR}/cmake/Config.cmake.in
@PACKAGE_INIT@
# Automatically include all exported library files
file(GLOB EXPORT_FILES "${CMAKE_CURRENT_LIST_DIR}/*_export.cmake")
foreach(EXPORT_FILE ${EXPORT_FILES})
include(${EXPORT_FILE})
endforeach()
check_required_components(core_project)
Passing this cmake.in
file into configure_package_config_file
we create a ${PROJECT_NAME}Config.cmake
in the current build folder. Note that this file is still not "installed", i.e., it is not present in the install folder just yet!
Before we install it though, we'll also need to generate another very similar file ${PROJECT_NAME}ConfigVersion.cmake
by calling the write_basic_package_version_file
command:
# Create a versioned config file that CMake uses to compare version of the package.
write_basic_package_version_file(
${PROJECT_NAME}ConfigVersion.cmake
VERSION ${PACKAGE_VERSION}
COMPATIBILITY AnyNewerVersion
)
This file is needed if we want to specify a version of our package. In our example we choose a version to match our project version as well as what the compatibility of this version is. This plays a role when we specify a version within the find_package(core_project 1.0)
command. We will not go into more details here but I'm sure after this tutorial everyone should be able to look it up in the docs.
Finally, our last step is to copy the just-generated config files into the install folder, which we can do with yet another install(FILES ...)
command, providing all of our config files in the build folder into it and, as before, specifying their install destination:
# Copy these files into the install directory.
install(
FILES
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake
${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake
DESTINATION
share/cmake/${PROJECT_NAME}
)
And now we're done with installing! If we run cmake --install
command again and look at how the install
folder tree looks like, it will contain all of our headers, all our binary files as well as the export and config files needed to configure all of the targets, should someone look for them.
One final thing to do now is to add a find_package
call to our dependent_project
of course. We've already seen this at the start of this tutorial:
cmake_minimum_required(VERSION 3.16..3.24)
project(dependent_project
VERSION 0.0.1
DESCRIPTION "Sample project that uses the core project"
LANGUAGES CXX)
# Set CMAKE_BUILD_TYPE if user did not provide it.
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE INTERNAL "Build type")
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")
find_package(core_project REQUIRED CONFIG)
add_executable(print_hello print_hello.cpp)
target_link_libraries(print_hello PRIVATE core_project::some_library)
And, just as before, we can configure and build this project. Because we've installed our core_project
into a custom folder, /tmp/install
in our case, we need to tell CMake where to look for packages by providing it with a CMAKE_PREFIX_PATH
:
cd dependent_project
cmake -DCMAKE_PREFIX_PATH="/tmp/install" -S . -B build
cmake --build build -j 12
Note that we don't need to provide an explicit CMAKE_PREFIX_PATH
value if we installed our core_project
in a standard system-wide location, like /usr/local
.
Running these commands builds our dependent_project
without issues and we can run the binary that we've just built too:
./build/print_hello
Hello World!
Now we've fully closed the loop on this topic. We started by understanding how find_package
works under the hood - what files it looks for in which folders. We then went bottom up and learned how to configure a project to install all of these files ending with a fully working minimal two-project example that we can return to whenever we need to setup a more complex installation procedure.
As always, I believe that learning new things comes from doing, so I urge you to play around with these examples, which you can find on my GitHub as complete projects.
There is a number of questions I urge you to think about. How would you install more libraries than one? What changes in the generated export files if we make the library header-only? How to install dependent_project
too? If you are able to answer all of these questions, then, for my money, you understand this topic quite well!
The CMake installation process is not a thing cast in stone. It changes over the years and, just as we move to a more target-based build, we are probably also going to be moving away from what is presented here and towards a more target-based install. But as it stands now, I believe that what we've covered today is still the most popular way to install packages and can be found in most CMake projects in the wild with slight differences here and there, which, after this tutorial, should be easier to spot and understand.