From Global Includes to Target‑Based CMake: Why Visibility Matters
Modern CMake replaces global include/definition commands with target‑based properties. Learn how visibility keywords (PRIVATE, PUBLIC, INTERFACE) control propagation, see a concrete header‑only example, and understand trade‑offs like overusing PUBLIC.
19 Sept 2026, 20:13 UTC

Problem: Global Build Pollution in Legacy CMake
In older CMake scripts you’ll often see include_directories(), add_definitions(), and link_libraries() sprinkled throughout the directory tree. Those commands modify global state, so every target in the project inherits the same include paths and compiler flags. In a small codebase this is acceptable, but as projects grow the risk of name collisions, accidental usage of the wrong library, and excessive recompilation becomes real.
Target‑Based Modern CMake (3.0+)
Modern CMake treats a library or executable as a first‑class object—a target. All build instructions and usage requirements live on the target, and the build system can enforce strict boundaries between consumers and providers.
add_library()oradd_executable()creates the target.target_include_directories(),target_compile_definitions(), andtarget_compile_options()attach properties to that target.- When another target links to it via
target_link_libraries(), the linker automatically propagates the provider’s properties to the consumer.
This model eliminates the need for the global variables that once cluttered CMakeLists.txt files.
Visibility Keywords: PRIVATE, PUBLIC, INTERFACE
Visibility controls which properties are visible to the target itself and which are forwarded to its consumers.
| Visibility | Applies To | Result |
|---|---|---|
| PRIVATE | Only the target | Include dirs, defs, and flags are used only when building the target. |
| PUBLIC | Target & consumers | Same as PRIVATE for the target, plus consumers inherit those properties. |
| INTERFACE | Only consumers | The target itself does not need the property, but anything that links to it will receive it. |
Choosing the right keyword is key. Using PUBLIC for everything is tempting but can make the dependency graph explode, leading to longer compile times.
Concrete Example: Header‑Only Library + Binary
Let’s build a small project that demonstrates the flow of include paths and compile definitions from a library to an executable.
# CMakeLists.txt (root)
cmake_minimum_required(VERSION 3.15)
project(HeaderOnlyDemo LANGUAGES CXX)
# Header‑only library
add_library(foo INTERFACE)
# All consumers of foo need the header directory and a macro
# INTERFACE means only consumers see these properties
# PRIVATE would restrict them to foo itself
# PUBLIC would propagate to both foo and its consumers
# We deliberately use INTERFACE here because foo produces no binary
# Set include directory relative to the library source
target_include_directories(foo
INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
# Add a compile definition that consumers must see
target_compile_definitions(foo INTERFACE FOO_ENABLED)
# Add the executable that uses foo
add_executable(bar src/main.cpp)
# Link bar to foo – this pulls in the include dir and macro
# The visibility is respected automatically
target_link_libraries(bar PRIVATE foo)
Directory layout:
HeaderOnlyDemo/
├── CMakeLists.txt
├── include/
│ └── foo.hpp
└── src/
└── main.cpp
Contents of foo.hpp:
#pragma once
#ifdef FOO_ENABLED
#include <iostream>
#endif
Contents of main.cpp:
#include <foo.hpp>
int main() {
#ifdef FOO_ENABLED
std::cout << "Foo is enabled!" << std::endl;
#endif
return 0;
}
Build steps (from the project root):
mkdir build && cd buildcmake ..– CMake configures the project.cmake --build .– Build the executable.- Run
./barto see the output.
Verification:
- Open
build/CMakeFiles/bar.dir/build.make(or the equivalent for Ninja). You should see-DFOO_ENABLEDand-I../includeflags only for thebartarget. - Running
ccmake ..orcmake --graphviz=graph.dotwill show thatfoois an interface library and thatbardepends on it.
Trade‑offs & Limitations
- Overusing PUBLIC – If you mark a dependency as PUBLIC when it is actually only needed for building the provider, you inflate the transitive include list. This can cause unnecessary recompilations when any consumer changes.
- Version Variations –
target_sources()was refined in CMake 3.13 to support source ordering. If you target older versions, be aware that source files may be compiled in an unexpected order. - Legacy Commands – Mixing
include_directories()with target‑based commands can lead to unpredictable results because the former still affects global state. Keep to target‑based commands whenever possible.
Actionable Takeaways
- Replace all
include_directories(),link_libraries(), andadd_definitions()with target‑specific commands. - Use
INTERFACEfor header‑only libraries; usePUBLIConly when a dependency must be present for both the target and its consumers. - Audit existing CMakeLists.txt files for global state pollution; refactor into target‑based style.
- After refactoring, run
cmake --graphviz=deps.dotto visualize the dependency graph and confirm that it matches the intended design.
Adopting target‑based CMake not only cleans up your build scripts but also gives the compiler precise information about what each target needs, leading to faster builds and fewer accidental dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.