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 toadd_executable/add_librarytargets, 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 |
|
A binary. |
Library |
`add_library(mylib STATIC |
SHARED |
Interface library |
|
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? |
|---|---|---|
|
Yes |
No |
|
Yes |
Yes |
|
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 |
|---|---|---|
|
|
Deps installed on the system (via Conan, vcpkg, apt, brew). The right default. |
|
Downloads and builds source at configure time |
Tiny/pinned deps you want vendored; hermetic reproducible builds. GoogleTest, Google Benchmark. |
|
Legacy, runs at build time |
Almost never. Use |
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 |
|---|---|---|
|
Applies globally, leaks into siblings |
|
|
Overwrites everything, unscoped |
|
|
CMake can’t detect new files → stale builds |
List sources explicitly |
|
Old, global |
|
No version in |
Silent breakage on upgrade |
|
|
Global, unscoped |
|
Global |
Forces consumers to eat your warnings |
Keep |
Building in-source ( |
Pollutes source tree |
Always |
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)¶
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.Write a header-only library
stringutilsas anINTERFACEtarget with tests.Build the same project with three preset flavors:
debug,release,release-asan. Run tests under each.Add
install()+ export config so a second project canfind_package(stringutils).