The end of a semester is here and, as I grade our student's semestral works, I get to use Makefiles and CMakeLists of dubious quality . After seeing the same errors repeated over and over again, I decided to write a short tutorial towards writing simple Makefiles and CMakeLists. This is the CMake tutorial, the Make one can be found here.

Through these tutorials, I'll use a very simple example from one of our labs. It is start of an implementation of growing array (ala std::vector ), consisting of 5 files:

main.cpp

vector.hpp

vector.cpp

array.hpp

array.cpp

Their exact contents do not matter , but main.cpp includes vector.hpp , vector.cpp includes array.hpp and both vector.cpp and array.cpp include their respective headers, vector.hpp and array.hpp .

It is important to note that these tutorials are not meant to build a bottom-up understanding of either of the two, but rather provide a person with an easy-to-modify template that they can use for themselves and quickly get back to the interesting part -- their code.

CMake

CMake is cross-platform meta build-system. What this means is that CMake does not build things, it generates files for other build systems to use. This has a number of advantages, e.g. it can output MSBuild files for Visual Studio when used on Windows, but can also output makefiles when used on Linux.

CMake works by reading a single input file named CMakeLists.txt and generating platform-specific files for different build systems from the declarations and commands within. A large problem with CMake is that there are many tutorials giving bad advice, including its own documentation .

This is an example of CMakeLists.txt that contains two fundamental problems, that are painfully common.

cmake_minimum_required(VERSION 2.8) project(pjc-lab5) set(CMAKE_CXX_FLAGS "-std=c++14 -Wall ${CMAKE_CXX_FLAGS}") include_directories(${CMAKE_CURRENT_SOURCE_DIR}) add_executable(vector-test array.cpp vector.cpp main.cpp )

The first problem is that it is non-portable because it sets GCC/Clang specific flags ( -Wall , -std=c++14 ) globally, no matter the platform/compiler. The second is that it changes compilation flags and include paths globally, for all binaries/libraries. This is not a problem for a trivial build like this, but as with many things, it is better to get into the habit of doing things the correct way right from the start.

The proper way, sometimes also called modern CMake, minimizes the use of global settings and combines using target-specific properties with CMake's understanding of building C++. The modern CMake version of the CMakeLists.txt for the same toy problem is this:

cmake_minimum_required(VERSION 3.5) project(pjc-lab5 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_executable(vector-test array.cpp vector.cpp main.cpp )

Notice that we had to bump the required CMake version for this to work. We also told CMake that this project will use only C++ -- this cuts down the time it needs to create projects, as it does not have to look for a C compiler, check if it works, etc.

The desired C++ standard is still set globally. There are some arguments for setting it per-target, and some good arguments against , but at the time of writing this I am against setting C++ standard per-target.

Setting CMAKE_CXX_STANDARD to 14 tells CMake that we want to add whatever flags are needed for our compiler to be able to compile C++14. For GCC/Clang this is -std=c++14 (or -std=gnu++14 ), for MSVC this is nothing (it supports C++14 by default). Enabling CMAKE_CXX_STANDARD_REQUIRED tells CMake to fail generation step if C++14 is not supported (the default is to keep going with older standard) and disabling CMAKE_CXX_EXTENSIONS tells CMake to prefer flags that do not enable compiler-specific extensions -- this means that GCC will be given -std=c++14 rather than -std=gnu++14 .

You might have noticed that there are now no warnings. This is a bit of a sore spot because CMake does nothing to help you with setting (un)reasonable warning levels in a cross-platform fashion, so you have to do it yourself, by using appropriate flags for each compiler, like so :

if ( CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang|GNU" ) target_compile_options( vector-test PRIVATE -Wall -Wextra -Wunreachable-code -Wpedantic) endif() if ( CMAKE_CXX_COMPILER_ID MATCHES "Clang" ) target_compile_options( vector-test PRIVATE -Wweak-vtables -Wexit-time-destructors -Wglobal-constructors -Wmissing-noreturn ) endif() if ( CMAKE_CXX_COMPILER_ID MATCHES "MSVC" ) target_compile_options( vector-test PRIVATE /W4 /w44265 /w44061 /w44062 ) endif()

With this, we have a CMake build file that lets us build our toy project with GCC/Clang on Linux/OS X/BSD/others and with MSVC on Windows, with a reasonable set of warnings and using C++14 features. Note that we did not have to do any work to track dependencies between files, as CMake does that for us.

Generated project

The provided CMakeLists.txt template works well for building the project, but does not generate good project files, as it just dumps all .cpp files into a project, without any grouping or headers, as shown in this picture:



We can fix this by changing the CMakeLists.txt a bit, and add the header files as components of the executable. Because CMake understands C++, it will not attempt to build these header files, but will include them in the generated solution, as shown in this picture:



Let's pretend for a bit that our project has grown, and we would like to have extra folders for grouping our files, e.g. "Tests" for grouping files that are related to testing our binary, rather than towards implementing it. This can be done via the source_group command. If we decide to use main.cpp as our test file, we will add this to our CMakeLists.txt

source_group("Tests" FILES main.cpp) source_group("Implementation" FILES array.cpp vector.cpp)

The result will look like this:



Tests

The CMake set of tools also contains a test runner called CTest. To use it, you have to request it explicitly, and register tests using add_test(NAME test-name COMMAND how-to-run-it) . The default success criteria for a test is that it returns with a 0, and fails if it returns with anything else. This can be customized via set_tests_properties and setting the corresponding property.

For our project we will just run the resulting binary without extra checking:

include(CTest) add_test(NAME plain-run COMMAND $<TARGET_FILE:vector-test>)

That weird thing after COMMAND is called a generator-expression and is used to get a cross-platform path to the resulting binary .

Final CMakeLists.txt template

After we implement all of the improvements above, we end up with this CMakeLists.txt:

cmake_minimum_required(VERSION 3.5) project(pjc-lab5 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_executable(vector-test array.cpp vector.cpp main.cpp array.hpp vector.hpp ) source_group("Tests" FILES main.cpp) source_group("Implementation" FILES array.cpp vector.cpp) if ( CMAKE_CXX_COMPILER_ID MATCHES "Clang|AppleClang|GNU" ) target_compile_options( vector-test PRIVATE -Wall -Wextra -Wunreachable-code -Wpedantic) endif() if ( CMAKE_CXX_COMPILER_ID MATCHES "Clang" ) target_compile_options( vector-test PRIVATE -Wweak-vtables -Wexit-time-destructors -Wglobal-constructors -Wmissing-noreturn ) endif() if ( CMAKE_CXX_COMPILER_ID MATCHES "MSVC" ) target_compile_options( vector-test PRIVATE /W4 /w44265 /w44061 /w44062 ) endif() include(CTest) add_test(NAME plain-run COMMAND $<TARGET_FILE:vector-test>)

It provides cross-platform compilation with warnings, can be easily reused for different sets of source files, and the generated IDE project files will be reasonably grouped.

Closing words

I think that both Make and CMake are terrible. Make is horrible because it does not handle spaces in paths, contains some very strong assumptions about running on Linux (and maybe other POSIX systems) and there are many incompatible dialects (GNU Make, BSD Make, NMake, the other NMake, etc.). The syntax isn't anything to write home about either.

CMake then has absolutely horrendous syntax, contains a large amount of backward compatibility cruft and lot of design decisions in it are absolutely mindboggling -- across my contributions to OSS projects I've encountered enough crazy things that they need to be in their own post.

Still, I am strongly in favour of using CMake over Make, if just for supporting various IDEs well and being able to deal with Windows properly.

I made a part 2 of this post, about consuming and creating libraries.