Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 8 of 10

Make tells the computer what commands to run. CMake describes what the project is, and generates those commands for whatever compiler and platform you are on.

That is the whole pitch, and it would be unremarkable except for one thing: CMake has two distinct styles, roughly pre-2014 and post, and most material you will find online is the old one. The difference is the subject of this lesson.

A complete project

// include/geometry.hpp
#pragma once

double circle_area(double r);
// src/circle.cpp
#include "geometry.hpp"

double circle_area(double r) { return 3.14159265358979 * r * r; }
// src/main.cpp
#include <iostream>
#include "geometry.hpp"

int main() { std::cout << circle_area(2.0) << "\n"; }
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(geometry_demo LANGUAGES CXX)

add_library(geometry src/circle.cpp)
target_include_directories(geometry PUBLIC include)
target_compile_features(geometry PUBLIC cxx_std_20)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE geometry)

Eight lines, and they replace everything the Makefile in lesson 17.7 had to spell out:

cmake -S . -B build -G Ninja | tail -2
cmake --build build
./build/app
-- Generating done (<time>)
-- Build files have been written to: build
[1/4] Building CXX object CMakeFiles/geometry.dir/src/circle.cpp.o
[2/4] Linking CXX static library libgeometry.a
[3/4] Building CXX object CMakeFiles/app.dir/src/main.cpp.o
[4/4] Linking CXX executable app
12.5664

-S is the source directory, -B is the build directory, -G chooses the generator. CMake does not build anything itself — it writes build files for Ninja, Make, Visual Studio or Xcode, and cmake --build invokes whichever you chose.

Note what you did not have to write: no compiler name, no -std=c++20, no -Iinclude, no -MMD -MP, no ar invocation, no dependency plumbing. CMake set up the dependency tracking that 17.7 had to add by hand, and you can ask Ninja what it knows:

ninja -C build -t deps | grep -A4 'circle.cpp.o:' | sed 's/deps mtime [0-9]*/deps mtime .../'
echo "--- and how many the main.cpp object has:"
ninja -C build -t deps | grep -oE 'main.cpp.o: #deps [0-9]+'
CMakeFiles/geometry.dir/src/circle.cpp.o: #deps 3, deps mtime ... (VALID)
    src/circle.cpp
    /usr/include/stdc-predef.h
    include/geometry.hpp

--- and how many the main.cpp object has:
main.cpp.o: #deps 195

include/geometry.hpp is in the list — the exact dependency whose absence in lesson 17.7 produced area=0. Nobody wrote it down; the compiler reported it and the build system recorded it.

The 195 dependencies of main.cpp.o are lesson 17.1's 196 headers, arriving from the other end: that is what one #include <iostream> costs, itemised.

Ninja stores these in its own .ninja_deps database rather than leaving .d files lying about, which is why ninja -t deps is the way to inspect them. With the Makefile generator you get .d files exactly as in lesson 17.7.

Everything is a target

A target is a library or an executable, and it carries its own properties. Every command you need begins with target_:

| Command | What it attaches | |---|---| | target_include_directories | header search paths | | target_link_libraries | other targets and libraries | | target_compile_definitions | -D macros | | target_compile_options | raw flags | | target_compile_features | cxx_std_20 and friends | | target_sources | additional source files |

The old style used directory-scoped commands — include_directories(), add_definitions(), link_libraries() — which apply to everything declared afterwards in this directory and below. They still work. They are also the reason large old CMake projects are unmaintainable: nothing tells you which flags a given target actually has, because the answer depends on where in the file it was declared.

Rule: if a command does not start with target_, look for one that does.

PUBLIC, PRIVATE, INTERFACE

This is the idea worth the whole lesson. Every target_ command takes one of three keywords, and they answer a question: does this requirement apply to building me, to using me, or both?

  • PRIVATE — needed to build this target. Not passed on.
  • INTERFACE — needed to use this target. Not used for building it.
  • PUBLIC — both.

It is not a style choice, and getting it wrong breaks the build. Change one word:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(geometry_demo LANGUAGES CXX)

add_library(geometry src/circle.cpp)
target_include_directories(geometry PRIVATE include)   # <-- was PUBLIC
target_compile_features(geometry PUBLIC cxx_std_20)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE geometry)
rm -rf build
cmake -S . -B build -G Ninja > /dev/null
cmake --build build 2>&1 | grep -E 'fatal error|FAILED' | head -2
FAILED: CMakeFiles/app.dir/src/main.cpp.o
src/main.cpp:3:10: fatal error: geometry.hpp: No such file or directory

