CodeRigor

FREE LESSON

Building real C++ projects: translation units, linking and CMake targets

How a multi-file C++ project becomes binaries: headers and the ODR, reading linker errors, static vs shared libraries, and target-based CMake — usage requirements, presets, packages and compile_commands.json.

Interview exercises live in one file. Jobs don't: a renderer, a Qt app or a firmware image is hundreds of .cpp files, a dozen libraries, third-party packages and several build configurations. The first week on such a team you will be asked to add a file, add a dependency, or fix a link error, and the questions behind those tasks are the same everywhere: what does each file contain, what does the linker need, and how does CMake decide which flags reach which compile? This lesson answers them in that order.

1. From files to a program

Each .cpp file is compiled on its own. The preprocessor first pastes in every #included header and expands macros; the result is a translation unit (TU). The compiler turns one TU into one object file (.o) holding machine code plus a symbol table: the names it defines and the names it uses but doesn't define. Only the linker sees all the object files and libraries together, resolving references to definitions. It can diagnose many missing or duplicate symbols, but cannot establish every ODR requirement.

Separate compilation
Each .cpp becomes a translation unit and an object file on its own; only the linker sees them together. A header is compiled once per TU that includes it.
Separate compilation
Each .cpp becomes a translation unit and an object file on its own; only the linker sees them together. A header is compiled once per TU that includes it.

Two consequences shape everything else. A header is compiled once per TU that includes it, so whatever it contains must be safe to appear in many TUs. And the compiler of main.cpp knows nothing about render.cpp except what the headers told it, so headers carry declarations: promises that a definition exists somewhere.

2. Declarations, definitions, headers

A declaration introduces a name and its type; a definition is the declaration that also provides the thing itself (the function body, the storage, the class members). Compatible declarations can repeat. Definitions have the per-TU and whole-program restrictions explained below.

C++
#include <print>

// --- what "geometry.hpp" would hold: declarations only
extern int splat_count;          // a variable some TU defines
int area(int w, int h);          // a function, body elsewhere
struct Image;                    // an incomplete type: pointers/references only

int pixels(const Image& im);     // fine: a reference to an incomplete type

// --- what "geometry.cpp" would hold: the definitions
int splat_count = 3;
int area(int w, int h) { return w * h; }
struct Image { int w, h; };
int pixels(const Image& im) { return area(im.w, im.h); }

int main() {
    Image im{4, 2};
    std::println("pixels {} splats {}", pixels(im), splat_count);
}

The forward declaration struct Image; is how large projects keep compile times sane: a header that only passes Image& around doesn't need to include image.hpp, so editing image.hpp doesn't recompile every file that includes the first header.

Include guards. A header reaches a TU more than once as soon as two headers include it. A class defined twice in one TU is an error:

C++
// main.cpp after the preprocessor pasted "vec.hpp" twice (no include guard):
struct Vec3 { float x, y, z; };
struct Vec3 { float x, y, z; };

int main() {}

So every header starts with a guard. Both spellings are fine in production; pick the project's convention:

C++
// splat/vec.hpp
#pragma once                       // supported by GCC, Clang, MSVC; not in the standard

// or the portable classic:
#ifndef SPLAT_VEC_HPP
#define SPLAT_VEC_HPP
struct Vec3 { float x, y, z; };
#endif

#pragma once goes by file identity, so it can be fooled by the same header reachable through two different paths (copies, some network filesystems); macro guards can be fooled by two headers picking the same macro name. Both failures are rare; a unique, path-derived macro name avoids the second.

Make each header self-contained: include the headers for names it uses instead of relying on a consumer's include order. A .cpp should include its own interface header so signature mismatches are visible. Use quoted includes for project-relative headers and angle brackets for external/library headers; follow the project's filename convention. Keep include dependencies directed and avoid cycles; forward-declare a type when an incomplete pointer/reference interface is sufficient. Do not add a global using namespace directive or an unnamed namespace to a public header to conceal an ODR problem. These are the source-file concerns in Core Guidelines SF.4–SF.13 and SF.20–SF.22.

3. The one-definition rule and inline

The One Definition Rule (ODR) has two boundaries. Within one TU, a class, function or variable must not be defined twice. Across the whole program, an odr-used ordinary non-inline function or variable with external linkage needs one definition. In this lesson, calling an ordinary function or taking an object's address are examples of odr-use. Simply declaring an unused function does not require a body anywhere.

