CMake has followed the C++ standard on the road to modernization, which leads to simpler package and dependency management. As opposed to the old ways of doing CMake like setting CMAKE_CXX_FLAGS
directly, modern cmake introduces lots of facilities to handle dependencies more cleanly. But also like C++, CMake is a huge monster now which is very hard to tame. Although there are a few talks and tutorials about modern CMake on the Internet, I still find them hard to follow for the first time.
Here I’ll present a detailed explaination on how to use modern CMake, especially for library developers who want to package their libraries for downstream developers to use easily with CMake. Basic knowledge of CMake is preferred, as I’ll not cover too much on some basic commands.
Project Structure
The example code in this blog post is an simplified version of my project yart, stripping off the real files and 3rd-party dependencies. If you’d like to see a working example, you could try the code itself, there are only two external dependencies you need to install for it to work.
Now suppose we are building a library yart
, and the project is structured like bellow,
1 | - yart |
Let’s start with the parent level CMakeLists.txt
. Nothing interesting here, I’d like to use C++17 so CMake 3.10 is required. The project
command will create a YART
project as well as versioning variables including YART_VERSION
, YART_VERSION_MAJOR
, YART_VERSION_MINOR
and YART_VERSION_PATCH
.
1 | cmake_minimum_required(VERSION 3.10) |
A Song of Targets and Properties
Targets are the main objects CMake manipulate for building a project. Your library is a target, your executable is a target, and you’ll also meet some other types of targets when setting up the build system.
And each target has a set of properties attached to them, in an OO sense that they even have access control. You’ll soon notice that many commands in CMake has a signature similar to command(your-target [PUBLIC|INTERFACE|PRIVATE] properties)
. An INTERFACE
property means that a user will need to respect this property when depending on this target, while a PRIVATE
property means that this property is only used internally. And PUBLIC
means both.
Create a Target
Without further ado, let’s set up our library first in src/CMakeLists.txt
.
1 | add_library(yart |
You might have seen or have used file(GLOB ...)
before, please be advised that you should explicitly list the source files like the example above for the build system to automatically reconfigure CMake when you add a new source file.
1 | add_library(yart::yart ALIAS yart) |
This line enables you to use yart::yart
in the example
target, will see late
Configure the Target
We would like the users to choose whether to build a shared library or a static one,
1 | option(BUILD_SHARED_LIBS "Build shared library" ON) |
There’re a lot of interesting thing going on here. In the first command, BUILD_SHARED_LIBS
is read by CMake to switch between static and shared library, and a user could alter this option in cache.
Well, the generate_export_header
command creates a header file which helps switch between building shared and static libraries. And here is the generated common.h
file with msvc, and you should use these macros to export your library symbols like void YART_API my_api_fcn();
1 |
|
The pattern $<:>
you see earlier is generator-expressions which works just like if
statement but could be compactly inserted into other cmake commands like this target_compile_definitions
command. Notice the PUBLIC
keyword here, it says that this definition is a public property of yart
.
And now, we’d like to configure the compiler options,
1 | target_compile_features(yart PUBLIC cxx_std_17) |
With target_compile_features
, you could directly demand a language standard version like I did, or you could require specific c++ features like target_compile_feature(yart PUBLIC cxx_const_expr)
. The second command specify compile options depending on the compilers and build type, again with generator-expressions.
1 | target_include_directories(yart |
The target_include_directories
command set up the include directories of yart
. Public api is located in $<CMAKE_SOURCE_DIR>/include/
, as well as the generated common.h
file, and the private header file is in the same directory as $<CMAKE_CURRENT_SOURCE_DIR>
. Notice that $<INSTALL_INTERFACE:include>
is needed for users to find yart
headers after installing yart
onto their system. The include
directory is a relative path to ${CMAKE_INSTALL_PREFIX}
which is often /usr/local
on Linux and C:\Program Files
on Windows.
And we would also like to organize the build tree a bit and configure where to output generated binaries,
1 | set_target_properties(yart PROPERTIES |
*.lib will go to ARCHIVE_OUTPUT_DIRECTORY
, *.so will go to LIBRARY_OUTPUT_DIRECTORY
, and *.dll will go to RUNTIME_OUTPUT_DIRECTORY
.
Handle 3rd Party Dependencies
Suppose we have two 3rd-party dependencies,
1 | find_package(libmodern REQRUIED) |
Since libmodern
is written with modern CMake as well, we could simply do,
1 | target_link_libraries(yart PUBLIC libmodern::libmodern) |
And that’s it, modern CMake could handle target dependencies transitively, which means that you could forget about the messy variables and every property needed to use libmodern
is handled correctly.
On the other hand, liblegacy
is too old for this, so you have to switch to the old method,
1 | target_include_directories(yart PUBLIC $<BUILD_INTERFACE:${LIBLEGACY_INCLUDE_DIRS}>) |
And be alert that the public include requirement is not handled transitively so that yart
users won’t know anything about it, yet. We’ll fix that later on. Notice you could also write a FindLibLegacy.cmake
file for it and handle all sorts of usage requirements there, and finally export only a liblegacy::liblegacy
target. You can find a decent example here.
Install and Export the Target
Everything should be able to compile by now. But the library is not readily available for other developers to use right now, after they hit make install
and write find_package(yart)
in their CMakeLists.txt
.
First, we need to ensure everything is installed to the correct places on system.
1 | include(GNUInstallDirs) |
GUNInstallDirs
is a cross-platform solution to install directories. And we issue the install
command to install our files. It has several signatures, in the above block are install(TARGETS yart ...)
, which installs the compiled library, and install(DIRECTORY ...)
, which installs the include directory to the right place. Note EXPORT yart-targets
line also exports this target to be used later, and the INCLUDE DESTINATION ${LIBLEGACY_INCLUDE_DIRS}
line injects the include dependencies into yart-targets
so that our dependency problem mentioned above is solved here.
1 | install(EXPORT yart-targets |
Talking about EXPORT
, installing an EXPORT
target will generate a yart-targets.cmake
file which contains essential commands to build with yart
correctly, like bellow,
1 | # Create imported target yart::yart |
What’s more, the find_package
command expects a FindYART.cmake
file or a yart-config.cmake
(YARTConfig.cmake
) to find this library. The first method is an old way to hell so we definitely want the second one,
1 | include(CMakePackageConfigHelpers) |
The configure_package_config_file
command reads an scaffolding yart-config.cmake.in
file and creates a yart-config.cmake
file with the right paths. And let’s see what the .in file looks like,
1 | @PACKAGE_INIT@ |
Quite simple right? You need to call @PACKAGE_INIT@
first, find dependencies and finally include the generated yart-targets.cmake
file.
A version file is also preferred by users and could be created easily with write_basic_package_version_file
. Notice that these files are generated in your build tree (${CMAKE_BINARY_DIR}
) and you need to install them into your system. The install directory is ${CMAKE_INSTALL_LIBDIR}/cmake/yart
which is the default search directory of find_package
.
One last step, remember that we also have another example
alongside this library? We need to call the export
command,
1 | export(EXPORT yart-targets |
So that example
could refer to yart
without finding the package.
Use the Target
example/CMakeLists.txt
couldn’t be more simple,
1 | add_executable(yart-example |
While using yart
from an outside project requires one more magic touch,
1 | find_package(yart) |
Conclusion
That’s it folks. It’s still quite tricky to do everything right, but I’ve covered the most common use cases. If you still find it hard to wrap your head around it, just remember your library is a target and its build and usage requirements are properties set with target_xxx
commands. Other exported targets and config files are auxillary infrastructures to help down stream developers to use your library easily.
For other bits and pieces, please refer to the document, which is not very intuitive unfortunately. You might also want to refer to
- Effective CMake Talk
- It’s time to Do CMake Right
- Eigen, which shows a good example on how to do CMake right
- The official wiki, which contains some explanations not showing in the documentation