5.3.8 · D2Build Systems & Toolchain

Visual walkthrough — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

2,016 words9 min readBack to topic

Prerequisites we lean on, each linked so you can refresh: Compilation Pipeline preprocess-compile-assemble-link (why headers and linking are two separate problems), Header files and Include Guards (what an "include path" is), Static vs Dynamic Linking (what "link" means physically). The parent is the CMake topic note.


Step 1 — Two completely separate needs: find the header vs pull in the code

WHAT. Before any CMake keyword makes sense, we must see that building a program that uses a library needs two independent favours, not one:

  1. an include path — a folder the compiler adds to its search list so that #include "add.h" resolves to a real file. This is a compile-time, text thing.
  2. a link edge — an instruction to the linker: "the machine code for add() lives in libmathlib.a, glue it in." This is a link-time, binary thing.

WHY split them? Because in Compilation Pipeline preprocess-compile-assemble-link these happen at different stages. The preprocessor needs the header; the linker needs the compiled object. You can have one without the other, and CMake tracks them as two separate streams.

PICTURE. The mint arrow is the include path; the coral arrow is the link edge. They start at the library and must both reach the program.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

Step 2 — One library, one program: the base case with no propagation yet

WHAT. Take the parent's example. A library mathlib (built from math/add.cpp, header in math/) and a program calc that calls a function from it.

add_library(mathlib STATIC math/add.cpp)         # (A) make a linkable unit
target_include_directories(mathlib PUBLIC math)   # (B) mathlib owns the folder math/
add_executable(calc app/main.cpp)                 # (C) make a runnable unit
target_link_libraries(calc PRIVATE mathlib)       # (D) calc depends on mathlib

WHY line (B) says PUBLIC? Look at the file add.h. It is not private scratch code inside mathlib — it is the doorway through which calc talks to mathlib. So the folder math/ must reach both mathlib itself (to compile add.cpp, which includes add.h) and everyone who links mathlib. "Both me and my users" is exactly the meaning of PUBLIC.

WHY line (D) says PRIVATE? calc is an executable. Nothing in the world links against an executable — it is the end of the line. So there is no "user" to propagate anything to. The minimal, honest choice is PRIVATE.

PICTURE. Watch the mint include-path arrow leave mathlib (opened by PUBLIC at B) and arrive at calc, alongside the coral link edge created by (D).

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

Step 3 — Add a third target: now we have somewhere to propagate to

WHAT. The magic of the keywords is invisible with only two targets, because an executable has no users. So introduce a chain of three, which is the parent's Section 4 example:

add_library(util STATIC util.cpp)     # bottom
add_library(core STATIC core.cpp)     # middle — uses util
add_executable(app app.cpp)           # top — uses core

WHY three? Propagation is about the middle target passing (or blocking) what came from below. You need a below (util), a middle (core), and an above (app) to even ask the question.

PICTURE. Three boxes stacked. Below sits util with its own include folder and its own machine code. The question marks on the two upward arrows are what Steps 4–5 resolve.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

Step 4 — The PUBLIC valve: core's header exposes util, so util flows all the way up

WHAT. Suppose core.h itself contains #include "util.h" — a struct from util appears in a function signature that core shows to the world. Then:

target_link_libraries(core PUBLIC util)   # core's HEADER exposes util
target_link_libraries(app PRIVATE core)

WHY does util reach app? Trace it. When app includes core.h, the preprocessor immediately hits #include "util.h". For that to succeed, app's compiler command must contain util's include path. And since util's struct becomes part of core's compiled interface, app must also link util's machine code. Because core → util is PUBLIC, the valve is open: both streams flow through core and continue up into app. This is precisely why the parent's Forecast answered ✅.

PICTURE. The PUBLIC valve on the core→util arrow is open (mint + coral pass straight through core into app). The green tick confirms app links util transitively.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

Step 5 — The PRIVATE valve: util is sealed inside core's .cpp, so app never sees it

WHAT. Now flip one keyword. Say util is used only inside core.cpp — no mention in core.h:

target_link_libraries(core PRIVATE util)   # util hidden in core's implementation
target_link_libraries(app PRIVATE core)

