August 2023
Reacting to compile-time type properties in C++17
Warning: this post will make you very happy or very sad depending on which C++ standard you can use at work.
Like many other code kobolds, I follow the evolution of the C++ language with a mix of hopeful curiosity and hopeless desperation. It turns out that, if you mix design by committee with a “train model” of releases, the result is a relentless pile-up of middle-ground compromises that makes everyone unhappy. Which they do say is the hallmark of good compromises, but it seems like no way to advance a language.
So the Vasa is sinking, and even ISO C++ committee members like Timur Doumler have a hard time staying afloat:
C++ gets bigger and bigger faster than I can learn it, which means the proportion of C++ I know actually shrinks over time!
(from ADSP episode #136, around minute 44).
However!
With recent standards, template metaprogramming actually got nicer and simpler. Unarguably.
I love when this happens, because unlike mammals I have very limited brain space.
Consider these scenarios:
- you are writing some code that needs to support both a modern and a legacy version of a class, where the legacy version lacks a certain data member
- you are doing some policy-based design and you need to branch on whether the type that implements the policy supports or not some advanced features
- you are writing generic code that deals with
std::vector
and you have been hurt enough times to know that itsstd::vector<bool>
specialization does not have adata()
member function (but you don’t want to rewrite all code twice)
In all these cases we would like to produce different code depending on whether a certain type exposes or not a certain member, and possibly also depending on the compile-time value of the data member.
To fix ideas, let’s say we are writing generic code that needs to behave differently depending on whether a parameter
type exposes a compile-time static_size
data member or not:
“When you are done with cave paintings, I’d love to see some code”
Yeah, yeah. Thanks to a synergic combination of variable templates (C++14), constexpr-if (C++17) and std::void_t
(also C++17),
solving the problem above requires relatively few lines of relatively readable code (open in Compiler Explorer):
// base case: // the second template parameter is just a placeholder: // we will need it in the specialization below template <class T, class = void> constexpr bool has_static_size = false; // this is a specialization so it takes precedence over the base case... // ...but only if the template parameter is well-formed (SFINAE) // ...i.e. only if `void_t<decltype(T::static_size)>` is template <class T> constexpr bool has_static_size<T, std::void_t<decltype(T::static_size)>> = true; void rest_of_function(int *buf); template <class T> void generic_function(const T &vec) { if constexpr (has_static_size<T>) { int buf[T::static_size]; rest_of_function(buf); } else { // using vector as an exception-safe memory buffer std::vector<int> buf(vec.size()); rest_of_function(buf.data()); } }
A similar technique can be used to detect member functions rather than data members.
It might be C++ Stockholm syndrome talking, but I find this solution quite nice, all things considered.
My original version wrapped has_static_size
in helper struct
s. My friend Bernhard
suggested using variable templates instead, which is much cleaner! He also pointed out to me that C++20 requires
clauses
simplify this even further. No need for helpers at all, you can just write:
if constexpr (requires { T::static_size; })
Thank you Bernhard!
Back in my day, or: the pain we leave behind
Before constexpr-if, std::void_t
and variable templates, ingenious code kobolds
would abuse a collection of seemingly unrelated features in order to trick the compiler
to generate the code they wanted. In C++11, this is the nicest way I know of
to achieve the same as the code above (open in Compiler Explorer):
void rest_of_function(int *buf); // base case // the catch-all second argument will be needed to disambiguate between overloads template <class T, class LowPriorityOverload> void generic_function_impl(const T& vec, LowPriorityOverload) { std::vector<int> buf(vec.size()); rest_of_function(buf.data()); } // this overload only participates if its signature is valid, // including the expression in the trailing return type, i.e. T::static_size // when it participates, it will have priority if the second argument is an int template <class T> auto generic_function_impl(const T& vec, int) -> decltype(T::static_size, void()) { int buf[T::static_size]; rest_of_function(buf); } template <class T> void generic_function(const T &vec) { generic_function_impl(vec, 0); }
Now, it might not seem like a lot more code than the C++17 solution, but this second version is more insidious, like a Mimic pretending to be a half-broken door (don’t worry if you don’t get the reference, just avoid dimly-lit dungeons and I’m sure you’ll be fine):
- it relies on performing SFINAE using trailing return types, which is harder to follow
- it relies on that dummy extra second parameter and overload ordering rules, which is slower to parse (for humans, kobolds and compilers)
- it’s not as generic as the C++17 solution: instead of a reusable
has_static_size
operation we have to write dedicated helper functions whenever we want to branch on a compile-time property
Some readers might be more comfortable implementing a C++11 version that performs SFINAE
via std::enable_if
rather than via expressions in trailing return types, like this.
Similar arguments apply, but more vehemently.
I only put these examples here so that newer generations don’t forget the struggle of their ancestors. And to those of us that work on codebases that are restricted to C++11 or C++14 still today: godspeed to you, brave soul.
Did you find this useful? Does this get better in C++23? Let me know at codekobold@pm.me!