geometry compiled fine — it needs include to build itself, and PRIVATE provides that. But app includes geometry.hpp too, and nothing told it where to look.

PUBLIC says "I need this to build, and so does anyone who links me":

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(geometry_demo LANGUAGES CXX)

add_library(geometry src/circle.cpp)
target_include_directories(geometry PUBLIC include)
target_compile_features(geometry PUBLIC cxx_std_20)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE geometry)
rm -rf build
cmake -S . -B build -G Ninja > /dev/null
cmake --build build | tail -1
./build/app
[4/4] Linking CXX executable app
12.5664

Choosing correctly

The question is always "is this visible in my public headers?"

  • A dependency your headers mention → PUBLIC. Anyone including your header needs it too.
  • A dependency used only in your .cpp files → PRIVATE. Nobody else's business.
  • A header-only library → INTERFACE. There is nothing to build.

app links geometry as PRIVATE in the example above, and that is right: app is an executable, so nothing links it, and its requirements cannot propagate anywhere. Executables should use PRIVATE for everything.

Propagation is the point

With three layers the behaviour becomes visible:

// core/core.hpp
#pragma once

int core_value();
// core/core.cpp
#include "core.hpp"

int core_value() { return 7; }
// mid/mid.hpp
#pragma once

int mid_value();
// mid/mid.cpp
#include "mid.hpp"
#include "core.hpp"

int mid_value() { return core_value() * 2; }
// app/main.cpp
#include <iostream>
#include "mid.hpp"

int main() { std::cout << mid_value() << "\n"; }
// app/peek.cpp
#include "core.hpp"

