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

Put me in the +1 column here – the generated config.hpp file is a massive issue that breaks header only usage without cmake and cases like godbolt. Besides the library above this solution has created issues in inplace_vector, scope, any_view and iterator_interface – there’s probably more but those are discussions I’ve personally been involved with.

@dsankel says that supporting dropping headers into a project isn’t a case we should support, but I continue to disagree strongly. I’d argue it’s far more important than the more complicated cases which the rule is attempting to provide benefit. Part of this is that the majority of Beman libraries are in fact header-only – and as you observe this is exactly when the code needs to stand alone.

I think what you’re suggesting is the moral equivalent of boost.config? To me that seems like overkill for the bounds_test and many other cases.

Like I’ve argued for any_view I don’t think that selecting the built-in as an implementation detail on gcc impacts in any fashion the desire for link time compatibility – which is at the root of the rule. As a header only choice that’s internal to the offered api, if you compile with gcc you get the optimization in the .o (.so, .a) and there’s no impact on linkage. Or am I missing some nuance?

So after we potentially remove cases where I think the flag forking rule currently over reaches or is misapplied, lets discuss the no-exceptions fork for inplace_vector. As it stands that BEMAN_INPLACE_VECTOR_NO_EXCEPTIONS flag also does not impact linkage – however it clearly impacts behavior (abort versus exception). This library also uses the config.hpp when present – and I think that’s also not a violation of the flag forking requirement. Specifically, in the header here the library consumes the generated config.hpp if it exists which can set the flags. However, if the file does not exist the library selects a default. This allows inplace vector to work in godbolt without heroics - and for user to copy the header into a project and get appropriate defaults.

tldr: I think we need a much more nuanced definition of what can be forked and what cannot – and we need to allow libraries to set defaults in code like above when the config.hpp does not exist.

Thoughts?

I’m unfamiliar with the full scope of what boost.config achieves (and trying to get a quick overview from the boost docs was unsuccessful), but this is already implemented in bounds_test.

Conceptually it looks like this:

if(HAS_GNU_OVERFLOW)
  target_include_directories(beman::bounds_test
    INTERFACE
      "${_IMPORT_PREFIX}/include/beman/bounds_test/plat/gnu"
  )
elseif(HAS_MSVC_OVERFLOW)
  target_include_directories(beman::bounds_test
    INTERFACE
      "${_IMPORT_PREFIX}/include/beman/bounds_test/plat/msvc"
  )
else()
  target_include_directories(beman::bounds_test
    INTERFACE
      "${_IMPORT_PREFIX}/include/beman/bounds_test/plat/generic"
  )
endif()

The specific implementation points are here for the build tree and over here for when used via find_package().

Again this is flag forking via -I instead of -D, so this seems like a distinction without a difference and I don’t understand what we’re trying to achieve by banning the -D option. As has been mentioned this has been discussed ad nauseam so I’m certain I’m late to the party and there’s lots of use cases I’m not considering.

I’m unfamiliar with what the discussed use case was. On MSVC none of our inline functions or template instantiations get exported by default, so by taking no action with regards to template/function visibility we’re safe. On GNU-likes it’s up to the consumer of the header only library to correctly use -fvisibility=hidden to ensure the internal ABI details are hidden from the world.

In GCC, template instantiations are given the visibility of their template. I’ve implicitly raised a couple times that Beman needs a policy or switch to control visibility, see Exporting Symbols · Issue #161 · bemanproject/exemplar · GitHub.

If we either give our templates hidden visibility, or give users a switch to control the visibility of header symbols (preferably defaulting to hidden), the question of ABI collision goes away.