Dominik Berner

C++ Coder, Agilist, Rock Climber


Project maintained by bernedom

Cmake line by line - creating a header-only library

Cmake line by line - creating a header-only library

Cmake can be hard to figure out. I love cmake, but unfortunately its documentation is more focused on completeness than on providing hands-on-examples. Since I found it hard to find a comprehensive example of how a header only library can be set up, I decided to provide an example of a CMakeLists.txt file for such a library here and analyze it line by line. The example is taken from SI, a header only library that provides strongly typed physical units. In order to keep the cmake file as small as possible, few possible optimizations are omitted. Notably I stripped any information relating to testing out of the project.

Using a header only library

The usage of our header only library should be as simple as calling find_package and then using target_link_library on it. See here for a full example

Overview

In order to make the interface library usable, the following things have to be done.

  1. Set up the cmake project
  2. Define the library to be built as a header only library and adding files
  3. Define installation properties
  4. Specify which files to copy into the installation directory
For a quick overview: Click here to expand the full CMakeLists.txt
cmake_minimum_required(VERSION 3.12)

project(
  "SI"
  VERSION 1.0.1
    DESCRIPTION
    "A header only c++ library that provides type safety and user defined literals for handling pyhsical values defined in the International System of Units."
    HOMEPAGE_URL  "https://github.com/bernedom/SI")

include(GNUInstallDirs)

add_library(${PROJECT_NAME} INTERFACE)

# Adding the install interface generator expression makes sure that the include
# files are installed to the proper location (provided by GNUInstallDirs)
target_include_directories(
  ${PROJECT_NAME}
  INTERFACE $<BUILD_INTERFACE:${${PROJECT_NAME}_SOURCE_DIR}/include>
            $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)

target_compile_features(${PROJECT_NAME} INTERFACE cxx_std_17)

enable_testing()
add_subdirectory(test)

# locations are provided by GNUInstallDirs
install(TARGETS ${PROJECT_NAME}
        EXPORT ${PROJECT_NAME}_Targets
        ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
        LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
        RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})

include(CMakePackageConfigHelpers)
write_basic_package_version_file("${PROJECT_NAME}ConfigVersion.cmake"
                                 VERSION ${PROJECT_VERSION}
                                 COMPATIBILITY SameMajorVersion)

configure_package_config_file(
  "${PROJECT_SOURCE_DIR}/cmake/${PROJECT_NAME}Config.cmake.in"
  "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
  INSTALL_DESTINATION
  ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

install(EXPORT ${PROJECT_NAME}_Targets
        FILE ${PROJECT_NAME}Targets.cmake
        NAMESPACE ${PROJECT_NAME}::
        DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

install(FILES "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
              "${PROJECT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
        DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

install(DIRECTORY ${PROJECT_SOURCE_DIR}/include/SI DESTINATION include)

Setting up the project

The first line of all cmake files is the minimum required version. Here version 3.12 Generally I try to pick the lowest version number that supports all the features I’m using. As SI is using C++17 features, I chose 3.12 because it supplies the necessary keywords and allows to define targets without specifying the source files up front.

cmake_minimum_required(VERSION 3.12)

The next line denotes the name of the project "SI" in our case and the version (1.0.1) of the package. Generally usage of semantic versioning is recommended. Then there is a package description and the link to the project homepage, which is the github repo in this case.

project("SI" VERSION 1.0.1
             DESCRIPTION "A header only c++ library that provides type safety and user defined literals for handling pyhsical values defined in the International System of Units."
             HOMEPAGE_URL "https://github.com/bernedom/SI")

Since this library will be plattform independent but still be installable following best practices, before we wet anything up, we include GNUInstallDirs (included in cmake) which will provide us with a set of variables containing installation directories for various artifacts. The variables will be used later.

include(GNUInstallDirs)

Defining how to “build” the header-only library

add_library tells cmake that we want to build a library and to set up the logical target. Good practice is to use the project name as a variable ${PROJECT_NAME} as the name for the library/target for consistency reasons. The target-name is important to remember, as all further options for building and installing are tied to it. The keyword INTERFACE makes our target a header only library that does not need to be compiled.

add_library(${PROJECT_NAME} INTERFACE)

So far the target of the library is set up, but it does not contain any files yet. target_inlcude_directories lets us add them. The first parameter ${PROJECT_NAME} is again the variable containing or project name “SI”, next the keyword INTERFACE tells cmake that the files are to be exposed in the library interface, which means they are publicly visible when using the library. What follows is a list of include directories wrapped in generator expressions. The Generator expressions are evaluated at the time when the build system is generated and allow to have different values for when the library is used directly over cmake or when it is installed.

$<BUILD_INTERFACE:${${PROJECT_NAME}_SOURCE_DIR}/include> tells cmake that if the library is used directly by another cmake target (such as when building tests for the library or when it is included as a sub directory), then the include path is ${PROJECT_SOURCE_DIR}/include} which is a nested variable. ${${PROJECT_NAME}_SOURCE_DIR} contains an automatically generated variable which points to the directory in which the CMakeLists.txt lies that contains the project() call. This expands to /directory/where/CmakeList.txt/is/include

$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}> defines the path if the project is installed. The paths are relative to the install-root chosen when installing projects. The variable CMAKE_INSTALL_INCLUDEDIR is provided by the GNUInstallDirs package included above. The target path for installation can be set by setting the CMAKE_INSTALL_PREFIX variable.

target_include_directories(
  ${PROJECT_NAME}
  INTERFACE $<BUILD_INTERFACE:${${PROJECT_NAME}_SOURCE_DIR}/include>
            $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)

As the code of the SI-library uses some features of the C++17 standard, this dependency is passed along using target_compile_features. This specifies compiler features to be enabled in a compiler-agnostic way. Again the target is the by now familiar ${PROJECT_NAME} and the keyword INTERFACE again marks this feature to be exposed and required when using the library. cxx_std_17 is the catch-all feature to enable the whole C++17 standard, here individual features such as use of constexpr or auto could also be specified.

If a compiler does not support the specified feature, building the library will fail.

target_compile_features(${PROJECT_NAME} INTERFACE cxx_std_17)

Installation instructions

By this point the project itself is set up an can be built locally. However it cannot yet be installed to the system. For this we specify where to install by using the install command.

First cmake needs to know which targets to install TARGETS ${PROJECT_NAME}, again using the variable ${PROJECT_NAME} to the target to install. TheEXPORT associates the installation with an export-form named SI_Targets, built again using the variable. This export is defined later in an install(EXPORT...) call.

Next the folders for the installation-artifacts are set. The folder names are relative to what the user defines in the CMAKE_INSTALL_PREFIX variable.

  • ARCHIVE - All files that are neither executables, shared libraries (.so), header files or executables.
  • LIBRARY - All shared libraries (.so/.dll) files, tyically all binaries produced by a add_library call
  • RUNTIME - All executables built by an add_excutable call
  • INCLUDE - All public header files, which is omitted here because the INSTALL_INTERFACEalready specifies this.

The target paths are again provided by GNUInstallDirs

install(TARGETS ${PROJECT_NAME}
        EXPORT ${PROJECT_NAME}_Targets
        ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
        LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
        RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}))

