Contents

Why CMake sucks?

CMake has become a de facto industry standard as a natural ancestor superseding autotools. But is it actually an improvement? Personally, after spending signifficant amount of time with CMake projects, I’m inclined to conclude that no, not really. Below, I present why CMake sucks and why you shouldn’t use it for any of your projects.

Just as a disclaimer, I’m presenting my personal views here, which are very subjective. You’re entitled to have your own opinions. You have been warned :).

Autotools hell

CMake took the world by storm, because compared to autotools, it’s been significantly simpler to deal with and faster. Can you imagine that prior to CMake and a single CMakeLists.txt file, defining all the build rules you’d have to perform the following:

1
2
3
4
5
6
7
8
9
    $ autoscan
    $ mv configure.scan configure.ac
    $ autoheader
    $ autoconf
    $ touch Makefile.am
    $ touch README NEWS AUTHORS ChangeLog
    $ aclocal
    $ autoconf # yes, again
    $ automake --add-missing

This is insane and I didn’t even mention any file editing required. Try to memorise all of that. After all these shenanigans here’s a list of all new files that autotools created:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Untracked files:
  (use "git add <file>..." to include in what will be committed)
        AUTHORS
        COPYING
        ChangeLog
        INSTALL
        Makefile.am
        Makefile.in
        NEWS
        README
        aclocal.m4
        config.h.in
        configure
        configure.ac
        depcomp
        install-sh
        missing

Exceptionally… bad. Compared to all of that, CMake indeed is a blessing. You’ve got a single text file and no massive boilerplate required to achieve most fundamental stuff so, why do I complain?

CMake language is bad

Yes, it’s like a vimscript of build system languages. Inconsistent, confusing and without a reference manual at hand all the time, impossible to memorise (at least for me).

Weird statements

Does declaring conditional statements which resembles function invocation looks intuitive to anybody?

1
2
3
4
5
if(condition)
    ...
else()
    ...
endif()

Same applies to function definitions:

1
2
3
function(foo)
    ...
endfunction()

Weird function/macro invocation

What the hell is this (?):

1
my_install(TARGETS foo bar DESTINATION bin OPTIONAL blub CONFIGURATIONS)

Oh boy, so many questions. Amongst many:

  • Where do I place the keywords, i.e. DESTINATION, can it be anywhere, is its position well defined?
  • Where are the commas, are they needed at all, what’s the argument delimiter, are new lines important?
  • What’s the set of accepted keywords (how do I even check that without having to fallback to documentation or inspect the source code myself for a given function/macro)?
  • Does a given keyword accept a single argument or multiple ones?
  • Which keywords are mandatory and which are optional?
  • Does the order matter?
  • What types am I expected to use after any given keyword?

I’m not sure what the motivation was during the design phase but this is simply unbearable. In big projects with lots of custom functions and macros it literally brings the development to a halt without having to constantly check examples (if any), documentation (if available) or attempted usage somewhere else. Many times, you have to deal with a cobweb of custom functions like that and before you even get to the code itself, you all of a sudden notice that you’ve wasted an entire day trying to understand the basics of how it gets built.

Weird function definitions

The invocation is a mess but wait, there’s more. Defining a function is even less appealing. Following CMake’s documentation to actually be able to use arguments in a function like my_install, used as an example above, the arguments would have to be parsed, otherwise you’d be just dealing with unstructured set of values so, a typical function preamble looks the following way:

1
2
3
4
5
6
7
8
function(my_install)
    set(options OPTIONAL FAST)
    set(oneValueArgs DESTINATION RENAME)
    set(multiValueArgs TARGETS CONFIGURATIONS)
    cmake_parse_arguments(MY_INSTALL "${options}" "${oneValueArgs}"
                          "${multiValueArgs}" ${ARGN} )
    ...
endfunction()

