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.
.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.
#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:
// 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:
// 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.
#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; chooseinlineorexternplus 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
-Wodrcan catch some cases, not all of them.
Macros can change a definition before the compiler sees it
// 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; }
}
#endifIf 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:
// 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:
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:
#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:
/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 statusUndefined 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)?
/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 hereMultiple 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.
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:
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#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_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.
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, sosplat::corfails 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>. INTERFACElibraries 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:
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 packageUse $<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:
#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.
{
"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 } } ]
}cmake --preset debug # configure into build/debug
cmake --build --preset debug # build
ctest --preset debug # test
cmake --list-presetsFlags 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:
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:
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 waySystem 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.
cmake --list-presets, thencmake --workflow --preset check(configure, build, test). Build therelease,asanandtsanpresets too.- Read
CMakeLists.txt,core/CMakeLists.txt,cli/CMakeLists.txtandcmake/*.cmake. For everytarget_link_libraries,target_include_directoriesandtarget_compile_featurescall, say why it is PUBLIC, PRIVATE or INTERFACE using the rule from this lesson. Why doessplat::corelinkThreads::ThreadsPUBLIC? - Open
build/debug/compile_commands.json, findcli/main.cpp, and explain where each-I,-Dand-stdflag came from in the target graph. - 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. cmake --graphviz=deps.dot -S . -B build/graphand compare the graph withdocs/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/constexprfunctions andinlinevariables. 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
.cppor 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.jsonfor tools.