Modern CMake (3.15+) is built around targets — named build artifacts like executables and libraries that carry their own build requirements, dependencies, and interface properties. The core command is target_link_libraries(myapp PRIVATE|PUBLIC|INTERFACE dep) which not only links dep but automatically propagates dep‘s include paths, compile definitions, and flags to myapp. This transitive dependency model eliminates the error-prone manual management of include directories and compile flags that plagued older CMake. The three visibility keywords (PRIVATE, PUBLIC, INTERFACE) precisely control what is propagated to consumers of a target.
Introduction
Every non-trivial C++ project needs a build system — a tool that understands what to compile, how to compile it, what depends on what, and how to link everything together. For decades, developers wrote Makefiles by hand, then switched to various higher-level build systems, each with its own syntax and ecosystem.
CMake emerged as the de facto standard for C++ build systems because it solves the hardest problem: generating correct build files for every platform. A single CMakeLists.txt generates a Visual Studio solution on Windows, an Xcode project on macOS, and a Makefile or Ninja build on Linux. CMake does not build your code — it generates the build files that your platform’s native tools use.
But CMake has a history, and much code on the internet uses “old CMake” patterns from before version 3.0 — patterns involving global variables, include_directories(), add_definitions(), and explicit compiler flags set globally. These patterns are fragile, do not compose, and are the source of countless “but it works on my machine” problems.
Modern CMake (version 3.15 and later) is fundamentally different. It is built around targets and their properties. Everything — include paths, compile definitions, compile options, linked libraries — attaches to a specific target with a specific visibility. This article teaches modern CMake from the ground up: targets and properties, finding dependencies, fetching dependencies, generator expressions, testing, installation, and professional project structure.
Targets: The Foundation of Modern CMake
Everything in modern CMake revolves around targets:
# CMakeLists.txt — Project foundation
cmake_minimum_required(VERSION 3.20)
project(MyProject
VERSION 1.3.0
DESCRIPTION "A demonstration C++ project"
HOMEPAGE_URL "https://example.com/myproject"
LANGUAGES CXX
)
# ============================
# Global settings
# ============================
# C++ standard applied to all targets (default, can be overridden per target)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # No GNU/MSVC-specific extensions
# Compile commands JSON for IDE/clangd support
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Default to Release if nothing specified
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
# ============================
# Library target
# ============================
# Create a static library from these sources
add_library(geometry STATIC
src/geometry/point.cpp
src/geometry/rect.cpp
src/geometry/circle.cpp
)
# PRIVATE: only geometry itself uses these
# (not propagated to things that link geometry)
target_include_directories(geometry PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
# PUBLIC: geometry uses AND consumers of geometry use
# (propagated to everything that links geometry)
target_include_directories(geometry PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
# PRIVATE compile definition: only for geometry's own compilation
target_compile_definitions(geometry PRIVATE
GEOMETRY_BUILD_SHARED=0
)
# PUBLIC compile definition: also seen by consumers
target_compile_definitions(geometry PUBLIC
GEOMETRY_VERSION_MAJOR=1
)
# ============================
# Executable target
# ============================
add_executable(myapp
src/main.cpp
src/application.cpp
)
# Linking geometry: automatically gets geometry's PUBLIC include dirs
# and PUBLIC compile definitions — no manual include_directories needed!
target_link_libraries(myapp PRIVATE geometry)
# Executable-specific settings
target_compile_definitions(myapp PRIVATE
APP_NAME="MyApp"
APP_VERSION="${PROJECT_VERSION}"
)
# ============================
# Interface library (header-only)
# ============================
# Interface library: no sources, only properties to propagate
add_library(math_utils INTERFACE)
# INTERFACE: not used by math_utils itself (no sources), only by consumers
target_include_directories(math_utils INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include/math_utils
)
target_compile_features(math_utils INTERFACE cxx_std_20)
# Anyone who links math_utils automatically gets:
# - the include directory
# - the C++20 requirement
target_link_libraries(myapp PRIVATE math_utils)
# ============================
# Print a configuration summary
# ============================
message(STATUS "=== MyProject ${PROJECT_VERSION} ===")
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
message(STATUS "Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS "Install prefix:${CMAKE_INSTALL_PREFIX}")
Step-by-step explanation:
add_library(geometry STATIC ...)creates a named targetgeometry. Everything about howgeometryis built and how it is used by consumers is expressed through subsequenttarget_*commands on this target.target_include_directoriessets include paths with visibility:PRIVATE src/— used when compilinggeometry‘s own.cppfilesPUBLIC include/— used bygeometryAND by anything thattarget_link_libraries(...geometry). Consumers automatically seeinclude/without a separatetarget_include_directoriescall.
target_link_libraries(myapp PRIVATE geometry)does three things: links thegeometrylibrary, propagatesgeometry‘sPUBLICinclude directories tomyapp, and propagatesgeometry‘sPUBLICcompile definitions tomyapp. All automatically.INTERFACElibraries have no source files. They exist purely to carry properties. A header-only library is the perfect use case:math_utilshas no.cppfiles, but its headers need to be on the include path, and it may require a specific C++ standard. Anyone who links it gets these properties.CMAKE_EXPORT_COMPILE_COMMANDS=ONgeneratescompile_commands.jsonin the build directory — used byclangd,clang-tidy,VSCode,CLion, and other tools for code intelligence. Always enable this.
PRIVATE, PUBLIC, and INTERFACE: The Visibility Model
Understanding visibility is the key to composable CMake:
# Visibility rules illustrated with a concrete example
# Scenario: math library that uses Eigen internally but exposes its own API
add_library(mymath STATIC
src/mymath.cpp
src/mymath_impl.cpp
)
# ── PRIVATE: only when compiling mymath itself ──────────────────────────────
# Eigen is an internal detail — callers don't need to know about it
target_include_directories(mymath PRIVATE
${EIGEN3_INCLUDE_DIR} # Eigen headers — internal implementation detail
)
target_compile_definitions(mymath PRIVATE
MYMATH_INTERNAL=1 # Internal flag — callers don't see this
_USE_MATH_DEFINES # Needed for M_PI on MSVC — internal
)
# ── PUBLIC: used by mymath AND propagated to consumers ──────────────────────
# Callers include mymath's public headers, so they need mymath's include path
target_include_directories(mymath PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include # Public API headers
)
target_compile_features(mymath PUBLIC
cxx_std_17 # Our API uses C++17 features — callers must also use C++17+
)
# ── INTERFACE: propagated to consumers ONLY (not used by mymath itself) ─────
# Callers of mymath should enable all warnings — not mymath's own compilation
# (mymath is already compiled with its own warning settings)
target_compile_options(mymath INTERFACE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wconversion> # Recommend to consumers
)
# ────────────────────────────────────────────────────────────────────────────
# Now: anything that does target_link_libraries(... mymath) automatically:
# ✓ Gets include/ (from PUBLIC target_include_directories)
# ✓ Gets cxx_std_17 (from PUBLIC compile_features)
# ✓ Gets -Wconversion (from INTERFACE compile_options)
# ✗ Does NOT get EIGEN3_INCLUDE_DIR (PRIVATE — internal detail)
# ✗ Does NOT get MYMATH_INTERNAL=1 (PRIVATE — internal detail)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mymath)
# myapp now has:
# - mymath's public include dir on its include path
# - C++17 requirement
# - -Wconversion warning flag
# - mymath.a/.lib linked
# All without any extra target_include_directories calls!
# ────────────────────────────────────────────────────────────────────────────
# Propagation chain: transitivity
add_library(myapp_lib STATIC src/applib.cpp)
target_link_libraries(myapp_lib PUBLIC mymath)
# myapp_lib's consumers also get mymath's PUBLIC properties
# (because myapp_lib links mymath with PUBLIC)
add_executable(toplevel src/toplevel.cpp)
target_link_libraries(toplevel PRIVATE myapp_lib)
# toplevel gets mymath's properties transitively through myapp_lib
Finding Dependencies with find_package
find_package locates installed libraries and creates imported targets:
cmake_minimum_required(VERSION 3.20)
project(DependencyDemo CXX)
# ──────────────────────────────────────────────────────
# find_package: the modern way to consume installed libs
# ──────────────────────────────────────────────────────
# Required dependency — error if not found
find_package(OpenSSL REQUIRED)
# Creates targets: OpenSSL::SSL, OpenSSL::Crypto
find_package(ZLIB REQUIRED)
# Creates target: ZLIB::ZLIB
find_package(Threads REQUIRED)
# Creates target: Threads::Threads
# Optional dependency — check and conditionally use
find_package(Boost 1.80 COMPONENTS filesystem system)
if(Boost_FOUND)
message(STATUS "Boost found: ${Boost_VERSION}")
else()
message(STATUS "Boost not found — using fallback")
endif()
# Component-based: only the components you need
find_package(Qt6 COMPONENTS Core Widgets Network REQUIRED)
# Creates targets: Qt6::Core, Qt6::Widgets, Qt6::Network
# Custom version requirement
find_package(fmt 9.0 REQUIRED)
# Creates target: fmt::fmt
# ──────────────────────────────────────────────────────
# Using found packages
# ──────────────────────────────────────────────────────
add_executable(secure_client
src/main.cpp
src/tls_connection.cpp
src/http_client.cpp
)
# Link against found targets — clean, no manual include_directories
target_link_libraries(secure_client PRIVATE
OpenSSL::SSL # Provides OpenSSL headers + library
OpenSSL::Crypto # Crypto library
ZLIB::ZLIB # zlib compression
Threads::Threads # pthreads on Linux, nothing special on Windows
fmt::fmt # {fmt} formatting library
)
# Conditional linking
if(Boost_FOUND)
target_link_libraries(secure_client PRIVATE
Boost::filesystem
Boost::system
)
target_compile_definitions(secure_client PRIVATE HAVE_BOOST=1)
endif()
# ──────────────────────────────────────────────────────
# Writing a Find module for a library without CMake support
# (create cmake/FindMyLib.cmake)
# ──────────────────────────────────────────────────────
# Tell CMake where to look for custom Find modules
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
find_package(MyLib REQUIRED) # Uses cmake/FindMyLib.cmake
# cmake/FindMyLib.cmake — custom Find module
# find_path: look for a header file
find_path(MyLib_INCLUDE_DIR
NAMES mylib/mylib.h mylib.h
HINTS
$ENV{MYLIB_ROOT}
${MyLib_ROOT}
PATH_SUFFIXES include
)
# find_library: look for the library file
find_library(MyLib_LIBRARY
NAMES mylib libmylib mylib_static
HINTS
$ENV{MYLIB_ROOT}
${MyLib_ROOT}
PATH_SUFFIXES lib lib64
)
# Standard handling: sets MyLib_FOUND, version checking, required checking
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(MyLib
REQUIRED_VARS MyLib_LIBRARY MyLib_INCLUDE_DIR
VERSION_VAR MyLib_VERSION
)
# Create an imported target (modern approach)
if(MyLib_FOUND AND NOT TARGET MyLib::MyLib)
add_library(MyLib::MyLib UNKNOWN IMPORTED)
set_target_properties(MyLib::MyLib PROPERTIES
IMPORTED_LOCATION "${MyLib_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${MyLib_INCLUDE_DIR}"
)
endif()
# Hide internal vars from cmake-gui
mark_as_advanced(MyLib_INCLUDE_DIR MyLib_LIBRARY)
Step-by-step explanation:
find_package(OpenSSL REQUIRED)searches CMake’s module path and package registry for OpenSSL. If found, it creates imported targets —OpenSSL::SSLandOpenSSL::Crypto— that behave just like your own library targets.target_link_libraries(myapp PRIVATE OpenSSL::SSL)propagates the OpenSSL include directory, compile definitions, and library path automatically. Noinclude_directories(${OPENSSL_INCLUDE_DIR})needed — the imported target carries all of this.- The
REQUIREDkeyword makes CMake emit a fatal error if the package is not found. WithoutREQUIRED, the find is optional — check${PackageName}_FOUND. Threads::Threadsis a special portable target: on Linux it linkspthread, on macOS it may add-pthreads, on Windows it does nothing (threads are built into the runtime). Using it makes thread portability automatic.- The custom
FindMyLib.cmakecreates an imported targetMyLib::MyLib— once created, it is used exactly like any other CMake target. TheIMPORTEDkeyword marks it as external (not built by this project).
FetchContent: Managing Dependencies in-Tree
FetchContent downloads and builds dependencies as part of your build, without requiring them to be pre-installed:
cmake_minimum_required(VERSION 3.20)
project(FetchContentDemo CXX)
include(FetchContent)
# ──────────────────────────────────────────────────────
# Declare dependencies
# ──────────────────────────────────────────────────────
# Google Test
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0 # Always use a fixed tag, not 'main'
GIT_SHALLOW TRUE # Shallow clone: faster download
)
# {fmt} formatting library
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.2.1
GIT_SHALLOW TRUE
)
# nlohmann/json (header-only)
FetchContent_Declare(
nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
# spdlog (logging library)
FetchContent_Declare(
spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.13.0
GIT_SHALLOW TRUE
)
# ──────────────────────────────────────────────────────
# Configure dependencies before making them available
# ──────────────────────────────────────────────────────
# Don't install googletest when we install our project
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
# Don't build gmock
set(BUILD_GMOCK ON CACHE BOOL "" FORCE)
# Don't install fmt with our project
set(FMT_INSTALL OFF CACHE BOOL "" FORCE)
# Don't run nlohmann_json's own tests
set(JSON_BuildTests OFF CACHE INTERNAL "")
# ──────────────────────────────────────────────────────
# Make all dependencies available (download if needed)
# ──────────────────────────────────────────────────────
FetchContent_MakeAvailable(
googletest
fmt
nlohmann_json
spdlog
)
# After FetchContent_MakeAvailable, these targets exist:
# - GTest::gtest, GTest::gtest_main, GTest::gmock
# - fmt::fmt
# - nlohmann_json::nlohmann_json
# - spdlog::spdlog
# ──────────────────────────────────────────────────────
# Main application
# ──────────────────────────────────────────────────────
add_executable(myapp
src/main.cpp
src/server.cpp
src/config.cpp
)
target_link_libraries(myapp PRIVATE
fmt::fmt
nlohmann_json::nlohmann_json
spdlog::spdlog
)
# ──────────────────────────────────────────────────────
# Tests using GoogleTest
# ──────────────────────────────────────────────────────
enable_testing()
add_executable(unit_tests
tests/test_server.cpp
tests/test_config.cpp
tests/test_utils.cpp
)
target_link_libraries(unit_tests PRIVATE
GTest::gtest_main # Provides main() and test runner
GTest::gmock
fmt::fmt
nlohmann_json::nlohmann_json
)
# Register tests with CTest
include(GoogleTest)
gtest_discover_tests(unit_tests)
Step-by-step explanation:
FetchContent_Declareregisters a dependency with its source (Git repo, URL, or local path) and version. It does not download anything yet — declarations are just metadata.FetchContent_MakeAvailabledoes the actual work: if the dependency is not cached, it downloads (or clones) and configures it. On subsequent builds, the cached copy is used — no re-download.- Setting cache variables (
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)) beforeFetchContent_MakeAvailableconfigures the dependency’s own CMake options. This prevents test dependencies from polluting your install tree. GIT_TAG v1.14.0pins the dependency to an exact version. Never use branch names likemainormaster— they change over time and break reproducible builds.gtest_discover_tests(unit_tests)automatically registers eachTEST()macro in your test binary as a separate CTest test. This meansctestshows individual test names and can run or filter them individually, not just the whole binary.
Generator Expressions: Build-Time Logic
Generator expressions are CMake’s way of expressing conditions that depend on the build configuration, target, or language — evaluated when generating build files, not when CMake runs:
cmake_minimum_required(VERSION 3.20)
project(GenExDemo CXX)
add_library(mylib STATIC src/mylib.cpp)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mylib)
# ──────────────────────────────────────────────────────
# Configuration-dependent settings
# ──────────────────────────────────────────────────────
# Different compile options for Debug vs Release
target_compile_options(myapp PRIVATE
# Debug: add sanitizers and debug symbols
$<$<CONFIG:Debug>:-fsanitize=address,undefined -fno-omit-frame-pointer>
# Release: maximum optimization
$<$<CONFIG:Release>:-O3 -DNDEBUG>
# All configurations: warnings
-Wall -Wextra
)
target_link_options(myapp PRIVATE
$<$<CONFIG:Debug>:-fsanitize=address,undefined>
)
# ──────────────────────────────────────────────────────
# Compiler-dependent settings
# ──────────────────────────────────────────────────────
target_compile_options(mylib PRIVATE
# MSVC-specific flags
$<$<CXX_COMPILER_ID:MSVC>:/W4 /WX /permissive->
# GCC-specific flags
$<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra -Wpedantic -Werror>
# Clang-specific flags
$<$<CXX_COMPILER_ID:Clang>:-Wall -Wextra -Weverything -Wno-c++98-compat>
# GCC or Clang (not MSVC)
$<$<OR:$<CXX_COMPILER_ID:GNU>,$<CXX_COMPILER_ID:Clang>>:-fstack-protector-strong>
)
# ──────────────────────────────────────────────────────
# Platform-dependent settings
# ──────────────────────────────────────────────────────
target_compile_definitions(myapp PRIVATE
$<$<PLATFORM_ID:Windows>:WIN32_LEAN_AND_MEAN NOMINMAX>
$<$<PLATFORM_ID:Linux>:_GNU_SOURCE>
$<$<PLATFORM_ID:Darwin>:_DARWIN_C_SOURCE>
)
# ──────────────────────────────────────────────────────
# BUILD_INTERFACE vs INSTALL_INTERFACE
# (critical for installable libraries)
# ──────────────────────────────────────────────────────
add_library(portablelib SHARED src/portablelib.cpp)
target_include_directories(portablelib PUBLIC
# When building: use the source tree
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
# When installed: use the install prefix relative path
$<INSTALL_INTERFACE:include>
)
# ──────────────────────────────────────────────────────
# Target-dependent properties
# ──────────────────────────────────────────────────────
# Add a source only if the target is an executable
target_sources(myapp PRIVATE
$<$<STREQUAL:$<TARGET_PROPERTY:TYPE>,EXECUTABLE>:src/app_main.cpp>
)
# Link Windows-specific libraries only on Windows
target_link_libraries(myapp PRIVATE
$<$<PLATFORM_ID:Windows>:ws2_32 kernel32>
)
# Print what these evaluate to (at configure time for fixed values):
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS "Is Debug: $<CONFIG:Debug>") # Evaluates at generate time, not here
Step-by-step explanation:
$<$<CONFIG:Debug>:flag>is a generator expression: “includeflagif the build configuration is Debug”. Unlikeif(CMAKE_BUILD_TYPE STREQUAL "Debug"), this works correctly with multi-configuration generators (Visual Studio, Xcode) where the configuration is selected at build time, not configure time.$<$<CXX_COMPILER_ID:MSVC>:/W4 /WX>selects compiler-specific flags withoutif/elseblocks. The list of flags inside stays close to thetarget_compile_optionscall — easier to read and maintain than scatteredif(MSVC)blocks.$<BUILD_INTERFACE:...>vs$<INSTALL_INTERFACE:...>is critical for installable libraries. When building, headers are in the source tree. After installation, they are in${prefix}/include. This generator expression makes both work with the same target.$<OR:...>combines conditions.$<AND:...>and$<NOT:...>are also available. These can be nested to express complex conditions.- Generator expressions are a string substitution mechanism — they produce empty strings (
"") when their condition is false, so adding “nothing” to a list is harmless. This is why you can mix conditional and unconditional flags in the sametarget_compile_optionscall.
Testing with CTest
cmake_minimum_required(VERSION 3.20)
project(TestingDemo CXX)
enable_testing() # Must be called in the top-level CMakeLists.txt
# ──────────────────────────────────────────────────────
# Unit tests with GoogleTest
# ──────────────────────────────────────────────────────
include(FetchContent)
FetchContent_Declare(googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0 GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(googletest)
include(GoogleTest)
function(add_unit_test target_name)
add_executable(${target_name} ${ARGN})
target_link_libraries(${target_name} PRIVATE GTest::gtest_main GTest::gmock)
gtest_discover_tests(${target_name}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
PROPERTIES TIMEOUT 30
)
endfunction()
add_unit_test(test_math tests/test_math.cpp)
add_unit_test(test_parser tests/test_parser.cpp)
add_unit_test(test_networking tests/test_networking.cpp)
# ──────────────────────────────────────────────────────
# Integration tests
# ──────────────────────────────────────────────────────
# Add a script-based test
add_test(
NAME integration_test_smoke
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/smoke_test.sh
$<TARGET_FILE:myapp> --config test_config.json
)
# Set test environment variables
set_tests_properties(integration_test_smoke PROPERTIES
ENVIRONMENT "TEST_MODE=1;LOG_LEVEL=debug"
TIMEOUT 60
LABELS "integration"
)
# ──────────────────────────────────────────────────────
# Test configuration
# ──────────────────────────────────────────────────────
# Set output directory for test binaries
set_target_properties(test_math test_parser PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests
)
# Add test labels for selective running
set_tests_properties(test_math PROPERTIES
LABELS "unit;math"
)
# ──────────────────────────────────────────────────────
# CTest options (set in CTestCustom.cmake or command line)
# ──────────────────────────────────────────────────────
# Run tests: ctest --build-config Debug -j8 --output-on-failure
# Run only unit tests: ctest -L unit --output-on-failure
# Run with verbose output: ctest -V
# Run with timeout: ctest --timeout 120
Installation and Packaging
A proper CMake installation makes your library consumable by others via find_package:
cmake_minimum_required(VERSION 3.20)
project(InstallableLib VERSION 2.1.0)
# ──────────────────────────────────────────────────────
# The library
# ──────────────────────────────────────────────────────
add_library(installablelib SHARED
src/installablelib.cpp
)
target_include_directories(installablelib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
set_target_properties(installablelib PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
PUBLIC_HEADER "include/installablelib.h;include/installablelib_types.h"
)
# ──────────────────────────────────────────────────────
# Installation rules
# ──────────────────────────────────────────────────────
include(GNUInstallDirs) # Provides CMAKE_INSTALL_LIBDIR, INCLUDEDIR, etc.
install(TARGETS installablelib
EXPORT InstallableLibTargets # Name of the export set
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # DLL on Windows
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} # .so on Linux
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} # .a / .lib
PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/installablelib
)
# ──────────────────────────────────────────────────────
# CMake package config files
# (enables find_package(InstallableLib) for consumers)
# ──────────────────────────────────────────────────────
include(CMakePackageConfigHelpers)
# Generate the config file
configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/InstallableLibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/InstallableLibConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/InstallableLib
)
# Generate the version file
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/InstallableLibConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion # 2.1.0 is compatible with 2.x.y requests
)
# Install the config files
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/InstallableLibConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/InstallableLibConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/InstallableLib
)
# Install the export set (the target import file)
install(EXPORT InstallableLibTargets
FILE InstallableLibTargets.cmake
NAMESPACE InstallableLib:: # Prefix: InstallableLib::installablelib
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/InstallableLib
)
# cmake/InstallableLibConfig.cmake.in
@PACKAGE_INIT@
# Include the targets file
include("${CMAKE_CURRENT_LIST_DIR}/InstallableLibTargets.cmake")
# Verify required components are present
check_required_components(InstallableLib)
After installation, consumers can do:
find_package(InstallableLib 2.1.0 REQUIRED)
target_link_libraries(myapp PRIVATE InstallableLib::installablelib)
# Automatically gets headers, compile definitions, linked library — everything
Professional Project Structure
myproject/
├── CMakeLists.txt # Top-level: project(), subdirectories, install
├── cmake/
│ ├── FindMyDep.cmake # Custom Find modules
│ ├── MyProjectConfig.cmake.in # Package config template
│ └── CompilerWarnings.cmake # Reusable warning settings
├── include/
│ └── myproject/
│ ├── api.hpp # Public headers
│ └── types.hpp
├── src/
│ ├── CMakeLists.txt # Library target definition
│ ├── core.cpp
│ └── utils.cpp
├── apps/
│ ├── CMakeLists.txt # Executable targets
│ └── main.cpp
├── tests/
│ ├── CMakeLists.txt # Test targets
│ ├── test_core.cpp
│ └── test_utils.cpp
├── benchmarks/
│ ├── CMakeLists.txt # Benchmark targets
│ └── bench_core.cpp
└── docs/
└── CMakeLists.txt # Documentation generation (Doxygen)
# Top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Options users can set
option(MYPROJECT_BUILD_TESTS "Build unit tests" ON)
option(MYPROJECT_BUILD_BENCHMARKS "Build benchmarks" OFF)
option(MYPROJECT_BUILD_DOCS "Build documentation" OFF)
option(MYPROJECT_ENABLE_ASAN "Enable AddressSanitizer" OFF)
# Shared warning settings
include(cmake/CompilerWarnings.cmake)
# Fetch external dependencies
include(cmake/Dependencies.cmake)
# Subdirectories
add_subdirectory(src) # Defines myproject::core library
if(MYPROJECT_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
add_subdirectory(apps) # Defines executables
if(MYPROJECT_BUILD_BENCHMARKS)
add_subdirectory(benchmarks)
endif()
if(MYPROJECT_BUILD_DOCS)
add_subdirectory(docs)
endif()
# Install rules
include(cmake/Install.cmake)
# cmake/CompilerWarnings.cmake — reusable warning configuration
function(set_project_warnings target_name)
set(MSVC_WARNINGS
/W4 /WX /permissive-
/w14242 /w14254 /w14263 /w14265
/w14287 /we4289 /w14296 /w14311
/w14545 /w14546 /w14547 /w14549
/w14555 /w14619 /w14640 /w14826
/w14905 /w14906 /w14928
)
set(CLANG_WARNINGS
-Wall -Wextra -Wshadow -Wnon-virtual-dtor
-Wold-style-cast -Wcast-align -Wunused
-Woverloaded-virtual -Wpedantic -Wconversion
-Wsign-conversion -Wnull-dereference -Wdouble-promotion
-Wformat=2 -Wimplicit-fallthrough
)
set(GCC_WARNINGS
${CLANG_WARNINGS}
-Wmisleading-indentation -Wduplicated-cond
-Wduplicated-branches -Wlogical-op
-Wuseless-cast
)
if(MSVC)
set(WARNINGS ${MSVC_WARNINGS})
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
set(WARNINGS ${CLANG_WARNINGS})
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set(WARNINGS ${GCC_WARNINGS})
endif()
target_compile_options(${target_name} PRIVATE ${WARNINGS})
endfunction()
CMake Anti-Patterns to Avoid
# ──────────────────────────────────────────────────────
# OLD / BAD patterns (pre-CMake 3.0 style)
# ──────────────────────────────────────────────────────
# ✗ Global include_directories — contaminates ALL targets
include_directories(${SOME_LIB_INCLUDE_DIR})
# ✓ Use: target_include_directories(mytarget PRIVATE ...)
# ✗ Global link_directories — fragile and order-dependent
link_directories(/usr/local/lib)
# ✓ Use: target_link_libraries(mytarget PRIVATE imported_target)
# ✗ Global add_definitions — affects ALL targets
add_definitions(-DSOME_MACRO=1)
# ✓ Use: target_compile_definitions(mytarget PRIVATE SOME_MACRO=1)
# ✗ Manual flags via CMAKE_CXX_FLAGS — affects all, overrides everything
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
# ✓ Use: target_compile_options(mytarget PRIVATE -Wall)
# ✗ Hard-coded paths — breaks on every other machine
include_directories(/home/user/mylib/include)
# ✓ Use: find_package(MyLib REQUIRED) or FetchContent
# ✗ Not specifying minimum CMake version
# cmake_minimum_required(VERSION 2.8) — too old, enables legacy behavior
# ✓ Use: cmake_minimum_required(VERSION 3.20)
# ✗ FILE(GLOB ...) for source files — doesn't detect new files automatically
file(GLOB SOURCES "src/*.cpp") # CMake doesn't know about new files!
add_executable(myapp ${SOURCES})
# ✓ Explicitly list source files:
add_executable(myapp src/main.cpp src/app.cpp src/config.cpp)
# ✗ Using CMAKE_SOURCE_DIR in libraries — breaks with add_subdirectory
include_directories(${CMAKE_SOURCE_DIR}/include)
# ✓ Use CMAKE_CURRENT_SOURCE_DIR:
target_include_directories(mylib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
CMake Quick Reference
| Command | Purpose |
|---|---|
cmake_minimum_required(VERSION X) |
Set minimum CMake version |
project(Name VERSION X LANGUAGES CXX) |
Define project |
add_executable(name sources...) |
Create an executable target |
add_library(name STATIC/SHARED/INTERFACE sources...) |
Create a library target |
target_link_libraries(name PRIVATE/PUBLIC/INTERFACE deps...) |
Link and propagate deps |
target_include_directories(name PRIVATE/PUBLIC/INTERFACE dirs...) |
Set include paths |
target_compile_definitions(name PRIVATE/PUBLIC/INTERFACE defs...) |
Set compile definitions |
target_compile_options(name PRIVATE/PUBLIC/INTERFACE opts...) |
Set compiler flags |
target_compile_features(name PRIVATE/PUBLIC/INTERFACE feats...) |
Require language features |
find_package(Name REQUIRED COMPONENTS ...) |
Find installed package |
FetchContent_Declare/MakeAvailable |
Download+build dependency |
include(FetchContent/GNUInstallDirs/...) |
Load CMake modules |
add_subdirectory(dir) |
Include subdirectory’s CMakeLists.txt |
enable_testing() + add_test(...) |
Register tests with CTest |
gtest_discover_tests(target) |
Auto-register GoogleTest tests |
install(TARGETS ...) |
Define install rules |
$<CONFIG:Debug> |
Generator expression: config check |
$<CXX_COMPILER_ID:MSVC> |
Generator expression: compiler check |
$<BUILD_INTERFACE:path> |
Generator expression: build tree path |
$<INSTALL_INTERFACE:path> |
Generator expression: install tree path |
Conclusion
Modern CMake is a target-centric build description language. Once you internalize the mental model — every build artifact is a target, every property has visibility (PRIVATE/PUBLIC/INTERFACE), and visibility determines what propagates to consumers — CMake’s behavior becomes predictable and composable.
The most important single concept is target_link_libraries with visibility. When you write target_link_libraries(myapp PRIVATE geometry), you are not just linking a library — you are declaring that myapp consumes geometry, and CMake automatically propagates all of geometry‘s PUBLIC properties (include paths, compile definitions, linked libraries) to myapp. Remove all manual include_directories and add_definitions calls from your CMake files — they are signs of old-style CMake that should be replaced with target_* commands.
find_package with imported targets makes consuming installed libraries as simple as one target_link_libraries call — no manual include_directories(${OPENSSL_INCLUDE_DIR}) needed. FetchContent makes it equally simple to use libraries that are not installed — just declare, make available, and link.
Generator expressions solve the problem of build-configuration-dependent settings in a way that works correctly for both single-configuration generators (Ninja, Make) and multi-configuration generators (Visual Studio, Xcode). Use them for configuration-dependent flags, compiler-dependent options, and the critical BUILD_INTERFACE/INSTALL_INTERFACE distinction for installable libraries.
Master these patterns, and your CMakeLists.txt files will be clean, composable, and correct on every platform your users run.




