Chapter 22 of 24 | C++

Build systems and professional workflow

A build system records how source files, generated files, libraries, and compiler options fit together. A one-file command is useful for learning; a project with several targets benefits from a reproducible build configuration that teammates and automated tests can use.

Project structure

my-app/
  CMakeLists.txt
  include/
  src/
  tests/

Keep public headers separate from implementation where it helps users understand the interface. Store tests with the project, avoid committing generated build output, and keep source files focused on related responsibilities.

A small CMake configuration

cmake_minimum_required(VERSION 3.20)
project(MyApp LANGUAGES CXX)

add_executable(my_app
    src/main.cpp
    src/calculator.cpp
)
target_compile_features(my_app PRIVATE cxx_std_23)
target_include_directories(my_app PRIVATE include)

CMake generates build files for a selected toolchain; it does not replace the compiler. A typical workflow is to configure into a separate build directory, then build that directory. Exact generator and C++23 feature support depend on the installed CMake and compiler versions. Follow their documentation and report configuration failures instead of silently falling back to another standard.

Repeatable development workflow

  1. Format code consistently and use meaningful names.
  2. Build with warnings enabled and keep the build reproducible.
  3. Run focused tests after each change, then run the broader suite before delivery.
  4. Use version control to review a small, coherent diff.
  5. Document setup, build, test, and run commands for a new contributor.

Continuous integration can build and test each proposed change on a clean environment. Treat its failures as useful evidence: avoid depending on files or tools present only on your own machine.

Dependencies and configuration

Pin or constrain dependencies as appropriate for the project, keep licenses and update policies visible, and separate debug and release configurations. Do not store credentials in source or build files. Use environment-specific configuration for secrets.

Configure, build, and test separately

A generated build directory keeps compiler output and configuration state away from source files. A typical CMake workflow configures the project into that directory, builds a named target, and then runs its tests. The exact commands depend on the selected generator, but the important idea is that configuration, compilation, and testing are explicit steps whose failures can be reported independently.

Do not modify generated build files by hand: they are outputs of the configuration step and may be replaced. If a build behaves strangely after changing compilers or configuration options, use a fresh build directory so stale cached settings do not conceal the current state.

Targets make settings local

Modern CMake works best when configuration is attached to targets. The target's source files, include directories, language standard, warnings, and dependencies are declared together. This helps a library and the programs that use it receive the right settings without copying a long list of global compiler flags throughout the project.

Use a separate target for tests and link it to the code under test. Avoid copying implementation source into several executables if a library target can express their shared relationship. When exposing a library's include directory to consumers, distinguish public includes from private implementation includes.

Debug and release configurations

A debug build usually preserves more information for debugging and may enable assertions. A release build enables optimizations and may alter timing and behavior around code with undefined behavior. Test both configurations when practical; correctness should not depend on debug-only behavior. Do not benchmark an unoptimized build and report the result as production performance.

Version control and code review

Keep changes small enough that a reviewer can understand the problem, implementation, and test evidence. Review the diff for accidental generated files, debugging output, unrelated formatting, and secrets. Commit messages should explain the purpose of the change, while the repository documentation should explain how to build and test the project.

A clean checkout is an important test of project setup. It catches undeclared dependencies on local files, forgotten source files, stale generated output, and unrecorded environment settings. Continuous integration performs this clean build and test automatically for every change.

Practice

Key idea: a good workflow makes the same build and tests repeatable for you, teammates, and automation.

Build graphs and dependency correctness

A build system models targets and dependencies as a directed graph. A target should be rebuilt when one of its declared inputs changes, and independent targets may be built in parallel. Missing dependencies can create builds that work only by accident or fail nondeterministically under parallel execution. Generated files need explicit producers, and tools that generate them should be modeled as build steps rather than manual setup.

Correct dependency declarations support incremental builds and clean builds alike. If a header affects a source file but is not represented in the dependency graph, an incremental build may reuse a stale object file. Most modern build systems discover compiler dependencies, but custom generation steps and external tools still need deliberate declarations.

Reproducible and hermetic builds

A reproducible build aims to produce the same result from the same declared inputs. A hermetic build restricts undeclared access to the environment, such as arbitrary system paths or network resources. Exact bit-for-bit reproducibility can be difficult because timestamps, compiler versions, archive ordering, paths, and generated metadata may affect outputs.

For trustworthy release builds, pin dependencies, record toolchain versions, avoid fetching undeclared resources during compilation, and separate generated artifacts from source. Build from a clean environment and document inputs. Reproducibility helps investigate bugs and verify that a release corresponds to reviewed source.

Testing targets and continuous integration

Tests should be first-class build targets so they receive the same compiler settings and dependencies as the application. A test runner should report individual failures and return a nonzero process status when a test fails. CI should exercise a clean configuration, relevant compiler versions, and supported platforms without relying on local caches for correctness.

Use separate configurations for sanitizers, debug checks, and release optimization as appropriate. Record which configuration ran in CI, because a passing debug test does not prove that a release artifact was built correctly. For packages, also test installation and consumption from a separate sample project.

Build performance and engineering trade-offs

Large template-heavy headers and broad include graphs can make compile times a significant development cost. Measure clean and incremental build times separately. Reduce unnecessary dependencies, use precompiled headers or modules where they fit the toolchain, and avoid premature build-system complexity. A faster build is valuable, but not if it makes dependency behavior opaque or results irreproducible.

Build caches can accelerate repeated work, but cache keys must include the compiler, flags, dependencies, and relevant environment. A stale or incorrectly keyed cache can return an artifact built for different assumptions.

Advanced exercise

  1. Build two independent targets in parallel and identify the declared dependencies that allow safe scheduling.
  2. Compare clean and incremental build times after removing an unnecessary header dependency.
  3. Configure CI to build, test, and install a library, then consume it from a separate example target.
  4. Document the toolchain and dependency inputs needed to reproduce a release build.
0% read