A Hater's Case Against Config.hpp

[CMAKE.PASSIVE_TARGETS] states:

Preprocessor definitions intended for external use should be generated into a config.hpp file at CMake configuration time. This config.hpp should then be included by public headers.

And [CPP.NO_FLAG_FORKING] reiterates:

  1. Check for availability at CMake time using, for example, check_cxx_source_compiles.
  2. Create a CMake option (e.g. BEMAN_<short_name>_USE_DEDUCING_THIS) with a default value based on detected support.
  3. Generate a config.hpp with a #define macro set to the selected option.
  4. Use this macro in place of the feature test macro.

I believe this is wrong. I am a hater of config.hpp-style solutions to package configuration. I hope to convince you to be a hater too.

Background

Project configuration occurs for many reasons. Sometimes there are multiple approaches to a given problem space, for example an asynchronous server runtime might offer multiple backends for different underlying operating system syscalls like epoll() or io_uring.

When the side-effects of the configuration are limited to the translation units of the project itself it is appropriate to offer these configuration choices at build-time to the packager of the project. The packager makes a decision, and consumers abide by that decision.[1]

For such cases it’s traditional for each configuration-specific unit to be confined to its own set of source files, and only the selected configuration gets compiled and linked into the package. This use-case has no overlap with config.hpp.

The more interesting kind of project configuration is due to platform differences in consumer translation units.

The Problem

In C++ programming we must be constantly aware of “the platform,”[2] and more importantly the differences between various platforms our code might be consumed on. When writing header files and interface units there is an additional complication, we have not one platform to deal with, but two.

There is the packager’s platform, and the consumer’s platform.

Unlike source files, headers and interfaces units are built on the consumer’s platform, and the packager has no insight into what capabilities will be available in that context. If a config.hpp file is generated by the packager it will reflect the capabilities of their platform; possibly being inappropriate, even inconsumable, for a consumer on a different platform.

Motivating Example

Consider a possible implementation of P1619: Functions for Testing Boundary Conditions on Integer Operations. A natural implementation is header-only, implementing the templates as described in the paper.

However, we may wish to accelerate using compiler builtins where available, to get better codegen for the runtime case. Imagine we use the __builtin_add_overflow_p family on gcc, and fallback to a generic implementation on other platforms.

Following [CPP.NO_FLAG_FORKING], we check for the availability of the symbol at build time and generate a config.hpp. A packager builds the package with gcc and ships it to a package repository.

Later, a consumer installs the package from the repository and tries to build the project with clang. Despite a generic implementation being available, their build fails because config.hpp was generated targeting gcc.

Here we differed by available compiler builtins, but the case holds for operating system, language standard, stdlib implementation, or any other platform difference you can imagine.

Alternative Solutions

There are two possible solutions:

  1. Allow flag forking. This is the overwhelming industry standard solution (see [CORE.INDUSTRY_STANDARD]). Allow for detection of capabilities inside the preprocessor and conditional inclusion of code based on those capabilities.

  2. Separate capabilities into their own header files and/or interface units, perform platform introspection on the consumer’s platform (via checks in <package>-config.cmake), and add only the relevant platform-specific headers to the include path. This limits consumers to those specifically using CMake and find_package(), and ends up being isomorphic to flag forking, simply lifting the fork into the -I flags.

Or allow both, with individual projects determining which is best for them. (2) is not explicitly forbidden by the Beman standard right now, so is a viable solution for projects wishing to address this inside the current standards.


  1. The packager is the developer or system which builds, and more importantly, constructs the install tree for the project. The consumer is the developer or system which incorporates the packaged install tree into their own, downstream, project. ↩︎

  2. A slippery concept which we use the encompass the set of differing capabilities and restrictions within a given translation unit. Many elements contribute to “the platform”, operating system, compiler, machine architecture, and language standard, to name some of the most common. ↩︎

2 Likes