Ordinary textual headers can contain definitions of classes, templates and inline functions or variables that appear in several TUs. The definitions of the same entity must satisfy the ODR: matching tokens after preprocessing and matching name lookup, subject to the standard's exceptions. A single canonical header helps, but different macros or include contexts can still break that agreement. Named modules have additional restrictions; this section is about the ordinary #include model.

What belongs in a reusable header? Where is its definition?
int area(int, int); A matching non-inline definition in one .cpp
extern int count; int count = 0; in one .cpp
A class definition The same definition from one header, once per including TU
An inline function or inline int count = 0; Matching header definitions represent one shared entity
A function defined inside a class in an ordinary header Implicitly inline; matching definitions may appear across TUs
A template definition Usually visible in the header at instantiation; matching-definition rules still apply

A constexpr function is implicitly inline. An ordinary namespace-scope constexpr variable is not: without inline or external linkage it normally has internal linkage, creating a separate object in each TU. Use inline constexpr when the header should provide one shared constant object. A static constexpr data member is implicitly inline.

A full function-template specialization also needs care: template <> int twice<int>(int) is not implicitly inline just because the primary is a template. Define it once in a .cpp, or explicitly make the header definition inline. See the template lesson.

C++
#include <print>
#include <string_view>

// Everything here may sit in a header included by many .cpp files.
inline int next_id() {               // one function program-wide…
    static int id = 0;               // …so one `id`, shared by every TU
    return ++id;
}
inline constexpr std::string_view k_format = "splat-ply-1";  // C++17 inline variable
template <class T> T twice(T v) { return v + v; }            // templates: fine in headers
constexpr int square(int x) { return x * x; }                // constexpr: implicitly inline
struct Registry {
    static inline int created = 0;   // inline static data member: no .cpp definition needed
    Registry() { ++created; }
};

int main() {
    Registry a, b;
    int first = next_id();
    int second = next_id();
    std::println("{} {} {} {} {}", first, second, twice(21), square(7), Registry::created);
    std::println("{}", k_format);
}

What goes wrong in practice:

  • An ordinary external non-inline function or variable defined in a header. Each including TU defines the same entity. Linkers commonly report multiple definition. Keep its declaration in the header and its definition in one .cpp, or use an intentional inline definition in the header.
  • static (or an unnamed namespace) in a header. It compiles and links — every TU gets its own private entity. That can be valid, but it is not one shared counter or shared object identity. Prefer .cpp-local helpers for implementation details; choose inline or extern plus one definition when state must be shared. Internal-linkage names can also invalidate a shared inline definition when that definition binds to different entities below.
  • Different definitions of the same external class or inline function. This can be ill-formed, no diagnostic required. Successful linking is not evidence of correctness and no particular run-time result is guaranteed. LTO and GCC's -Wodr can catch some cases, not all of them.

Macros can change a definition before the compiler sees it

C++
// config.hpp — intentionally unsafe if consuming TUs disagree on FRAME_SAMPLES
#ifndef CRUCIBLE_EXAMPLE_CONFIG_HPP
#define CRUCIBLE_EXAMPLE_CONFIG_HPP
namespace demo {
struct Frame { int samples[FRAME_SAMPLES]; };
inline int frame_samples() { return FRAME_SAMPLES; }
}
#endif

If capture.cpp includes this header with FRAME_SAMPLES=4 and display.cpp with FRAME_SAMPLES=8, both the class layout and inline function body differ after preprocessing. The guard works in each TU and the program still violates ODR. Macros are not themselves C++ functions or classes; their expansions create the mismatch. Keep public definitions' configuration consistent across all consumers, or move configuration-dependent implementation into one .cpp. Use distinct types or explicit template arguments for deliberate alternatives.

Even identical tokens are not enough if lookup changes their meaning:

C++
// common.hpp — intentionally depends on a name supplied before the include
inline int transform(int x) { return adjust(x); }

// a.cpp, before #include "common.hpp"
namespace { int adjust(int x) { return x + 1; } }
// b.cpp, before #include "common.hpp"
namespace { int adjust(int x) { return x + 2; } }

transform has the same tokens in both TUs but calls distinct internal-linkage functions. That violates the matching-lookup requirement. Make headers self-contained and have shared inline definitions name the same declared entities, with explicit namespace qualification where useful.