On first glance, it’s impossible to decrypt that without, again, spending some time with the documentation. After a while, it becomes apparent that MY_INSTALL is a variable prefix and cmake_parse_arguments will parse “options” (which in this case are value-less keywords or nullary keywords or whatever you want to call a set of flags), “one value arguments” - name is self explaining, in case of this example it’ll be i.e DESTINATION, and multi value keywords like a list of files or targets. But wait… there’s more, it’s not returning a structured set of parsed data in a form of a dictionary or something similar, like you’d expect from any sane language, instead it’ll define a set of MY_INSTALL_ prefixed variables containing parsed values in the current scope. Yes, they’ve introduced a prefix for the sake of macros, since unlike functions, they don’t define their own scope. It feels like a workaround introduced as early as the design stage! So, quoting the documentation, after invoking my_install the following way:

1
my_install(TARGETS foo bar DESTINATION bin OPTIONAL blub CONFIGURATIONS)

You’ll land up with these new variables within your function scope (or global scope in case we’re talking of macro()):

1
2
3
4
5
6
7
8
9
MY_INSTALL_OPTIONAL = TRUE
MY_INSTALL_FAST = FALSE # was not provided in the call to my_install
MY_INSTALL_DESTINATION = "bin"
MY_INSTALL_RENAME = <UNDEFINED> # was not provided
MY_INSTALL_TARGETS = "foo;bar"
MY_INSTALL_CONFIGURATIONS <UNDEFINED> # was not provided
MY_INSTALL_UNPARSED_ARGUMENTS = "blub" # nothing expected after "OPTIONAL"
MY_INSTALL_KEYWORDS_MISSING_VALUES = "CONFIGURATIONS"
         # No value for "CONFIGURATIONS" given

Lovely!

CMake errors suck

So, imagine you’re getting a package from a 3rd party that you wish to integrate with the rest of your system. You try to build it and:

1
2
3
4
5
6
7
8
9
  Policy CMP0054 is not set: Only interpret if() arguments as variables or
  keywords when unquoted.  Run "cmake --help-policy CMP0054" for policy
  details.  Use the cmake_policy command to set the policy and suppress this
  warning.

  Quoted variables like "ABC" will no longer be
  dereferenced when the policy is set to NEW.  Since the policy is not set
  the OLD behavior will be used.
This warning is for project developers.  Use -Wno-dev to suppress it.

or:

1
2
3
  Policy CMP0074 is not set: find_package uses <PackageName>_ROOT variables.
  Run "cmake --help-policy CMP0074" for policy details.  Use the cmake_policy
  command to set the policy and suppress this warning.

So, again you have to go the online manual and decrypt the meaning. It seems that the semantics of CMake language have changed in regards to variables dereferencing. CMake, by default, is still maintainig the old behaviour and warns about the change. Seems good on first glace, doesn’t it? Well, kind of. The fact that the language semantics changed is a bad thing in the first place. Even though the old behaviour is still maintained, it is now deprecated, which means that new versions of CMake are very likely to remove it. Yeah, that’s fine you may say. You’ve got time to upgrade. Well, no I don’t - I’m not the maintainer of this package and licensing restrictions may forbid me from touching it whatsoever, which means that if newer CMake removes deprecated behaviour, I’ll have to have TWO versions of CMake in my system. The new one (whatever it’s going to be) and the old one, just for the purpose of building that one particular package.

Weird dependencies

CMake comes with its own curl implementation - cmcurl. It wouldn’t be that strange, since it provides ways to deal with remote source code repos. The consequence of that is all of a sudden, your build system requires openssl as a dependency:

1
2
3
4
5
loading initial cache file /usr/src/cmake-3.21.1/Bootstrap.cmk/InitialCacheFlags.cmake
-- Could NOT find OpenSSL, try to set the path to OpenSSL root folder in the system variable OPENSSL_ROOT_DIR (missing: OPENSSL_CRYPTO_LIBRARY OPENSSL_INCLUDE_DIR)
CMake Error at Utilities/cmcurl/CMakeLists.txt:525 (message):
  Could not find OpenSSL.  Install an OpenSSL development package or
  configure CMake with -DCMAKE_USE_OPENSSL=OFF to build without OpenSSL.

Using cmake sucks

Do I really have to type:

cmake -DCMAKE_INSTALL_PREFIX=... .

instead of just --prefix like a normal person? Yes, autotools do suck immensely but there were some good parts, like a reasonable variable names to name one :).

Anatomy of a CMakeLists.txt file

