Lesson 9 of 10
C++ has no npm install. There is no single package manager everyone uses, which means getting a
dependency is a decision rather than a command.
There are three answers, and a real project usually uses more than one.
1. find_package: use what is already installed
// main.cpp
#include <iostream>
#include <string>
#include <vector>
#include <zlib.h>
int main() {
const std::string text(200, 'a');
std::vector<unsigned char> out(compressBound(text.size()));
uLongf out_len = static_cast<uLongf>(out.size());
const int rc = compress(out.data(), &out_len,
reinterpret_cast<const Bytef*>(text.data()), text.size());
std::cout << "zlib version " << zlibVersion() << "\n";
std::cout << "rc=" << rc << " " << text.size() << " bytes -> " << out_len << "\n";
return rc != Z_OK;
}
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(zdemo LANGUAGES CXX)
find_package(ZLIB REQUIRED)
add_executable(zdemo main.cpp)
target_compile_features(zdemo PRIVATE cxx_std_20)
# ZLIB::ZLIB is an IMPORTED TARGET: it carries the include path and the
# library, so no -I or -l anywhere.
target_link_libraries(zdemo PRIVATE ZLIB::ZLIB)
cmake -S . -B b -G Ninja | grep -i zlib
cmake --build b | tail -1
./b/zdemo
-- Found ZLIB: /usr/lib/x86_64-linux-gnu/libz.so (found version "1.3")
[2/2] Linking CXX executable zdemo
zlib version 1.3
rc=0 200 bytes -> 12
Two lines of CMake, and 200 bytes compressed to 12.
The important part is ZLIB::ZLIB. A modern find_package gives you an imported target — a
target you did not define, carrying the same PUBLIC/INTERFACE requirements as your own
(lesson 17.8). Linking it brings the include path with it. That is why there is no -I or -lz
anywhere in the file, and why this works unchanged on a machine where zlib lives somewhere else.
Older packages export variables instead — ${ZLIB_INCLUDE_DIRS} and ${ZLIB_LIBRARIES} — and you
plumb them by hand. Prefer the Namespace::Target form whenever a package offers it.
When it is not there
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(missing LANGUAGES CXX)
find_package(Boost REQUIRED)
cmake -S . -B bmissing 2>&1 | grep -B1 -A2 'Could NOT find' | head -6
CMake Error at /usr/share/cmake-3.28/Modules/FindPackageHandleStandardArgs.cmake:230 (message):
Could NOT find Boost (missing: Boost_INCLUDE_DIR)
Call Stack (most recent call first):
/usr/share/cmake-3.28/Modules/FindPackageHandleStandardArgs.cmake:600 (_FPHSA_FAILURE_MESSAGE)
REQUIRED stops the configure step with a named failure — which is what you want, because the
alternative is a link error forty lines into the build. The variants:
REQUIRED— fail now, with a clear message.- nothing — sets
Boost_FOUNDto false and carries on, so you must check it. QUIET— as above, without printing anything. For genuinely optional features.
The cost of find_package is that somebody has to have installed it. That is fine for a system
library like zlib, and it is why Linux distribution packaging exists. It is a poor answer for a
library your project needs at a specific version.
2. FetchContent: build it as part of your build
CMake clones the dependency at configure time and adds it to your project as if you had written it:
// upstream/include/tinylog/tinylog.hpp
#pragma once
#include <string>
namespace tinylog { std::string stamp(const std::string& msg); }
// upstream/src/tinylog.cpp
#include "tinylog/tinylog.hpp"
namespace tinylog { std::string stamp(const std::string& msg) { return "[tinylog] " + msg; } }
# upstream/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(tinylog VERSION 1.0.0 LANGUAGES CXX)
add_library(tinylog src/tinylog.cpp)
target_include_directories(tinylog PUBLIC include)
target_compile_features(tinylog PUBLIC cxx_std_20)
That is a complete, ordinary CMake library — the kind of thing you are consuming when you fetch anything. Turn it into a repository with a release tag:
cd upstream
git init -q
git add -A
git -c user.email=dev@example.com -c user.name=dev commit -qm "tinylog 1.0.0"
git tag v1.0.0
cd ..
echo "tagged: $(git -C upstream tag)"
tagged: v1.0.0
// main.cpp
#include <iostream>
#include "tinylog/tinylog.hpp"
int main() { std::cout << tinylog::stamp("hello from FetchContent") << "\n"; }
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(consumer LANGUAGES CXX)
include(FetchContent)
FetchContent_Declare(
tinylog
GIT_REPOSITORY ${CMAKE_CURRENT_SOURCE_DIR}/upstream
GIT_TAG v1.0.0
)
FetchContent_MakeAvailable(tinylog)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE tinylog)
cmake -S . -B b -G Ninja > /dev/null
cmake --build b | tail -1
./b/app
echo "--- what FetchContent created:"
ls b/_deps
[4/4] Linking CXX executable app
[tinylog] hello from FetchContent
--- what FetchContent created:
tinylog-build
tinylog-src
tinylog-subbuild
A local path is used here so the lesson is reproducible, but the mechanism is identical with a real URL — and in practice that is what you write:
# fetch_real.cmake
# NOT RUN in this lesson: it would need the network.
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.2 # a tag, never a branch -- see below
GIT_SHALLOW TRUE # skip the history you do not need
)
FetchContent_MakeAvailable(fmt)
target_link_libraries(app PRIVATE fmt::fmt)
FetchContent_MakeAvailable does the clone and then calls add_subdirectory on the result, which
is why the dependency's targets simply appear. _deps/tinylog-src is the checkout,
_deps/tinylog-build its build tree.
Set the dependency's options before MakeAvailable
Most libraries build their own tests and examples by default. Inside your build that is wasted time, and occasionally a broken build that is not your fault.
# upstream/CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(tinylog VERSION 1.1.0 LANGUAGES CXX)
option(TINYLOG_BUILD_TESTS "Build tinylog's own tests" ON)
add_library(tinylog src/tinylog.cpp)
target_include_directories(tinylog PUBLIC include)
target_compile_features(tinylog PUBLIC cxx_std_20)
if(TINYLOG_BUILD_TESTS)
message(STATUS "tinylog: building our own tests")
add_executable(tinylog_tests test/test.cpp)
target_link_libraries(tinylog_tests PRIVATE tinylog)
endif()
// upstream/test/test.cpp
#include "tinylog/tinylog.hpp"
int main() { return tinylog::stamp("x").empty() ? 1 : 0; }
cd upstream
git add -A
git -c user.email=dev@example.com -c user.name=dev commit -qm "tinylog 1.1.0"
git tag v1.1.0
cd ..
sed -i 's/GIT_TAG v1.0.0/GIT_TAG v1.1.0/' CMakeLists.txt
rm -rf b && cmake -S . -B b -G Ninja | grep 'tinylog:'
echo "link steps in the build: $(cmake --build b 2>&1 | grep -c Linking)"
-- tinylog: building our own tests
link steps in the build: 3
Now set the option first:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(consumer LANGUAGES CXX)
include(FetchContent)
FetchContent_Declare(
tinylog
GIT_REPOSITORY ${CMAKE_CURRENT_SOURCE_DIR}/upstream
GIT_TAG v1.1.0
)
# A dependency's options must be set BEFORE MakeAvailable, because that is
# when its CMakeLists.txt runs.
set(TINYLOG_BUILD_TESTS OFF)
FetchContent_MakeAvailable(tinylog)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE tinylog)
rm -rf b && cmake -S . -B b -G Ninja > /dev/null
echo "'building our own tests' messages: $(cmake -S . -B b2 -G Ninja 2>&1 | grep -c 'tinylog: building')"
echo "link steps in the build: $(cmake --build b 2>&1 | grep -c Linking)"
./b/app
'building our own tests' messages: 0
link steps in the build: 2
[tinylog] hello from FetchContent
Three link steps became two, and the message is gone. The ordering is the whole trick: set()
after MakeAvailable has no effect at all, because the dependency's CMakeLists.txt has already
run. This is the commonest FetchContent mistake after pinning.
Pin to a tag or a commit, never a branch
GIT_TAG main is legal and it makes your build unreproducible: two people configuring on different
days get different code, and a build that worked last week cannot be recreated. Worse, CMake will
not re-fetch a branch by default, so you keep building the version you first cloned while a
colleague gets a newer one — and the bug report says "works on my machine".
A tag is better. A full commit SHA is best, because a tag can be moved.
3. Vendoring: keep a copy in your repository
# vendor.cmake
# NOT RUN here: this is the shape, and it is the simplest of the three.
add_subdirectory(third_party/tinylog EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE tinylog)
Copy the source into your tree — as a git submodule or just files — and add_subdirectory it. No
network at configure time, no version drift, and the code you ship is the code in your repository.
EXCLUDE_FROM_ALL means "build these targets only if something needs them", which keeps a
dependency's tests and tools out of your default build without needing its options.
The cost is that updating is a manual act, and your repository carries code you did not write. That is a real cost and also sometimes exactly the point: a vendored dependency cannot disappear because a maintainer deleted a tag.
Choosing
| | find_package | FetchContent | Vendoring |
|---|---|---|---|
| Needs it preinstalled | yes | no | no |
| Network at configure | no | yes | no |
| Exact version control | weak | good (if pinned) | total |
| Build time cost | none | compiles it | compiles it |
| Works offline | yes | only after first fetch | yes |
| Updating | the system's job | change one line | manual |
In practice:
- System libraries (zlib, OpenSSL, threads) →
find_package. - A library you need at a specific version →
FetchContentwith a pinned tag. - Something you must never lose, or have patched → vendor it.
The hybrid pattern is worth knowing, because it is what serious projects do: try find_package
first, fall back to FetchContent. The exercise builds it.
There are also real package managers — vcpkg and Conan — which integrate with CMake and give
you a lockfile and a binary cache. For a large project they are the right answer, and both work by
making find_package succeed.
Try it
Write a CMakeLists.txt that depends on a library two ways: use the installed copy if there is
one, and otherwise fetch and build it. Report which path it took. Then prove the fallback works by
configuring with the search deliberately disabled.
Checkpoint
- C++ has no default package manager, so acquiring a dependency is a design decision.
find_packageuses an installed copy and gives you an imported target likeZLIB::ZLIB.- An imported target carries its own include paths — no
-Ior-lin yourCMakeLists.txt. - Prefer
Namespace::Targetover old${X_LIBRARIES}variables. REQUIREDfails at configure time with a named error; plain orQUIETsetsX_FOUNDfor you to check.FetchContentclones at configure time andadd_subdirectorys the result into your build.- Sources land in
_deps/<name>-src, built in_deps/<name>-build. - Set a dependency's options before
FetchContent_MakeAvailable— after is too late, itsCMakeLists.txthas already run. - Measured: turning off a dependency's tests took the build from three link steps to two.
- Pin
GIT_TAGto a tag or commit, never a branch. A branch makes builds unreproducible and CMake will not re-fetch it. - Vendoring with
add_subdirectory(third_party/x EXCLUDE_FROM_ALL)needs no network and cannot drift. - System libraries →
find_package. Specific versions → pinnedFetchContent. Must-never-lose → vendor. - An
ALIAStarget lets the rest of your file stop caring which path provided a dependency. CMAKE_DISABLE_FIND_PACKAGE_<name>=ONforces a fallback path so you can test it.- An optional dependency needs
if(X_FOUND)in CMake and#ifdefin the code —target_compile_definitionsis the bridge.
Questions about this lesson
Log in to ask a question.