Check with separate consumers

The ODR lab in projects/odr-lab/ has a guarded header, two .cpp files, one ordinary function definition, an extern variable, a template and shared inline state. From the repository root run:

Shell
python projects/odr-lab/check.py --compiler g++ --compiler clang++

It compiles the files separately and checks object identity and shared state. It also demonstrates a same-TU redefinition error, duplicate external function and variable link errors, and a missing-definition link error. Macro variants are preprocessed and inspected, not linked or executed as a valid program. The app's normal coding tasks combine the submission and tests into one TU; passing them alone does not establish multi-TU header safety. This local lab is ungraded and awards no mastery or XP.

For the exact language rules, see the C++ draft's ODR section and inline section. The Core Guidelines' source-file rules give practical header conventions.

4. Linking, and reading its errors

The linker matches names, and C++ names carry their types: the compiler mangles splat::load_ply(const std::filesystem::path&) into a symbol such as _ZN5splat8load_plyERKNSt10filesystem7__cxx114pathE. Linker messages are demangled for you, but nm output and some tools aren't; c++filt (or the runtime ABI helper below) turns them back:

C++
#include <cstdlib>
#include <cxxabi.h>
#include <print>

int main() {
    const char* symbols[] = {
        "_ZN5splat8load_plyERKNSt10filesystem7__cxx114pathE",
        "_ZN5splat6renderERKNS_5SceneEi",
        "_Z7clamp01f",
    };
    for (const char* s : symbols) {
        int status = 0;
        char* readable = abi::__cxa_demangle(s, nullptr, nullptr, &status);
        std::println("{:52} {}", s, status == 0 ? readable : "?");
        std::free(readable);
    }
}

The two errors you will meet weekly, as GNU ld prints them:

Text
/usr/bin/ld: CMakeFiles/splat-render.dir/main.cpp.o: in function `main':
main.cpp:(.text+0x5d): undefined reference to `splat::load_ply(std::filesystem::__cxx11::path const&)'
collect2: error: ld returned 1 exit status

Undefined reference: something uses the name and nothing defines it — with this exact signature. Read the signature first, then ask, in order: Is the function defined at all (declared in a header, body never written)? Is the .cpp that defines it part of a target this executable links? Does the definition match — same namespace, same const, same parameter types (a const path& declaration and a path definition are two different functions)? Is the library on the link line, and before the libraries that need it? Was it compiled as C (extern "C" missing, or extra)? Is it in a shared library that hides it (see §5)?

Text
/usr/bin/ld: CMakeFiles/splat_core.dir/io.cpp.o: in function `splat::clamp01(float)':
io.cpp:(.text+0x0): multiple definition of `splat::clamp01(float)';
CMakeFiles/splat_core.dir/render.cpp.o:render.cpp:(.text+0x0): first defined here

Multiple definition: two object files both define it. Nine times out of ten a header defines a non-inline function or variable (§3); otherwise the same .cpp is listed in two targets that end up in one binary. With lld the wording is ld.lld: error: undefined symbol: / duplicate symbol: followed by >>> referenced by main.cpp:7 lines: same diagnosis.

Your tools: nm -C --defined-only libsplat_core.a | grep load_ply (is it defined? T = in text, U = used but undefined), nm -D libsplat_io.so (the dynamic symbols a shared library exports), ldd ./splat-render (which shared libraries the loader will find), and cmake --build build -v to see the real link command.

5. Static and shared libraries

A static library (libsplat_core.a) is an archive of object files. It is never linked by itself: when the final executable is linked, the linker copies in the members that resolve symbols still undefined at that point. Two consequences: the archive's own undefined symbols (its calls into libpng.a) are resolved only in the final link, and order matters — GNU ld scans the command line left to right, once, so a library must come before the libraries it uses.

A single-pass link
start / 3
GNU ld keeps a set of undefined symbols and pulls archive members that resolve them. libpng.a listed before libsplat_core.a would be scanned while nothing needed it yet: undefined reference to png_read_info.
A single-pass link
GNU ld keeps a set of undefined symbols and pulls archive members that resolve them. libpng.a listed before libsplat_core.a would be scanned while nothing needed it yet: undefined reference to png_read_info.

