Creating Reusable Libraries with Cmake
Continuing our series on building with CMake, in this installment we’re taking a look at how to create a library that others can use for their own applications. (If you missed the earlier installments, part 1 shows how to make a simple application, while part 2 explains how to find libraries to use in your application.)
When we create a library that others can use, we ensure the library is platform-agnostic and does not restrict the compiler or operating system. While these are important requirements, they do create a few issues – one being operating system differences and another being the differences between the compilers used on those systems.
Further, these requirements mean we’ll also have to ship additional things with our library, such as headers and a Findnumbers.cmake or numbersConfig.cmake so that others can use find_package to find our library once it has been installed.
And there are a few more details to worry about in order to ensure that our library works correctly if it’s installed or being included in source builds.
So, here we go…
Make Configuration Changes
When we create a library that we want to distribute we need to make sure that the library target is going to provide the required files so the dependent project can find, include, link and deploy the target. To achieve this, we must be sure to set up a basic CMakeLists.txt project and have a project call that initializes the VERSION and LANGUAGES options.
Split the List of Headers and Sources
Out-of-source libraries need to provide headers for projects to include. These must be installed on the system. The library author must split the list of sources into headers that will be shipped and source files that will not be shipped. I like to hold a separate list of each, like this:
set(numbers_src numbers.cpp)
set(numbers_headers numbers.h)
By organizing things this way, when we do our add_library call we can simply use the two lists since they will contain all of the items we need. We may also want to call a second add_library using ALIAS, like this:
add_library(numbers ${numbers_src} ${numbers_headers})
add_library(numbers::numbers ALIAS numbers)
The Alias call of add_libarary will let you use foo::foo as an alias when linking the library numbers. This is helpful when your project is going to provide several libraries since it allows you to create a group. For example, if you provide libnumbers and libbar they can later be linked with numbers::numbers and numbers::bar respectively.
Once we have created the target we can now set some of the properties.
set_target_properties(numbers PROPERTIES
PUBLIC_HEADER "${numbers_headers}"
)
Later, when we do the install for the target numbers we’ll set a PUBLIC_HEADER DESTINATION and `COMPONENT`.
Include Directories for Headers
One thing that’s a little tricky is the fact that the path of our installed libraries' headers differs from their path when building in source. This can create some build issues for those who are building against our libraries.
Fortunately, we can mitigate those issues. If we’re using target_include_directories in our library target, we can account for differences between the install and build environments that might prevent headers from being found when building your library or someone linking to it. To do so, we need to modify our header include paths to reflect this so our includes can always be found. We set versions for BUILD_INTERFACE and INSTALL_INTERFACE . The build_interface path will be used at build time, and the install_interface at install time.
If you want your clients to be able to include the library headers with #include <numbers.h>. For The BUILD_INTERFACE we use the ${CMAKE_CURRENT_SOURCE_DIR} and ${CMAKE_CURRENT_BINARY_DIR}. And for the install interface we use `include/numbers` Since this is the path we will install the headers into when the library is deployed.
target_include_directories(numbers PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
$<INSTALL_INTERFACE:include/numbers>
)
If your project has an include top-level directory, you would use this instead:
target_include_directories(numbers PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/include>
$<INSTALL_INTERFACE:include>
)
In this case you would use #include <numbers/numbers.h> to include your header.
Dealing with System Differences
Each operating system is unique. If we want to ensure that our project is built correctly on our target systems, care is needed to account for differences in OS. Here are some things to look out for.
File Types and Names
Windows, Linux and macOS each have their own naming conventions for libraries. Windows libraries are DLL files and they have a linking target with a LIB extension so headers are not needed. Linux uses SO for its library extension and headers are needed to build, while macOS uses DYLIB files. They can have external headers and symbols or FRAMEWORKS.
In addition, the names of libraries are libnumbers on Unix-like systems and numbers on Windows when building with msvc or libnumbers when using mingw. This problem is completely solved in places where we can use generator expressions. Generator Expressions are a kind of CMake variable that is replaced to configure time with information specific to that configuration.
You can see them easily in CMake files because they have a syntax that is unlike other variables taking the form of $<Foo:Bar>. We used one above to configure the include directories. The generator expression variables are a fixed set of commands. There are also several kinds. We’ll focus on the Target Based Expressions. They can work only with targets so you will need to have an add_executable or add_library call to create a target before you can use these expressions.
When we want to do things with a targets file we can use $<TARGET_FILE:numbers> to get the file for the target Bar or $<TARGET_BUNDLE_CONTENT_DIR:numbers> to get the path inside our bundle’s content directory (useful for MacOS). Anytime we need to do something with a target that does not allow us to use the target directly, a Target-based generator expression will be helpful to sort out the naming issues.
Symbol Exposure
We also need to decide what to export for symbols from our library. By default on Windows, all symbols are hidden when building with msvc. All symbols are visible on Linux with gcc.
To get started, we need to decide our Policy. Do we want to default to hidden or visible? We will follow what Microsoft does here and hide everything by default. In our main CMakeLists.txt we want to set two things:
set(CMAKE_CXX_VISIBLITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINE_HIDDEN YES)
This creates a new issue where we now need to define what is exposed. (Once again, the way to do this is different on each compiler.) We will now need to decide what to expose from our library. To expose from your library you will need to provide a hint for the compiler to let it know you want to expose the class or item. This again changes per compiler. Here are examples:
Linux/MacOS
__attribute__((visibility("default")))
MSVC
__declspec(dllexport) used when building
__declspec(dllimport) used when import a lib
Again, CMake provides us with a helpful macro to create these. To use it first we need to:
include(GenerateExportHeader)
After we’ve made a library target we can use:
generate_export_header(numbers)
This will generate a file in the ${CMAKE_CURRENT_BINARY_DIR} named numbers_export.h This file will contain several macros:
NUMBERS_EXPORT marks an item for exposure
NUMBERS_NO_EXPORT marks an item to not be exposed
NUMBERS_DEPRECATED marks an item deprecated
NUMBERS_DEPRECATED_EXPORT item is exposure and deprecated
NUMBERS_DEPRECATED_NO_EXPORT item is not exposed and deprecated
We need to include this header in the headers for the items we want to be exposed from the library. For example, in our numbers.h file we have to include the file and use the macro for the class.
#include <numbers_export.h>
class NUMBERS_EXPORT Number
{
.....
};
We also need to add this to our list of headers In our CmakeLists.txt:
set(numbers_headers numbers.h ${CMAKE_CURRENT_BINARY_DIR}/numbers_export.h)
The path ${CMAKE_CURRENT_BINARY_DIR} is used because by default this is the path in which the file will be generated.
Linking
When our targets have dependencies we have to link them. Since we are going to be creating a target that others will link to we need to take a moment to evaluate which of our linked items are needed to be linked when a consuming item uses your library. This will change our target_link_libraries call.
We must consider if things you are linking against are or are not required by anything that will be needed when using your library. Make all of the links that will be needed when something links your library PUBLIC. Any dependencies needed by the library and only the library should be made PRIVATE. Any dependencies that your library does not use – but a linking object would need – should be made an INTERFACE.
By correctly setting the type of links, you’ll ensure that when your library is used it is able to pass the needed dependencies to the consuming object. That ensures that object links with any additional libraries it will need to use your library are satisfied.
Package Config.cmake
In order for find_package to find your library, you must ship either a Config.cmake file or a Findnumbers.cmake file since CMake will populate your package config. (I find it's easier to make the config file.)
Once again, there’s helpful set of macros in an optional CMake include:
include(CMakePackageConfigHelpers)
After the include, we can use the package config macros:
configure_package_config_file (
${CMAKE_CURRENT_SOURCE_DIR}/numbersConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/numbersConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/numbers
)
We will need to make the numbersConfig.cmake.in file. This file provides info about the targets.
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
include ("${CMAKE_CURRENT_LIST_DIR}/numbersTargets.cmake")
It also provides information about dependencies, if you have any. (For example, if we need Qt core.)
find_dependency(Qt6 "@REQUIRED_QT_VERSION@" COMPONENTS Core)
Note that you may have different dependencies, which is fine. Just be sure to put them all here.
We also want in our main CMake to set a variable REQUIRED_QT_VERSION so we can use it elsewhere. If your library depends on one of the project’s targets, do not use find_dependency. These will be found by the numberTargets.cmake file as long as we export the targets.
Tracking Your Targets
This one is really easy. Any CMakeLists.txt that makes at least one target should call the export method with a name and a FILE.
export(EXPORT numbersTargets FILE ${CMAKE_CURRENT_BINARY_DIR}/numbersTargets.cmake
Later on we will install this file to ${CMAKE_INSTALL_LIBDIR}/cmake/numbers
Project Version
We can easily generate a version file that CMake can use when importing our project:
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/numbersConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)
The compatibility part of this command will change if a version is considered compatible with a version a project has requested. Make sure that the library API is compatible for these versions in your code base; this setting only reflects how CMake will treat compatibility.
Compatibility Meaning
- SameMajorVersion - Compatible if the major version matches
- SameMinorVersion - Compatible if the major and minor version matches
- ExactVersion - Only Compatible with this version
- AnyNewerVersion - Any higher version number is compatible.
Remember to install this file into ${CMAKE_INSTALL_LIBDIR}/cmake/foo
Install
When we are installing, we have to do a few extra things. We start with our normal install. To this we will add an EXPORT name and COMPONENTS for both numbers-lib and numbers-dev. The components are useful if we later decide to make split packages so users who only need the lib get the library and developers get the library and the dev files.
install(TARGETS numbers
EXPORT numbersTargets
LIBRARY
COMPONENT numbers-lib
DESTINATION ${CMAKE_INSTALL_LIBDIR}
PUBLIC_HEADER
COMPONENT numbers-dev
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/numbers
)
After the first install part we now have to install the EXPORT from above:
install(EXPORT numbersTargets
NAMESPACE numbers::
COMPONENT numbers-dev
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/numbers
)
This leaves us with the Config generated files from CMake. We have one last install statement for these:
install(
FILES
${CMAKE_CURRENT_BINARY_DIR}/numbersConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/numbersConfigVersion.cmake
COMPONENT numbers-dev
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/numbers
)
Conclusion
Creating a reusable library is not especially difficult, but it does require completing several extra steps as outlined above to correctly install and use the files. Details are important! When you’re creating your library, don’t forget to test that it is building and linking correctly on each of your target systems.