01 — Modern CMake

Modern CMake (3.15+, but really 3.20+ for CMakePresets.json) has one central idea: targets, not variables. Instead of setting global include paths and compile flags and hoping every subdirectory picks them up, you attach properties directly to add_executable / add_library targets, and CMake propagates them through the dependency graph using three scope keywords: PRIVATE, PUBLIC, INTERFACE. If you learn nothing else from this file, learn that.

The rest of this file walks the target model, then the propagation rules, then external deps (find_package vs FetchContent), then CMakePresets.json, then how to export your library so other CMake projects can consume it via find_package(YourLib). Every code block is copy-paste-able; the full template at the bottom is what your Phase 4 project P4.1 will use.

1. The target model

A target is a first-class object CMake tracks. There are three flavors you’ll use daily:

Target type

Command

What it produces

Executable

add_executable(myapp src/main.cpp)

A binary.

Library

`add_library(mylib STATIC

SHARED

Interface library

add_library(myheaders INTERFACE)

No compilation. Header-only or pure config.

Once you have a target you attach everything to it:

add_library(mynn STATIC src/layer.cpp src/train.cpp)

target_include_directories(mynn
    PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include   # consumers need this
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src       # only we need this
)

target_compile_features(mynn PUBLIC cxx_std_20)   # need C++20 to use us
target_compile_options(mynn  PRIVATE -Wall -Wextra -Wpedantic)  # do NOT force -Wall on consumers
target_link_libraries(mynn   PUBLIC  Eigen3::Eigen)   # consumers link Eigen too

Everything you’d have set as a global variable in “old” CMake (CMAKE_CXX_STANDARD, CMAKE_CXX_FLAGS, include_directories, link_libraries) has a target_* equivalent. Use the target_* form. Global state is what makes old CMake unmaintainable.

2. PUBLIC, PRIVATE, INTERFACE — the propagation model

Every target_*_directories, target_link_libraries, target_compile_options, target_compile_definitions takes one of three scope keywords. This is the single most important concept in modern CMake.

Scope

Applied when this target compiles?

Applied when a consumer links this target?

PRIVATE

Yes

No

PUBLIC

Yes

Yes

INTERFACE

No

Yes

Concrete example

You build libmynn that internally uses <vector> (STL, standard) but its headers include <Eigen/Dense>. Any consumer of libmynn must therefore see Eigen’s headers too. Also, you use <spdlog> internally in .cpp files, but consumers don’t need to know that.

find_package(Eigen3 3.4 REQUIRED)
find_package(spdlog REQUIRED)

add_library(mynn STATIC src/layer.cpp src/train.cpp)
target_include_directories(mynn PUBLIC  include)   # our public headers
target_link_libraries(mynn      PUBLIC  Eigen3::Eigen)  # our headers use Eigen -> PUBLIC
target_link_libraries(mynn      PRIVATE spdlog::spdlog) # only .cpp uses it -> PRIVATE
target_compile_features(mynn    PUBLIC  cxx_std_20)

A consumer:

add_executable(train_mnist apps/train_mnist.cpp)
target_link_libraries(train_mnist PRIVATE mynn)
# consumer automatically gets Eigen include paths and C++20, does NOT get spdlog leaked in.

Interface library (header-only)

add_library(mymath INTERFACE)
target_include_directories(mymath INTERFACE include)
target_compile_features(mymath    INTERFACE cxx_std_20)
# No PRIVATE ever — there's nothing to compile.

Rule of thumb: if it appears in any header of your library, it’s PUBLIC (or INTERFACE for header-only). If it only appears in .cpp, it’s PRIVATE.

3. Warnings — the classic mistake

Do not do this:

# BAD: forces -Wall -Werror on every consumer of your library
target_compile_options(mynn PUBLIC -Wall -Wextra -Werror)

Do this:

# GOOD: consumers pick their own warning policy
target_compile_options(mynn PRIVATE -Wall -Wextra -Wpedantic)
if(ENABLE_WERROR)
    target_compile_options(mynn PRIVATE -Werror)
endif()

4. External dependencies — the three routes

You will use each of these. Know when to reach for which.

Route

Command

Best for

find_package

find_package(fmt 10.2 REQUIRED)

Deps installed on the system (via Conan, vcpkg, apt, brew). The right default.

FetchContent

Downloads and builds source at configure time

Tiny/pinned deps you want vendored; hermetic reproducible builds. GoogleTest, Google Benchmark.

ExternalProject_Add

Legacy, runs at build time

Almost never. Use FetchContent instead.

find_package (with Conan or vcpkg providing the packages)

find_package(fmt 10.2 CONFIG REQUIRED)
target_link_libraries(mynn PRIVATE fmt::fmt)

FetchContent (GoogleTest is the canonical use case)

include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.15.2   # never track main; always pin
)
FetchContent_MakeAvailable(googletest)

enable_testing()
add_executable(mynn_tests tests/test_layer.cpp)
target_link_libraries(mynn_tests PRIVATE mynn GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(mynn_tests)

Pin every tag. Never GIT_TAG main — you’ll get non-reproducible builds. Never GIT_TAG master either. Use a release tag or full SHA.

5. CMakePresets.json — stop typing cmake -DCMAKE_BUILD_TYPE=... -DCMAKE_TOOLCHAIN_FILE=... -DENABLE_ASAN=ON

Presets (CMake ≥ 3.19, version 3 schema for the good stuff) let you declare configurations once. Now cmake --preset asan is one command.

{
  "version": 6,
  "cmakeMinimumRequired": { "major": 3, "minor": 25 },
  "configurePresets": [
    {
      "name": "base",
      "hidden": true,
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
        "CMAKE_CXX_STANDARD": "20",
        "CMAKE_CXX_STANDARD_REQUIRED": "ON",
        "CMAKE_CXX_EXTENSIONS": "OFF"
      }
    },
    {
      "name": "debug", "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    },
    {
      "name": "release", "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    },
    {
      "name": "asan", "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "ENABLE_ASAN": "ON", "ENABLE_UBSAN": "ON"
      }
    },
    {
      "name": "tsan", "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "ENABLE_TSAN": "ON"
      }
    }
  ],
  "buildPresets": [
    { "name": "debug",   "configurePreset": "debug" },
    { "name": "release", "configurePreset": "release" },
    { "name": "asan",    "configurePreset": "asan" },
    { "name": "tsan",    "configurePreset": "tsan" }
  ],
  "testPresets": [
    { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } },
    { "name": "asan",  "configurePreset": "asan",  "output": { "outputOnFailure": true } }
  ]
}

Usage:

cmake --preset asan
cmake --build --preset asan
ctest --preset asan

You can also add a CMakeUserPresets.json (git-ignored) for personal overrides like CMAKE_TOOLCHAIN_FILE for vcpkg or Conan.

6. Exporting your library so others can find_package(mynn)

Once your library grows past a single project, you want to install and export it. Skeleton:

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

install(TARGETS mynn
    EXPORT  mynnTargets
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT mynnTargets
    FILE mynnTargets.cmake
    NAMESPACE mynn::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mynn)

configure_package_config_file(
    cmake/mynnConfig.cmake.in
    ${CMAKE_CURRENT_BINARY_DIR}/mynnConfig.cmake
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mynn)
write_basic_package_version_file(
    ${CMAKE_CURRENT_BINARY_DIR}/mynnConfigVersion.cmake
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion)
install(FILES
    ${CMAKE_CURRENT_BINARY_DIR}/mynnConfig.cmake
    ${CMAKE_CURRENT_BINARY_DIR}/mynnConfigVersion.cmake
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mynn)

Now a downstream user does find_package(mynn 1.0 REQUIRED) and gets mynn::mynn as an imported target with all your PUBLIC include dirs and link deps.

7. Common bugs — what to never do

Anti-pattern

Why it’s wrong

Fix

include_directories(...) at top

Applies globally, leaks into siblings

target_include_directories(target ...)

set(CMAKE_CXX_FLAGS "...")

Overwrites everything, unscoped

target_compile_options(target PRIVATE ...)

file(GLOB ...) for sources

CMake can’t detect new files → stale builds

List sources explicitly

add_definitions(...)

Old, global

target_compile_definitions(target PRIVATE FOO=1)

No version in find_package

Silent breakage on upgrade

find_package(fmt 10.2 REQUIRED)

link_libraries(...) (no target)

Global, unscoped

target_link_libraries(target ...)

Global -Werror on PUBLIC

Forces consumers to eat your warnings

Keep -Werror PRIVATE, opt-in via option

Building in-source (cmake .)

Pollutes source tree

Always cmake -B build or use presets

8. Full working CMakeLists.txt template

Commit this in your P4.1 template repo, top-level. It’ll grow, but this is the correct skeleton.

cmake_minimum_required(VERSION 3.25)
project(mynn
    VERSION 0.1.0
    DESCRIPTION "Applied C++ ML template"
    LANGUAGES CXX)

# --- global defaults (only project-level policy, not compile flags) ---
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()

# --- options ---
option(MYNN_BUILD_TESTS      "Build tests"      ON)
option(MYNN_BUILD_BENCHMARKS "Build benchmarks" ON)
option(ENABLE_ASAN  "AddressSanitizer"          OFF)
option(ENABLE_UBSAN "UndefinedBehaviorSanitizer" OFF)
option(ENABLE_TSAN  "ThreadSanitizer"           OFF)
option(ENABLE_MSAN  "MemorySanitizer (Clang, Linux)" OFF)

include(cmake/Sanitizers.cmake)   # defines add_sanitizers(target)

# --- deps (either from Conan/vcpkg via find_package, or FetchContent for gtest/benchmark) ---
find_package(fmt 10 CONFIG REQUIRED)

# --- our library ---
add_library(mynn
    src/layer.cpp
    src/train.cpp)
add_library(mynn::mynn ALIAS mynn)
target_include_directories(mynn PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
target_compile_features(mynn PUBLIC cxx_std_20)
target_compile_options(mynn PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall -Wextra -Wpedantic>)
target_link_libraries(mynn PRIVATE fmt::fmt)
add_sanitizers(mynn)

# --- executable ---
add_executable(train_mnist apps/train_mnist.cpp)
target_link_libraries(train_mnist PRIVATE mynn::mynn)
add_sanitizers(train_mnist)

# --- tests ---
if(MYNN_BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

# --- benchmarks ---
if(MYNN_BUILD_BENCHMARKS)
    add_subdirectory(benchmarks)
endif()

And cmake/Sanitizers.cmake:

function(add_sanitizers target)
    if(ENABLE_ASAN)
        target_compile_options(${target} PRIVATE -fsanitize=address -fno-omit-frame-pointer)
        target_link_options(${target}    PRIVATE -fsanitize=address)
    endif()
    if(ENABLE_UBSAN)
        target_compile_options(${target} PRIVATE -fsanitize=undefined -fno-omit-frame-pointer)
        target_link_options(${target}    PRIVATE -fsanitize=undefined)
    endif()
    if(ENABLE_TSAN)
        target_compile_options(${target} PRIVATE -fsanitize=thread -fno-omit-frame-pointer)
        target_link_options(${target}    PRIVATE -fsanitize=thread)
    endif()
    if(ENABLE_MSAN)
        target_compile_options(${target} PRIVATE -fsanitize=memory -fno-omit-frame-pointer -fPIE)
        target_link_options(${target}    PRIVATE -fsanitize=memory -pie)
    endif()
endfunction()

9. Practice exercises (do all four)

  1. Take any old project of yours with a hand-rolled Makefile. Port it to modern CMake. No global variables. Explain (out loud) every PUBLIC/PRIVATE/INTERFACE.

  2. Write a header-only library stringutils as an INTERFACE target with tests.

  3. Build the same project with three preset flavors: debug, release, release-asan. Run tests under each.

  4. Add install() + export config so a second project can find_package(stringutils).


Nav: ← Phase 4 README · Next: 02 Conan and vcpkg →