A general build model for C++

2021 October 15

In the last post I talked about how C++ artifacts are built. The basic model looks like this:

  1. Compile one or more translation units into objects.
  2. Link those objects into an artifact.
cc ${acflags} -o a.o -c a.cpp
cc ${bcflags} -o b.o -c b.cpp
cc ${ccflags} -o c.o -c c.cpp
ld ${ldflags} -o ${artifact} a.o b.o c.o ...

In this post, I want to expand this model to talk about how artifacts are linked together, i.e. how libraries affect the compilation of their dependents... and vice versa.

Imagine artifact B depends on library A. That dependency has four main effects on the construction of B:

  • To the compiler command for every translation unit of B:

    1. Add an include path that points to a directory containing A's headers.
    2. Add all the compiler flags and definitions used to build A.
  • To the linker command for B:

    1. Add a library path that points to a directory containing A's binaries.
    2. Add a linker flag to link A.

We assume the same compiler and linker are used for both A and B. If that is not the case, then it is the responsibility of the builder to ensure that they emit compatible application binary interfaces (ABI).

It doesn't stop there, however. Like I explained in the last post, it is possible for any compiler flag or definition to change the ABI. Determining whether one does in the general case is too much to ask from a build system, and thus the conservative assumption is that they all do. This means that A needs to be re-built, copying all the compiler flags and definitions introduced by B.

It's easy to see why. If a header H is imported by both A and B, then its declarations could be compiled to incompatible ABIs depending on the flags used to compile B but not A. That header might come from A, but it might also come from a common dependency, like the standard library.

This forwards and backwards propagation of every flag and definition starts to look like a nightmare for developers. If you build one dependency with a symbol table, must you build them all with a symbol table? With the same linkage? With assertions enabled? With warnings treated as errors? No, we can relax the propagation of flags and definitions when they don't affect the ABI. But because we cannot make that determination in the build system, it falls to the library author. They can categorize flags and definitions as local (meant only for that translation unit or artifact) or global (must be shared everywhere).

We might expect that a build system would collect global flags and definitions from all artifacts in a dependency graph, and then spread them everywhere. No build system works like this to my knowledge. CMake has a similar idea, labeling properties "private" and "public", and it propagates public properties forward to dependents, but not backwards to dependencies. It might work if project authors added global flags to the default CFLAGS, which are used for all targets, but only if all dependency graphs in the project share the same global flags. If each dependency graph had its own CFLAGS, then the build system might need to build each artifact differently for each graph it appears in.

What if two global flags conflict? It was just a matter of time before they would run into each other. Conflicting flags from two libraries will collide, even with only forward propagation, when a third artifact depends on both. We might expect that a build system would present the full set of global flags to the builder for review, to resolve any conflicts, before building the dependency graph. In that environment, compiler-specific tools might be created to automate the detection and perhaps even resolution of conflicts.

What if two global definitions conflict? This happens when two different artifacts share a definition name with different semantics. The solution is for library authors to use "namespace" prefixes for all definitions, which is already commonly recommended best practice.

In the next post, I explore the difficulty of mixing debug and release builds in the same dependency graph when using CMake.