The C++ Textbook

Part 9 · Building real software

Dependencies and packaging

Using other people's code without making your build unreproducible.

By the end of this chapter you can

  • Add a dependency with FetchContent or a package manager
  • Explain the trade-offs of vendoring
  • Pin versions so a build is reproducible

C++ has no cargo, no npm, no pip. It has four incompatible answers to “how do I use somebody else’s library”, and which one is right depends on who has to build your project and where.

This chapter is about picking one deliberately, and about the property that matters more than convenience: that a build done today and a build done in two years produce the same program.

The four options

Approach You get You pay
System package (apt install libfmt-dev) nothing to build whatever version the distribution has; different on every machine
Vendoring (the source, checked into your repo) total control, offline builds updating is manual; your diffs contain other people’s code
FetchContent (CMake downloads and builds it) one build system, versions pinned in your CMakeLists.txt rebuilds the dependency yourself; needs network on first configure
Package manager (vcpkg, Conan) binary caching, transitive dependencies, a lockfile another tool everyone must install and learn

There is no default answer. A library, whose users have their own opinions, should find_package and let them decide. An application, which controls its own build, is usually happiest with FetchContent or a package manager.

find_package: use it if it is there

find_package(fmt 10 REQUIRED)
target_link_libraries(myapp PRIVATE fmt::fmt)

This asks the machine whether the library is installed. When it is, you get an imported target carrying its include paths and its own dependencies — the usage requirements from Chapter 9.3, published by whoever installed it.

When it is not, you get this:

Could not find a package configuration file provided by "fmt" (requested
version 10) with any of the following names:

    fmtConfig.cmake
    fmt-config.cmake

Add the installation prefix of "fmt" to CMAKE_PREFIX_PATH or set "fmt_DIR"
to a directory containing one of the above files.

That message is the whole problem with system packages in one paragraph: the build works on the machine where someone installed the right version, and fails everywhere else with instructions the reader has to act on manually.

FetchContent: bring it with you

CMake can fetch and build a dependency as part of your own configure step.

cmake_minimum_required(VERSION 3.24)
project(depdemo CXX)
set(CMAKE_CXX_STANDARD 20)

include(FetchContent)
FetchContent_Declare(
  doctest
  GIT_REPOSITORY https://github.com/doctest/doctest.git
  GIT_TAG        v2.4.11        # a tag, not a branch
  GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(doctest)

add_executable(tests tests.cpp)
target_link_libraries(tests PRIVATE doctest::doctest)
$ cmake -S . -B build -G Ninja
-- Configuring done (2.9s)

$ cmake --build build
[1/2] Building CXX object CMakeFiles/tests.dir/tests.cpp.o
[2/2] Linking CXX executable tests

$ ./build/tests
[doctest] test cases: 1 | 1 passed | 0 failed | 0 skipped
[doctest] assertions: 2 | 2 passed | 0 failed |
[doctest] Status: SUCCESS!

Three seconds to configure, and the dependency’s source lands in build/_deps/doctest-src — 9 MB of it here, checked out at exactly the commit the tag names:

$ git -C build/_deps/doctest-src rev-parse HEAD
ae7a13539fb71f270b87eb2e874fbac80bc8dda2
$ git -C build/_deps/doctest-src describe --tags
v2.4.11

Because it lives under build/, rm -rf build removes it, and nothing about the dependency is in version control except the four lines that name it. That is the appeal: the CMakeLists.txt is the manifest.

What it costs: your build now compiles the dependency’s sources, every clean build re-fetches, and the first configure needs the network. For a header-only test framework that is nothing. For Boost it is an afternoon.

Pinning, and what “reproducible” means

Every one of the four approaches can be made reproducible, and every one of them defaults to not being.

Pin to an immutable revision. GIT_TAG main is not a version; it is “whatever that branch says today”, so two people configuring on different days get different code and one of them has a bug the other cannot reproduce. A tag is better, and a commit hash is the only thing that is actually immutable — tags can be moved, and occasionally are.

GIT_TAG v2.4.11                                     # good
GIT_TAG ae7a13539fb71f270b87eb2e874fbac80bc8dda2    # unambiguous
GIT_TAG main                                        # not a version

Pin the transitive graph too. Your dependency’s dependencies matter just as much, and FetchContent gives you no lockfile — if the library you pinned fetches something else by branch, you have pinned nothing. This is the concrete argument for vcpkg or Conan on a project with more than a handful of dependencies: both produce a lockfile covering the whole graph.

Pin the toolchain, eventually. Same source, different compiler, different program — occasionally a differently-behaving one. Beyond a certain project size the answer is a container image or a Nix expression, and the honest thing to say is that “reproducible” is a spectrum and most projects sit further down it than they think.

Vendoring

Copying the source into your repository — third_party/, external/, vendor/ — is the oldest answer and still sometimes the right one.

It is right when you need offline and hermetic builds, when the upstream is unmaintained or you carry local patches, when the dependency is small enough that reviewing it is realistic, or when the dependency is load-bearing and you would rather own a fork than be surprised.

It is wrong as a default, and the reasons are practical rather than philosophical:

  • Updating is a manual merge, so it does not happen, so you sit on a three-year-old version with known CVEs.
  • Your history is full of other people’s code, and git log and code review both get worse.
  • Nobody can tell what version you have, unless you write it down — and if you patched it, “what version” no longer has an answer.

If you vendor, vendor deliberately: record the upstream URL and the exact commit in a file next to the code, keep local patches as separate commits or a patch series rather than editing in place, and put a reminder somewhere to look at upstream occasionally.

Package managers

vcpkg and Conan are the two that matter. Both fetch, build and cache binaries, resolve transitive dependencies, and produce a manifest and a lockfile.

vcpkg in manifest mode is a vcpkg.json next to your CMakeLists.txt:

{
  "name": "myapp",
  "version": "0.1.0",
  "dependencies": [ "fmt", "catch2", "nlohmann-json" ]
}

with a toolchain file passed at configure time. Conan uses a conanfile.txt or conanfile.py and generates CMake files you include. Both give you what FetchContent does not: a binary cache, so the dependency is compiled once per machine rather than once per clean build, and a lockfile pinning the full graph.

The cost is a tool that every contributor and every CI job must have, plus its own concepts — triplets, profiles, host and target — and the day the package you need is not in the registry at the version you want.

A reasonable default policy, and this is a judgement rather than a rule: FetchContent while the dependency count is small and the dependencies are source-friendly; a package manager once the count or the build time makes that painful; find_package in anything you publish as a library, so your users choose.

Check yourself

Practice