C++ / NAMESPACES, HEADERS, AND BUILDS
Building multi-file projects with CMake
Split a C++ program into library and executable targets, wire them with target_link_libraries, and build it out-of-source with two cmake commands.
What you will learn
- Declare library and executable targets and connect them with target_link_libraries
- Pick PRIVATE, PUBLIC or INTERFACE so include paths reach exactly the right targets
- Configure once with cmake -S . -B build, then rebuild with cmake --build build
- Spread a tree over add_subdirectory files without any global include or flag commands
Understanding Building multi-file projects with CMake
CMake does not compile anything. The configure step reads CMakeLists.txt top to bottom as a small imperative script, caches answers to questions like which compiler exists and what it supports, and writes a real build system (Makefiles or Ninja files) into a separate build directory. The build step then runs that generated tool, which is what actually invokes g++. Keeping the two directories apart is what makes deleting build/ a complete clean, and the generated build system contains a rule that re-runs configure by itself whenever CMakeLists.txt changes.
The vocabulary of a modern CMake file is targets, not compiler flags. add_library and add_executable create nodes, the target_* commands attach properties to one node, and target_link_libraries draws an edge between two nodes. Every property is tagged with who it applies to: PRIVATE means only while compiling this target's own sources, INTERFACE means only for whatever links this target, PUBLIC means both. A library therefore states once what a caller needs from it (header directory, language standard, its own dependencies), and each link edge carries that statement outward transitively.
Larger projects put a CMakeLists.txt in each directory and pull them in with add_subdirectory, but target names live in a single global namespace, so tests/CMakeLists.txt can link a library defined in src/ without knowing where its headers live. The older directory-scoped commands include_directories, add_definitions and link_libraries apply to every target created after them in that directory and below, which produces builds that compile only because some unrelated subdirectory was processed first. Attaching requirements to targets instead removes that ordering guesswork and lets a target be moved or reused unchanged.
# ---- CMakeLists.txt ----
cmake_minimum_required(VERSION 3.16)
project(shapes LANGUAGES CXX)
add_library(geometry src/geometry.cpp)
target_include_directories(geometry PUBLIC include)
target_compile_features(geometry PUBLIC cxx_std_17)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE geometry)
// ---- include/geometry.hpp ----
namespace geo {
double rectangle_area(double w, double h);
double circle_area(double r);
}
// ---- src/geometry.cpp ----
"geometry.hpp"
namespace geo {
double rectangle_area(double w, double h) { return w * h; }
double circle_area(double r) { return 3.14159265358979 * r * r; }
}
// ---- src/main.cpp ----
<iostream>
"geometry.hpp"
int main() {
std::cout << "rectangle " << geo::rectangle_area(3.0, 4.5) << '\n';
std::cout << "circle " << geo::circle_area(2.0) << '\n';
}
// build and run:
// cmake -S . -B build
// cmake --build build
// ./build/appA CMake project is a graph of targets, and each target carries the usage requirements that consumers inherit when they link it.
Worked examples
PUBLIC and PRIVATE decide who sees a definition
Shows that a PUBLIC compile definition reaches the consumer's command line while a PRIVATE one stays inside the library.
# ---- CMakeLists.txt ----
cmake_minimum_required(VERSION 3.16)
project(defs LANGUAGES CXX)
add_library(config src/config.cpp)
target_include_directories(config PUBLIC include)
target_compile_definitions(config PUBLIC APP_BUILD=1407)
target_compile_definitions(config PRIVATE INTERNAL_TRACE=1)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE config)
// ---- include/config.hpp ----
namespace cfg { int trace_level(); }
// ---- src/config.cpp ----
"config.hpp"
namespace cfg {
int trace_level() {
INTERNAL_TRACE
return INTERNAL_TRACE;
return 0;
}
}
// ---- src/main.cpp ----
<iostream>
"config.hpp"
int main() {
std::cout << "build " << APP_BUILD << '\n';
INTERNAL_TRACE
std::cout << "main sees INTERNAL_TRACE\n";
std::cout << "main does not see INTERNAL_TRACE\n";
std::cout << "library trace level " << cfg::trace_level() << '\n';
}Example explained
Line 1PUBLIC APP_BUILD=1407 is placed on config.cpp's command line and on every consumer's, which is why main.cpp can print it.
Line 2PRIVATE INTERNAL_TRACE=1 reaches only config.cpp, so trace_level() returns 1 while main.cpp's #ifdef fails.
Line 3target_link_libraries(app PRIVATE config) is what copies config's PUBLIC requirements onto app; without that line main.cpp would not compile.
Line 4Had APP_BUILD been PRIVATE, the failure would appear in main.cpp as 'APP_BUILD was not declared in this scope', not in the library.
A header-only library as an INTERFACE target
Demonstrates a target that has no sources and exists only to hand its include path and standard to consumers.
# ---- CMakeLists.txt ----
cmake_minimum_required(VERSION 3.16)
project(hdr LANGUAGES CXX)
add_library(strutil INTERFACE)
target_include_directories(strutil INTERFACE include)
target_compile_features(strutil INTERFACE cxx_std_17)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE strutil)
// ---- include/strutil.hpp ----
<string>
namespace strutil {
inline std::string repeat(const std::string& s, int n) {
std::string out;
for (int i = 0; i < n; ++i) out += s;
return out;
}
}
// ---- main.cpp ----
<iostream>
"strutil.hpp"
int main() {
std::cout << strutil::repeat("ab", 3) << '\n';
}Example explained
Line 1add_library(strutil INTERFACE) creates a graph node with no sources, so the compiler is never invoked for strutil itself.
Line 2INTERFACE is the only valid keyword here because there is no own compilation for a PRIVATE requirement to apply to.
Line 3Linking strutil adds -Iinclude and -std=c++17 to app's compile lines and adds nothing to its link line.
Line 4app is still the only target that produces an object file, which is why the library needs no .cpp to be usable.
A header generated during configure
Shows configure_file writing a header into the build directory and why that directory must be on the include path.
# ---- CMakeLists.txt ----
cmake_minimum_required(VERSION 3.16)
project(demo VERSION 2.1.0 LANGUAGES CXX)
configure_file(version.hpp.in version.hpp @ONLY)
add_executable(app main.cpp)
target_include_directories(app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
// ---- version.hpp.in ----
DEMO_NAME
DEMO_VERSION
// ---- main.cpp ----
<iostream>
"version.hpp"
int main() {
std::cout << DEMO_NAME << " v" << DEMO_VERSION << '\n';
}Example explained
Line 1project(demo VERSION 2.1.0 ...) sets PROJECT_NAME and PROJECT_VERSION, the variables the template asks for.
Line 2configure_file runs in the configure phase, before any compilation, and a relative output path lands in the build directory rather than next to the sources.
Line 3@ONLY limits substitution to @VAR@ placeholders, so any ${...} text in a template survives untouched.
Line 4Because version.hpp only exists under the build directory, omitting CMAKE_CURRENT_BINARY_DIR from the include path gives 'version.hpp: No such file or directory'.
Important notes
A link item that is not a target and contains no :: is handed to the linker verbatim as -lname, so a mistyped target name appears as 'cannot find -lgeomtry' from the linker; names containing :: are checked while CMake generates, which is one reason to link through ALIAS names such as geo::geometry.
Editing CMakeLists.txt needs no manual cmake run because the generated build system reconfigures itself, but cached choices such as the compiler are fixed by the first configure, so switching compilers means a fresh build directory.
Common mistakes
Marking the library's header directory PRIVATE: the library itself compiles, but the executable that links it dies with 'geometry.hpp: No such file or directory', because PRIVATE requirements are never passed to consumers.
Adding the header path with a top-level include_directories() and then forgetting target_link_libraries: every target can see the header so main.cpp compiles cleanly, and the missing edge only surfaces as 'undefined reference to geo::rectangle_area' at link time.
Collecting sources with file(GLOB ...) instead of listing them: adding a new .cpp does not change CMakeLists.txt, so nothing triggers a reconfigure, the file is never compiled, and every function in it becomes an undefined reference until you configure by hand.
Try it yourself
Change, predict, then run
Take a one-file program that prints stats::mean of a vector, split it into include/stats.hpp, src/stats.cpp and src/main.cpp, and write the CMakeLists.txt that builds stats as a library at C++17 and links it into app. Then state which single target_* line you would change to make main.cpp's #include fail, and what the error would say.
Open the C++ workspaceCheck your understanding
Library target net declares target_include_directories(net PRIVATE include), and an executable that does call target_link_libraries(app PRIVATE net) includes net.hpp from main.cpp. What happens?
- It builds and runs, because target_link_libraries copies every include directory of net onto app
- CMake stops during configure and reports that net has no usable include directory
- app fails to compile, because PRIVATE include directories are not handed to consumers
- app compiles but the linker reports an undefined reference to the function declared in net.hpp
Show answer
PRIVATE requirements apply only while compiling net's own sources, so nothing is added to app's compile line and the preprocessor cannot find net.hpp; only PUBLIC and INTERFACE requirements travel along a link edge. Option 3 is tempting because a missing library usually shows up as an undefined reference, but here the missing thing is a header search path, so compilation of main.cpp fails and the linker is never reached.