1. A Warning
If you search for how to use CMake, you will very likely find code like this:
include_directories(${PROJECT_SOURCE_DIR}/include)
link_libraries(curl ssl crypto)
add_executable(myapp main.c)
It runs. But if that is how you write it —
it means you are looking at CMake from a bygone era. This style was mainstream in the CMake 2.x days, but from CMake 3.0 onwards the community gradually turned to another style: modern CMake.
This article sets out to make one thing clear: why the old style is bad, and what is good about the new one. This may be the most important shift in thinking you make while learning CMake.
2. The Core Problem of the Old Style: Global Pollution
The old style follows a typical pattern of “global settings plus local targets”:
# The old style — global pollution
include_directories(include)
include_directories(third_party/json/include)
include_directories(third_party/zlib/include)
link_libraries(curl ssl crypto z)
add_executable(myapp main.c)
The problem with this approach is that include_directories and link_libraries are global commands — they take effect for every target that comes after them.
If a project has only one target, that is not a problem. But a real project is quite likely to have several targets:
include_directories(include)
add_executable(myapp main.c)
add_library(mylib lib.c) # mylib is forced to inherit the include path above too
add_executable(tests test.c) # and so is tests
You cannot precisely control which target should have which header paths. This kind of global pollution produces accidental dependencies and blurs the module boundaries of the project.
An analogy: the old style is like fitting a big house with a single master power switch — either the whole house is lit or the whole house is dark. Modern CMake fits each room with its own switch, under precise control.
3. The Core of Modern CMake: Think in Targets
Modern CMake’s philosophy is: everything centres on the target, and dependencies between targets are declared explicitly.
Everything starts from a target created by add_executable or add_library, and the target_* family of commands binds properties precisely to each target.
# The modern CMake style
add_executable(myapp main.c)
target_include_directories(myapp PRIVATE include)
target_link_libraries(myapp PRIVATE curl ssl)
target_include_directories: takes effect only for themyapptarget; other targets are unaffectedtarget_link_libraries: likewise takes effect only formyapp
Each target minds its own business without interfering with the others.
4. The PUBLIC, PRIVATE, INTERFACE Trilogy
These three keywords are the most confusing part of the target_* commands. But once you understand them, more than half of modern CMake falls into place.
Suppose you have written a library called mylib:
add_library(mylib mylib.c)
It has a public header, mylib.h, sitting in the include/ directory. Its internal implementation also uses zlib.
PRIVATE
Things private to mylib — it needs them itself, but consumers (downstream targets) do not need to know about them:
# zlib is used by mylib's internal implementation; consumers don't need to care
target_link_libraries(mylib PRIVATE z)
PUBLIC
Things public to mylib — it needs them itself, and consumers must inherit them too:
# The headers under include/ are needed by mylib itself and must also be visible to consumers
target_include_directories(mylib PUBLIC include)
When another target links mylib:
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE mylib)
myapp automatically receives the include/ header path — there is no need to write another target_include_directories(myapp ...) line.
This is interface propagation: dependency information travels automatically along the chain of targets.
INTERFACE
For consumers only; not used by itself:
# A header-only library: it has no compilation output of its own, only the header path needs to propagate
add_library(myheaderlib INTERFACE)
target_include_directories(myheaderlib INTERFACE include)
5. A Decision Table
| Situation | What to use |
|---|---|
| Needed by itself, consumers don’t need to know | PRIVATE |
| Needed by itself, consumers need it too | PUBLIC |
| Not needed by itself, only given to consumers | INTERFACE |
Remember this simple test: ask yourself, “if nobody links my target, is this thing still needed?”
- No →
PRIVATE - Yes →
INTERFACE - Needed here and needed downstream too →
PUBLIC
6. A Complete Example
cmake_minimum_required(VERSION 3.10)
project(MyProject C)
# header-only library
add_library(config INTERFACE)
target_include_directories(config INTERFACE include/config)
# A static library, using zlib internally, exposing the interfaces in api.h
add_library(engine engine.c)
target_include_directories(engine
PUBLIC include/engine # consumers need to see engine's public headers
PRIVATE third_party/helper # tooling that only engine itself uses
)
target_link_libraries(engine
PUBLIC config # engine makes its dependency on config public; it propagates
PRIVATE z # zlib is an implementation detail; it does not propagate
)
# The executable
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE engine)
# myapp automatically receives the PUBLIC header paths of config and engine
# but is not affected by engine's PRIVATE dependencies
Compared with the old style, this version:
- makes the dependencies obvious at a glance
- keeps the blast radius precisely under control when you modify a library
- introduces no accidental inheritance when you add a target
7. Summary
Modern CMake has just two core tenets:
- Think in targets as the basic unit; don’t use global commands
- Express dependencies precisely with PUBLIC / PRIVATE / INTERFACE
Once this way of thinking becomes second nature, you will find that writing CMake is no longer about “what configuration makes it run” but about “what each target should and should not know” — an architectural question, not merely a configuration one.
In the next article we will tackle a practical problem: in a real multi-directory project, how should the CMakeLists.txt files be organised?
A small step forward every day is a new height!