gsl-lite / gsl-lite

gsl-lite – A single-file header-only version of ISO C++ Guidelines Support Library (GSL) for C++98, C++11, and later
MIT License
897 stars 108 forks source link

Not all configuration macros should hold values #182

Closed mbeutel closed 3 years ago

mbeutel commented 5 years ago

Many configuration macros need to be defined with a value (i.e. -D<macro>=<value>). For some this cannot be avoided (e.g. gsl_api, gsl_CONFIG_SPAN_INDEX_TYPE), but most simply switch a particular feature on or off:

The problem with value-sensitive definitions is that different requirements cannot be merged by the build system. For example, a library might depend on the implicit macro and the Owner() macro, so it would define:

add_library(MyLib)
target_link_libraries(MyLib
    PUBLIC
        gsl::gsl-lite)
target_compile_definitions(MyLib
    PUBLIC
        gsl_FEATURE_IMPLICIT_MACRO=1
        # don't need gsl_FEATURE_OWNER_MACRO=1 this since the feature is on by default
)

A consumer of this library doesn't need the implicit and Owner() macros in her own code, so she disables these features:

add_executable(MyApp)
target_link_libraries(MyApp
    PRIVATE
        gsl::gsl-lite
        MyLib::MyLib)
target_compile_definitions(MyApp
    PRIVATE
        gsl_FEATURE_IMPLICIT_MACRO=0
        gsl_FEATURE_OWNER_MACRO=0)

CMake now calls the compiler with both -Dgsl_FEATURE_IMPLICIT_MACRO=1 and -Dgsl_FEATURE_IMPLICIT_MACRO=0, which has unspecified behavior and may or may not cause a diagnostic. If the latter definition overrides the former, this will break the headers of MyLib which rely on the macro being available. Also, gsl_FEATURE_OWNER_MACRO=0 unconditionally disables the Owner() macro, again causing problems in public headers of MyLib included by MyApp.

Proposed resolution:

petamas commented 5 years ago

Hi,

In my humble opinion, configuration macros with values are superior to macros where the library makes decision based on the "definedness" of a config macro.

Please note that I have limited experience with developing libraries, so I may be wrong on some points, but at our company, we opted for valued config macros because of the following reasons.

Changing the default value

The default value of a macro (i.e. the value when the config macro is not defined) can be easily changed in later revisions. If you use #ifdef gsl_FEATURE_OWNER_MACRO, then you can't decide later that is should be on by default. (The only way to do this is to introduce a "disabler" config macro, but then you have to decide what should happen if the feature is both enabled and disabled by the separate macros, etc.)

Config macros with more than two options

When there are multiple options, value-less config macros quickly become a chore to maintain. For example, take the macros defining the behavior of GSL when a contract is violated:

#if 2 <= defined( gsl_CONFIG_CONTRACT_VIOLATION_THROWS ) + defined( gsl_CONFIG_CONTRACT_VIOLATION_TERMINATES ) + defined ( gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER )
# error only one of gsl_CONFIG_CONTRACT_VIOLATION_THROWS, gsl_CONFIG_CONTRACT_VIOLATION_TERMINATES and gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER may be defined.
#elif defined( gsl_CONFIG_CONTRACT_VIOLATION_THROWS )
# define        gsl_CONFIG_CONTRACT_VIOLATION_THROWS_V 1
# define        gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER_V 0
#elif defined( gsl_CONFIG_CONTRACT_VIOLATION_TERMINATES )
# define        gsl_CONFIG_CONTRACT_VIOLATION_THROWS_V 0
# define        gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER_V 0
#elif defined( gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER )
# define        gsl_CONFIG_CONTRACT_VIOLATION_THROWS_V 0
# define        gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER_V 1
#else
# define        gsl_CONFIG_CONTRACT_VIOLATION_THROWS_V 0
# define        gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER_V 0
#endif

