Do not use static_assert
to constrain the types you are developing. Huh? What does that even mean? Why not? Is that even an issue?
[Update: Added dbj_constraint]
Currently using std::
well-known containers, to the surprise of many, one can create all sorts of timebomb types, and then someone else, sometimes in the future, unknowingly and innocently might code all sorts of well-hidden timebombs, using those types.
We call them “timebomb types”, because they might be unnoticed “under the radar”, for months or even years and just then make your code not compile, aka “bomb”. Godbolt is here.
The key problem
Technically template is not a type. The template is a type waiting to happen. Template definition is a type.
static_assert
does not constrain the creation of type, it does constrain the instantiation of a type.
Consider this example from one young c++ author.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// inside hyper_log library template<typename T, std::size_t k> class hyper_log_log { public: static_assert(k >= 4 && k <= 30, "k must be in a range [4; 30]"); } ; using timebomb_type = hyper_log_log<bool, 1> ; // inside users code // few months (or years) after the above was released and adopted: timebomb_type boom ; |
timebomb_type
is a derived type. timebomb type can be defined as a new type and compiled as such, but no instances of that type can be ever made. Any attempt to do so will be stopped by the compiler.
Exactly the same timebomb “constraint”, using
static_assert
is deployed inside (for example) MS STL.
To add the oil to that grill, the key issue is that timebomb_type
can be used to create other types further down the line. That is because static_assert
(if used that is, to check the type constraints) will “kick in” just when someone tries to instantiate the type. Not before. Few examples, with important info on the location of the offending code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// user one lib --------------------------------------------------------- // user one has bought hyper_log lib (seen above) using timebomb_type = hyper_log_log<bool, 1>; // user two lib --------------------------------------------------------- // user two the has bought user one lib // timebomb succesfully hidden here using boson_fp = timebomb_type (*)( timebomb_type * ); // user three lib --------------------------------------------------------- // user three is using user two lib, he has bought too // timebomb doubly hidden in here using domino_effect_type = std::array<boson_fp,4> ; // user four code --------------------------------------------------------- // user four and three are working in the same company // user four has defined the constnt describing the size of // an impossible to use type constexpr auto impossible_constant = sizeof( typeid(boson_fp(0)) ); |
Above is what one might name “C++ sitcom”. Comedy of errors no one (in the comedy) is aware of. And, there is more. Template instantiation anyone? We have that too.
1 2 3 4 5 6 7 8 9 10 11 |
// template instantiation // this compiles, no problem template class std::basic_string_view<void***>; // Wot?! Yes this compiles perfectly too std::basic_string_view<void***> * wrongy_wrongy = nullptr ; // and by now the usual suspects work too, based on the // timebomb types sneaked in above static_assert(sizeof(std::basic_string_view<void***>)); static_assert(sizeof(wrongy_wrongy)); |
Every compiler will obey and compile the above without a single grudge. Imagination is the only constraint (pun intended) here. All sorts of well-hidden timebombs and foot guns can be produced in standard C++ with no or very little warning from the compiler.
And no warning to the future unfortunate users carrying around third-party code with timebomb types. Try it yourself in the Godbolt.
The simple remedy
Just a type-constrained template is enough to stop wrong type definitions down the line. And you constrain the type with std::enable_if
; in standard C++17.
1 2 3 4 5 6 7 8 9 10 |
// properly type constrained template // this type can not be used to create other types using it // C++17 template <typename T, std::size_t k, std::enable_if_t<k >= 4 && k <= 30, int> = 0> class better_hyper_log_log { // implementation here } |
The outcome is, the user (or you) can not introduce types whose usage will not compile only a few months or years down the line.
1 2 3 4 5 6 |
// does not compile // immediately noticeable using impossible_type = better_hyper_log_log<bool, 1> ; // compiles ok using ok_type = better_hyper_log_log<bool, 13> ; |
This post describes a peculiarity one might describe as “C++ foot gun with a timer” …
C++20
Using C++20 and beyond, one can boldly use the “dreaded” constraints, everybody is afraid of. Here is one example to try and convince you to look into the C++20 requires
keyword:
1 2 3 4 5 6 7 |
#if __cplusplus > 201703 // C++20 // forward declaration of a type constrained template template <typename T, std::size_t k> requires (k >= 4 && k <= 30) class constrained_hyper_log_log ; #endif |
Not that complicated. It looks quite clear actually. If you try and create a “wrong” type from that template you will get much clearer error messages vs using the good old std::enable_if
. Try.
But wait, there is even more!
It was brought to my attention, there are unfortunate souls not allowed to enjoy C++17, 14, and even C++11. Here is the solution.
1 2 3 4 5 6 7 8 |
// here is a simple solution // for any C++ version having templates template <bool ok> struct Constraint { using satisfied = bool ; }; template <> struct Constraint <false> {}; |
With simple ad-hoc macros, we create the ad-hoc “requirements”. Any compile-time expression that resolves to true or false will do.
1 2 3 4 5 6 7 |
// not very generic but usefull #define REQUIRE_TEN(a,b) Constraint< (a) + (b) == 10 > #define REQUIRE_INSIDE(k) Constraint< (k) >= 1 && (k) <= 0xFF > // or a bit more complicated but generic // obviously will not readily compile if all the three arguments // are not of the same type #define REQUIRE_INSIDER(k, L, H) Constraint< (k) >= (L) && (k) <= (H) > |
Let us use the above to create a constrained type for pre C++11 versions.
1 2 3 4 5 6 |
// requirement is that sum of arguments must be 10 template <int a, int b, REQUIRE_TEN(a,b)::satisfied = true > struct sum_must_be_10 { sum_must_be_10() { } }; |
Macro aficionados can make that code more “clever” or “convoluted”, it depends on you letting them in your space. And now the usage:
1 2 3 4 5 |
// compiles, 2 + 8 = 10 sum_must_be_10<2,8> c_1 ; //does not compile, 3 + 8 > 10 sum_must_be_10<3,8> c_2 ; |
For me, that might be even better vs std::enable_if
. It certainly seems simpler. And here is the mandatory Godbolt,