CMake Mastery: Modern C++ Build Systems

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:

  1. add_library(geometry STATIC ...) creates a named target geometry. Everything about how geometry is built and how it is used by consumers is expressed through subsequent target_* commands on this target.
  2. target_include_directories sets include paths with visibility:
    • PRIVATE src/ — used when compiling geometry‘s own .cpp files
    • PUBLIC include/ — used by geometry AND by anything that target_link_libraries(...geometry). Consumers automatically see include/ without a separate target_include_directories call.
  3. target_link_libraries(myapp PRIVATE geometry) does three things: links the geometry library, propagates geometry‘s PUBLIC include directories to myapp, and propagates geometry‘s PUBLIC compile definitions to myapp. All automatically.
  4. INTERFACE libraries have no source files. They exist purely to carry properties. A header-only library is the perfect use case: math_utils has no .cpp files, 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.
  5. CMAKE_EXPORT_COMPILE_COMMANDS=ON generates compile_commands.json in the build directory — used by clangd, 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:

  1. find_package(OpenSSL REQUIRED) searches CMake’s module path and package registry for OpenSSL. If found, it creates imported targets — OpenSSL::SSL and OpenSSL::Crypto — that behave just like your own library targets.
  2. target_link_libraries(myapp PRIVATE OpenSSL::SSL) propagates the OpenSSL include directory, compile definitions, and library path automatically. No include_directories(${OPENSSL_INCLUDE_DIR}) needed — the imported target carries all of this.
  3. The REQUIRED keyword makes CMake emit a fatal error if the package is not found. Without REQUIRED, the find is optional — check ${PackageName}_FOUND.
  4. Threads::Threads is a special portable target: on Linux it links pthread, on macOS it may add -pthreads, on Windows it does nothing (threads are built into the runtime). Using it makes thread portability automatic.
  5. The custom FindMyLib.cmake creates an imported target MyLib::MyLib — once created, it is used exactly like any other CMake target. The IMPORTED keyword 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:

  1. FetchContent_Declare registers a dependency with its source (Git repo, URL, or local path) and version. It does not download anything yet — declarations are just metadata.
  2. FetchContent_MakeAvailable does 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.
  3. Setting cache variables (set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)) before FetchContent_MakeAvailable configures the dependency’s own CMake options. This prevents test dependencies from polluting your install tree.
  4. GIT_TAG v1.14.0 pins the dependency to an exact version. Never use branch names like main or master — they change over time and break reproducible builds.
  5. gtest_discover_tests(unit_tests) automatically registers each TEST() macro in your test binary as a separate CTest test. This means ctest shows 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:

  1. $<$<CONFIG:Debug>:flag> is a generator expression: “include flag if the build configuration is Debug”. Unlike if(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.
  2. $<$<CXX_COMPILER_ID:MSVC>:/W4 /WX> selects compiler-specific flags without if/else blocks. The list of flags inside stays close to the target_compile_options call — easier to read and maintain than scattered if(MSVC) blocks.
  3. $<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.
  4. $<OR:...> combines conditions. $<AND:...> and $<NOT:...> are also available. These can be nested to express complex conditions.
  5. 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 same target_compile_options call.

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.

Hot this week

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

Topics

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

Related Articles

Popular Categories