WHY does util's include path stop at core? Because app never includes anything that pulls in util.h. Handing app the folder util/ would be pointless leakage. So the PRIVATE valve blocks the mint arrowapp's compiler command does not get util's include path. This is the correct outcome: app genuinely does not need util's headers.

A subtlety about the coral link arrow (STATIC case). With static libraries, the machine code of util may still have to be physically present at final link time (the parent's Static vs Dynamic Linking explains why static code gets copied in). CMake handles that ordering automatically. But the crucial usage-requirement — the include path and any compile flags — is what PRIVATE seals off. From the programmer's mental model: app cannot #include "util.h", and that's exactly what we wanted.

PICTURE. The PRIVATE valve is shut: the mint include arrow is chopped off at core; a red ✗ marks that app has no visibility of util.h.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

Step 6 — The INTERFACE valve: a target that gives everything and uses nothing

WHAT. A header-only library has no .cpp — there is nothing to compile, only headers to hand out. In CMake:

add_library(tinyjson INTERFACE)                        # no sources at all
target_include_directories(tinyjson INTERFACE include) # give the folder to USERS only
add_executable(app app.cpp)
target_link_libraries(app PRIVATE tinyjson)            # app receives tinyjson's include path

WHY INTERFACE and never PUBLIC here? PUBLIC = "me and my users." But tinyjson compiles no code of its own — there is no "me" that needs the folder. Everything it owns exists purely to be given away. INTERFACE = "not me, only my users." Marking it PRIVATE would keep the flags for a "me" that doesn't exist, so nobody would ever get them — the header-only library would silently do nothing (the parent's fourth mistake).

PICTURE. tinyjson is a hollow box (no compiled code, drawn dashed). Only the mint include-path arrow leaves it, opened by the INTERFACE valve, and lands in app.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries
Recall Which valve for which situation?

Header exposes the dependency (it appears in your .h)? ::: PUBLIC Dependency used only inside your .cpp? ::: PRIVATE You are header-only, so you have no .cpp at all? ::: INTERFACE


Step 7 — Degenerate cases, so no scenario surprises you

WHAT & WHY, one at a time:

  • The lone executable (Step 2, target calc). It has no downstream users, so PUBLIC and PRIVATE on its link are indistinguishable in effect — but write PRIVATE as the honest, minimal choice.
  • A diamond. If app links both core (PUBLIC→util) and util directly, util is requested twice. CMake de-duplicates — it appears once in the final link, no double-copy, no error.
  • Cycle attempt. If A links B and B links A, CMake reports an error, because the dependency graph must be acyclic to compute a build order (there is no "who first?").
  • Missing target. target_link_libraries(app PRIVATE nolib) where nolib was never created fails at configure time — CMake cannot find the node in its graph.
  • INTERFACE with sources. add_library(x INTERFACE x.cpp) is illegal: INTERFACE means no build output, so a source file contradicts it.

PICTURE. Left: a diamond, showing util reached by two paths but drawn once at the final link ("de-duplicated"). Right: a cycle marked with a red ✗ and "no valid order."

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries

The one-picture summary

Everything above compresses into a single valve diagram: three targets stacked, and each link is a valve. PUBLIC opens both streams onward; PRIVATE blocks the include stream at that node; INTERFACE emits streams from a hollow (source-less) box. The mint arrow is where the compiler looks for headers; the coral arrow is what the linker pulls in. That is the entire propagation model of modern CMake.

Figure — CMake — CMakeLists.txt, add_executable, add_library, target_link_libraries
Recall Feynman retelling — say it like a story

Two favours travel through a project: a header path (so #include finds the file) and a link edge (so the linker finds the compiled code). Every dependency you declare is an arrow, and every PUBLIC/PRIVATE/INTERFACE keyword is a valve on that arrow. If your header mentions a dependency, your users will trip over it too, so you must let it flow onward — that is PUBLIC. If only your hidden .cpp uses it, you keep it to yourself — that is PRIVATE, and it correctly stops the header path from leaking upward. If you are header-only, you have no code of your own, so you keep nothing and give everything — that is INTERFACE. Executables are the top of every chain, so their keyword never propagates; write PRIVATE and move on. CMake reads all these arrows as a graph, removes duplicates, refuses cycles, and computes the link order for you — which is why, unlike raw g++, you never write -lcore -lutil in the right order by hand.