I’m gonna try to prove that CMake is not necessarily the panacea we’ve been all looking for. Let’s work with an example. I’ve got a very simple library that I wish to build with cmake:

1
2
3
4
├── include
│   └── lib1
│       └── lib.h
└── lib1.cpp

Simple stuff. One header file, one c++ source file. I need a CMakeLists.txt. I’ll create one with the following content:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.8)
project(lib1)

add_library(lib1 SHARED
    include/lib1/lib.h
    lib1.cpp
)

Nice and simple. But this’ll just build the library. I still need to install it. Okay, fair enough:

1
install(TARGETS lib1)

But, this only installs the target and I still want to install the headers. No problems there:

1
install(DIRECTORY include/lib1 DESTINATION include)

Cool. Job done! Well, no, it’s not. How will the client code find my library? I can just add a pkg-config file but CMake comes with CMake packages instead so, it must be better? Sure, let’s try that. First thing first, the target installation has to be slightly altered:

1
install(TARGETS lib1 EXPORT lib1Targets)

Here I’ve defined a package export. Clients of my library will be able to find it as a package and all targets that it exports. Cool, am I done? No… The target itself has to be installed as well:

1
2
3
4
install(EXPORT lib1Targets
    NAMESPACE lib1::
    FILE lib1Targets.cmake
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1)

You might have noticed that I’m using these cool variables CMAKE_INSTALL_LIBDIR to denote paths in the system. Well, they don’t come for free. I had to include a package for that:

1
include(GNUInstallDirs)

Am I finally done? Nah :). I still need to create package config files so, the clients can just find_package(lib1 0.1 EXACT). But wait! How to incorporate the version information? I just need to add some cmake magic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
set (version 0.1)

set_property(TARGET lib1 PROPERTY VERSION ${version})
set_property(TARGET lib1 PROPERTY SOVERSION 0)
set_property(TARGET lib1 PROPERTY INTERFACE_lib1_MAJOR_VERSION 0)
set_property(TARGET lib1 APPEND PROPERTY COMPATIBLE_INTERFACE_STRING lib1_MAJOR_VERSION)

configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/Config.cmake.in
  "${CMAKE_CURRENT_BINARY_DIR}/lib1Config.cmake"
  INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1
)

write_basic_package_version_file(
  "${CMAKE_CURRENT_BINARY_DIR}/lib1ConfigVersion.cmake"
  VERSION "${version}"
  COMPATIBILITY AnyNewerVersion
)

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/lib1Config.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/lib1ConfigVersion.cmake"
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1
)

Am I finally done? Well, no! There are references to a strange file Config.cmake.in. Yeah, skeleton for this one has to be provided as well:

1
2
3
4
5
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/lib1Targets.cmake")

check_required_components(lib1)

It must be over now, right? No, it’s not. I’m installing the headers, but the clients have no idea about these paths so, these have to be exported as well:

1
2
3
4
target_include_directories(lib1 PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

There you go! To bring a simple library to a production quality which other people can actually use (or even yourself, as part of a bigger project), I had to write 48 lines of CMake code. Here’s the file in its entirety:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
cmake_minimum_required(VERSION 3.8)
project(lib1)

set (version 0.1)

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

add_library(lib1 SHARED
    include/lib1/lib.h
    lib1.cpp
)

set_property(TARGET lib1 PROPERTY VERSION ${version})
set_property(TARGET lib1 PROPERTY SOVERSION 0)
set_property(TARGET lib1 PROPERTY INTERFACE_lib1_MAJOR_VERSION 0)
set_property(TARGET lib1 APPEND PROPERTY COMPATIBLE_INTERFACE_STRING lib1_MAJOR_VERSION)

target_include_directories(lib1 PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/Config.cmake.in
  "${CMAKE_CURRENT_BINARY_DIR}/lib1Config.cmake"
  INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1
)

write_basic_package_version_file(
  "${CMAKE_CURRENT_BINARY_DIR}/lib1ConfigVersion.cmake"
  VERSION "${version}"
  COMPATIBILITY AnyNewerVersion
)

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/lib1Config.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/lib1ConfigVersion.cmake"
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1
)