// app has no business knowing about core. Does the build agree?
int peek() { return core_value(); }
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(layers LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(core core/core.cpp)
target_include_directories(core PUBLIC core)

add_library(mid mid/mid.cpp)
target_include_directories(mid PUBLIC mid)
# PRIVATE: mid uses core internally, and does not expose it in its own headers.
target_link_libraries(mid PRIVATE core)

add_executable(app app/main.cpp)
target_link_libraries(app PRIVATE mid)
cmake -S . -B b -G Ninja > /dev/null
cmake --build b | tail -1
./b/app
[6/6] Linking CXX executable app
14

14, which is 7 * 2 — app calls mid, mid calls core, and the static libraries were linked in the right order without anyone thinking about it (lesson 17.6's ordering problem, gone).

Now add peek.cpp, which reaches past mid to core directly:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(layers LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(core core/core.cpp)
target_include_directories(core PUBLIC core)

add_library(mid mid/mid.cpp)
target_include_directories(mid PUBLIC mid)
target_link_libraries(mid PRIVATE core)

add_executable(app app/main.cpp app/peek.cpp)
target_link_libraries(app PRIVATE mid)
cmake -S . -B b -G Ninja > /dev/null
cmake --build b 2>&1 | grep -E 'fatal error' | head -2
app/peek.cpp:2:10: fatal error: core.hpp: No such file or directory

core.hpp: No such file or directory. mid linked core as PRIVATE, so core's include directory stopped there. The build system is enforcing your architecture — a layer cannot be reached around, and you find out at compile time rather than in review.

Change that one word to PUBLIC and peek.cpp compiles. Which you should do only if you genuinely mean core to be part of mid's published interface.

Header-only libraries

A library with no sources cannot be built, only used, so it is an INTERFACE target:

// hdr/clamp.hpp
#pragma once

template <typename T>
T clamp_between(T v, T lo, T hi) { return v < lo ? lo : (v > hi ? hi : v); }
// app/main.cpp
#include <iostream>
#include "clamp.hpp"
#include "mid.hpp"

int main() { std::cout << clamp_between(mid_value(), 0, 10) << "\n"; }
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(layers LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(core core/core.cpp)
target_include_directories(core PUBLIC core)

add_library(mid mid/mid.cpp)
target_include_directories(mid PUBLIC mid)
target_link_libraries(mid PUBLIC core)

# A header-only library has no sources, so it is an INTERFACE target: it
# carries usage requirements and nothing else.
add_library(clamp INTERFACE)
target_include_directories(clamp INTERFACE hdr)

add_executable(app app/main.cpp app/peek.cpp)
target_link_libraries(app PRIVATE mid clamp)
cmake -S . -B b -G Ninja > /dev/null
cmake --build b | tail -1
./b/app
[3/3] Linking CXX executable app
10

clamp_between(14, 0, 10) is 10. Note that target_link_libraries(app PRIVATE clamp) does not link anything — there is no library file. It attaches clamp's usage requirements, which is all an INTERFACE target has.

Build types

One variable, and CMake knows what flags each configuration wants:

for bt in Debug Release RelWithDebInfo MinSizeRel; do
  cmake -S . -B bt-$bt -G Ninja -DCMAKE_BUILD_TYPE=$bt \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON > /dev/null
  flags=$(python3 -c "
import json
for e in json.load(open('bt-$bt/compile_commands.json')):
    if 'core.cpp' in e['file']:
        print(' '.join(p for p in e['command'].split()
                       if p.startswith('-O') or p in ('-g', '-DNDEBUG')))
        break
")
  printf '%-16s %s\n' "$bt" "${flags:-(none)}"
done
Debug            -g
Release          -O3 -DNDEBUG
RelWithDebInfo   -O2 -g -DNDEBUG
MinSizeRel       -Os -DNDEBUG

Note Debug gets -g and no -O flag at all — not -O0, just nothing, which means the compiler default. And Release is -O3, not the -O2 most people assume.

Two things follow. A single-configuration generator like Ninja or Make bakes the build type in at configure time, so you need one build directory per configuration — which is why they are so cheap to have. And if CMAKE_BUILD_TYPE is unset, you get no optimisation flags and no debug info, which is nobody's intent; always pass it.

-DCMAKE_EXPORT_COMPILE_COMMANDS=ON writes compile_commands.json, the exact command for every file. Every C++ language server reads it, so this one flag is what makes editor completion work in a CMake project.

What to remember

A modern CMakeLists.txt is a short list of targets and their requirements. The commands you need are almost exactly these:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(example LANGUAGES CXX)

add_library(mylib src/a.cpp src/b.cpp)
target_include_directories(mylib PUBLIC include PRIVATE src)
target_compile_features(mylib PUBLIC cxx_std_20)
target_link_libraries(mylib PUBLIC dep_in_my_headers PRIVATE dep_in_my_cpps)

add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mylib)

Things to avoid, all of which you will see in older material:

  • include_directories(), add_definitions(), link_libraries() — directory-scoped, unknowable.
  • file(GLOB ...) for sources — CMake cannot notice a new file appearing, so builds go stale.
  • set(CMAKE_CXX_FLAGS "...") — overrides the build type's flags and applies to everything.
  • Setting CMAKE_CXX_STANDARD when you could say target_compile_features(... cxx_std_20), which propagates to consumers.

Try it

Convert lesson 17.7's Makefile project to CMake: a library, two executables sharing it, and a test that ctest can run. Then prove your PUBLIC/PRIVATE choices are right by adding a file that should not compile and checking that it does not.

Checkpoint

  • CMake describes the project; it generates build files for Ninja, Make, MSVC or Xcode.
  • cmake -S . -B build -G Ninja configures; cmake --build build builds.
  • CMake generates the -MD dependency tracking that lesson 17.7 had to add by hand.
  • A target is a library or executable and carries its own requirements.
  • Use the target_* commands. If a command does not start with target_, look for one that does.
  • Directory-scoped include_directories() and friends are the old style and make flags unknowable.
  • PRIVATE = needed to build me. INTERFACE = needed to use me. PUBLIC = both.
  • Choose by asking: is this visible in my public headers?
  • Executables should use PRIVATE for everything — nothing links an executable.
  • Measured: PRIVATE include directories made a dependent target fail with No such file or directory.
  • Measured: mid linking core as PRIVATE stopped app reaching around it — the build enforces your layering.
  • A header-only library is add_library(x INTERFACE); linking it attaches requirements, not code.
  • Debug gets -g and no -O flag; Release is -O3, not -O2.
  • An unset CMAKE_BUILD_TYPE means no optimisation and no debug info. Always pass it.
  • One build directory per configuration; single-config generators bake it in at configure time.
  • -DCMAKE_EXPORT_COMPILE_COMMANDS=ON is what makes editor completion work.
  • Avoid file(GLOB) for sources, and set(CMAKE_CXX_FLAGS ...).

Questions about this lesson

Log in to ask a question.