The next two lines are a shortcut to avoid having to write the boilerplate cmake code to manage version comparison. First the cmake package containing the macros parsing versions is included. This is deliverd with the standard cmake installations since 3.5. Then the macro write_basic_package_version_file is called and instructed to create a file SIConfigVersion.cmake. The version specified is the one supplied in the project dierective at the beginning of the file and since semantic versioning is used versions of the same major digit are considerd compatible.

include(CMakePackageConfigHelpers)
write_basic_package_version_file("${PROJECT_NAME}ConfigVersion.cmake"
                                 VERSION ${PROJECT_VERSION}
                                 COMPATIBILITY SameMajorVersion)

After setting up the version, another cmake macro configure_package_config_file is called, to generate the configuration file which cmake uses for using an installed package.

The content of the input file SIconfig.cmake.in (It’s quite simple).
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake")
check_required_components("@PROJECT_NAME@")

All the placeholders marked with @ in the input file are replaced with the explicit values and the file is written to the target .cmake file. By specifying INSTALL_DESTINATION cmake is told where to place it when creating the installation artifact. Usually these go to /usr/share or similar, but since the exact path is provided by GNUInstallDirs we do not have to care.

configure_package_config_file(
  "${PROJECT_SOURCE_DIR}/cmake/${PROJECT_NAME}Config.cmake.in"
  "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
  INSTALL_DESTINATION
  ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

Selecting targets and files to install

After setting up the configuration for installing the library, the files to be installed can be passed to cmake by using the install function.

First the file containing the installation-targets as defined above, is created and copied to the installation folder. The EXPORT keyword at the beginning tells cmake to export the installation targets which are defined in the file SITargets.cmake which is created in the build folder when building the project to the DESTINATION specified. All targets are to be placed in the NAMESPACE SI::.

install(EXPORT ${PROJECT_NAME}_Targets
        FILE ${PROJECT_NAME}Targets.cmake
        NAMESPACE ${PROJECT_NAME}::
        DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

The created .cmake files containing the build configuration and informartion about version compatibilty are to be installed to the install folder as well. By providing the FILES keyword a list of files is given to be saved in a folder specified by DESTINATION.

install(FILES "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
              "${PROJECT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
        DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/${PROJECT_NAME}/cmake)

Finally the header files are copied to the installation folder. For header only libraries ususally all header files are supplied, so instead of providing individual files the whole include directory is copied using the DIRECTORY keyword.

install(DIRECTORY ${PROJECT_SOURCE_DIR}/include/SI DESTINATION include)

Installation and usage

After setting all up the library can be built and installed like this:

cmake .. -DCMAKE_INSTALL_PREFIX:PATH=/your/installation/path
cmake --build . --config Release --target install -- -j $(nproc)

For using it, use:

project("SI-Example")

find_package(SI CONFIG REQUIRED)

add_executable(${PROJECT_NAME} src/main.cc)
target_link_libraries(${PROJECT_NAME} SI::SI)

the cmake file of the SI library at the time of writing

Written on June 24, 2019