#if gsl_ELIDE_CONTRACT_EXPECTS
# define Expects( x )  /* Expects elided */
#elif gsl_CONFIG( CONTRACT_VIOLATION_THROWS_V )
# define Expects( x )  ::gsl::fail_fast_assert( (x), "GSL: Precondition failure at " __FILE__ ":" gsl_STRINGIFY(__LINE__) );
#elif gsl_CONFIG( CONTRACT_VIOLATION_CALLS_HANDLER_V )
# define Expects( x )  ::gsl::fail_fast_assert( (x), #x, "GSL: Precondition failure", __FILE__, __LINE__ );
#else
# define Expects( x )  ::gsl::fail_fast_assert( (x) )
#endif

If we had only one configuration macro, it would be much simpler:

#if ! defined( gsl_CONFIG_CONTRACT_VIOLATION_RESPONSE )
# define gsl_CONFIG_CONTRACT_VIOLATION_RESPONSE 2 // terminate
#endif

#if gsl_ELIDE_CONTRACT_EXPECTS
# define Expects( x )  /* Expects elided */
#else
# if gsl_CONFIG( CONTRACT_VIOLATION_RESPONSE )==0 // throw
#  define Expects( x )  ::gsl::fail_fast_assert( (x), "GSL: Precondition failure at " __FILE__ ":" gsl_STRINGIFY(__LINE__) );
# elif gsl_CONFIG( CONTRACT_VIOLATION_RESPONSE )==1 // use custom handler
#  define Expects( x )  ::gsl::fail_fast_assert( (x), #x, "GSL: Precondition failure", __FILE__, __LINE__ );
# elif gsl_CONFIG( CONTRACT_VIOLATION_RESPONSE )==2 // terminate
#  define Expects( x )  ::gsl::fail_fast_assert( (x) )
# elif
#  error Unknown gsl_CONFIG_CONTRACT_VIOLATION_RESPONSE value
# endif
#endif

Usage can be made a bit nicer if we define aliases for 0, 1 and 2.

Consumers may need to really disable features

In your example, I think the creator of MyApp is in the wrong: she shouldn't disable the macros because she "doesn't need the implicit and Owner() macros in her own code". In my opinion, disabler macros should be used to disable features that are forbidden for some reason (eg. target compiler cannot handle it; it contradicts company coding guidelines; it provides duplicate functionality in the codebase; etc.), not to skip parts of the library she's not using currently.

In the above listed cases, your solution can cause subtle problems: if for some reason MyApp absolutely cannot use Owner (eg. it causes subtle runtime problems), then it could be problematic that it gets silently enabled. With your solution, the creator of MyApp cannot explicitly disable the feature, while with a valued macro, they can. Unfortunately, even with valued macros the compilers do not necessarily emit a diagnostic for the "redefinition" of the config macro, but at least they have a chance. (Or you can check for contradictions from the build system.)

martinmoene commented 5 years ago

FYI, my choice for configuration macros holding values and #if and is inspired on the article Advanced preprocessor tips and tricks by Anders Lindgren, section #if versus #ifdef which one should we use?

mbeutel commented 5 years ago

Thank you both for engaging in the discussion. Let me address your points individually.

Given that they are prettier and easier to maintain, we can agree that we should try to stick with value-holding macros if they don't cause problems.

However, gsl-lite is a library, and configuration options for libraries may be transitively carried into other projects by build systems such as CMake. This can cause problems with competing options which are hard to diagnose.

Build systems don't know how to coalesce options with different values. Please allow me to illustrate this with a macro-less example. Assume we have the following:

add_library(Foo INTERFACE)
target_compile_options(Foo INTERFACE /std:c++17) # never mind the compiler-specific flag
add_library(Bar INTERFACE)
target_compile_options(Bar INTERFACE /std:c++14)
add_executable(Baz)
target_link_libraries(Baz PRIVATE Foo Bar)

Clearly we want to compile Baz with the highest language setting, i.e. /std:c++17. But CMake doesn't understand the semantics of raw compile options; it will just emit both. MSVC will then issue a warning and take the latter option, thus causing compile errors for C++17 code in Foo's headers.

However, we can instead express our desire in a way CMake will understand:

add_library(Foo INTERFACE)
target_compile_features(Foo INTERFACE cxx_std_17)
add_library(Bar INTERFACE)
target_compile_features(Bar INTERFACE cxx_std_14)
add_executable(Baz)
target_link_libraries(Baz PRIVATE Foo Bar)