install(TARGETS lib1 EXPORT lib1Targets)

install(EXPORT lib1Targets
    NAMESPACE lib1::
    FILE lib1Targets.cmake
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/lib1)

install(DIRECTORY include/lib1 DESTINATION include)

… and the funny thing is that some of you will still be able to find many things wrong with this code.

Dealing with more complex projects

This is the biggest issue, in my opinion. Software stack is rarely comprised of one project. There’s a set of projects, interdependent on each other and there’s a need to express that dependency. All of a sudden you can’t rely on CMake package files to find dependencies in one of your projects because the dependencies themselves are not yet built and the package files are missing. Consider the following:

1
2
3
.
├── CMakeLists.txt
└── main.cpp

I’ve got a very simple project which uses lib1. The CMakeLists.txt looks the following way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cmake_minimum_required(VERSION 3.8)
project(foo)

find_package(lib1 0.1 EXACT)

add_executable(foo
    main.cpp
)

target_link_libraries(foo PRIVATE lib1::lib1)

My stack has two projects lib1 and exec. exec directly depends on lib1. lib1 has to be built first then. Here’s the “stack” overview:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
.
├── CMakeLists.txt
├── README.md
├── exec
│   ├── CMakeLists.txt
│   └── main.cpp
├── lib1
│   ├── CMakeLists.txt
│   ├── Config.cmake.in
│   ├── include
│   │   └── lib1
│   │       └── lib.h
│   └── lib1.cpp

Of course this is greatly simplified and contrived example. In reality there would be a lot of libraries each coming with their dedicated unit tests and potentially interdependent on one another.

If you build lib1 yourself manually and then follow to exec then obviously, everything will be fine. Nobody wants to do that though and it’s obvious that the desire is to have a global CMakeLists.txt to manage all dependencies at once. So, I’ve got a global CMakeLists.txt where:

1
2
3
4
5
cmake_minimum_required(VERSION 3.8)
project(all)

add_subdirectory(lib1)
add_subdirectory(exec)

Seems logical and one could assume that CMake will figure out the dependencies between the targets itself but, it will not. CMake build model involves three stages:

  • configuration stage
  • generation stage
  • build stage

During the configuration stage, CMake goes through all CMakeLists.txt and tries to determine dependencies, configure file templates etc. Since global CMakeLists.txt acts as a single super project aggregator, the implication is that it’s impossible to enforce build order that way for projects so, the dependencies can’t be incrementally satisfied, since during the configuration stage, all package exports and their files would have to be already present. This is a major bummer.

During the configuration stage all package files have to be installed, otherwise exec will complain (rightly so) that lib1 cannot be found. So, to actually make it work you have to resort to hacks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
cmake_minimum_required(VERSION 3.8)
project(foo)

add_executable(foo
    main.cpp
)

if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
    find_package(lib1 0.1 EXACT)
    target_link_libraries(foo PRIVATE lib1::lib1)
else()
    message(STATUS "Skipping package lookup")
    target_link_libraries(foo PRIVATE lib1)
endif()

Where exec is build in two modes. When it’s a standalone package, built out of tree, it tries to locate all its dependencies by looking for the package files. When it’s built as part of the entire stack, it is just directly linking lib1 target. In other words, you have to simulate a mode when all targets are built as part of a single project. This is a terrible hack! It can be achievable if you’re working alone and don’t depend at all on any 3rd party components. If you do, you’ll have to patch them all, just to manage the dependencies.

Other option is to create a set of custom scripts with build order defined but that’s even worse. You won’t be able to rebuild quickly since the change detection has been compromised that way (you’ll be going through all projects all the time).

Note

UPDATE 2024-02-12

ExternalProject is a mitigation to solve that problem in a cleaner way. I’ve wrote a short post about it which you might be interested in.

UPDATE 2024-02-23

Recently I’ve been playing around with conan as a package manager in my cmake projects. I was positively impressed with it. You can find out more in this post.

What’s the alternative?

This is gonna be a short advice. For the love of God! Do yourself a favour and don’t use CMake! I strongly suggest to transition to meson, or bazel but that’s a topic for another post. Hope to see you there.