A shared library (libsplat_io.so) is linked once, as its own binary: its dependencies are resolved then, recorded as NEEDED entries, and loaded by the dynamic loader at run time. Consumers only need its exported symbols. On Linux every non-static symbol is exported by default, which makes the library's internals part of its ABI and slows loading; production libraries build with hidden visibility and mark the API explicitly:

CMake
set(CMAKE_CXX_VISIBILITY_PRESET hidden)       # -fvisibility=hidden
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)       # -fvisibility-inlines-hidden
include(GenerateExportHeader)
generate_export_header(splat_io)              # writes splat_io_export.h with SPLAT_IO_EXPORT
C++
#include "splat_io_export.h"
namespace splat {
SPLAT_IO_EXPORT Scene load_scene(const std::filesystem::path& file);  // exported
Scene parse_header(std::string_view text);                            // hidden: internal
}

Forget the macro on a function the executable calls and you get an undefined reference even though nm libsplat_io.so shows the code is there (nm -D won't). A shared library that needs to find its neighbours at run time uses an RPATH such as $ORIGIN/../lib — the release skill covers that.

6. Target-based CMake

Modern CMake (3.x and 4.x) is organised around targets: a library or an executable, with properties. You never set global flags; you state, per target, what it needs (PRIVATE), what its users need (INTERFACE), or both (PUBLIC). These are its usage requirements, and CMake propagates them along target_link_libraries.

CMake
cmake_minimum_required(VERSION 3.25)
project(scene_viewer VERSION 0.3.0 LANGUAGES CXX)

add_library(splat_math INTERFACE)                              # header-only: no artifact
target_include_directories(splat_math INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/math/include)

add_library(splat_core STATIC core/src/scene.cpp core/src/render.cpp)
add_library(splat::core ALIAS splat_core)                       # the name consumers use
target_compile_features(splat_core PUBLIC cxx_std_20)           # users need C++20 for our headers
target_include_directories(splat_core
    PUBLIC  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/core/include>
            $<INSTALL_INTERFACE:include>
    PRIVATE core/src)
target_compile_definitions(splat_core PRIVATE SPLAT_BUILDING_CORE)
target_link_libraries(splat_core
    PUBLIC  splat_math                                          # our headers include its headers
    PRIVATE PNG::PNG)                                           # only our .cpp files use libpng

add_executable(splat-render cli/main.cpp)
target_link_libraries(splat-render PRIVATE splat::core)

The rule for choosing: if a dependency's types or headers appear in your public headers, it's PUBLIC; if they appear only in your .cpp files, it's PRIVATE; if only your users need it (header-only wrappers), INTERFACE. Getting it wrong in the PRIVATE direction breaks consumers ("png.h: No such file" from a file that never mentioned png — through your header); getting it wrong in the PUBLIC direction leaks your internals into everyone's include path and makes them silently depend on it.

Usage requirements flow along PUBLIC edges
start / 3
What reaches splat-render's compile: its own items, then splat::core's PUBLIC items, then splat_math's INTERFACE items. PNG::PNG's include directory stops at splat_core — but libpng.a still reaches the link line.
Usage requirements flow along PUBLIC edges
What reaches splat-render's compile: its own items, then splat::core's PUBLIC items, then splat_math's INTERFACE items. PNG::PNG's include directory stops at splat_core — but libpng.a still reaches the link line.

Three details that interviews and code reviews probe:

  • ALIAS and :: names. target_link_libraries(x PRIVATE splat_cor) with a typo is not an error at configure time: an unknown plain name is passed to the linker as -lsplat_cor, and you find out at link time. A name with :: must be a target, so splat::cor fails immediately with a clear message. Give every library an ALIAS in its namespace and link by that name — the same spelling consumers use with the installed package.
  • Static libraries pass PRIVATE dependencies to the link line. For compiling, PRIVATE stops propagation; for linking a static library it can't: CMake records the dependency as $<LINK_ONLY:PNG::PNG>.
  • INTERFACE libraries have no artifact. They carry include directories, definitions, compile features and further dependencies for header-only code — or bundle a set of warning flags all your targets link to.

Generator expressions ($<…>) are evaluated at generate time, per target and per configuration. The ones you will actually write:

CMake
target_compile_options(splat_core PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic>
    $<$<CONFIG:Debug>:-fno-omit-frame-pointer>)
target_compile_definitions(splat_core PUBLIC $<$<CONFIG:Debug>:SPLAT_CHECKS=1>)
target_include_directories(splat_core PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/core/include>   # while building this project
    $<INSTALL_INTERFACE:include>)                                 # for users of the installed package

Use $<CONFIG:…> rather than if(CMAKE_BUILD_TYPE STREQUAL …): the if runs once at configure time and is wrong under multi-config generators.

7. Build types, flags and the C++ standard

A single-config generator (Makefiles, Ninja) builds one configuration per build directory, chosen with -DCMAKE_BUILD_TYPE=…. A multi-config generator (Ninja Multi-Config, Visual Studio, Xcode) holds all of them and ignores CMAKE_BUILD_TYPE; you pick at build time: cmake --build build --config Release. GCC/Clang defaults:

CMAKE_BUILD_TYPE flags for
Debug -g stepping through code; sanitizer builds (add -O1 if they are too slow)
Release -O3 -DNDEBUG shipping, benchmarks
RelWithDebInfo -O2 -g -DNDEBUG profiling, crash dumps from production builds
MinSizeRel -Os -DNDEBUG firmware, size-constrained targets
(empty) none a trap: no optimisation, no -g

-DNDEBUG disables assert, so an assert(load_config()) simply doesn't call load_config() in Release. Code can ask what it was built as:

C++
#include <print>

int main() {
#ifdef NDEBUG
    std::println("NDEBUG is defined: assert() compiles to nothing");
#else
    std::println("NDEBUG is not defined: assert() checks");
#endif
#ifdef __OPTIMIZE__
    std::println("optimised build (-O1 or higher)");
#else
    std::println("unoptimised build (-O0)");
#endif
    std::println("__cplusplus = {}", __cplusplus);
}

The standard is per target. target_compile_features(splat_core PUBLIC cxx_std_20) says "compiling me, and code using my headers, needs at least C++20"; CMake adds -std=… only when the compiler's default is older. It is a minimum that propagates like any usage requirement, so a consumer that wants C++23 can still have it. Set CMAKE_CXX_EXTENSIONS OFF (or the target property CXX_EXTENSIONS) to get -std=c++20 rather than -std=gnu++20. set(CMAKE_CXX_STANDARD 20) at the top level is the blunt alternative: it sets the default for every target created afterwards but doesn't tell consumers of an installed package anything.

8. Presets: one command line for everyone

CMakePresets.json (committed) names the configurations your team and CI use, so nobody has to remember -G Ninja -DCMAKE_BUILD_TYPE=… -DCMAKE_CXX_FLAGS=…. CMakeUserPresets.json (git-ignored) holds personal ones.

JSON
{
  "version": 6,
  "configurePresets": [
    { "name": "base", "hidden": true, "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": { "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } },
    { "name": "debug",   "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } },
    { "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } },
    { "name": "asan", "inherits": "debug",
      "cacheVariables": { "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer" } }
  ],
  "buildPresets": [ { "name": "debug", "configurePreset": "debug" } ],
  "testPresets":  [ { "name": "debug", "configurePreset": "debug",
                      "output": { "outputOnFailure": true } } ]
}
Shell
cmake --preset debug           # configure into build/debug
cmake --build --preset debug   # build
ctest --preset debug           # test
cmake --list-presets

Flags that every developer needs belong in CMakeLists.txt (on targets); presets hold the ones that differ between builds (sanitizers, build type, toolchain file). One build directory per preset keeps them from overwriting each other's cache.

9. Dependencies: packages and imported targets

find_package(fmt 11 CONFIG REQUIRED) looks for fmtConfig.cmake (installed by fmt, a distro package, vcpkg or Conan) and creates imported targets such as fmt::fmt: targets that represent something already built, with their usage requirements attached. You link them like your own:

CMake
find_package(fmt 11 CONFIG REQUIRED)
find_package(PNG REQUIRED)                 # module mode: CMake's FindPNG.cmake → PNG::PNG
find_package(Threads REQUIRED)             # Threads::Threads, the right -pthread for this compiler
target_link_libraries(splat_core PRIVATE fmt::fmt PNG::PNG Threads::Threads)

Never paste include_directories(/usr/include/libpng16) and -lpng by hand: the imported target knows the paths, the definitions and its own dependencies. CONFIG asks for the package's own config file (what modern libraries ship); without it CMake first tries a Find<Name>.cmake module.

FetchContent downloads and builds a dependency as part of your build — handy for test frameworks and small libraries, and reproducible if you pin an exact tag or commit. Since CMake 3.24 it can prefer an installed package and fall back to fetching:

CMake
include(FetchContent)
FetchContent_Declare(doctest
    GIT_REPOSITORY https://github.com/doctest/doctest.git
    GIT_TAG        v2.4.12               # a tag or commit hash, never a branch
    FIND_PACKAGE_ARGS 2.4)               # try find_package(doctest 2.4) first
FetchContent_MakeAvailable(doctest)      # provides doctest::doctest either way

System packages build faster and match the platform; fetched ones are pinned and identical everywhere. Distros and package maintainers want the find_package path to work, so keep it working even when you fetch by default.

compile_commands.json. With CMAKE_EXPORT_COMPILE_COMMANDS=ON (Ninja and Makefile generators) the build directory gets one JSON entry per source file: the exact compiler command with every include path and define that the target graph produced. clangd, clang-tidy and most editors read it; link or copy it to the source root (ln -s build/debug/compile_commands.json .). When the editor says a header isn't found but the build works, this file is stale or missing.

10. When the build breaks

symptom look at
fatal error: foo.h: No such file or directory in a file you didn't change a dependency that should be PUBLIC is PRIVATE (or not linked at all); compile_commands.json shows the real -I list
undefined reference to X signature match, the defining target linked?, library order, visibility, extern "C"
multiple definition of X a non-inline definition in a header; a source in two targets
cannot find -lfoo a typo or missing package in target_link_libraries; prefer foo::foo names
Release crashes, Debug works assert with side effects, uninitialised memory, UB the optimiser exploits — run the asan preset
different flags than you expected cmake --build build -v prints every command; cmake --graphviz=deps.dot draws the target graph

In the project

Ungraded practice in projects/scene-viewer/ (stage 1); the evidence is your own build and test output.

  1. cmake --list-presets, then cmake --workflow --preset check (configure, build, test). Build the release, asan and tsan presets too.
  2. Read CMakeLists.txt, core/CMakeLists.txt, cli/CMakeLists.txt and cmake/*.cmake. For every target_link_libraries, target_include_directories and target_compile_features call, say why it is PUBLIC, PRIVATE or INTERFACE using the rule from this lesson. Why does splat::core link Threads::Threads PUBLIC?
  3. Open build/debug/compile_commands.json, find cli/main.cpp, and explain where each -I, -D and -std flag came from in the target graph.
  4. Do exercise 03-usage-requirements (exercises/start.sh 03-usage-requirements): fix it from the error alone, then add the header self-containment check its README describes.
  5. cmake --graphviz=deps.dot -S . -B build/graph and compare the graph with docs/ARCHITECTURE.md.

Summary

  • One .cpp + its headers = one translation unit = one object file; only the linker sees the whole program.
  • Headers hold declarations and the things the ODR allows in many TUs: classes, templates, inline/constexpr functions and inline variables. Guard them.
  • Keep macro-expanded definitions and the meaning of names consistent across TUs; include guards alone cannot establish that. A successful link is not an ODR proof.
  • Undefined reference: nothing defines that exact signature on the link line (or it's hidden, or in the wrong order). Multiple definition: check for duplicate external definitions; use one .cpp or an intentional inline header.
  • Static libraries resolve their dependencies in the final link, so order and their PRIVATE dependencies matter there; shared libraries resolve them themselves and export only what you mark.
  • CMake: targets + usage requirements. PRIVATE for me, INTERFACE for my users, PUBLIC for both; link by ns::name; generator expressions for per-config and build/install differences; presets for the command lines; imported targets for packages; compile_commands.json for tools.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Symbol visibility, export headers, shared vs static, RPATH and install/export of targets — the library-side half of this skill, from the author of Professional CMake.

Targets, PUBLIC/PRIVATE/INTERFACE usage requirements and how they propagate — the model you implement in the build task, explained with diagrams.

The classic 'treat CMake code like production code' talk: targets instead of variables, find_package and imported targets, and what not to do.

Uses usage requirements to keep module boundaries honest: why PRIVATE dependencies matter for design, not only for build speed.