CMake understands that language features are additive, and hence compiles Baz with cxx_std_17.

The situation with value-holding and value-less configuration macros is similar. CMake will simply concatenate mismatching configuration macros, and the example at https://gcc.godbolt.org/z/9-Q0Kf demonstrates that not all compilers issue a warning if the same macro is defined with multiple contradicting values, so this can easily go unnoticed. The discussion in #121 explains why e.g. introducing a value-holding gsl_CONFIG_SELECT_SPAN macro would be a recipe for disaster for exactly this reason.

With value-less configuration macros, we can let the build system accumulate all transitive flags and implement the desired semantics ourselves. For example, we can make different options mutually exclusive:

#if 2 <= defined( gsl_CONFIG_CONTRACT_VIOLATION_THROWS ) + defined( gsl_CONFIG_CONTRACT_VIOLATION_TERMINATES ) + defined ( gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER )
# error only one of gsl_CONFIG_CONTRACT_VIOLATION_THROWS, gsl_CONFIG_CONTRACT_VIOLATION_TERMINATES and gsl_CONFIG_CONTRACT_VIOLATION_CALLS_HANDLER may be defined.
#elif ...

Or we can implement additive semantics, e.g. if we have macros gsl_CPLUSPLUS11, gsl_CPLUSPLUS14 etc.:

#if defined( gsl_CPLUSPLUS20 )
# define gsl_CPLUSPLUS 202000L // doesn't matter if gsl_CPLUSPLUS17 is also defined
#elif defined( gsl_CPLUSPLUS17 )
# define gsl_CPLUSPLUS 201703L
#elif ...

We can also combine both semantics, e.g. if we have competing gsl_CPLUSPLUS_MAX17, gsl_CPLUSPLUS_MAX14, gsl_CPLUSPLUS_MAX11 macros:

#if defined( gsl_CPLUSPLUS_MAX17 )
# define gsl_CPLUSPLUS_MAX 201703L
#elif defined( gsl_CPLUSPLUS_MAX14 )
# define gsl_CPLUSPLUS_MAX 201402L 
#elif ...
#endif

#if gsl_CPLUSPLUS > gsl_CPLUSPLUS_MAX
# error Conflicting minimum and maximum language requirements
#endif

Whether these checks are useful is a different question, and probably best answered individually for every macro. I slightly changed the title of the issue to acknowledge this.

mbeutel commented 5 years ago

The first question regarding any configuration macro should be, can this macro reasonably appear in the INTERFACE part of a library? The following should not:

All these macros could be value-holding configuration macros. But having value-less macros for contract-related settings is a slight benefit because we get a compiler error if we erroneously define a contract setting in an INTERFACE:

add_library(Foo STATIC)
target_compile_definitions(Foo PUBLIC gsl_CONFIG_CONTRACT_LEVEL_ASSUME) # bug, should be `PRIVATE`
add_library(FooTest)
target_compile_options(FooTest PRIVATE gsl_CONFIG_CONTRACT_LEVEL_AUDIT gsl_CONFIG_CONTRACT_VIOLATION_THROWS)
target_link_libraries(FooTest PRIVATE Foo) # error because both *_ASSUME and *_AUDIT are defined

But now for the hard part.

1: ODR violations are possible but usually without grave consequences (though I wish we didn't have gsl_noexcept).

mbeutel commented 5 years ago

In conjunction with #179, I could imagine to arrive in a state where applying any configuration macro, except for purely additive ones such as gsl_FEATURE_EXPERIMENTAL_RETURN_GUARD and gsl_CONFIG_ALLOWS_NONSTRICT_SPAN_COMPARISON, for contract-related macros, and for gsl_MIGRATE_CXX*, would lead to a warning that says "You have applied a configuration macro that causes problems if applied transitively. Please don't do this if your project is a library, otherwise you force this configuration setting upon all of your users. Define gsl_ACKNOWLEDGE_NONSTANDARD_CONFIG to make this warning go away."

mbeutel commented 3 years ago

I'm closing this because #272 added extensive safety checks for configuration macros, both value-holding and value-less. I currently don't plan to make incompatible changes to configuration macros, so the issues with conflicting transitive configuration settings probably cannot be addressed.