>_devkit
modern-cpp
skills/modern-cpp/

references/build.md

Build and toolchain

The build is where most C++ bugs are cheapest to catch, and where most projects leave the largest amount of value on the table.

CMake: everything is a target

Modern CMake means properties on targets, never global mutation. include_directories, add_definitions, and setting CMAKE_CXX_FLAGS are the old style and they leak into everything.

cmake_minimum_required(VERSION 3.28)
project(auctions LANGUAGES CXX)

add_library(auctions_core src/order.cpp src/settlement.cpp)

target_include_directories(auctions_core
    PUBLIC  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    PRIVATE src)

target_compile_features(auctions_core PUBLIC cxx_std_23)
target_link_libraries(auctions_core PUBLIC fmt::fmt PRIVATE sqlite3)

The visibility keywords are the whole point:

KeywordMeaning
PRIVATENeeded to build this target, not to use it. An implementation detail.
PUBLICNeeded to build it and appears in its headers, so it propagates.
INTERFACENot needed to build it, only to use it. Header-only libraries.

Getting these right is what stops a leaf dependency becoming a transitive dependency of the whole codebase. PUBLIC on everything works and destroys the information.

CMakePresets.json so a build is one command and CI runs exactly what you run. Have at least: dev (Debug, sanitizers on), release (RelWithDebInfo, LTO), and ci.

set(CMAKE_EXPORT_COMPILE_COMMANDS ON) so clangd, clang-tidy, and IWYU all work without configuration.

Dependencies

ToolFits
CPM.cmakeSmall projects, header-only dependencies, zero setup. A single file you commit.
vcpkgA manifest, a lockfile, and binary caching. The default for most teams.
ConanSame, with more control over the build matrix and stronger binary caching.

Any of them beats vendored copies and FetchContent with a moving GIT_TAG. Pin exact versions, commit the lockfile, and update on a schedule. Upgrade cost is roughly linear in how far behind you are, right up until it stops being linear.

Warnings

Warnings you do not turn on are bugs you find in production.

target_compile_options(auctions_core PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:
        -Wall -Wextra -Wpedantic
        -Wconversion -Wsign-conversion   # the ones that catch real arithmetic bugs
        -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-align
        -Woverloaded-virtual -Wnull-dereference -Wdouble-promotion
        -Wimplicit-fallthrough -Wformat=2>
    $<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->)

-Wconversion is the one that hurts to enable on an existing codebase and the one that catches the most real defects: silent narrowing between int, size_t, and int64_t is behind a large share of overflow bugs.

Warnings as errors in CI, not locally. -Werror on a developer machine blocks work over an unused variable during debugging; in CI it is the only thing that stops the count creeping up. Set it in the ci preset.

Sanitizers

The highest return on effort in the entire toolchain. Each is a separate build.

BuildFlagsCatches
ASan + UBSan-fsanitize=address,undefined -fno-omit-frame-pointer -gUse-after-free, use-after-scope, buffer overflow, leaks, signed overflow, misaligned access, bad casts
TSan-fsanitize=thread -gData races, lock-order inversions
MSan-fsanitize=memoryReads of uninitialised memory. Needs a fully instrumented stdlib, so it is the awkward one.

ASan and TSan are mutually exclusive. Run the whole test suite under ASan+UBSan on every CI run, and TSan on every run if you have threads and it is fast enough, nightly otherwise.

Add -fsanitize=undefined -fno-sanitize-recover=all in CI so UBSan failures fail the build rather than printing and continuing.

Harden the standard library in debug and, increasingly, in release:

target_compile_definitions(auctions_core PRIVATE
    $<$<CONFIG:Debug>:_GLIBCXX_ASSERTIONS>            # libstdc++: bounds and precondition checks
    $<$<CONFIG:Debug>:_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE>)

_GLIBCXX_ASSERTIONS turns vector::operator[] out of range into an abort rather than undefined behaviour, for near-zero cost. Many projects now ship it in release too, on the grounds that a crash beats a exploitable overflow.

Static analysis

clang-tidy, with a checked-in .clang-tidy:

Checks: >
  bugprone-*,
  cppcoreguidelines-*,
  modernize-*,
  performance-*,
  readability-*,
  -modernize-use-trailing-return-type,
  -readability-magic-numbers
WarningsAsErrors: 'bugprone-*,performance-*'

Enable the whole families and subtract what you disagree with. Turning checks on one at a time means the list never grows. Run it on changed files in CI, not the whole tree, or the runtime makes people skip it.

.clang-format, checked in, enforced in CI. Formatting should never be a review comment. Which style you pick matters far less than that it is mechanical.

Include hygiene

Every include is a rebuild trigger for everyone downstream.

  • Include what you use. The IWYU tool automates the check.
  • Forward declare in headers, include in the .cpp. A header that needs only

class Order; should not pull in order.hpp.

  • Never include a header for convenience. Adding <algorithm> to a header

fifty translation units include is a measurable build-time regression.

  • PIMPL for anything whose implementation churns and whose header is widely

included.

Build speed

Measure before optimising, same as runtime.

cmake --preset dev -DCMAKE_CXX_FLAGS="-ftime-trace"
cmake --build --preset dev
ClangBuildAnalyzer --all build/ capture && ClangBuildAnalyzer --analyze capture

That prints the templates that cost the most to instantiate, the slowest functions to codegen, and the most-included headers. The usual finding is one over-generic template dominating, and the usual fix is a concept that shrinks its overload set.

Then, in rough order of payoff: ccache, the Ninja generator, -gsplit-dwarf and a faster linker (mold or lld) for link-heavy builds, precompiled headers for the stable third-party surface, and explicit instantiation (extern template) for hot generic types. Unity builds work and make incremental builds worse; treat them as a CI-only trick.

Release builds

  • RelWithDebInfo for anything you will ever have to debug in production, which

is everything. Symbols cost disk, not speed.

  • LTO (CMAKE_INTERPROCEDURAL_OPTIMIZATION) is usually a real win and a real

link-time cost. Enable it in release only.

  • -march=native is a trap for anything shipped: it produces a binary that

crashes with an illegal instruction on an older CPU than the build machine. Target a baseline like x86-64-v2 or v3 deliberately.

  • PGO if you have a representative workload. It is usually worth more than any

micro-optimisation you would do by hand.

Testing wiring

Catch2 or GoogleTest, registered with CTest so ctest --preset ci runs everything and CI has one command. Add a libFuzzer target for every parser:

add_executable(fuzz_config fuzz/config.cpp)
target_compile_options(fuzz_config PRIVATE -fsanitize=fuzzer,address)
target_link_options(fuzz_config    PRIVATE -fsanitize=fuzzer,address)

Keep the corpus in the repository. A fuzzer that starts from nothing on every run re-derives what it already knew.